ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

东南大学编译原理实验:Java实现词法分析到目标代码生成全流程

东南大学编译原理实验:Java实现词法分析到目标代码生成全流程 简介这份资源是东南大学软件学院编译原理课程的实验项目压缩包面向正在学习编译原理、需要动手实现完整编译器流程的高校学生与自学者。它围绕词法分析、语法分析、语义分析、中间代码生成、目标代码生成与代码优化等核心环节构建了一个从源代码到可执行代码的模拟系统适合课程实验复现、编译流程梳理与工程能力训练。包内共28个文件以12个java源文件和12个class编译产物为主体另含2个txt说明、1个iml工程配置与1个md文档压缩包约20KB体量轻便便于快速导入IDE运行与阅读。目前已有63人学习下载。通过该平台读者可对照抽象语法树构建、语义检查、中间表示转换、指令选择与寄存器分配等关键知识点理解各阶段模块的职责划分与衔接方式并借助说明文档与工程结构梳理编译器的整体设计思路为课程实验验收和后续深入学习打下基础。1. 从东南大学软件学院编译原理实验项目说起一个完整编译器模拟系统到底要做什么很多同学第一次看到“东南大学软件学院编译原理课程实验项目”这个标题第一反应是“这不就是写个词法分析器交作业吗”。真动手才发现从源代码到可执行代码的完整链路远比想象中长。这个综合性实践平台要做的是把词法分析、语法分析、语义分析、中间代码生成、目标代码优化这几大模块串成一条能跑通的流水线输入一段类 C 或类 Pascal 的源程序输出可执行的汇编或机器码。它解决的核心问题是让你真正理解编译器每个阶段的数据结构怎么设计、模块之间怎么衔接、错误怎么定位。适合正在上编译原理实验课的学生也适合想系统补一遍编译全流程的后端工程师。热搜里“编译原理实验”“java编译原理”反复出现说明大家最关心的还是怎么用 Java 把这条链路落地。2. 词法分析与语法分析从字符流到语法树的落地路径2.1 词法分析器为什么不能只靠正则表达式硬扫词法分析的本质是把字符流切成有意义的 Token 序列。很多人第一版会写一堆 if-else 或者正则去匹配关键字、标识符、数字、运算符跑简单用例没问题一旦遇到ab这种连续运算符或者浮点数1.2e-3后面紧跟正则回溯就会翻车。常见做法是手写确定性有限自动机DFA用状态转移表驱动。状态表用二维数组或 Map 存行是状态列是输入字符类别。这样每个字符只扫一遍复杂度 O(n)而且错误定位精确到列号。下面是一个 Java 版词法分析器核心骨架用状态机处理标识符和数字public class Lexer { private final String src; private int pos 0; private int line 1; // Token 类型枚举ID, NUM, KEYWORD, OP, EOF public Token nextToken() { while (pos src.length()) { char c src.charAt(pos); if (Character.isWhitespace(c)) { if (c \n) line; pos; continue; } // 标识符或关键字字母开头后跟字母数字下划线 if (Character.isLetter(c) || c _) { int start pos; while (pos src.length() (Character.isLetterOrDigit(src.charAt(pos)) || src.charAt(pos) _)) { pos; } String word src.substring(start, pos); // 查关键字表命中则返回 KEYWORD否则 ID return KeywordTable.contains(word) ? new Token(TokenType.KEYWORD, word, line) : new Token(TokenType.ID, word, line); } // 数字支持整数和简单浮点 if (Character.isDigit(c)) { int start pos; while (pos src.length() Character.isDigit(src.charAt(pos))) pos; if (pos src.length() src.charAt(pos) .) { pos; while (pos src.length() Character.isDigit(src.charAt(pos))) pos; } return new Token(TokenType.NUM, src.substring(start, pos), line); } // 运算符单字符和双字符, !, , // 这里省略双字符前瞻逻辑实际需判断下一个字符 pos; return new Token(TokenType.OP, String.valueOf(c), line); } return new Token(TokenType.EOF, , line); } }逻辑说明pos是全局扫描指针line用于报错。标识符识别用贪心策略先吃满字母数字下划线再查关键字表避免把ifdef误判成if。数字部分只处理了整数和简单小数科学计数法需要额外状态。参数调整上关键字表建议用HashSetO(1) 查询如果语言大小写敏感查表前不要转小写。失败时看line和pos能直接定位到源文件哪一行哪一列。2.2 递归下降语法分析手写 LL(1) 的边界与调试技巧语法分析把 Token 流变成抽象语法树AST。实验项目里最常用的是递归下降因为代码直观、报错友好。每个非终结符对应一个函数函数内部按产生式右部依次匹配 Token。比如表达式文法E - T EE - T E | ε写成 Java 就是parseE()调parseT()再调parseEPrime()。递归下降的硬边界是左递归。如果文法写成E - E T | T直接递归会栈溢出。解决办法是改写文法消除左递归变成右递归形式。另一个坑是 FIRST 集和 FOLLOW 集没算对导致parseEPrime()在遇到)时不知道该不该返回。我一般会在每个 parse 函数入口打印当前 Token 和函数名跑一个ab*c的用例看调用栈是否符合预期。// 消除左递归后的表达式解析 private ASTNode parseE() { ASTNode left parseT(); return parseEPrime(left); } private ASTNode parseEPrime(ASTNode left) { Token t peek(); if (t.type TokenType.OP t.value.equals()) { consume(); ASTNode right parseT(); BinaryNode node new BinaryNode(, left, right); return parseEPrime(node); // 右递归继续找下一个 } return left; // ε 产生式直接返回 }逻辑说明peek()看当前 Token 不消费consume()消费并前进。parseEPrime收到就构造左结合的二叉树然后递归处理后续。参数上peek()需要支持向前看一个 Token实验里通常用ListToken加索引实现。如果遇到abc会构造((ab)c)符合左结合语义。失败时检查peek()返回的 Token 是否在预期集合内不在就抛语法错误并带上行号。3. 语义分析与中间代码生成类型检查、符号表和四元式3.1 符号表设计作用域嵌套时怎么查、怎么插、怎么删语义分析的核心是符号表。每个变量、函数、类型都要登记还要处理作用域嵌套。常见做法是用栈式符号表进入一个作用域就压入一个新 HashMap离开就弹出。查找时从栈顶往下找找到第一个匹配就返回。这样内层变量可以遮蔽外层同名变量符合大多数语言语义。public class SymbolTable { private DequeMapString, Symbol scopes new ArrayDeque(); public SymbolTable() { scopes.push(new HashMap()); // 全局作用域 } public void enterScope() { scopes.push(new HashMap()); } public void exitScope() { scopes.pop(); } public void define(String name, Symbol sym) { scopes.peek().put(name, sym); // 只在当前作用域插入 } public Symbol resolve(String name) { for (MapString, Symbol scope : scopes) { if (scope.containsKey(name)) return scope.get(name); } return null; // 未声明 } }逻辑说明Deque当栈用push压入新作用域pop弹出。define只写栈顶保证同名变量在内层重新定义不会覆盖外层。resolve从栈顶往下遍历实现“最近声明优先”。参数上Symbol类至少存类型、种类变量/函数/类型、行号。如果语言支持函数重载define的 key 要改成name 参数类型列表。失败时看resolve返回 null 的位置就是未声明标识符。3.2 四元式生成把 AST 翻译成中间代码的固定套路中间代码生成通常用四元式(op, arg1, arg2, result)。遍历 AST 时遇到二元运算就生成一条四元式把结果存到临时变量。比如a b c * d先生成(*, c, d, t1)再生成(, b, t1, t2)最后生成(, t2, _, a)。临时变量用t1, t2...递增命名。public class QuadGenerator { private int tempCount 0; private ListQuad quads new ArrayList(); public String newTemp() { return t (tempCount); } public String genBinary(String op, String left, String right) { String temp newTemp(); quads.add(new Quad(op, left, right, temp)); return temp; } public void genAssign(String target, String value) { quads.add(new Quad(, value, _, target)); } // 遍历 AST 的入口 public String visit(ASTNode node) { if (node instanceof BinaryNode) { BinaryNode bin (BinaryNode) node; String l visit(bin.left); String r visit(bin.right); return genBinary(bin.op, l, r); } if (node instanceof IdNode) { return ((IdNode) node).name; // 变量直接返回名字 } if (node instanceof NumNode) { return ((NumNode) node).value; // 常量直接返回字面量 } throw new RuntimeException(未知节点类型); } }逻辑说明visit是递归入口二元节点先递归左右子树拿到操作数再生成四元式并返回临时变量名。genAssign处理赋值语句把右值四元式结果写到左值。参数上tempCount全局递增保证临时变量不重名。如果要做常量折叠优化可以在visit里判断左右都是NumNode时直接算出结果不生成四元式。失败时看四元式列表哪条的操作数没定义就是语义错误。4. 目标代码优化与生成从四元式到可执行汇编的最后一公里4.1 基本块划分与局部优化删冗余、合并常量拿到四元式列表后先划分基本块。基本块是顺序执行、无跳转进入、无跳转退出的最大指令序列。划分规则遇到标号、跳转目标、跳转指令后的下一条就切新块。然后在每个块内做局部优化常见的有常量传播、公共子表达式消除、死代码删除。// 常量传播如果四元式两个操作数都是常量直接算出结果 for (int i 0; i quads.size(); i) { Quad q quads.get(i); if (isConstant(q.arg1) isConstant(q.arg2) !q.op.equals()) { String folded eval(q.op, q.arg1, q.arg2); // 把这条替换成赋值后续用到 result 的地方替换成 folded quads.set(i, new Quad(, folded, _, q.result)); replaceAll(q.result, folded); } }逻辑说明isConstant判断操作数是否是数字字面量。eval按运算符算出结果。replaceAll把后续四元式中所有引用q.result的地方替换成折叠后的常量。参数上常量折叠只对算术和逻辑运算有效涉及变量或函数调用不能折。失败时看优化后四元式数量是否减少没减少说明没命中常量对。4.2 目标代码生成寄存器分配与指令选择的最小实现最后一步是把优化后的四元式翻译成汇编。实验项目通常选 MIPS 或 x86 子集。最简单的方法是“一条四元式对应几条汇编”用栈式分配所有临时变量都放内存需要运算时 load 到寄存器算完 store 回去。虽然效率低但正确性容易保证。// 四元式 (, a, b, t1) 生成 MIPS 汇编 // lw $t0, a // lw $t1, b // add $t2, $t0, $t1 // sw $t2, t1 for (Quad q : quads) { if (q.op.equals()) { emit(lw $t0, q.arg1); emit(lw $t1, q.arg2); emit(add $t2, $t0, $t1); emit(sw $t2, q.result); } // 其他运算符类似减法是 sub乘法是 mul }逻辑说明emit把汇编指令追加到输出列表。每个四元式独立翻译不跨指令复用寄存器所以不会冲突。参数上变量名直接当内存标签用临时变量也在数据段分配空间。如果要做寄存器分配优化可以引入活跃变量分析把不冲突的变量分到同一寄存器。失败时看汇编里有没有未定义的标签或者sw的目标地址是否合法。5. 避坑与排查编译原理实验里最容易翻车的 5 个地方5.1 词法分析把关键字识别成标识符现象if、while被当成普通变量名语法分析报“期望表达式但遇到 ID”。原因关键字表没建或者查表时大小写没统一。解决在词法分析器初始化时把所有关键字塞进HashSet识别完标识符后先查表再返回 Token 类型。5.2 语法分析左递归导致栈溢出现象解析abcd...长表达式时抛StackOverflowError。原因文法写成E - E T递归下降直接左递归。解决改写成E - T EE - T E | ε把递归转成循环或右递归。5.3 符号表作用域没弹出导致变量泄漏现象内层块定义的变量在外层也能访问或者同名变量覆盖了外层。原因进入作用域没push离开没pop。解决在语法分析进入{}块时调enterScope()退出时调exitScope()确保成对出现。5.4 四元式临时变量重名现象优化后计算结果错乱两个不同表达式的临时变量都叫t1。原因tempCount是局部变量每次生成四元式都重置。解决把tempCount提成类成员变量全局递增或者用t1_1、t1_2带作用域后缀。5.5 目标代码生成寄存器冲突现象汇编跑出来结果不对某个变量值被覆盖。原因多条四元式复用同一个寄存器$t0但没保存前一个值。解决最简单的办法是每条四元式都重新 load不跨指令复用寄存器进阶做法是做活跃变量分析只在不同时活跃的变量间复用。6. 进阶技巧用测试用例反推编译器正确性写完编译器最怕的是“看起来能跑一换用例就崩”。我一般会准备三组测试第一组是正常程序覆盖赋值、算术、条件、循环、函数调用第二组是边界用例比如空语句、嵌套十层的括号、超长标识符第三组是错误用例故意写未声明变量、类型不匹配、缺少分号看报错行号对不对。验证方法上可以用“差分测试”同一个源程序分别用你的编译器和 GCC 编译跑出来结果对比。如果结果不一致先查中间代码再查目标代码。另一个技巧是给每个阶段加 dump 开关比如-dump-tokens、-dump-ast、-dump-quads出问题时逐层往下看很快能定位到哪个模块翻车。# 假设编译器主类叫 Compiler java Compiler -dump-tokens test.c # 只看词法 java Compiler -dump-ast test.c # 看语法树 java Compiler -dump-quads test.c # 看四元式 java Compiler test.c -o test.s # 生成汇编参数说明-dump-*是调试开关正式编译时不加。-o指定输出文件。如果汇编生成后还要汇编和链接可以用gcc test.s -o test跑最终可执行文件。我自己的习惯是每改一个模块先把对应 dump 打开跑一遍最小用例确认输出符合预期再往下走。编译原理实验最忌讳一口气写完再调分层验证能省下大量后悔药。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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