资讯动态

编译原理实验通关指南:从词法分析到代码生成的完整实现路径

发布时间:2026/10/13 7:59:19 来源:尧图企业网站定制
简介这份资源是面向高校计算机专业学生的编译原理实验合集覆盖从词法分析到代码生成的完整编译流程适合正在修读编译原理课程或准备相关课程设计的同学参考。压缩包共248个文件约446.47MB以xz、zst等压缩归档文件为主同时包含c源码、h头文件、makefile构建脚本、y语法文件、l词法文件以及docx、pdf实验文档另有少量可执行文件与输出日志便于对照源码与文档理解各阶段实现。已有491人学习下载。内容按实验一到八递进展开实验一梳理编译器基本组成模块实验二至四分别实现词法分析器、递归下降解析器与语义分析实验五至七聚焦常量折叠、冗余消除、寄存器分配等优化议题实验八则整合前端处理、错误检测与目标代码生成完成一个简易编译器综合项目。读者可借此获得解析器与词法分析器的实际编程经验加深对程序从源码到运行全过程的理解。1. 编译原理实验一到八从正则到代码生成的完整通关路径很多人学编译原理课上听懂了 LL 文法、LR 分析表一到动手就卡住。某高校计算机专业的编译原理课程通常配八个实验从词法分析一路做到目标代码生成覆盖编译器前端到后端的核心链路。这八个实验不是孤立的练习题而是一条完整的流水线你写的词法分析器输出 token 流语法分析器消费 token 建语法树语义分析在树上做类型检查中间代码生成把树翻译成四元式优化和汇编再往下走。每个实验的产出是下一个实验的输入所以接口设计比算法本身更容易翻车。这篇文章按实验顺序拆解每一步的实现要点、参数设置和踩坑记录适合正在做这八个实验但卡在某个环节的同学也适合想系统过一遍编译器实现路径的开发者。2. 实验一到三词法分析、语法分析与语义分析的实现细节2.1 词法分析器正则表达式到 DFA 的工程化落地实验一通常要求实现一个词法分析器输入是源程序文本输出是 token 序列。理论课上讲的是正则表达式转 NFA 再转 DFA但实际动手时很少有人真的从正则表达式动态构造 DFA常见做法是手写状态转移或者用查表法。我一般会先用正则表达式把各类 token 描述清楚再用一个简单的扫描器逐字符读取。核心逻辑是维护一个当前位置指针每次尝试匹配最长可能的 token。下面是一个简化版的核心框架import re # token 模式表顺序很重要关键字要在标识符之前匹配 TOKEN_PATTERNS [ (KEYWORD, r\b(if|else|while|return|int|float)\b), (IDENTIFIER, r[a-zA-Z_][a-zA-Z0-9_]*), (NUMBER, r\d(\.\d)?), (OPERATOR, r[\-*/!]), (DELIMITER, r[;,(){}]), (WHITESPACE, r\s), ] def tokenize(source_code): tokens [] pos 0 while pos len(source_code): match None for token_type, pattern in TOKEN_PATTERNS: regex re.compile(pattern) match regex.match(source_code, pos) if match: value match.group(0) if token_type ! WHITESPACE: # 跳过空白字符 tokens.append((token_type, value, pos)) pos match.end() break if not match: raise SyntaxError(f非法字符 {source_code[pos]} 在位置 {pos}) return tokens这段代码的关键在于模式表的顺序。如果把 IDENTIFIER 放在 KEYWORD 前面if会被识别成标识符而不是关键字。另一个容易忽略的点是位置信息的记录——后续语法分析报错时需要知道 token 在源码中的位置所以每个 token 要带上起始偏移量。参数方面正则表达式的贪婪匹配默认行为通常是对的但遇到和这种前缀重叠的运算符时需要把长的放前面。如果实验要求支持注释记得在模式表最前面加注释模式并且注释不产生 token。2.2 语法分析器递归下降与 LL(1) 分析表的取舍实验二一般要求实现语法分析器。如果实验一的词法分析器输出 token 流这里就要消费 token 流并构建语法树。常见做法有两种递归下降和基于分析表的 LL(1) 或 LR 分析。递归下降写起来直观每个非终结符对应一个函数函数内部根据当前 token 决定走哪个产生式。缺点是遇到左递归文法需要改写而且错误恢复比较麻烦。LL(1) 分析表需要先计算 FIRST 集和 FOLLOW 集然后查表驱动。LR 分析更强大但手写分析表容易出错。我一般会建议如果实验课时紧用递归下降快速跑通如果要求完整实现 LL(1) 或 LR那就老老实实算 FIRST/FOLLOW 集。下面是一个递归下降的表达式解析片段class Parser: def __init__(self, tokens): self.tokens tokens self.pos 0 def peek(self): return self.tokens[self.pos] if self.pos len(self.tokens) else (EOF, , -1) def consume(self, expected_typeNone): token self.peek() if expected_type and token[0] ! expected_type: raise SyntaxError(f期望 {expected_type}实际 {token[0]} 在位置 {token[2]}) self.pos 1 return token def parse_expression(self): # expression - term (( | -) term)* left self.parse_term() while self.peek()[1] in (, -): op self.consume()[1] right self.parse_term() left (binary_op, op, left, right) return left def parse_term(self): # term - factor ((* | /) factor)* left self.parse_factor() while self.peek()[1] in (*, /): op self.consume()[1] right self.parse_factor() left (binary_op, op, left, right) return left def parse_factor(self): token self.peek() if token[0] NUMBER: self.consume() return (number, token[1]) elif token[1] (: self.consume(DELIMITER) expr self.parse_expression() self.consume(DELIMITER) # 期望 ) return expr else: raise SyntaxError(f意外的 token {token})这里用元组表示语法树节点简单够用。如果实验要求可视化语法树可以换成类或者字典。注意parse_factor里对左括号的处理消费左括号后递归解析表达式再消费右括号。如果源程序括号不匹配这里会抛出异常错误信息里带上位置方便定位。2.3 语义分析符号表管理与类型检查的边界情况实验三通常是语义分析核心任务是建符号表和作用域管理以及类型检查。符号表可以用栈式结构实现进入新作用域时压栈退出时弹栈。每个符号记录名字、类型、作用域层级和其他属性。类型检查的难点在于隐式转换和表达式类型推导。比如int float应该推导出float赋值时float赋给int变量要不要报错取决于语言规范。常见做法是定义一个类型兼容性表左类型右类型是否兼容结果类型intint是intintfloat是floatfloatint是floatfloatfloat是floatintstring否报错符号表操作的关键接口包括插入符号、查找符号从当前作用域往外找、进入作用域、退出作用域。下面是一个简化实现class SymbolTable: def __init__(self): self.scopes [{}] # 栈底是全局作用域 def enter_scope(self): self.scopes.append({}) def exit_scope(self): if len(self.scopes) 1: self.scopes.pop() def declare(self, name, type_info): current self.scopes[-1] if name in current: raise SemanticError(f重复声明 {name}) current[name] type_info def lookup(self, name): for scope in reversed(self.scopes): if name in scope: return scope[name] raise SemanticError(f未声明的标识符 {name})注意declare只在当前作用域检查重复lookup从内到外逐层查找。如果实验要求支持函数重载或结构体成员符号表的结构需要更复杂的设计但基本思路不变。3. 实验四到六中间代码生成、优化与目标代码生成3.1 四元式生成从语法树到线性中间表示的映射规则实验四一般要求把语法树翻译成四元式。四元式是(op, arg1, arg2, result)的形式比如(, a, b, t1)表示t1 a b。生成过程通常是在语法树上做后序遍历每个节点返回一个临时变量名。关键点是临时变量的管理和控制流语句的翻译。if-else和while需要生成标签和跳转指令。下面是一个表达式翻译的示例class QuadGenerator: def __init__(self): self.quads [] self.temp_count 0 self.label_count 0 def new_temp(self): self.temp_count 1 return ft{self.temp_count} def new_label(self): self.label_count 1 return fL{self.label_count} def emit(self, op, arg1, arg2, result): self.quads.append((op, arg1, arg2, result)) def gen_expr(self, node): if node[0] number: return node[1] elif node[0] binary_op: left self.gen_expr(node[2]) right self.gen_expr(node[3]) temp self.new_temp() self.emit(node[1], left, right, temp) return temp elif node[0] identifier: return node[1]对于if语句翻译模式是生成条件表达式的四元式生成条件为假时跳转到 else 标签的四元式翻译 then 分支生成跳过 else 分支的跳转放置 else 标签翻译 else 分支放置结束标签。每个标签对应一个四元式位置索引。参数方面临时变量的命名只要不冲突即可但建议统一前缀方便调试。四元式的输出格式要跟后续实验对接如果实验五的优化器读四元式那实验四的输出格式必须严格一致。3.2 局部优化基本块划分与常量折叠的实现实验五通常是优化常见的是局部优化包括常量折叠、常量传播、公共子表达式消除和死代码消除。第一步是划分基本块从第一条四元式开始遇到跳转目标或者跳转指令就切分。常量折叠的实现是扫描每个基本块如果某条四元式的两个操作数都是常量就直接计算结果并替换。公共子表达式消除需要维护一个表达式到变量的映射表遇到相同表达式时复用已有结果。def constant_folding(quads): result [] for op, arg1, arg2, res in quads: # 尝试把操作数转成数字 try: v1 float(arg1) if arg1 else None v2 float(arg2) if arg2 else None except ValueError: result.append((op, arg1, arg2, res)) continue if v1 is not None and v2 is not None: if op : val v1 v2 elif op -: val v1 - v2 elif op *: val v1 * v2 elif op /: if v2 0: result.append((op, arg1, arg2, res)) continue val v1 / v2 else: result.append((op, arg1, arg2, res)) continue # 用常量替换整条四元式 result.append((, str(val), None, res)) else: result.append((op, arg1, arg2, res)) return result注意除法要检查除零。另外常量折叠后可能产生新的折叠机会比如t1 2 3折叠成t1 5后面t2 t1 * 4如果 t1 没有其他赋值可以继续传播。简单做法是迭代到不动点但实验里跑一两轮通常够了。3.3 目标代码生成寄存器分配与指令选择的简化策略实验六一般要求生成汇编代码或者某种目标机的指令序列。核心任务是指令选择和寄存器分配。指令选择是把四元式映射到目标指令比如(, a, b, t1)映射成LOAD R1, a; ADD R1, b; STORE t1, R1。寄存器分配最简单的是线性扫描或者直接每个变量分配一个寄存器寄存器不够就溢出到栈。实验环境下变量数量通常不多可以简化处理。def generate_code(quads, register_count4): code [] reg_map {} # 变量到寄存器的映射 free_regs [fR{i} for i in range(register_count)] def get_reg(var): if var in reg_map: return reg_map[var] if free_regs: reg free_regs.pop(0) else: # 简单溢出策略释放第一个寄存器 old_var, reg next(iter(reg_map.items())) code.append(fSTORE {reg}, {old_var}) del reg_map[old_var] reg_map[var] reg return reg for op, arg1, arg2, res in quads: if op : r get_reg(res) code.append(fLOAD {r}, {arg1}) elif op in (, -, *, /): r1 get_reg(arg1) r2 get_reg(arg2) instr {: ADD, -: SUB, *: MUL, /: DIV}[op] code.append(f{instr} {r1}, {r2}) reg_map[res] r1 elif op if_goto: code.append(fJMP {res}) elif op label: code.append(f{res}:) return code这个简化版没有做真正的图着色分配但实验通常只要求功能正确。注意reg_map[res] r1这行复用了第一个操作数的寄存器前提是 arg1 之后不再使用。如果后续还要用 arg1需要额外处理。4. 实验七到八完整编译器串联与测试用例设计4.1 把八个实验串成一条流水线接口对齐与数据格式约定实验七和实验八通常是综合实验要求把前面的模块串起来输入源程序输出可执行结果或者模拟执行结果。这时候最大的坑不是算法而是接口。每个实验的输出格式必须严格对齐。比如实验一输出的 token 是(type, value, pos)三元组实验二的解析器就必须按这个格式消费。如果实验二的同学自己改了格式串联时就会崩。我一般会建议在开始写实验二之前先把实验一的输出格式固定下来写一个接口文档后面所有模块都按这个文档来。另一个常见问题是错误处理。词法分析报错后语法分析不应该继续跑。需要一个统一的错误传播机制比如抛异常或者返回错误码。如果每个模块各自处理错误串联时错误信息会丢失。4.2 测试用例设计覆盖边界情况的最小用例集测试用例要覆盖正常情况和边界情况。正常情况包括简单的赋值、算术运算、条件语句和循环。边界情况包括空输入、只有注释、括号不匹配、未声明变量、类型不匹配、除零、深层嵌套。我一般会准备一组最小用例每个用例只测一个特性# 用例1简单赋值和输出 int a; a 1 2; # 用例2条件语句 int a; a 1; if (a 0) { a a 1; } else { a a - 1; } # 用例3循环 int i; i 0; while (i 10) { i i 1; } # 用例4错误情况——未声明变量 x 1; # 用例5错误情况——类型不匹配 int a; a hello;每个用例跑完后检查输出是否符合预期。如果实验要求生成汇编可以用模拟器跑或者手工检查关键指令。注意用例不要写太大否则出错时很难定位是哪个模块的问题。5. 避坑与排查编译原理实验里最容易翻车的五个地方5.1 词法分析吞掉换行导致行号全错现象语法分析报错时显示的行号跟实际源码对不上所有错误都指向第一行。原因词法分析器在跳过空白字符时把换行也跳过了但没有更新行号计数器。很多实现只记录字符偏移量报错时用偏移量除以每行平均长度估算行号结果完全不准。解决在词法分析器里维护line和column两个计数器。每消费一个换行符line加一column归零。token 的位置信息里同时记录偏移量、行号和列号。报错时直接用行号和列号不要用偏移量估算。5.2 递归下降遇到左递归直接栈溢出现象解析表达式时程序卡死或者抛出递归深度超限异常。原因文法里写了expression - expression term这样的左递归产生式递归下降解析器会无限递归。解决把左递归改写成右递归或者用循环替代。比如expression - term (( | -) term)*在代码里用 while 循环处理运算符和右操作数。如果实验要求保留原始文法那就改用 LL(1) 分析表或者 LR 分析器。5.3 符号表作用域退出时没清理导致变量泄漏现象在内层作用域声明的变量退出作用域后还能被访问到或者同名变量重复声明不报错。原因符号表用单一字典实现进入作用域时没有压栈退出时没有弹栈。所有变量都在同一个命名空间里。解决用栈式符号表每个作用域一个字典。enter_scope时压入新字典exit_scope时弹出。declare只检查当前栈顶字典lookup从栈顶往下找。注意函数参数的作用域处理参数应该属于函数体所在的作用域。5.4 四元式临时变量命名冲突导致优化结果错误现象优化后的代码运行结果跟优化前不一致某些变量值被意外覆盖。原因临时变量命名用了全局计数器但优化过程中删除了部分四元式后临时变量编号出现空洞或者重复。公共子表达式消除时复用了变量名但没有检查生命周期。解决临时变量命名用单调递增计数器不要复用已删除的编号。公共子表达式消除时确保被复用的变量在当前位置仍然有效。简单做法是只在同一个基本块内做公共子表达式消除跨基本块的不处理。5.5 目标代码生成时寄存器分配不考虑活跃范围现象生成的汇编代码运行结果错误某些变量的值在中途被覆盖。原因寄存器分配时简单地按顺序分配没有分析变量的活跃范围。一个寄存器被分配给新变量时旧变量的值可能后面还要用。解决至少实现一个简单的活跃变量分析。对每个基本块从后往前扫描记录每个变量在哪些位置活跃。分配寄存器时如果所有寄存器都被占用选择一个活跃范围已经结束的变量溢出到栈。实验环境下可以简化每个变量分配固定寄存器寄存器不够就全部溢出到栈用内存操作数。6. 进阶技巧用差分测试验证编译器正确性八个实验做完后怎么确认你的编译器是对的手工看几个用例只能覆盖有限情况。我一般会用差分测试准备一个参考实现比如用 Python 的eval或者手写一个解释器对同一批随机生成的源程序分别用你的编译器和参考实现跑比较结果。随机程序生成可以用语法指导的方式根据文法规则随机展开生成合法的表达式和语句。注意控制深度避免程序过大。下面是一个简单的随机表达式生成器import random def gen_expr(depth0): if depth 3 or random.random() 0.3: return str(random.randint(1, 100)) op random.choice([, -, *, /]) left gen_expr(depth 1) right gen_expr(depth 1) if op /: right str(random.randint(1, 100)) # 避免除零 return f({left} {op} {right}) def gen_program(num_stmts5): lines [] for _ in range(num_stmts): expr gen_expr() lines.append(fint t{_};) lines.append(ft{_} {expr};) return \n.join(lines)生成一批程序后用你的编译器编译执行同时用 Python 的eval计算每个表达式的值比较结果。如果不一致就缩小范围定位是哪个模块的问题。差分测试的好处是能发现手工用例覆盖不到的边界情况比如运算符优先级、结合性、整数溢出等。另一个技巧是给每个模块单独写单元测试。词法分析器测 token 序列语法分析器测语法树结构语义分析器测符号表和类型检查结果。单元测试跑得快定位问题准。集成测试放在最后跑确认整条流水线通畅。我自己的习惯是每写完一个实验先跑单元测试再跑集成测试最后跑差分测试。三层都过了才提交。这个习惯帮我省了很多后悔药——有次语义分析的符号表在退出作用域时没弹栈单元测试没覆盖到嵌套作用域集成测试跑一个带嵌套 if 的用例才暴露出来。后来我把嵌套作用域的用例加进了单元测试再也没翻过车。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑