编译原理课程实验包:从词法分析到目标代码的完整链路拆解

发布时间:2026/10/2 4:29:23
编译原理课程实验包:从词法分析到目标代码的完整链路拆解 简介这份资源是东南大学软件学院编译原理课程的实验项目面向计算机相关专业学生及希望深入理解编译器工作流程的自学者。它构建了一个从源代码到可执行代码的完整编译器模拟系统覆盖词法分析、语法分析、语义分析、中间代码生成、目标代码生成与代码优化等核心环节帮助读者在动手实践中掌握编译原理的理论与工程实现。压缩包共28个文件以12个java源文件和12个class编译文件为主体另含2个txt说明、1个iml工程配置与1个md文档整体约20KB结构紧凑便于直接导入IDE运行调试。目前已有63人学习下载。通过该平台读者可对照各阶段模块理解记号流、抽象语法树、语义检查与中间表示等关键概念并尝试指令选择、寄存器分配与代码优化等实现思路适合作为课程实验参考或编译器入门练手项目。1. 编译原理课程实验包从词法到目标代码的完整链路拆解很多同学学完编译原理考试能默写 LL(1) 分析表真给一段源码却不知道从哪下手把它变成可执行文件。这份东南大学软件学院的编译原理课程实验项目就是冲着这个断层来的——它把词法分析、语法分析、语义分析、中间代码生成、目标代码优化串成一条完整流水线最终产出一个从源代码到可执行代码的编译器模拟系统。适合正在上编译原理课、需要交大作业的本科生也适合想补回编译全流程实操经验的开发者。我拿到包后第一件事不是看文档而是直接翻源码目录结构确认它到底覆盖了哪几个阶段、每个阶段用什么算法实现。下面按我实际拆包的顺序把这条链路讲清楚。2. 实验包结构与编译阶段映射先搞清楚每个目录对应哪一步2.1 目录布局与阶段划分拿到一个课程实验包最怕的是文件散落各处、找不到入口。这个项目的组织方式比较典型按编译阶段分目录每个阶段一个独立模块最后有一个 driver 把各阶段串起来。常见做法是compiler-lab/ ├── lexer/ # 词法分析 ├── parser/ # 语法分析 ├── semantic/ # 语义分析 ├── ir/ # 中间代码生成 ├── optimizer/ # 目标代码优化 ├── codegen/ # 目标代码生成 ├── tests/ # 测试用例 └── main.py # 主入口这个结构的好处是每个阶段可以单独测试。比如你只想验证词法分析器能不能正确切分 token不需要跑完整编译流程直接调 lexer 模块就行。我一般会先确认每个目录下有没有对应的测试文件有测试说明作者至少自己跑通过。2.2 各阶段输入输出契约编译器的核心是阶段之间的接口。每个阶段的输出就是下一阶段的输入接口定义不清楚后面全是血泪。这个项目里各阶段的契约大致如下阶段输入输出关键数据结构词法分析源代码字符串Token 序列Token(type, value, line, col)语法分析Token 序列抽象语法树 ASTASTNode 树形结构语义分析AST带类型标注的 AST符号表 类型环境中间代码生成标注后 AST三地址码/四元式四元式列表目标代码优化中间代码优化后中间代码基本块 控制流图目标代码生成优化后中间代码目标汇编/伪指令寄存器分配表理解这张表的意义在于当编译结果不对时你能快速定位是哪个阶段的输出出了问题。比如最终生成的代码算错了表达式可能是语义分析阶段类型推断错了也可能是中间代码生成时运算符优先级搞反了。有了阶段划分排查就有了方向。2.3 环境准备与首次运行在动手改代码之前先确保能跑通。我一般会按这个顺序来# 1. 确认 Python 版本多数课程实验用 Python 3.8 python3 --version # 2. 安装依赖如果有 requirements.txt pip install -r requirements.txt # 3. 跑一个最简单的测试用例 python3 main.py tests/hello.src # 4. 查看输出目录 ls output/如果第 3 步报错先看报错信息指向哪个模块。常见的是路径问题——测试用例里的文件路径写死了绝对路径换台机器就跑不了。解决办法是把路径改成相对于项目根目录的写法或者用os.path.dirname(__file__)动态获取。提示首次运行前先看一眼 README 或实验指导书里有没有指定 Python 版本和依赖库。有些实验包用了 PLYPython Lex-Yacc或者 ANTLR不装对应库直接跑会报 ImportError。3. 词法分析与语法分析手写还是用生成器这是个问题3.1 词法分析器的实现路径词法分析的核心任务就一个把字符流变成 Token 流。听起来简单但边界情况不少——关键字和标识符怎么区分、注释怎么跳过、字符串里的转义怎么处理、行号和列号怎么维护。这个实验包里词法分析器大概率是手写 DFA 或者用正则表达式驱动的。手写 DFA 的典型结构是# lexer/lexer.py 核心逻辑示意 KEYWORDS {if, else, while, int, float, return} def tokenize(source): tokens [] pos 0 line 1 col 1 while pos len(source): ch source[pos] # 跳过空白字符同时维护行列号 if ch in \t: pos 1; col 1 continue if ch \n: pos 1; line 1; col 1 continue # 标识符/关键字字母开头后跟字母数字下划线 if ch.isalpha() or ch _: start pos while pos len(source) and (source[pos].isalnum() or source[pos] _): pos 1 word source[start:pos] if word in KEYWORDS: tokens.append(Token(KEYWORD, word, line, col)) else: tokens.append(Token(ID, word, line, col)) col pos - start continue # 数字字面量 if ch.isdigit(): start pos while pos len(source) and (source[pos].isdigit() or source[pos] .): pos 1 tokens.append(Token(NUMBER, source[start:pos], line, col)) col pos - start continue # 运算符和界符 if ch in -*/(){};!: tokens.append(Token(OP, ch, line, col)) pos 1; col 1 continue raise LexError(fUnexpected char {ch} at line {line}, col {col}) return tokens这段代码的关键点有三个一是行列号的维护报错时没有行列号等于没有报错二是最长匹配原则遇到不能只切一个就完事三是错误处理遇到非法字符要给出明确位置而不是直接崩掉。参数方面KEYWORDS集合决定了哪些标识符会被识别为关键字。如果你要扩展语言特性比如加for循环就在这里加一个关键字然后在语法分析里加对应规则。Token的字段设计也值得注意——type用于语法分析做分支判断value用于语义分析取值line和col用于报错定位。3.2 语法分析递归下降 vs LR 分析语法分析是把 Token 序列变成 AST。课程实验里常见两种路线递归下降手写和 LR 分析用生成器或手写分析表。递归下降的优点是直观、好调试每个非终结符对应一个函数。缺点是左递归文法要改写不然会无限递归。比如表达式文法E - E T | T是左递归的得改写成E - T EE - T E | ε。LR 分析的优点是能处理左递归分析能力更强。缺点是需要构造分析表手写的话代码量大用 PLY 或 Yacc 的话又多了学习成本。这个实验包如果用的是递归下降那parser/目录下应该能看到类似parse_expr()、parse_stmt()、parse_program()这样的函数。如果用的是 LR那应该有一个parse_table或者.y文法文件。我一般会先看语法分析器怎么处理表达式优先级。加减乘除的优先级如果搞错了1 2 * 3会算成9而不是7。递归下降里通常用分层函数解决——parse_expr调parse_termparse_term调parse_factor每一层对应一个优先级。# parser/parser.py 表达式优先级处理示意 def parse_expr(self): 处理加减法最低优先级 left self.parse_term() while self.current_token and self.current_token.value in (, -): op self.current_token.value self.advance() right self.parse_term() left BinOpNode(op, left, right) return left def parse_term(self): 处理乘除法较高优先级 left self.parse_factor() while self.current_token and self.current_token.value in (*, /): op self.current_token.value self.advance() right self.parse_factor() left BinOpNode(op, left, right) return left def parse_factor(self): 处理括号和原子表达式最高优先级 token self.current_token if token.type NUMBER: self.advance() return NumberNode(token.value) if token.value (: self.advance() node self.parse_expr() self.expect()) return node raise ParseError(fUnexpected token {token})这种分层写法的好处是优先级关系一目了然越靠下的函数优先级越高。改的时候也方便——要加一元负号在parse_factor里加一个分支就行。3.3 语法错误恢复语法分析最容易被忽略的是错误恢复。学生实验里经常是遇到第一个语法错误就抛异常退出但实际编译器应该尽量多报几个错让用户一次改完。常见的错误恢复策略有两种恐慌模式跳过 Token 直到遇到同步点比如分号或右花括号和短语级恢复插入缺失的 Token。课程实验里至少应该做到恐慌模式def expect(self, expected_value): if self.current_token and self.current_token.value expected_value: self.advance() else: # 不直接崩溃记录错误后尝试恢复 self.errors.append(fLine {self.current_token.line}: expected {expected_value}, fgot {self.current_token.value}) # 跳过当前 Token继续解析 self.advance()这样即使源码里有多个语法错误也能一次性全部报出来而不是改一个跑一次。4. 语义分析与中间代码生成类型检查和四元式落地4.1 符号表的设计与作用域管理语义分析阶段最核心的组件是符号表。符号表记录每个标识符的类型、作用域、存储位置等信息。没有符号表你没法判断x y 1里的y到底有没有声明、是什么类型。符号表的实现方式常见有三种线性表、哈希表、树形结构。课程实验里用哈希表加作用域栈是最常见的做法# semantic/symbol_table.py 作用域栈示意 class SymbolTable: def __init__(self): # 栈顶是当前作用域栈底是全局作用域 self.scopes [{}] def enter_scope(self): 进入新作用域如函数体、if 块 self.scopes.append({}) def exit_scope(self): 退出当前作用域 self.scopes.pop() def declare(self, name, type_info): 在当前作用域声明变量 if name in self.scopes[-1]: raise SemanticError(fVariable {name} already declared in this scope) self.scopes[-1][name] type_info def lookup(self, name): 从内到外查找变量 for scope in reversed(self.scopes): if name in scope: return scope[name] raise SemanticError(fUndeclared variable {name})这里的关键设计是scopes用列表模拟栈。进入函数体或复合语句时enter_scope()退出时exit_scope()。查找变量时从栈顶往下找找到第一个匹配的就返回——这正好实现了「内层遮蔽外层」的语义。参数方面type_info可以是一个字典包含typeint/float/array、kindvariable/function/parameter、scope_level等字段。类型检查时根据这些信息判断运算是否合法。4.2 类型检查与类型推断类型检查的核心规则就几条算术运算要求两边都是数值类型、赋值要求左右类型兼容、函数调用要求实参和形参类型匹配。但实现起来边界情况不少——隐式类型转换怎么处理、数组下标是不是整数、函数返回值类型怎么推导。# semantic/type_checker.py 类型检查核心逻辑 def check_binop(self, node): left_type self.infer_type(node.left) right_type self.infer_type(node.right) if node.op in (, -, *, /): if left_type not in (int, float) or right_type not in (int, float): raise SemanticError(fLine {node.line}: arithmetic on non-numeric type) # 类型提升int float - float if left_type float or right_type float: return float return int if node.op in (, , , !): if left_type ! right_type: raise SemanticError(fLine {node.line}: comparison between different types) return bool raise SemanticError(fUnknown operator {node.op})这段代码里有个容易翻车的点类型提升规则。int float应该返回float但如果你忘了写这个分支结果类型就会错后面中间代码生成时可能生成错误的指令。4.3 四元式生成与回填技术中间代码生成最常见的形式是四元式(op, arg1, arg2, result)。比如a b c生成(, b, c, t1)和(, t1, _, a)。四元式生成本身不难难的是控制流语句的回填。if和while语句在生成跳转指令时跳转目标地址还不知道需要先留空等目标确定后再回填。# ir/quad_generator.py 回填技术示意 class QuadGenerator: def __init__(self): self.quads [] self.next_quad 0 def emit(self, op, arg1, arg2, result): 生成一条四元式返回其索引 self.quads.append((op, arg1, arg2, result)) self.next_quad 1 return self.next_quad - 1 def gen_if(self, cond_node, then_body): 生成 if 语句的四元式 # 先生成条件表达式的四元式 cond_result self.gen_expr(cond_node) # 生成条件跳转目标地址暂时留空 jump_idx self.emit(jf, cond_result, _, None) # 生成 then 体 self.gen_stmts(then_body) # 回填跳转目标为当前指令位置 self.quads[jump_idx] (jf, cond_result, _, self.next_quad)回填的关键是emit返回四元式索引后面通过索引直接修改self.quads里对应位置的result字段。这种「先留空、后回填」的模式在if-else、while、for里都会用到是中间代码生成的核心技巧。注意回填时如果目标地址算错了生成的跳转指令会跳到错误的位置程序行为完全不可预测。调试时可以在每条四元式旁边标注对应的源码行号方便对照。5. 目标代码优化与常见问题排查别让优化把逻辑改错了5.1 常见优化手段与实现边界目标代码优化是编译原理实验里最容易「用力过猛」的环节。常见的优化手段包括常量折叠、公共子表达式消除、死代码删除、循环不变式外提。但课程实验里能把常量折叠和公共子表达式消除做对就已经不错了。常量折叠是在编译期把2 3直接算成5。实现上就是在遍历 AST 或四元式时如果发现两个操作数都是常量就直接计算结果替换掉原来的表达式。# optimizer/constant_folding.py 常量折叠示意 def fold_constants(quads): 对四元式列表做常量折叠 const_map {} # 记录哪些临时变量是常量 optimized [] for op, arg1, arg2, result in quads: # 如果两个操作数都是已知常量直接计算结果 if op in (, -, *, /) and arg1 in const_map and arg2 in const_map: val eval(f{const_map[arg1]} {op} {const_map[arg2]}) const_map[result] val optimized.append((, val, _, result)) else: optimized.append((op, arg1, arg2, result)) return optimized公共子表达式消除是如果同一个表达式被计算了多次就只算一次后面直接复用结果。实现上需要维护一个「表达式 → 临时变量」的映射表每次生成新表达式前先查表。但优化有个铁律不能改变程序语义。常量折叠时如果遇到除零不能直接崩掉应该保留原表达式让运行时处理。公共子表达式消除时如果两个表达式之间有函数调用或赋值可能改变了变量的值就不能消除。5.2 避坑与常见问题排查现象一词法分析报「Unexpected char」但源码看起来没问题。原因通常是不可见字符——从网页复制代码时带入了全角空格或零宽字符。解决方法是先用repr()打印出问题位置的字符确认它的 Unicode 码点然后在词法分析器里显式处理或过滤。现象二语法分析报错位置和实际错误位置差了好几行。原因是 Token 的行号维护有误常见于多行注释或字符串跨行时没有正确更新行号。解决方法是每消费一个字符都检查是不是换行符是的话行号加一、列号归零。别偷懒只在 Token 生成时更新行号。现象三语义分析通过但中间代码生成后结果不对。先检查四元式的操作数顺序。比如减法a - b生成(-, a, b, t1)是对的但如果你写成了(-, b, a, t1)结果就反了。这种错误在调试时很难发现因为四元式看起来「差不多」。建议每生成一条四元式就打印出来对照源码检查。现象四优化后程序行为变了。最常见的原因是死代码删除时误删了有副作用的语句。比如x f()这行如果x后面没被用到死代码删除可能会把它删掉但f()可能有副作用比如打印输出。解决方法是死代码删除前先做副作用分析有函数调用的语句不能随便删。现象五目标代码生成时寄存器不够用。课程实验里目标机通常假设有无限寄存器但如果你真的做寄存器分配就会发现寄存器不够。常见做法是先用简单的「每个临时变量分配一个寄存器」策略跑通后面再引入图着色或线性扫描做真正的寄存器分配。5.3 测试用例的设计与验证编译器测试不能只跑一个hello world。我一般会按这个顺序设计测试用例测试类型测试内容预期结果词法边界最长匹配、关键字识别、注释跳过Token 序列正确语法边界优先级、结合性、嵌套括号AST 结构正确语义边界未声明变量、类型不匹配、重复声明报错信息准确代码生成算术表达式、控制流、函数调用运行结果正确优化验证常量折叠、公共子表达式优化后结果不变每类至少准备 3 个用例覆盖正常情况和边界情况。跑测试时不要只看「通过/不通过」要看输出和预期的差异在哪里。6. 从实验包到可复现编译器我的调试习惯与一个关键技巧把这份实验包跑通只是第一步真正有价值的是能改、能扩展。我自己的习惯是拿到任何编译器实验代码先做一件事——在每一个阶段的入口和出口加日志把中间表示打印出来。# 在 main.py 里加一个调试开关 DEBUG True def compile_source(source): tokens tokenize(source) if DEBUG: print( Token 序列 ) for t in tokens: print(f {t.type}: {t.value} (line {t.line})) ast parse(tokens) if DEBUG: print( AST ) print_ast(ast) semantic_check(ast) quads generate_quads(ast) if DEBUG: print( 四元式 ) for i, q in enumerate(quads): print(f {i}: {q}) optimized optimize(quads) if DEBUG: print( 优化后四元式 ) for i, q in enumerate(optimized): print(f {i}: {q}) code generate_code(optimized) return code这个习惯帮我省了无数时间。编译器是典型的「黑匣子」——输入进去、输出出来中间错了你根本不知道是哪一步的问题。把中间表示打出来错误就无处藏身。比如最终代码算错了你看四元式就能发现是中间代码生成阶段的问题还是优化阶段引入的。另一个关键技巧是「最小复现」。遇到 bug 时不要拿完整程序去调把源码缩减到能触发 bug 的最短片段。比如一个 200 行的程序算错了你花半小时删到 5 行可能 5 分钟就定位到问题了。我一般会二分删除——先删一半看 bug 还在不在在就继续删那一半不在就删另一半。从那以后我每次拿到新的编译器实验代码都强制走一遍「加日志 → 跑测试 → 最小复现」的流程再开始改功能。希望帮到你。本文还有配套的精品资源点击获取