ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

编译原理课设实战:从词法分析到四元式的小型编译器指南

编译原理课设实战:从词法分析到四元式的小型编译器指南 简介编译原理课程设计资料包涵盖词法分析、LL(1)语法分析、LR(0)与SLR(1)语法分析、四元式生成以及汇编代码生成等核心实验模块同时附带小型编译器和课程设计报告。资源共有14个文件以cpp、c源程序为主辅以h头文件、txt测试文法和doc报告文档整个压缩包约557KB内容组织清晰。目前已有2347人学习下载适合正在完成编译原理课设的本科生也适合需要对照经典语法分析方法复习备考的读者。通过学习这套资料可以获取一套完整可运行的词法、语法分析代码掌握LL(1)、LR(0)、SLR(1)文法表构建与解析流程并能参考四元式及汇编代码生成部分理解编译前端到后端的衔接实验报告则能提供设计思路和关键步骤参考。1. 编译原理课设资源拆解词法、语法到小型编译器的一条龙方案编译原理是计算机专业里少有的「理论课上得明白、课设写不出来」的课程。DFA 最小化、LR 分析这些名词考卷上是推理题到课程设计要交代码就变成实打实的工程问题。这份资源把整个课设拆成三层词法分析生成 Token 流、语法分析构建语法树、语义加工输出四元式外加一份可直接对照改写的实验报告。它适合两类人时间只剩一两周、要先跑通代码再改成自己版本的学生工作后想补编译底层知识的开发者拿这份代码当骨架看清「一条声明语句到中间指令」的完整链路。先说结论这份资源最值钱的不是「能编译过」而是模块边界。把词法层、语法层、四元式层的接口理顺才算真正拿到这门课的核心。2. 词法分析模块正则到状态机的落地与 Token 流转词法分析是编译器的第一道门槛任务一句话讲完把源程序字符流切成一个个有意义的单词附上类型和位置信息交给语法分析器。课设里至少三分之一的分压在这个模块因为它是可视化程度最高、最容易演示给老师看成果的部分。资源包里的词法分析器是用 Java 写的正好对应「java 编译原理课设」这条最常见的检索路径我下面拆的实现思路和它保持一致。2.1 先定 Token 规范类型枚举和关键字表动手写扫描循环之前先把 Token 的类型定下来。我见过不少同学上来就写if (ch )这种硬编码每个运算符一个分支代码膨胀到没法维护。常见的做法是先定义枚举把词素分类再给每种类型绑定识别规则。public enum TokenType { KEYWORD, // 关键字if else while int return 等 IDENTIFIER, // 标识符变量名、函数名 CONSTANT, // 常量整数、浮点数、字符常量 OPERATOR, // 运算符 - * / ! DELIMITER, // 界符; ( ) { } , EOF // 文件结束标记 }类型枚举定了之后词法分析器的输出就有了统一形式。每个 Token 至少携带三个字段类型、词素文本、行号和列号。行号列号不是可有可无的——语法分析报错时如果只给「syntax error」不带位置老师演示时第一句就会问「错误在哪一行」。关键字表的处理有个经典顺序问题到底是先识别成标识符再查表还是直接匹配关键字正确做法是先按标识符规则读完整串再查关键字表。因为关键字本质上是「被保留的标识符」如果你在识别过程中遇到i就停下来判断是不是if那int和init都会被拆散。资源包这段代码写得很标准private static final MapString, TokenType KEYWORDS new HashMap(); static { KEYWORDS.put(if, TokenType.KEYWORD); KEYWORDS.put(else, TokenType.KEYWORD); KEYWORDS.put(while, TokenType.KEYWORD); KEYWORDS.put(int, TokenType.KEYWORD); KEYWORDS.put(return, TokenType.KEYWORD); }查表的时间复杂度是 O(1)换成TreeMap或二分查找也行但对课设规模的文法HashMap 足够表里没匹配到的串一律按 IDENTIFIER 处理。2.2 主扫描循环一个状态机怎么吃下所有词素主循环是一个大while每次消费一个字符并推进状态。最朴素的实现是手工判断字符类别字母开头的走标识符路径数字开头的走常量路径运算符和界符各自匹配最长前缀空白和换行直接跳过。public ListToken tokenize(String source) { ListToken tokens new ArrayList(); int pos 0; int line 1; while (pos source.length()) { char ch source.charAt(pos); if (isWhitespace(ch)) { if (ch \n) line; pos; continue; } if (isLetter(ch) || ch _) { int start pos; while (pos source.length() isLetterOrDigit(source.charAt(pos))) pos; String word source.substring(start, pos); TokenType type KEYWORDS.containsKey(word) ? TokenType.KEYWORD : TokenType.IDENTIFIER; tokens.add(new Token(type, word, line, start)); } else if (isDigit(ch)) { int start pos; while (pos source.length() isDigit(source.charAt(pos))) pos; tokens.add(new Token(TokenType.CONSTANT, source.substring(start, pos), line, start)); } else { // 运算符和界符走最长匹配见下面的说明 } } tokens.add(new Token(TokenType.EOF, , line, pos)); return tokens; }这段代码的核心逻辑是「读一个完整的词素再判定类型」。注意标识符分支里内部while结束时pos已经停在第一个非字母数字字符上外层循环会从正确的位置继续消费下一个词素所以不需要额外的指针回退。参数上start记录词素起点line跨行时递增这两个信息就是后面报错定位的依据。这里有个容易被忽略的细节运算符的最长匹配。输入是时不能读到就返回赋值号要再向后看一眼把整体作为关系运算符。我一般先写一个运算符表每个运算符配好长度扫描时先尝试长度为 2 的运算符匹配不到再回退到长度为 1 的。这段代码和完整运算符表的实现都被放在资源包的 Lexer 类里了。2.3 状态转移表 vs 硬编码什么样的设计算有深度课设答辩时老师常问的一句是「你的词法分析器用自动机实现的还是直接写的代码」。如果只交硬编码的扫描循环老师可能觉得深度不够。状态转移表方案的核心是把字符类别抽象成几类字母、数字、运算符、其他再定义状态集合和转移矩阵。状态字母数字运算符其他起始 S0S1S2S3报错标识符 S1S1S1终态终态数字 S2报错S2终态终态运算符 S3终态终态终态终态把这个表实现成二维数组后扫描循环变得很短查表、推进状态、判断当前状态是否终态、终态时回退一格取出词素。这种设计的好处是后期加新词法规则不需要改代码结构只改表。如果你在报告里附一张状态转移图再解释「为什么标识符和关键字共用 S1 状态」答辩会明显加分。资源包里同时给了这两种实现你可以对比着看。3. 语法分析模块递归下降和 LL(1) 预测分析的实战取舍词法分析把字符流变成 Token 流语法分析要把 Token 流按文法规则组织成树。课设最常见的语法范围是变量声明、赋值语句、算术表达式、if-else 分支、while 循环。这份资源用的是递归下降加 LL(1) 预测分析的混合方案——函数里用递归下降保证可读性遇到分支冲突时用预测分析表的结论做决策依据。3.1 文法的设计与改写先消除左递归写语法分析器之前先把文法写在纸上。典型的小型语言文法长这样program → stmt_list stmt_list → stmt stmt_list | ε stmt → assign | if_stmt | while_stmt assign → id expr ; if_stmt → if ( expr ) stmt | if ( expr ) stmt else stmt while_stmt → while ( expr ) stmt expr → expr term | expr - term | term term → term * factor | term / factor | factor factor → ( expr ) | id | num这个文法看起来自然但直接拿去做递归下降会原地爆炸——expr → expr term是左递归递归下降函数会无限调用自己栈溢出是必然的。所以第一步必须消除左递归把expr → expr term | term改写成expr → term expr、expr → term expr | - term expr | ε。更工程化的写法是直接改用 EBNF用{}表示重复。这样语法分析函数里用一个while循环就能处理连续加法// expr → term { (|-) term } private ASTNode expr() { ASTNode left term(); while (isOperator() || isOperator(-)) { String op peek().getText(); nextToken(); ASTNode right term(); left new BinaryOpNode(op, left, right); } return left; }这个函数体现了递归下降的核心每个非终结符对应一个方法方法内部按产生式右侧的顺序逐个匹配终结符或调用其他非终结符的方法。while循环处理的就是 EBNF 里的{}——零个或多个。EBNF 加递归下降的组合代码量更少报错位置也更直观我建议课设直接用这种写法。提示消除左递归和提取左公因子是两件事。前者解决「无限递归」后者解决「同一个产生式在预测分析表里填两行」。写代码前先花十分钟把文法改写成 EBNF贴在源文件头部当注释函数结构直接照着注释抄能省一整晚的调试时间。3.2 First 集与 Follow 集预测分析表的计算要点如果课设要求提到 LL(1) 预测分析表就绕不开 First 集和 Follow 集。First 集的定义是「一个非终结符能推导出的所有终结符的首符号集合」Follow 集是「在所有句型中紧跟该非终结符之后的终结符集合」。手工计算时注意三条规则First 集里含 ε 的记号要特别留意ε 直接影响预测分析表里空产生式的填写Follow 集的计算从开始符号起步开始符号的 Follow 集里一定有$结束符产生式A → αBβ里如果 β 的 First 集含 ε那么 Follow(A) 要并进 Follow(B)。实现上建议写一个通用不动点算法// 计算 First 集反复迭代直到所有集合不再变化 boolean changed true; while (changed) { changed false; for (Production p : productions) { SetString first firstSet(p.getLeft()); for (Symbol s : p.getRight()) { int before first.size(); if (s.isTerminal()) { first.add(s.getName()); } else { first.addAll(firstSet(s.getName())); } boolean containsEpsilon firstSet(s.getName()).contains(ε); if (!containsEpsilon) break; // 含 ε 才继续传播 if (first.size() before) changed true; } } }这个算法的思想是「闭包传播」每次遍历所有产生式把右边非终结符的 First 集传播到左边直到所有集合稳定。终止条件!containsEpsilon很关键——只有当前符号能推导出 ε才需要继续看下一个符号如果不含 εFirst 集的传播在这里就结束了。漏掉这一步First 集会偏大预测分析表也会错。资源包里对 First 集、Follow 集和 ε 的处理都写了注释对照着看很容易理解。3.3 预测分析表的构建与错误恢复预测分析表是二维表行是终结符含$列是非终结符。填表规则一句话对产生式A → α如果a ∈ First(α)就在M[A][a]填这个产生式如果 α 能推导出 ε那么对b ∈ Follow(A)也在M[A][b]填这个产生式。同一个格子被填两条产生式文法就不是 LL(1)。实际代码里我更喜欢把预测分析表当「决策字典」用不打印整张二维表而是遇到if、while、标识符等 Token 时根据下一个 Token 的类型决定走哪个分支。判断逻辑和预测分析表保持一致代码更短调试更方便。private ASTNode stmt() { Token token peek(); if (token.is(TokenType.KEYWORD, if)) { return parseIf(); } else if (token.is(TokenType.KEYWORD, while)) { return parseWhile(); } else if (token.is(TokenType.IDENTIFIER)) { return parseAssign(); } else { throw new SyntaxException( 语法错误意外的词素: token.getText(), token.getLine()); } }错误恢复是课设里容易被轻视的部分。老师一定会输入一段有语法错误的代码来测报错能力如果程序一条错误就崩印象分大打折扣。常见的做法是「恐慌模式」报错后跳过当前语句的所有 Token直到遇见分号或右花括号再继续分析下一句。这样一次运行能报出多个错误演示效果明显更好。资源包里的 Parser 类在synchronize()方法里实现了这个逻辑。4. 小型编译器AST 构建、四元式与符号表的协同词法、语法都跑通之后课设的第三块是小型编译器。这里的「编译」不需要生成目标机器的汇编做到中间代码四元式就够。资源包给的框架是语法分析过程中同步构建 AST然后遍历 AST 生成四元式符号表贯穿全程。4.1 从语法树到 AST语义动作挂在哪递归下降的每个函数返回值就是一个 AST 节点每个节点在返回前把自己的子节点挂好语法分析结束AST 就完整了。节点类的设计要能覆盖所有语句和表达式类型public abstract class ASTNode { int line; public ASTNode(int line) { this.line line; } } public class BinaryOpNode extends ASTNode { String op; // 、-、*、 等 ASTNode left, right; public BinaryOpNode(String op, ASTNode left, ASTNode right) { super(/* 传入行号 */); this.op op; this.left left; this.right right; } } public class AssignNode extends ASTNode { String varName; ASTNode expr; public AssignNode(String varName, ASTNode expr) { super(/* 传入行号 */); this.varName varName; this.expr expr; } }AST 和语法树的区别在于语法树保留所有推导细节AST 只保留编译需要的语义信息。a b c * d的语法树里括号、优先级都体现在树的形状里了AST 就是一棵节点挂a和节点节点再挂b和*节点。括号消掉了优先级体现在树的层级里。这里容易翻车的是运算顺序。如果语法分析时表达式的优先级处理不当AST 的形状会错四元式生成的顺序也会错。检验方法很简单输入a 1 2 * 3生成的四元式必须是先算乘法再算加法。如果顺序反了说明表达式文法里term和factor的层级关系没写对。4.2 四元式生成每条语句就是一条指令四元式是(op, arg1, arg2, result)四元组。遍历 AST 生成四元式的过程本质上是把树拍平成指令序列。表达式树的后序遍历顺序就是四元式的生成顺序——先递归生成左右子树的四元式再生成当前运算符的四元式。public class Quadruple { String op; // 操作符, -, *, /, , JMP, JZ String arg1, arg2; // 操作数变量名或临时变量 String result; // 结果变量名或临时变量 } private String genExpr(ASTNode node, ListQuadruple quads) { if (node instanceof ConstantNode) { return ((ConstantNode) node).getValue(); } if (node instanceof IdentifierNode) { return ((IdentifierNode) node).getName(); } BinaryOpNode bin (BinaryOpNode) node; String arg1 genExpr(bin.left, quads); // 先生成左子树 String arg2 genExpr(bin.right, quads); // 再生成右子树 String temp newTempVar(); quads.add(new Quadruple(bin.op, arg1, arg2, temp)); return temp; }这个递归函数的返回值是表达式最终落在哪个变量上——叶子节点返回变量名或常量字面量非叶子节点生成临时变量t1、t2并把名字返回给上层。四元式序列里每条语句的操作数要么是源程序里的变量要么是前面四元式生成的临时变量这个数据依赖链就是后续优化和寄存器分配的基础。if-else 和 while 要引入跳转四元式JZ和JMP。if (x 0) a 1; else a 2;会生成类似下面的序列(, x, 0, t1) (JZ, t1, -, L1) // x 0 为假跳到 else 分支 (, 1, -, a) (JMP, -, -, L2) (L1, -, -, -) // 标签不是真正的指令 (, 2, -, a) (L2, -, -, -)标签在四元式里是特殊操作数生成时先占位等分支结构分析完再回填。回填时机有讲究JZ的目标地址在分析 if 条件时还不知道要等else语句分析完才知道跳到哪。我一般用「待回填列表」记录这些跳转指令的下标条件结构结束时统一回填。这一步做不好分支嵌套一深跳转目标就全乱了。4.3 符号表作用域管理从一层表开始小型编译器的符号表不需要多复杂一张哈希表存「名字 → 类型/符号信息」就够。真正的坑在作用域。如果只用一个 HashMap内层声明的变量和外层同名变量会互相覆盖生成的四元式里变量名就串了。最简单正确的做法是维护一个作用域栈每进入一个{}块压一层表声明变量只在当前层插入查找从内往外public class SymbolTable { DequeMapString, SymbolInfo scopes new ArrayDeque(); public SymbolTable() { scopes.push(new HashMap()); // 全局作用域 } public void enterScope() { scopes.push(new HashMap()); } public void exitScope() { scopes.pop(); } public void declare(String name, SymbolInfo info) { scopes.peek().put(name, info); } public SymbolInfo lookup(String name) { for (MapString, SymbolInfo scope : scopes) { if (scope.containsKey(name)) return scope.get(name); } return null; } }Deque的 push/pop 就是进入和退出作用域。查找从栈顶当前作用域往下找找到即返回符合编译原理里「最近嵌套作用域」的规则。课设阶段做到这个程度够用不用上符号表树那种重量级结构。有一类语义错误必须靠符号表才能查使用未声明的变量、重复声明、类型不匹配。这些错误语法分析发现不了——语法是合法的但语义不合法。在生成四元式时顺带做一次符号表校验lookup返回null就报「未声明变量」错误这能在答辩时展示你对语义分析的理解深度。5. 课设避坑指南词法到四元式的五个高频翻车现场这一章写的是拆课设代码、帮人调 bug 过程中沉淀下来的高频问题。每一条都是真实发生过的照着检查能省下一整晚的调试时间。5.1 标识符被截断int 被拆成 i 和 nt现象输入int a 10;词法分析器把int拆成了i和nt两个 Token语法分析报错。原因扫描循环里遇到i就停下来查关键字表而不是把整个词素读完再查。搞混了「匹配规则」和「判定时机」——前者是状态机的工作后者是查表的工作。解决把查关键字的动作放到整个标识符读取完成之后。先按「字母开头字母数字延续」规则读完整串再查 KEYWORDS 表。对应到 2.2 的代码就是substring(start, pos)之后才做KEYWORDS.containsKey(word)判断。5.2 递归下降栈溢出表达式套两层就 StackOverflow现象程序一跑带表达式的语句就抛StackOverflowError控制台刷屏。原因文法没消除左递归expr()内部先调expr()形成无限递归。这是写递归下降最容易踩的坑几乎人人都会踩一次。解决先把文法写在纸上把expr → expr term | term改写成 EBNF 的expr → term { (|-) term }再写函数。我自己的习惯是写代码前花十分钟把整份文法改写成 EBNF贴到文件头部当注释函数结构直接照抄。5.3 被识别成两个 现象输入if (a b)词法分析输出两个赋值号语法分析直接懵掉。原因运算符匹配没做最长匹配读到第一个就急着出结果。解决运算符表按长度降序排列先尝试匹配长度为 2 的运算符、!、、失败再回退到长度 1。扫描时用peek()向后看一个字符避免消费了还不回退。资源包的 OperatorTable 类里已经按这个顺序排好了。5.4 四元式跳转目标全指向同一个标签现象多个 if-else 嵌套生成的跳转语句结果全部指向L1控制流乱套。原因标签计数器没有正确递增或关键代码在循环里每次都重置了标签号。解决标签生成用独立计数器每次调用newLabel()返回L (labelCount)保证全局唯一。回填时用一个链表记录所有待回填的四元式下标结构分析完统一填目标标签。5.5 实验报告和代码对不上演示时被老师问破防现象报告里写的语法分析用的是 LL(1) 预测分析表实际代码是纯递归下降老师一翻代码就问「你的预测分析表在哪」。原因报告直接套模板或抄了别人的框架没跟自己的代码同步。解决课程设计提交前把报告里出现的每个类名、函数名、数据结构跟源码逐一核对。我的做法是让报告的总体设计章节直接引用源代码里的类名和方法名答辩时老师怎么追问都不会出岔子。这个坑不属于技术问题但它最影响分数。6. 实验报告与验收技巧让老师快速看懂你的编译器课设最后交的不只是一堆能跑的代码还有实验报告。资源包里那份报告的复用价值在结构「需求分析 → 总体设计 → 详细设计 → 测试与结果 → 问题与解决」。答辩时老师翻得最多的就是测试部分建议你补一张对照表用例编号输入代码预期四元式实际表现T01int a 1 2 * 3;先乘后加t1 2 * 3在前通过T02if (a 0) b 1; else b 2;JZ 跳转到 else 标签通过T03while (i 10) i i 1;JZ 回跳正确通过T04未声明的变量 x语义错误提示通过演示时的操作顺序也有讲究。老师通常只有两三分钟别一上来跑几千行的文件。我一般准备三个十行以内的小测试文件分别覆盖词法、语法、四元式跑一步讲一步最后再跑一个错误输入的示例展示行号定位。给编译器加命令行参数也是加分项-tokens只打印 Token 流-quads只打印四元式一条命令就能把中间产物亮给老师看。java -cp build MiniCompiler -tokens test/lex_test.c java -cp build MiniCompiler -quads test/syntax_test.c java -cp build MiniCompiler test/error_test.c我第一次跑通这份资源时最大的教训是「别急着调最后一行的错误」。当时调四元式跳转 bug 调了一晚上后来从词法输出一层层查才发现是词法层把和搞混了四元式层不过是背锅。从那以后我每次拿到课设代码都强制自己先验证词法、再验证语法、最后才看中间代码每层输出对上了再往下一层走。希望帮到你——先分层验证再谈优化编译器这条路没有捷径。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进