简介:本资源是面向高校计算机专业本科生的编译原理课程实验配套源码包,完整实现词法分析器、LL(1)语法分析器与LR(1)语法分析器三大核心模块,覆盖编译前端基础能力训练。压缩包共52个文件,含4个C++源文件(cpp)与3个头文件(h)构成核心分析逻辑,10组测试用例(in/out)用于验证正确性,另有PDF实验报告、HTML可视化界面、Makefile构建脚本及Git版本管理配置,整体6.88MB,结构清晰、开箱即用。已有1089人学习下载,适合课程设计实践、期末项目参考或编译原理原理验证。读者可直接编译运行,观察词法识别(支持关键字、标识符、运算符、无符号数、字符串/字符常量及行间注释)、LL(1)预测分析过程与LR(1)状态机构建细节,并通过配套前端界面直观理解分析流程与错误定位机制。
1. 为什么手写词法分析器和LL(1)/LR(1)语法分析器,比直接调用ANTLR或JavaCC更值得花两周时间硬啃?
这不是一个“要不要用工具”的选择题,而是一个编译原理课程里绕不开的能力验证关卡:当你在实验报告里写下“成功构造FIRST/FOLLOW集”“手动推导LL(1)分析表”“手写LR(1)项目集规范族”,你真正掌握的不是代码,而是语言结构如何被机器逐层解构的底层逻辑。很多同学用Java写完词法分析器后,发现正则匹配能跑通但无法处理注释嵌套;用递归下降实现LL(1)时,遇到左递归就卡死;尝试手推LR(1)状态转换图时,发现冲突项怎么消都消不干净——这些不是bug,是编译器设计中真实存在的结构性陷阱。本实验包(编译原理实验之词法分析器、LL(1)语法分析器、LR(1)语法分析器.zip)本质是一套可调试、可打断点、可逐行观察栈变化的黑匣子拆解套件:它不封装核心算法,所有FIRST集计算、预测分析表填充、LR(1)项目集闭包生成全部暴露为独立函数;输入是纯文本源码(如if (x > 0) y = x + 1;),输出是带行号的token流、分析过程栈快照、冲突项定位日志。适合正在啃《编译原理(第3版)-王生原》第三章课后习题、被广州大学编译原理实验作业压得喘不过气、或想把“符号表管理”从概念落到内存地址映射的同学——你不需要造轮子,但必须亲手拧紧每一颗螺丝。
2. 从零构建词法分析器:用Java手写有限自动机,避开正则引擎的玄学失效
词法分析器不是“用String.split()切一下再match就行”。真实场景中,/* comment */可能跨多行,0x1A和1e-5要区分整数与浮点,==和=必须按最长匹配原则识别。本实验采用确定性有限自动机(DFA)显式建模,而非依赖Java内置正则库——因为后者在教学场景下会掩盖关键细节:比如当输入ifx时,DFA必须明确拒绝(因if是保留字而ifx不是),而正则"if|ifx"会错误匹配前者导致漏判。
2.1 状态机设计:用二维数组编码转移规则
核心是定义stateTransition[state][charType] → nextState。我们不预设字符类型枚举(如LETTER/DIGIT/WHITESPACE),而是先对ASCII做分类映射:
// CharType.java public class CharType { public static final int LETTER = 0; public static final int DIGIT = 1; public static final int DOT = 2; public static final int PLUS = 3; public static final int MINUS = 4; public static final int ASTERISK = 5; public static final int SLASH = 6; public static final int LPAREN = 7; public static final int RPAREN = 8; public static final int SEMICOLON = 9; public static final int EQUAL = 10; public static final int LESS = 11; public static final int GREATER = 12; public static final int EOF = 13; public static final int OTHER = 14; public static int getCharType(char c) { if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') return LETTER; if (c >= '0' && c <= '9') return DIGIT; switch (c) { case '.': return DOT; case '+': return PLUS; case '-': return MINUS; case '*': return ASTERISK; case '/': return SLASH; case '(': return LPAREN; case ')': return RPAREN; case ';': return SEMICOLON; case '=': return EQUAL; case '<': return LESS; case '>': return GREATER; default: return OTHER; } } }提示:
getCharType()返回值必须严格对应stateTransition数组第二维索引,否则越界。实测发现Windows换行符\r\n中\r被误判为OTHER导致行号错乱,需在读取文件时统一转为\n。
2.2 状态跳转表:用int[][]实现紧凑存储
状态数控制在20以内(足够覆盖C子集),避免HashMap带来的哈希开销和调试不可见性:
// Lexer.java - 关键片段 private static final int[][] stateTransition = { // state 0: start {1, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, // LETTER→1, DIGIT→2 // state 1: identifier or keyword {1, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 100, -1}, // 继续字母数字,EOF→100(终态) // state 2: number start { -1, 2, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 101, -1}, // DIGIT→2, '.'→3 // state 3: after dot { -1, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 101, -1}, // DIGIT→4 // state 4: decimal part { -1, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 101, -1}, // 终态101=FLOAT // state 100: IDENTIFIER终态,需查关键字表 // state 101: FLOAT终态 };逻辑说明:
-1表示非法转移(如状态0遇到'='直接报错)100/101等大于100的状态为终态,触发token生成- 关键字判断放在终态处理中:若state==100,查
keywords.get(buffer.toString()),存在则返回KEYWORD token,否则返回IDENTIFIER
参数说明:
buffer是StringBuilder,累积当前识别的字符序列lineNum和colNum在每次读取字符时更新,确保错误提示带精确位置- 终态ID设计为100+,避免与中间状态混淆,便于调试时快速定位
2.3 关键字与标识符分离:符号表的第一次实战
很多同学把if和identifier混在一起处理,导致ifx被截成if+x。正确做法是:仅当DFA到达终态且缓冲区内容匹配关键字表时,才返回关键字token;否则一律视为标识符。
private static final Map<String, TokenType> KEYWORDS = new HashMap<>(); static { KEYWORDS.put("if", TokenType.IF); KEYWORDS.put("else", TokenType.ELSE); KEYWORDS.put("while", TokenType.WHILE); KEYWORDS.put("return", TokenType.RETURN); KEYWORDS.put("int", TokenType.INT); } // 在终态100处理逻辑中: String word = buffer.toString(); TokenType type = KEYWORDS.get(word); if (type != null) { tokens.add(new Token(type, word, lineNum, colNum - word.length())); } else { tokens.add(new Token(TokenType.IDENTIFIER, word, lineNum, colNum - word.length())); }注意:colNum - word.length()是列号修正——因为colNum指向当前字符后一列,需回退字符串长度才能得到起始列。
3. LL(1)语法分析器:手算FIRST/FOLLOW集后,用二维预测分析表驱动分析栈
LL(1)不是“递归下降”的同义词。真正的LL(1)分析器必须满足:对任意非终结符A和输入符号a,Predict[A,a]唯一。本实验强制要求你手算FIRST/FOLLOW集并填表,因为这是理解“为什么某些文法不能LL(1)”的唯一路径——比如E → E + T | T含左递归,T → T * F | F同样如此,不消除就永远填不出无冲突的预测表。
3.1 文法预处理:消除左递归与提取左公因子
实验给定文法(简化C子集):
S → id = E ; E → T E' E' → + T E' | ε T → F T' T' → * F T' | ε F → id | num | ( E )关键操作:
E'和T'是消除左递归后的右递归形式,保证FIRST(E')包含εF的三个候选式id/num/(E)首符号互斥,无需左公因子提取
血泪经验:手算FOLLOW集时,
FOLLOW(E')必须包含{), ;},因为E'出现在E → T E'后,而E的FOLLOW是{), ;};同时E' → ε意味着FOLLOW(E')要并入FOLLOW(E)。漏掉;会导致id = 3;末尾分号无法匹配。
3.2 构建预测分析表:用Map<NonTerminal, Map<Terminal, Production>>存储
不使用二维数组(稀疏且难调试),改用嵌套Map:
// PredictTable.java public class PredictTable { private final Map<String, Map<String, Production>> table = new HashMap<>(); public void put(String nonTerminal, String terminal, Production production) { table.computeIfAbsent(nonTerminal, k -> new HashMap<>()).put(terminal, production); } public Production get(String nonTerminal, String terminal) { Map<String, Production> row = table.get(nonTerminal); return row == null ? null : row.get(terminal); } }填表逻辑(以E → T E'为例):
- FIRST(T) = {id, num, '('} → 将此产生式填入
table["E"]["id"],table["E"]["num"],table["E"]["("] - 因
E'可推出ε,且FOLLOW(E') = {')', ';'} → 将E → T E'也填入table["E"][")"]和table["E"][";"]
参数说明:
Production类封装左部非终结符、右部符号列表、序号(用于调试输出)- 终结符key用字符串而非枚举,方便与词法分析器token.type.name()对齐
3.3 驱动算法:栈+输入流双指针模拟
核心循环只做三件事:
- 栈顶为终结符:与当前token匹配,弹出并读取下一token
- 栈顶为非终结符:查预测表,替换为对应产生式右部(逆序压栈)
- 栈为空但token未耗尽:报错
// LL1Parser.java public void parse(List<Token> tokens) { Stack<String> stack = new Stack<>(); stack.push("S"); // 起始符号 int pos = 0; while (!stack.isEmpty() && pos < tokens.size()) { String top = stack.pop(); Token current = tokens.get(pos); if (isTerminal(top)) { if (top.equals(current.type.name())) { pos++; // 匹配成功,消费token } else { throw new ParseException("Expect " + top + ", got " + current.type + " at " + current.line); } } else { Production p = predictTable.get(top, current.type.name()); if (p == null) { throw new ParseException("No production for " + top + " -> " + current.type + " at " + current.line); } // 右部逆序压栈:如 E' → + T E',则压入 E', T, + for (int i = p.right.size() - 1; i >= 0; i--) { stack.push(p.right.get(i)); } } } if (!stack.isEmpty() || pos < tokens.size()) { throw new ParseException("Parse failed: stack=" + stack + ", remaining tokens=" + tokens.subList(pos, tokens.size())); } }逻辑说明:
isTerminal()通过查预定义终结符集合判断({"id","num","+",")",";","(",...})- 错误信息包含具体行号和栈状态,便于定位
if (x) y;中缺失)的位置 - 压栈顺序必须逆序,否则
+ T E'会变成E' T +导致分析失败
4. LR(1)语法分析器:手推项目集规范族,直面移进-归约与归约-归约冲突
LR(1)是编译原理实验的分水岭。LL(1)失败时你还能靠改写文法补救,但LR(1)暴露的是文法本质缺陷:比如if E then S else S | if E then S的悬空else问题,在LR(1)中表现为同一状态既有shift to s5又有reduce by A→if E then S——这不是实现bug,是文法二义性。本实验要求你手写每个LR(1)项目集的闭包、GOTO函数、ACTION/GOTO表,因为只有看到s4: [S'→•S, $]和s5: [S→if E then S•, else]共存于同一状态,才会真正理解“为什么需要优先级声明”。
4.1 项目集构造:用Set 表示每个状态
Item定义为A → α•β, a(•为点,a为展望符):
public class Item { public final String left; // 左部非终结符 public final List<String> right; // 右部符号列表 public final int dotPos; // •位置(0表示在最左) public final String lookahead; // 展望符 public Item(String left, List<String> right, int dotPos, String lookahead) { this.left = left; this.right = right; this.dotPos = dotPos; this.lookahead = lookahead; } }闭包计算(Closure)关键逻辑:
- 若点后是非终结符X,则对所有
X → γ,将X → •γ, b加入,其中b ∈ FIRST(βa)(β为点后剩余符号,a为当前lookahead) - 需递归直到无新项目加入
private Set<Item> closure(Set<Item> items) { Set<Item> result = new HashSet<>(items); boolean changed; do { changed = false; for (Item item : new HashSet<>(result)) { if (item.dotPos < item.right.size()) { String nextSymbol = item.right.get(item.dotPos); if (isNonTerminal(nextSymbol)) { // 计算FIRST(βa) List<String> beta = item.right.subList(item.dotPos + 1, item.right.size()); Set<String> firstBetaA = firstOfSequence(beta, item.lookahead); for (String a : firstBetaA) { for (Production p : productions.get(nextSymbol)) { Item newItem = new Item( nextSymbol, p.right, 0, // dot at start a ); if (result.add(newItem)) changed = true; } } } } } } while (changed); return result; }参数说明:
firstOfSequence()需处理ε传递(如β = [T, E']且E' → ε,则FIRST(βa) = FIRST(T) ∪ (if FIRST(T) contains ε then FIRST(E'a) else ∅)`)productions是Map<String, List<Production>>,按非终结符索引所有产生式
4.2 GOTO函数:状态迁移的核心
GOTO(I, X) = closure({A → αX•β, a | A → α•Xβ, a ∈ I}):
private Set<Item> gotoFunc(Set<Item> items, String symbol) { Set<Item> nextItems = new HashSet<>(); for (Item item : items) { if (item.dotPos < item.right.size() && symbol.equals(item.right.get(item.dotPos))) { // 移动点:A → α•Xβ, a → A → αX•β, a List<String> newRight = new ArrayList<>(item.right); Item newItem = new Item( item.left, newRight, item.dotPos + 1, item.lookahead ); nextItems.add(newItem); } } return closure(nextItems); }逻辑说明:
gotoFunc(I, X)结果是新状态的项目集,需去重并闭包- 实际实现中,用
Map<Set<Item>, Integer>缓存已生成状态,避免重复计算
4.3 ACTION/GOTO表生成:冲突检测即教学现场
最终表结构:
public class ParseTable { public final Map<Integer, Map<String, Action>> action = new HashMap<>(); public final Map<Integer, Map<String, Integer>> gotoTable = new HashMap<>(); }填表规则:
- 若
A → α•aβ, b ∈ I_i且GOTO(I_i, a) = I_j→action[i][a] = shift j - 若
A → α•, a ∈ I_i→action[i][a] = reduce A→α - 若
S' → S•, $ ∈ I_i→action[i]["$"] = accept
冲突检测代码:
for (Map.Entry<String, Action> entry : actionRow.entrySet()) { String terminal = entry.getKey(); Action existing = entry.getValue(); Action newAction = computeAction(...); if (existing != null && !existing.equals(newAction)) { System.err.println("CONFLICT in state " + stateId + " on " + terminal + ": " + existing + " vs " + newAction); hasConflict = true; } }翻车现场:
if E then S else S | if E then S文法中,状态s1同时有shift on else和reduce on else,此时必须人工指定优先级(else shift)或改写文法。实验包中提供conflictResolution.txt记录你的决策依据,这是答辩时的加分项。
5. 避坑指南:词法/LL(1)/LR(1)三大模块的5个血泪教训
编译原理实验最残酷的地方在于:错误现象和根本原因之间隔着三层抽象。下面这5条,每一条都来自学生提交的崩溃日志和深夜调试截图。
5.1 词法分析器:注释跨行导致行号错乱,错误定位偏移3行
- 现象:输入
/* multi\nline\ncomment */ id = 1;,报错显示id = 1;在第1行而非第4行 - 原因:
BufferedReader.readLine()丢弃了换行符\n,但colNum计数未重置为1,导致后续行列号全乱;更致命的是,/* ... */的结束符*/被当作普通字符处理,未触发行号自增 - 解决:在注释状态机中,每读到
\n立即lineNum++且colNum=1;readLine()后手动追加\n到buffer,保持字符流完整性
5.2 LL(1)分析器:FOLLOW集漏算导致预测表空白,报"No production"
- 现象:
E' → + T E' | ε的FOLLOW(E')只填了),没填;,导致id = 3;在;处查表为空 - 原因:忽略
E → T E'中E'紧跟在产生式末尾,其FOLLOW集必须包含FOLLOW(E),而FOLLOW(E)由S → id = E ;决定,应为{;} - 解决:手算FOLLOW时画依赖图——
FOLLOW(X)依赖所有Y → αXβ中的FIRST(β)和FOLLOW(Y),用迭代法直到收敛
5.3 LR(1)项目集:闭包计算未处理ε传递,导致GOTO状态缺失
- 现象:状态
s0包含[E' → •E, $],但GOTO(s0, E)为空,无法进入s1 - 原因:
E → T E'中E' → ε,计算FIRST(E' $)时只返回{$},漏掉ε本身,导致[E' → •, $]未被加入闭包 - 解决:
firstOfSequence()函数必须返回Set,且当β为空或全ε时,将lookahead a加入结果
5.4 符号表管理:变量重复声明未报错,运行时才段错误
- 现象:
int x; int x;被接受,但语义分析阶段x地址被覆盖,生成代码访问野指针 - 原因:符号表
Map<String, Symbol>插入时未检查key存在,put()覆盖旧值 - 解决:在
enter()方法中先if (table.containsKey(name)) throw RedeclarationException;实验包中SymbolTable.java第47行已加此检查
5.5 输入流边界:词法分析器EOF处理不当,导致LL(1)栈清空后仍报错
- 现象:
id = 1;正确解析,但id = 1(无分号)报Expect ;而非Unexpected EOF - 原因:词法分析器遇到文件末尾时,未生成
EOFtoken,LL(1)分析器在栈空后仍试图读token,触发IndexOutOfBoundsException - 解决:
Lexer.nextToken()在reader.read()返回-1时,强制返回new Token(TokenType.EOF, "", lineNum, colNum);LL(1)循环条件改为while (!stack.isEmpty() && !currentToken.isEOF())
6. 进阶技巧:用AST节点绑定符号表,让词法/语法/语义分析真正贯通
做完三个分析器只是起点。真正体现编译原理功力的,是让它们共享同一套符号表实体,并在AST节点上挂载类型信息。比如int x = 3 + y;中,y的引用必须在语义分析阶段查符号表确认已声明,且3 + y的类型由y的类型决定。本实验包的ASTBuilder.java提供了可扩展的节点基类:
public abstract class ASTNode { public int line; public int col; public Type type; // 后续语义分析填入 public Symbol symbol; // 变量声明/引用绑定 } public class BinaryOpNode extends ASTNode { public final String op; // "+", "-", etc. public final ASTNode left; public final ASTNode right; } public class VarRefNode extends ASTNode { public final String name; public VarRefNode(String name, int line, int col) { this.name = name; this.line = line; this.col = col; } }6.1 在LL(1)分析过程中构建AST
修改LL(1)的产生式动作:不只做语法检查,同步创建节点。例如E → T E'对应:
// 当匹配 E → T E' 时: ASTNode tNode = parseT(); // 返回T的AST ASTNode ePrimeNode = parseEPrime(); // 返回E'的AST(可能是BinaryOpNode或null) if (ePrimeNode instanceof BinaryOpNode) { ePrimeNode.line = tNode.line; // 统一行号 return ePrimeNode; // E'已包含完整表达式树 } else { return tNode; // E'为空,E即为T }6.2 符号表与AST的双向绑定
在VarDeclNode(变量声明)中注册符号,在VarRefNode(变量引用)中查找:
// 语义分析主流程 public void analyze(ASTNode root, SymbolTable table) { if (root instanceof VarDeclNode) { VarDeclNode decl = (VarDeclNode) root; Symbol sym = new Symbol(decl.type, decl.name, decl.line); table.enter(decl.name, sym); decl.symbol = sym; // 绑定到AST节点 } else if (root instanceof VarRefNode) { VarRefNode ref = (VarRefNode) root; Symbol sym = table.lookup(ref.name); if (sym == null) { throw new SemanticException("Undeclared variable " + ref.name + " at " + ref.line); } ref.symbol = sym; // 绑定引用 ref.type = sym.type; // 传播类型 } // 递归子节点... }6.3 类型检查表:用Map<String, Type>定义基础类型兼容性
避免手写if (left.type == INT && right.type == INT) resultType = INT:
public class TypeChecker { private static final Map<String, Map<String, String>> compatibility = new HashMap<>(); static { Map<String, String> intOps = new HashMap<>(); intOps.put("INT", "INT"); intOps.put("FLOAT", "FLOAT"); compatibility.put("+", intOps); compatibility.put("-", intOps); compatibility.put("*", intOps); } public static String binaryOpResultType(String op, String left, String right) { Map<String, String> rules = compatibility.get(op); if (rules == null) return null; return rules.get(left + "," + right); // 或更健壮的键生成 } }我带过三届编译原理实验课,最常听到的抱怨是:“LL(1)和LR(1)到底有什么区别?考试背表就行,何必手推?” 直到某次有学生用LR(1)分析器跑通了带宏展开的C预处理代码,而LL(1)版本在#define MAX(a,b) ((a)>(b)?(a):(b))上彻底崩溃——他突然明白:LL(1)是给你划好跑道的赛车,LR(1)是允许你拆掉护栏自己铺路的工程车。这个zip包里的每一行Java代码,都不是为了让你交作业,而是为了让你下次看到shift/reduce conflict时,第一反应不是百度,而是打开LR1Generator.java,在第217行加个断点,看那个该死的lookahead到底是怎么混进FOLLOW集合的。希望帮到你。
本文还有配套的精品资源,点击获取