编译原理实验核心:词法分析、语法分析与符号表实战指南
简介电子科技大学编译原理实验完整代码包面向高校计算机专业学生及编译原理自学者用于深入理解词法分析、语法分析、语义分析等核心概念解决理论学习与实验实践脱节的问题也是课程设计或期末实验的极佳参考。压缩包内含21个文件总大小约203KB主要包含cpp、h等源代码文件以及docx运行说明文档、exe可执行程序和工程配置文件其中说明文档详细介绍了环境配置与命令行参数整体结构清晰便于直接编译运行。已有2112人学习下载资源热度较高。代码实际实现了词法分析器与语法分析器涵盖从源码分词、token流解析到抽象语法树构建的完整流程同时包含输入处理、语义分析可选等模块的组织方式可通过运行说明文档复现实验环境掌握正则表达式或有限状态自动机在词法分析中的应用理解LL/LR解析策略的工程实现并参考调试排错思路为后续编译器设计与开发打下坚实基础。1. 编译原理实验代码真正要交的不是能跑而是能解释为什么能跑编译原理这门课实验代码是最容易被误判的环节。很多同学把“编译通过输出正确”当作完成结果答辩时被问一句“这个状态机的终止态怎么处理的”就卡住。电子科技大学的编译原理实验核心目标是让每个人亲手实现一遍从词法分析到语法分析的完整链路而不是背教材上的定义。如果你准备选这门课、或者正在补做实验这篇笔记会用一种更接近真实工程的写法把每步实验的原理、代码结构和参数取舍讲清楚让你拿到题目后能直接照着搭出骨架。踩过坑的人也明白真正拉开差距的永远不是能跑通而是跑不通的时候你怎么排查、怎么改。2. 词法分析实验从正则到状态机的落地写法2.1 手写词法分析器的核心循环先理解“读字符”和“吞字符”的边界词法分析器的工作看起来简单读入源码字符串按规则切出一个个 token。但“按规则切”这四个字背后是你要自己决定“什么时候读下一个字符”和“什么时候停下来”。我第一次写的时候就把if和ifx搞混过——问题就出在关键字识别后没有检查下一个字符是不是字母或数字。常见做法是用一个两重循环外层循环跳过空白和注释内层循环匹配一个 token。每次进入内层循环时用一个可变长缓冲区累积字符直到当前字符不满足当前 token 的字符集约束。下面这段代码是 Java 版本的最小骨架适合做实验的第一个模块public class Tokenizer { private final String src; private int pos 0; private int line 1; public Tokenizer(String src) { this.src src; } public Token next() { skipWhitespaceAndComments(); if (pos src.length()) return new Token(TokenType.EOF, , line, pos); char c src.charAt(pos); if (Character.isDigit(c)) return readNumber(); if (Character.isLetter(c) || c _) return readIdentifierOrKeyword(); return readOperator(); } private Token readNumber() { int start pos; while (pos src.length() Character.isDigit(src.charAt(pos))) pos; return new Token(TokenType.NUMBER, src.substring(start, pos), line, start); } private Token readIdentifierOrKeyword() { int start pos; while (pos src.length() (Character.isLetterOrDigit(src.charAt(pos)) || src.charAt(pos) _)) pos; String word src.substring(start, pos); TokenType type keywordTable.get(word); return new Token(type null ? TokenType.IDENT : type, word, line, start); } private void skipWhitespaceAndComments() { while (pos src.length()) { char c src.charAt(pos); if (c || c \t || c \r || c \n) { if (c \n) line; pos; } else if (c / pos 1 src.length() src.charAt(pos 1) /) { while (pos src.length() src.charAt(pos) ! \n) pos; } else { break; } } } }逻辑说明next()是主入口先跳过空白和行注释再根据首字符分发到不同的读取方法。readNumber()只认数字字符遇到非数字就停所以123abc会被切成123和abc两个 token——这是标准的词法行为不是 bug。参数说明keywordTable是一个从字符串到TokenType的哈希表实验里一般放在构造方法里初始化。判断关键字必须在完整读出一个单词后再查表而不是在读第一个字母时就判断否则if和ifx会混在一起。行号line的信息必须在 token 生成时保存因为后续语法报错要用它定位。2.2 正则转自动机还是手写状态机三个选择依据实验题如果允许用工具常见的做法是 flex 或 JFlex 生成词法分析器如果要求手写你就要自己处理正则表达式到自动机的转换。我见过太多同学一上来就选 flex理由是“省事”结果环境装不好、生成代码看不懂最后连改个规则都要重新生成。实际实验里三个选择依据可以参考。第一看实验是否允许第三方工具。有的课程明确要求“不得使用 flex/javacc”这基本就是让你手写或自己实现转换。第二看你要支持的 token 种类数量。如果只需要 20 个左右的关键字、标识符、数字和操作符手写状态机比引入 flex 简单得多——你可以把所有逻辑装进一个 switch 里出错时任一行代码都知道在哪。第三看后续实验要不要你展示“自动机构造过程”。如果实验报告要求画出 DFA 并解释最小化过程那么手写状态机能让你对终止态、转移函数、表驱动有真正体感而 flex 生成的代码会把这些细节全部隐藏掉。如果你选择手写 DFA 表驱动我一般会把状态转移表做成二维数组int[][] dfa { // 状态0: 初始状态1: 数字中状态2: 数字后 { 1, -1 }, // 状态0: 数字 - 1其他 - -1 { 1, 2 }, // 状态1: 数字 - 1其他 - 2 { -1, 2 } // 状态2: 数字 - -1其他 - 2非法不是终止态 };这个例子过于简化但能说明表驱动的核心参数行是当前状态列是字符类别单元格是下一个状态-1表示错误。终止态集合要单独存一个 boolean 数组。实验里最容易漏掉的是“一个终止态同时也可以是中间态”比如识别3.14时读到3是终止态继续读.和14后仍是终止态但中间不能停。用表驱动时任何一列上的-1都会触发错误回退你要自己决定是报错还是回退到上一个终止态重新切分。2.3 词法错误恢复实验报告里最容易被追问的设计决策词法错误恢复是很多人的盲区。你不处理它遇到非法字符就直接崩溃这在在线评测系统里可能被算作运行时错误。常见做法是三种跳过当前字符继续、把非法字符包装成一个错误 token、或上报后终止。我一般推荐“跳过计数”因为这样能一次性暴露多个错误。具体实现是在next()的默认分支里加一段char bad src.charAt(pos); pos; errorCount; System.err.println(行 line : 非法字符 bad ); return null; // 让外层循环跳过注意这里返回null不是好设计——调用方要处理空指针。更稳的做法是返回一个ERROR类型的 token让语法分析阶段自己决定是否打错误信息。参数上要控制错误数量上限比如累计 20 个错误就停掉避免一个源码文件产生几千条重复报错把终端刷爆。3. 语法分析实验递归下降与 LR 的取舍3.1 递归下降为什么是实验课的主力语法分析的实验选项通常是两种手写递归下降分析器或使用 yacc/JavaCC 生成 LALR 分析器。除非题目指定要写 LR 分析表否则递归下降基本是优先选择——原因很简单代码和文法一一对应出错时能直接定位到文法的某一条产生式不需要查一张几百行状态的转移表。递归下降的核心是每个非终结符写一个函数函数体里按照产生式右侧的符号顺序依次调用终结符匹配函数或其他非终结符函数。以表达式文法为例标准写法是分成四层表达式、项、因子、主表达式。下面这个 Java 片段展示的是最经典的expr - term ((|-) term)*public class Parser { private Lexer lexer; private Token lookahead; public ExprNode parseExpr() { ExprNode left parseTerm(); while (lookahead.type TokenType.PLUS || lookahead.type TokenType.MINUS) { Token op lookahead; advance(); ExprNode right parseTerm(); left new BinaryOpNode(op, left, right); } return left; } private ExprNode parseTerm() { ExprNode left parseFactor(); while (lookahead.type TokenType.MUL || lookahead.type TokenType.DIV) { Token op lookahead; advance(); ExprNode right parseFactor(); left new BinaryOpNode(op, left, right); } return left; } }逻辑说明parseExpr先解析一个项然后循环处理加减号每次遇到运算符就把左右操作数包装成一个新的BinaryOpNode。这里的关键是返回的left是累积构建的实现了左结合。如果把while写成ifa - b - c会只解析出a - b然后发现 c 前面没有运算符而报错。参数说明lookahead是前看 token在构造方法里先读一个。每次匹配消耗掉一个 token 后必须调用advance()读取下一个。递归下降最容易出的问题是“函数没有消耗 token 就递归调用自己”这会导致死循环——比如factor - factor * factor这种左递归文法直接照搬成两个函数互相调用每次进入parseFactor时lookahead没变无限递归直到栈溢出。3.2 消除左递归和提取左因子遇到回溯就改文法实验题目给的文法往往是教科书版本直接拿来手写递归下降会踩坑。最典型的是左递归文法expr - expr term | term如果照抄成函数第一步就递归调用parseExpr永远读不进字符。解决办法是先消除左递归再提取左因子。消除左递归的标准变换是把文法改写成expr - term restrest - term rest | ε。这是形式化的做法但代码不是照搬这个文法而是用 while 循环实现 rest 的迭代——上面的代码就是这样。提取左因子要处理的是这种产生式stmt - if (expr) stmt | if (expr) stmt else stmt。两个分支的前缀完全一样递归下降会遇到前看一个 token 无法决定走哪个分支的情况。解法是合并前缀写成stmt - if (expr) stmt else_part然后else_part自己决定是else stmt还是空。实验里我建议随手写一个工具脚本把文法文件用空格缩进展示成树形很多重复前缀一眼就能看出来。3.3 AST 节点设计带位置信息是后续实验的后悔药语法分析的结果如果是直接输出语法树到 JSON节点设计就决定了后面语义分析的流畅度。很多同学把Token对象直接塞进 AST 节点答辩时被问“你这棵树的节点类型有几类”就卡住。常见做法是设计一个基类ASTNode子类只做几件事表达式节点、语句节点、声明节点。每个节点必须带行号和列号因为后续类型检查和中间代码报错都要引用源码位置。public abstract class ASTNode { public final int line, col; public ASTNode(int line, int col) { this.line line; this.col col; } } public class BinaryOpNode extends ASTNode { public final Token op; public final ExprNode left, right; public BinaryOpNode(Token op, ExprNode left, ExprNode right) { super(op.line, op.col); this.op op; this.left left; this.right right; } } public class NumberNode extends ASTNode { public final int value; public NumberNode(Token numberToken) { super(numberToken.line, numberToken.col); this.value Integer.parseInt(numberToken.text); } }参数说明line和col直接从 token 里拷贝不进构造函数另行传递。这能避免“节点位置是 0 0”这种尴尬——我在批改实验时见过太多节点没有位置信息错误定位全失效。Integer.parseInt在一个完整的词法分析器里不会炸因为在词法阶段NUMBERtoken 的文本已经被确认是纯数字。4. 语义分析与符号表实验里被低估的第三关4.1 符号表的作用域处理链式表 vs 哈希表很多学校的编译原理实验只要求做到语法树输出但电子科技大学的实验体系里语义分析几乎是必经的关卡。符号表是这部分的核心数据结构。作用域有两种处理方式链式表或者“哈希表作用域栈”。链式表实现简单每一层作用域是一个链表节点insert 当头插lookup 从头往后遍历直到找到为止。哈希表作用域栈的好处是查找快但处理嵌套作用域时要在进入/退出作用域时做 add/remove十分容易漏掉 delete。实验报告如果要展示符号表的插入和查找过程链式表更直观如果要处理几百个变量的源码哈希表也不会明显更差。我一般推荐链式表理由不是性能而是调试时机早——你能在 IDE 的变量面板里直接看整个链的结构lookup 走几步能找到目标一目了然。public class SymbolTable { private MapString, Symbol table; private SymbolTable parent; public SymbolTable(SymbolTable parent) { this.parent parent; this.table new HashMap(); } public void define(String name, Symbol sym) { table.put(name, sym); } public Symbol lookup(String name) { SymbolTable scope this; while (scope ! null) { Symbol sym scope.table.get(name); if (sym ! null) return sym; scope scope.parent; } return null; } }逻辑说明define只往当前作用域放lookup顺着 parent 链向上找一旦找到就返回。注意define不检查重复定义重复定义要放在调用方判断在lookup返回 null 时才能define否则报“变量已定义”错误。参数说明Symbol类至少要保存类型、声明行号、以及后续生成中间代码时需要的偏移量。实验里常见的坑是作用域链忘记在退出块时恢复 parent——如果你在进入函数时创建新符号表、退出函数时没有切回上一张表后续的变量全都会找不到定义。4.2 类型检查的落点表达式和赋值语句是重灾区类型检查要覆盖的核心是表达式树和赋值语句。实验里一个常见的设定是只支持 int 和 bool 两种类型禁止任何隐式转换。那检查逻辑就变得很直白——每次构建BinaryOpNode时先检查左右操作数的类型是否相同再根据运算符确定结果类型。加减乘除要求操作数是 int比较运算要求操作数是同类型、结果是 bool逻辑与或要求操作数是 bool。最容易翻车的是if (1)这样的表达式。语法分析阶段是合法的到类型检查就要报错。做法是在if语句的检查里调用一个expectType(expr, T_BOOL)方法void checkCondition(ExprNode cond) { Type t inferType(cond); if (t ! Type.BOOL) { error(cond.line, cond.col, 条件表达式必须是 bool实际是 t); } }这里inferType会递归地推算表达式类型NumberNode返回T_INTBinaryOpNode遍历子节点、递归推断并检查。需要注意的是inferType必须对整棵树去重调用不能在检查时调一次、在生成代码时又调一次否则错误信息会重复出现。4.3 生成中间代码前的属性传递把类型信息绑定到节点如果实验要做到中间代码生成这一步AST 节点上必须缓存推断出的类型而不是每次现算。否则到了遍历 AST 生成三地址码时遇到一个BinaryOpNode还要重新递归一遍它的左右子树才能知道类型代码量翻倍且容易算错。常见做法是在ExprNode上加一个type字段第一次inferType时填充后续直接读取。这个字段在生成中间代码时决定选择ADD_I还是ADD_B这样的指令码——如果你的中间代码有类型区分的话。参数上要注意类型是在语义分析阶段一次性计算完成的要避免在代码生成阶段对同一棵子树做两次遍历影响性能倒是小事后续调试时的堆栈信息会特别混乱。5. 编译原理实验避坑指南五个高频翻车现场5.1 词法分析吃掉了最后一个字符现象源码最后一行没有换行结果最后一个标识符或数字莫名其妙的丢了一半比如count变成coun。 原因跳过空白和注释的循环里写了while (c ! \n)遇到 EOF 时charAt(pos)越界或返回了特殊值循环提前退出。 解决在skipWhitespaceAndComments的第一个 while 条件里加上pos src.length()判断在读取标识符和数字的循环里同样检查边界不要依赖哨兵字符\0。5.2 递归下降分析器死循环现象输入一个a b * c的表达式程序直接栈溢出。 原因文法里有左递归parseFactor里调用了parseUnary而parseUnary又回头调parseFactor但全程没有消耗掉一个 token。 解决先画调用关系图确认每个递归调用前至少消费了一个 token。技巧是在每个语法分析函数的第一行打印lookahead的内容死循环时能看到它永远停在同一个 token 上。修复方式是消除左递归把factor - factor * term改成term - factor (* factor)*再照写代码。5.3 AST 节点没有保存位置信息现象语义分析报错时打印出来的行列号全是 0。 原因构造NumberNode时传的是0, 0而不是token.line, token.col。常见于把“节点内部维护的源码位置”理解成“语法树生成时间”。 解决在ASTNode构造函数里强制从 token 拷贝位置信息。如果你用的是我上面的设计BinaryOpNode的位置就是运算符 token 的位置不是左操作数的位置——这个细节在答辩时经常被问最好统一成“节点的位置是它文本起点在源码中的位置”然后在报告里写清楚。5.4 符号表作用域泄露现象函数里的局部变量在函数外还能查到或者两次调用同一个函数时第一次的变量残留。 原因实现符号表时用了同一个HashMap进函数时没有创建子作用域退出时也没有切回父作用域。 解决用作用域栈每次进入函数体push一个新的SymbolTable退出时pop。在lookup时用当前栈顶而不是用整个函数体的符号表去查。调试方法是在define里打印当前作用域的嵌套深度出现深度值跳跃就说明有人忘了 pop。5.5 测试用例只写合法输入错误恢复路径全是坑现象代码生成阶段的测试全通过但一加入错误输入比如int 123a 0;词法分析器直接抛StringIndexOutOfBoundsException。 原因所有测试都是正向用例错误分支从来没有被执行过。 解决每个实验模块至少准备三个维度的测试用例合法输入、非法字符、边界输入空文件、只有空白、只有注释、超长标识符。把测试文件放在项目根目录的tests/下每次改完代码跑一遍全部用例而不是只跑刚写的那个。这一点我现在已经养成习惯救过很多次命。6. 把实验代码变成可复用资产验证驱动的三个进阶技巧实验交完不等于完事。如果你后面要做编译原理相关的课程设计、保研面试或者开源项目这份代码值得按工程标准打磨一下。我自己的经验是第一步建一个测试驱动脚本用一组.c文件当作输入跑完编译流程后和预期输出 diff。这一步成本极低却能把回归风险压到最低。第二步给 AST 加一个 JSON 序列化方法调试时直接prettyPrint()看树形结构比在调试器里逐层展开对象快得多。第三步把符号表的打印输出成带缩进的表格一眼看出作用域嵌套关系是不是正确。我吃过最大的亏是第一遍实验时没有做回归测试词法分析器改了一个操作符的匹配优先级结果把一个正确的赋值语句切成了两个 token当时花了整整一个晚上定位。后来养成了一个习惯任何修改之后永远先跑一遍已有的全部测试用例再提交。你要是一开始就把这套验证流程搭起来后面每一步实验都会省下成倍的时间。希望帮到你。本文还有配套的精品资源点击获取