广工编译原理实验:手写词法与LL(1)语法分析器实战指南
简介本资源是广东工业大学编译原理课程配套实验材料面向计算机专业本科生及编译技术初学者聚焦PL/0教学编译器的扩展开发实践。内容围绕词法与语法分析能力提升重点实现保留字ELSE、FOR、TO等新增、不等号符号从#到的规范替换以及ELSE子句支持和、--等复合运算符扩展切实解决教学中对经典编译流程的深度理解与动手改造难题。压缩包共24个文件644KB含核心源码文件pl0.h、PL0.cpp、可执行程序exe、调试信息pdb、ilk、obj、工程配置dsw、dsp、opt及实验报告doc类型分布体现完整VS6.0开发环境下的编译、调试与文档闭环。已有789人学习下载提供可直接编译运行的修改版PL/0工程附带测试代码与结构化目录便于对照实验要求逐项验证语法增强效果是开展编译原理课程设计与实验拓展的实用参考。1. 广工编译原理实验不是抄代码是亲手造出一个能跑通的词法分析器和语法分析器你在广工上编译原理课老师刚布置完实验一用 C 或 Java 实现一个能识别整数、标识符、运算符、分隔符的词法分析器实验二紧接着要求你基于 LL(1) 文法手写递归下降分析器输入ab*c这类表达式输出语法树或错误提示。你翻着王生原《编译原理第3版》第三章对着课件里那张“词法分析→语法分析→语义分析→中间代码生成”的流程图发呆——这图很美但你连第一个 token 都没成功切出来。这不是理论考试是实打实要交可执行程序、要现场演示、要过老师验收的硬核实验。广工编译原理实验的核心诉求非常明确用最小可行代码在本地 Windows 或 Linux 环境下跑通从源码字符串到 token 序列、再到语法结构的完整链路。它不考你背文法而考你能不能把书上的 DFA、FIRST/FOLLOW 集、预测分析表变成一行行能 debug 的代码。适合刚学完前两章、对正则和递归调用有基本手感的大三学生也适合想补全编译器工程直觉的应届生——因为广工实验十三语法分析部分的验收标准从来不是“代码看起来像”而是“输入x (a b) * c;它真能吐出正确的 AST 节点”。2. 用 Java 手写词法分析器从正则匹配到状态机落地的最小闭环广工编译原理实验第一关本质是字符串处理能力的实战检验。很多同学卡在“为什么我的split(\\s)不能识别a中的两个”——因为词法分析不是简单切分而是按最长匹配原则和优先级顺序逐字符推进。我们跳过 Lex/Yacc 这类工具链广工实验明确要求手写直接用 Java 构建一个可调试、可扩展、能应对实验报告中所有测试用例的词法分析器。2.1 定义 Token 类型与保留字表先画清边界再动手广工实验常见测试样例包含int a 10;、if (x 0) { y x * 2; }、甚至带注释的/* hello */ float pi 3.14;。这意味着你的 Token 必须覆盖关键字int,if,while等标识符以字母/下划线开头后接字母数字整数常量123,0xFF不要求但123,-45要支持运算符,-,*,/,,!,等分隔符;,{,},(,)注释单行//和多行/* ... */// Token.java public class Token { public enum Type { KEYWORD, IDENTIFIER, INTEGER, OPERATOR, SEPARATOR, COMMENT, ERROR } public final Type type; public final String value; public final int line; public Token(Type type, String value, int line) { this.type type; this.value value; this.line line; } Override public String toString() { return String.format(Token{type%s, value%s, line%d}, type, value, line); } }提示广工实验报告五词法分析部分常要求输出 token 表格所以toString()必须格式统一方便重定向到文件。不要用System.out.println(new Token(...))直接打印而要封装成printTokens(ListToken)方法后续加颜色或 CSV 导出都留了接口。2.2 基于状态机的手写 Scanner拒绝正则黑匣子每个状态都可控用String.matches()或Pattern处理复杂 token如vs极易出错。广工实验十三验收时老师会故意输入ab看你是否能正确切分为a,,,b而不是a,,,b。这必须靠显式状态机控制。核心状态定义简化版实际需 8–12 个状态状态含义转移条件START初始态遇空格跳过遇字母进 IDENTIFIER遇数字进 NUMBER遇/先看下一个字符判断是否注释IDENTIFIER收集标识符继续读字母/数字/下划线遇非标识符字符停止查保留字表NUMBER收集整数继续读数字遇非数字字符停止注意123abc应切为123abcSLASH/单字符态下一字符是*→ 进 MULTILINE_COMMENT是/→ 进 SINGLELINE_COMMENT否则退回输出/// Scanner.java关键扫描逻辑 public ListToken scan(String source) { ListToken tokens new ArrayList(); int i 0, line 1; while (i source.length()) { char c source.charAt(i); switch (c) { case : case \t: case \r: i; break; // 跳过空白 case \n: line; i; break; case /: if (i 1 source.length()) { char next source.charAt(i 1); if (next *) { // /* ... */ i skipMultilineComment(source, i, line); break; } else if (next /) { // // i skipSingleLineComment(source, i, line); break; } } tokens.add(new Token(Token.Type.OPERATOR, /, line)); i; break; case : case -: case *: case %: case : case !: case : case : String op String.valueOf(c); if (i 1 source.length() isDoubleCharOp(c, source.charAt(i 1))) { op source.charAt(i 1); i 2; } else { i; } tokens.add(new Token(Token.Type.OPERATOR, op, line)); break; case 0: case 1: case 2: case 3: case 4: case 5: case 6: case 7: case 8: case 9: int start i; while (i source.length() Character.isDigit(source.charAt(i))) { i; } tokens.add(new Token(Token.Type.INTEGER, source.substring(start, i), line)); break; case _: case a: case b: ... case z: case A: ... case Z: start i; while (i source.length() (Character.isLetterOrDigit(source.charAt(i)) || source.charAt(i) _)) { i; } String id source.substring(start, i); if (KEYWORDS.contains(id)) { tokens.add(new Token(Token.Type.KEYWORD, id, line)); } else { tokens.add(new Token(Token.Type.IDENTIFIER, id, line)); } break; case ;: case {: case }: case (: case ): tokens.add(new Token(Token.Type.SEPARATOR, String.valueOf(c), line)); i; break; default: tokens.add(new Token(Token.Type.ERROR, Unknown char: c , line)); i; } } return tokens; }参数说明与可调点KEYWORDS是SetString初始化为Set.of(int, if, else, while, return)—— 广工实验要求覆盖至少 5 个关键字isDoubleCharOp()是辅助方法判断c和下一字符是否构成,!,,,,--等双字符运算符skipMultilineComment()和skipSingleLineComment()必须严格处理嵌套和换行否则/* /* nested */ */会翻车line计数必须在\n处更新且注释内换行也要计入——这是广工实验报告五的扣分点。3. 构建 LL(1) 语法分析器从文法推导到预测分析表的手动实现词法分析器产出 token 流后下一步是验证其是否符合语法规则并构建语法树。广工编译原理实验第二阶段常称“实验十三”明确要求基于给定 LL(1) 文法手动构造 FIRST 集、FOLLOW 集、预测分析表并用递归下降或预测分析器实现。别被“LL(1)”吓住——它本质是“从左到右扫描最左推导向前看 1 个符号”。广工常用文法是简化版算术表达式文法含赋值、括号、优先级例如S → ID E ; E → T E E → T E | - T E | ε T → F T T → * F T | / F T | ε F → ( E ) | ID | NUM这个文法能覆盖a b c * d;且满足 LL(1) 条件无左递归、无公共左因子、FIRST/FOLLOW 无交集。3.1 手动计算 FIRST 和 FOLLOW 集血泪经验告诉你哪三步不能跳FIRST 和 FOLLOW 是预测分析表的基石。广工实验报告要求手写计算过程但更重要的是——你写的表必须和代码里的一致否则 parser 一定崩。FIRST 集计算三步法针对上述文法终结符FIRST(ID) {ID},FIRST(NUM) {NUM},FIRST(() {(},FIRST() {}...非终结符FIRST(F)包含FIRST(() ∪ FIRST(ID) ∪ FIRST(NUM) {(, ID, NUM}FIRST(T)包含FIRST(*) ∪ FIRST(/) ∪ {ε}因T → ε递归展开FIRST(E) FIRST() ∪ FIRST(-) ∪ {ε}FIRST(E) FIRST(T)FIRST(T) FIRST(F) {(, ID, NUM}FOLLOW 集计算四要点易错FOLLOW(S)始终含$输入结束符若A → αBβ则FOLLOW(B)包含FIRST(β) \ {ε}若β ⇒* ε则还要加FOLLOW(A)若A → αB则FOLLOW(B)包含FOLLOW(A)关键陷阱FOLLOW(E)必须包含FOLLOW(E)因E → T E而FOLLOW(E)又含FOLLOW(S)因S → ID E ;E后是;故FOLLOW(E) {;}所以FOLLOW(E) {;, $}—— 这个$很多人漏掉导致E → ε触发条件错。注意广工实验十三验收时老师会问“为什么E的 FOLLOW 里有$”答不出就扣分。这不是理论题是验证你是否真懂 FOLLOW 的传播逻辑。3.2 构造预测分析表二维数组比 HashMap 更直观、更易 debug预测分析表M[A][a]表示当栈顶是非终结符A当前输入符号是a时该用哪条产生式。广工实验要求你手填表格但代码里必须精确复现。// ParseTable.java核心数据结构 public class ParseTable { // 行非终结符索引列终结符索引含 $ private final String[][] table; private final MapString, Integer nonTerminals; private final MapString, Integer terminals; public ParseTable() { // 初始化映射nonTerminals {S:0, E:1, E:2, T:3, T:4, F:5} // terminals {ID:0, NUM:1, (:2, ):3, :4, -:5, *:6, /:7, :8, ;:9, $:10} this.table new String[6][11]; // 6 行 × 11 列 initTable(); } private void initTable() { // S → ID E ; table[0][0] S → ID E ;; // S, ID // E → T E table[1][0] E → T E; // E, ID table[1][1] E → T E; // E, NUM table[1][2] E → T E; // E, ( // E → T E table[2][4] E → T E; // E, table[2][5] E → - T E; // E, - table[2][3] E → ε; // E, ) table[2][9] E → ε; // E, ; table[2][10] E → ε; // E, $ // T → F T table[3][0] T → F T; // T, ID table[3][1] T → F T; // T, NUM table[3][2] T → F T; // T, ( // T → * F T table[4][6] T → * F T; // T, * table[4][7] T → / F T; // T, / table[4][3] T → ε; // T, ) table[4][4] T → ε; // T, table[4][5] T → ε; // T, - table[4][9] T → ε; // T, ; table[4][10] T → ε; // T, $ // F → ( E ) table[5][2] F → ( E ); // F, ( // F → ID table[5][0] F → ID; // F, ID // F → NUM table[5][1] F → NUM; // F, NUM } public String getProduction(String nonTerminal, String terminal) { int row nonTerminals.getOrDefault(nonTerminal, -1); int col terminals.getOrDefault(terminal, -1); return (row -1 || col -1) ? null : table[row][col]; } }参数说明table是String[][]不是Map因为广工实验要求你“能一眼看出某行某列填了什么”二维数组天然支持table[i][j]查表终结符列表必须含$输入结束符且getProduction()对未知符号返回null便于后续报错initTable()中每条赋值对应一个预测分析表格单元必须和你手算的 FIRST/FOLLOW 严格一致——比如E行中)列填ε是因为FOLLOW(E)含)且FIRST( T E)不含)所以此处只能选ε。4. 预测分析器实现与 AST 构建让语法树真正可打印、可验证有了词法分析器产出 token 流和预测分析表指导推导路径最后一步是驱动分析栈、匹配输入、构建抽象语法树AST。广工实验十三的终极目标不是“能跑”而是“能输出结构化的语法树”用于后续语义分析或中间代码生成。很多同学止步于accept/reject但老师验收时会要求你printAST(root)输出缩进树。4.1 基于栈的预测分析器模拟教科书算法每步都可断点预测分析算法本质是维护一个栈初始为[$, S]和输入指针循环执行若栈顶是终结符与当前 token 匹配弹出栈顶指针后移若栈顶是非终结符A查表得产生式A → α弹出A压入α的逆序因栈是 LIFO若栈顶$且输入结束则成功否则报错。// Parser.java public class Parser { private final ParseTable table; private final ListToken tokens; private int pos 0; private final StackString stack new Stack(); public Parser(ParseTable table, ListToken tokens) { this.table table; this.tokens tokens; // 初始化栈$ 在底S 在顶 stack.push($); stack.push(S); } public ASTNode parse() throws ParseException { while (!stack.isEmpty()) { String top stack.pop(); if (top.equals($)) { if (pos tokens.size()) return null; // 成功 else throw new ParseException(Unexpected token: tokens.get(pos), pos); } if (isTerminal(top)) { if (pos tokens.size()) { throw new ParseException(Expected top , but input ended, pos); } Token token tokens.get(pos); if (match(top, token)) { pos; } else { throw new ParseException(Expected top , got token.type ( token.value ), pos); } } else { // 非终结符查表 String terminal getTerminalName(tokens.get(pos)); String production table.getProduction(top, terminal); if (production null) { throw new ParseException(No production for top on terminal, pos); } // 解析产生式如 E → T E → [E, T] ListString rhs parseRHS(production); // 压入逆序 Collections.reverse(rhs); stack.addAll(rhs); } } return null; } private boolean match(String expected, Token actual) { if (expected.equals(ID)) return actual.type Token.Type.IDENTIFIER; if (expected.equals(NUM)) return actual.type Token.Type.INTEGER; if (expected.equals(()) return actual.type Token.Type.SEPARATOR actual.value.equals((); if (expected.equals())) return actual.type Token.Type.SEPARATOR actual.value.equals()); if (expected.equals(;)) return actual.type Token.Type.SEPARATOR actual.value.equals(;); if (expected.equals()) return actual.type Token.Type.OPERATOR actual.value.equals(); if (expected.equals()) return actual.type Token.Type.OPERATOR actual.value.equals(); // ... 其他运算符 return false; } private String getTerminalName(Token token) { switch (token.type) { case IDENTIFIER: return ID; case INTEGER: return NUM; case SEPARATOR: return token.value; case OPERATOR: return token.value; default: return ; } } private ListString parseRHS(String production) { // E → T E → [T, E] String[] parts production.split(→); String rhs parts[1].trim(); return Arrays.stream(rhs.split(\\s)) .filter(s - !s.isEmpty() !s.equals(ε)) .collect(Collectors.toList()); } }关键设计点match()方法将 token 类型/值映射到文法符号如Token.Type.IDENTIFIER→ID这是连接词法层和语法层的桥梁parseRHS()处理ε空产生式时直接过滤不压栈stack存储字符串而非 AST 节点先保证分析过程正确再叠加 AST 构建——这是广工实验的推荐路径避免过早耦合。4.2 在分析过程中同步构建 AST每个产生式对应一个节点类型广工实验报告要求输出 AST意味着你不能只做 accept/reject。最佳实践是在parseRHS()执行时根据产生式创建对应节点。例如S → ID E ;→ 创建AssignNode(left: IdentifierNode, right: ExprNode)E → T E→ 创建BinaryOpNode(op: , left: T, right: E)F → ID→ 创建IdentifierNode(name)// ASTNode.java基类 abstract class ASTNode {} class AssignNode extends ASTNode { final IdentifierNode left; final ASTNode right; AssignNode(IdentifierNode left, ASTNode right) { this.left left; this.right right; } } class BinaryOpNode extends ASTNode { final String op; final ASTNode left, right; BinaryOpNode(String op, ASTNode left, ASTNode right) { this.op op; this.left left; this.right right; } } class IdentifierNode extends ASTNode { final String name; IdentifierNode(String name) { this.name name; } } // 修改 Parser.parse()在压栈时传入 builder lambda public ASTNode parse() throws ParseException { // ... 同上但压栈时绑定 builder stack.push(S); // 初始 return parseWithBuilder(S); } private ASTNode parseWithBuilder(String nonTerminal) throws ParseException { String terminal getTerminalName(tokens.get(pos)); String production table.getProduction(nonTerminal, terminal); if (production null) throw new ParseException(No production..., pos); switch (production) { case S → ID E ;: Token idToken tokens.get(pos); IdentifierNode left new IdentifierNode(idToken.value); expect(Token.Type.OPERATOR, ); // 消费 ASTNode right parseWithBuilder(E); expect(Token.Type.SEPARATOR, ;); // 消费 ; return new AssignNode(left, right); case E → T E: ASTNode t parseWithBuilder(T); ASTNode ePrime parseWithBuilder(E); // E 返回可能是 BinaryOpNode 或 nullε需合并 return mergeEPrime(t, ePrime); // ... 其他 case } return null; }玄学提示广工实验十三的“后悔药”是——先用纯字符串分析跑通再加 AST 构建。很多人一上来就边分析边建树结果E → ε时不知道返回null还是EmptyNode最终树结构错乱。我一般会先确保parse()返回true/false再在此基础上插入节点创建逻辑。5. 广工编译原理实验避坑指南验收翻车的 4 个高频雷区广工编译原理实验的“验收即答辩”老师会现场给你一段新代码如while (x 10) { x x 1; }让你的程序跑一遍并解释输出。以下是你大概率踩过的坑按现象→原因→解法列清全是血泪经验。5.1 现象词法分析器把a切成a,,而不是a,原因运算符匹配逻辑未按“最长匹配”实现。你的代码先看到就输出没继续看下一个字符是否也是。解决在case 分支里必须检查i1 length source.charAt(i1)成立则i2并输出否则i输出。同理处理--,,!,,。广工实验报告五明确要求支持和--。5.2 现象语法分析器对a b c * d;报错说*不在FOLLOW(T)里原因FOLLOW(T)计算遗漏。T出现在T → F T的右侧且T后无符号所以FOLLOW(T)必须包含FOLLOW(T)而T出现在E → T E中E可推出ε所以FOLLOW(T)包含FOLLOW(E)E出现在S → ID E ;中E后是;故FOLLOW(E) {;}但E自身的FOLLOW还含$而T的FOLLOW也必须含$因E → ε时T可能留在栈顶等待$。解决手算FOLLOW时对每个A → αBβ若β ⇒* ε则FOLLOW(B)加FOLLOW(A)对A → αBFOLLOW(B)加FOLLOW(A)最后检查所有FOLLOW是否含$——只要文法开始符号S的FOLLOW含$所有能推导到句末的非终结符的FOLLOW都可能含$。5.3 现象预测分析表M[E][*]为空但*属于FIRST(* F T)原因E的产生式是E → T E | - T E | εFIRST( T E) {}FIRST(- T E) {-}FIRST(ε) {ε}所以E的FIRST集不含*而*属于FOLLOW(E)因此M[E][*]应填ε。你误以为只有FIRST集里的符号才能触发ε产生式忽略了FOLLOW的作用。解决预测分析表规则是对A → α若a ∈ FIRST(α)则M[A][a] A → α若ε ∈ FIRST(α)则对所有b ∈ FOLLOW(A)M[A][b] A → α。所以E → ε这条必须填到FOLLOW(E)的所有符号列包括*,/,),;,$。5.4 现象AST 打印时出现null节点或a b c * d的树结构扁平化没体现*优先级原因AST 构建未反映文法的结合性。你的E → T E和T → F T文法本意是让E和T处理右结合的/-和*//但建树时若简单new BinaryOpNode(, t, ePrime)而ePrime是 T E的结果就会变成左结合。解决E和T的递归调用必须返回“右子树”并在父节点中作为整体参与运算。例如mergeEPrime(t, ePrime)函数应若ePrime是BinaryOpNode(, t2, ePrime2)则返回new BinaryOpNode(, new BinaryOpNode(, t, t2), ePrime2)—— 但这太复杂。广工实验接受的务实解法是AST 节点不存运算符优先级而由遍历顺序体现打印时用中序遍历*节点深度大于节点即可。重点是树结构能区分a(b*c)和(ab)*c不必强求*节点一定在节点下方。6. 验证与调优用广工真实实验样例反向校准你的分析器写完代码只是起点广工编译原理实验的“及格线”是你的程序能通过老师给的 5 个标准测试样例且每个 token、每个语法树节点都能人工追溯到文法规则。我习惯用三步法闭环验证比单纯跑测试用例更可靠。6.1 构建最小可验证样例集覆盖所有文法分支不要依赖网上搜的“编译原理实验答案”广工自己的实验指导书尤其“实验十三”附有 5 个必测样例我把它拆解成验证矩阵样例输入期望 token 数关键语法结构验证点1a 10;5Assign → ID NUM ;词法能否识别和;语法能否匹配S → ID E ;2b a c * d;11E和T嵌套*是否触发T → * F T是否触发E → T E3x (y z) * 2;13括号改变优先级F → ( E )是否被调用E内部是否递归分析4if (a 0) { a a - 1; }17关键字、复合语句KEYWORD类型是否正确{}是否作为SEPARATOR被捕获5/* test */ int p q;8注释不产 token注释处理/* ... */是否完全跳过不生成任何 token提示广工实验报告五要求你手写“样例 3 的分析过程”所以你的程序必须能输出每一步的栈状态和输入指针位置。我在Parser类里加了一个debugMode开关开启时打印stack.toString() | tokens.subList(pos, tokens.size())一行一行对照文法推导。6.2 用 AST 可视化确认结构正确性不靠眼力靠缩进广工老师不看你的System.out.println(root)而是要看树形缩进是否合理。我写了一个极简的printAST(ASTNode node, int indent)public static void printAST(ASTNode node, int indent) { String prefix .repeat(indent); if (node instanceof AssignNode n) { System.out.println(prefix Assign:); System.out.println(prefix left:); printAST(n.left, indent 2); System.out.println(prefix right:); printAST(n.right, indent 2); } else if (node instanceof BinaryOpNode n) { System.out.println(prefix BinaryOp( n.op ):); System.out.println(prefix left:); printAST(n.left, indent 2); System.out.println(prefix right:); printAST(n.right, indent 2); } else if (node instanceof IdentifierNode n) { System.out.println(prefix Identifier( n.name )); } }运行a b c * d;应输出Assign: left: Identifier(a) right: BinaryOp(): left: Identifier(b) right: BinaryOp(*): left: Identifier(c) right: Identifier(d)如果*节点和节点在同一层说明文法或建树逻辑错了——*必须比深一层因为T在E内部。6.3 参数调优清单让代码适配广工实验的“隐形要求”广工编译原理实验不是纯学术它有明确的工程约束。以下是我在三次带实验课后总结的调优项直接抄进你的代码参数默认值广工建议值为什么KEYWORDS集{int, if, else, while, return}必须含{int, if, else, while, for, return}实验十三样例含for循环注释处理仅跳过//和/*...*//*...*/必须支持嵌套/* /* inner */ outer */广工样例 4 有嵌套注释整数范围int改用long存储value字段防止 1本文还有配套的精品资源点击获取