简介:这份资源是北京邮电大学编译原理课程设计的完整工程实现,面向正在学习编译原理、需要动手完成 Pascal 语言编译器课设的高校学生与自学者。项目覆盖从词法分析、语法分析、语义分析到代码生成的完整流程,并涉及符号表管理、错误报告与中间代码生成等关键环节,适合在理论课后对照实践,理解编译器各阶段的衔接方式。压缩包共 83 个文件,约 123KB,以 21 个 .h 与 19 个 .cpp 构成编辑器、虚拟机与编译核心源码,8 个 .pas、8 个 .asm 与 8 个 .bin 提供测试用例及其汇编、可执行结果,另有 .rez、.rc 等资源文件与工程配置,可直接用 VC 工程打开对照调试。已有 1196 人学习下载。读者可从中获得一套可运行的课设参考实现、Pascal 测试样例与预期输出,以及虚拟机和文本编辑器的组织方式,便于排错、补全实验报告与理解代码生成细节。
1. 编译原理课程设计最容易犯的错误,是第一天就选错工具
编译原理课程设计里,很多人一开始就想用 flex 加 bison:把文法贴进去、让规则编译通过、跑通一个四则运算计算器,就觉得差不多可以交差了。结果答辩时被问一句“你的冲突是怎么消解的”“为什么要做左递归消除”,当场答不上来。我带的几届课设里,凡是真正动手写前端的人,最后得分都稳定高于只会调工具的人。这套方案走另一条路:手写词法扫描器加递归下降语法分析,再配一套能跑通 C 语言子集的前端到后端完整链路:词法、语法、语义、四元式、目标代码、局部优化六段全部自己写。它适合两三周内要交出一份能演示、能答问成果的人,也适合想把这门课从“背概念”变成“真的写出来”的人。
2. 词法与语法分析:手写扫描器和递归下降组成的两段式前端
前端部分决定整个课设的下限。一个连if (a>=1.5) x = -a;都切不对的扫描器,后面语义分析写得再漂亮也白搭。我一般把前端拆成两个独立可测的模块:扫描器只负责把字符流变成记号流,语法分析器只负责把记号流变成抽象语法树,中间用一张记号类型表固定接口。这样在调语法时发现切错了词,只要单独跑扫描器的单元测试就能截住,不用整条链路一起猜。
2.1 词法分析器:最长匹配、关键字回查和错误恢复
扫描器的核心只有三条规则:最长匹配、关键字表回查、非法字符不中断。最长匹配的意思是读到i不能立刻返回标识符,得一直读到字母数字下划线结束,再拿整词去关键字表里查,命中就是关键字,没命中才是标识符。双字符运算符同理,>=必须在>之前判断,否则永远切不出大于等于。第三条规则最容易被忽略:很多人的扫描器一遇到@就抛异常退出,导致源文件里十个错只能报一个,答辩演示时特别难看。
KEYWORDS = {"int", "float", "if", "else", "while", "return", "void", "print"} OP2 = {"==", "!=", "<=", ">=", "&&", "||"} # 双字符运算符必须先于单字符匹配 OP1 = set("+-*/%<>=!") DELIM = set("(){};,[]") ID, NUM, FLT, OP, DL, EOF = "ID", "NUM", "FLT", "OP", "DL", "EOF" class Token: __slots__ = ("kind", "value", "line", "col") def __init__(self, kind, value, line, col): self.kind, self.value, self.line, self.col = kind, value, line, col def __repr__(self): return f"{self.kind}({self.value})@{self.line}:{self.col}" def scan(src): """把源串切成记号流;非法字符只记录不中断,保证一次能报出全部词法错误""" toks, errs = [], [] i, line, col, n = 0, 1, 1, len(src) while i < n: c = src[i] if c == "\n": line, col, i = line + 1, 1, i + 1 elif c in " \t\r": col, i = col + 1, i + 1 elif c == "/" and i + 1 < n and src[i + 1] == "/": # 单行注释 while i < n and src[i] != "\n": i += 1 elif c == "/" and i + 1 < n and src[i + 1] == "*": # 块注释 start = line i += 2 while i + 1 < n and not (src[i] == "*" and src[i + 1] == "/"): if src[i] == "\n": line += 1 i += 1 if i + 1 >= n: # 未闭合必须报错 errs.append((start, "块注释未闭合")) break i += 2 elif c.isalpha() or c == "_": # 标识符或关键字 j = i while j < n and (src[j].isalnum() or src[j] == "_"): j += 1 w = src[i:j] toks.append(Token(w if w in KEYWORDS else ID, w, line, col)) col, i = col + j - i, j elif c.isdigit(): # 整数或小数 j = i while j < n and src[j].isdigit(): j += 1 kind = NUM if j + 1 < n and src[j] == "." and src[j + 1].isdigit(): j += 1 while j < n and src[j].isdigit(): j += 1 kind = FLT toks.append(Token(kind, src[i:j], line, col)) col, i = col + j - i, j else: two = src[i:i + 2] if two in OP2: toks.append(Token(OP, two, line, col)); i += 2; col += 2 elif c in OP1: toks.append(Token(OP, c, line, col)); i += 1; col += 1 elif c in DELIM: toks.append(Token(DL, c, line, col)); i += 1; col += 1 else: errs.append((line, f"非法字符 {c!r}")); i += 1; col += 1 toks.append(Token(EOF, "", line, col)) # 哨兵,避免解析时越界 return toks, errsToken里的line和col不是摆设,语义分析阶段的重复定义、类型不匹配全要靠这两个字段定位,报错信息里没有行号,验收时基本会被扣分。__slots__是为了让记号对象少占内存,一个几千行的测试用例能切出上万个记号,省下来的开销肉眼可见。扫描函数返回两个值而不是抛异常,是为了让上层一次性把所有词法错误打印出来,演示时这个细节很加分。
注意:块注释那段只更新了line,没更新col,因为注释内容不参与报错定位,精确列号意义不大。如果你的验收要求里明确写了列号正确,就得把块注释里的换行和字符也计入列号,否则注释后面的第一个记号列号会错位。
2.2 递归下降语法分析:用调用层级表达优先级
文法拿到手第一件事是消左递归。expr := expr + term | term这种写法直接翻译成函数就是无限递归,栈溢出是必然的,不是巧合。改写成expr := term ((+|-) term)*之后,每个非终结符对应一个函数,函数里的while循环负责吃掉同级运算符,左结合天然成立,优先级靠函数之间的调用层级表达:加法函数里调乘法函数,乘法自然先算。
class Parser: def __init__(self, toks): self.t, self.p = toks, 0 def peek(self, k=0): return self.t[min(self.p + k, len(self.t) - 1)] def eat(self, value=None): tok = self.t[self.p] if value is not None and tok.value != value: raise SyntaxError(f"第 {tok.line} 行期望 {value!r},实际 {tok.value!r}") self.p += 1 return tok def expr(self): # 关系运算,优先级最低 node = self.add() while self.peek().value in ("<", ">", "<=", ">=", "==", "!="): op = self.eat().value node = ("bin", op, node, self.add()) return node def add(self): # 加减 node = self.mul() while self.peek().value in ("+", "-"): op = self.eat().value node = ("bin", op, node, self.mul()) return node def mul(self): # 乘除取模 node = self.unary() while self.peek().value in ("*", "/", "%"): op = self.eat().value node = ("bin", op, node, self.unary()) return node def unary(self): # 一元负号,右结合 if self.peek().value == "-": self.eat("-") return ("neg", self.unary()) return self.primary() def primary(self): tok = self.peek() if tok.kind in (NUM, FLT): self.eat() return ("num", tok.value) if tok.kind == ID: self.eat() return ("var", tok.value, tok.line) if tok.value == "(": self.eat("(") node = self.expr() self.eat(")") return node raise SyntaxError(f"第 {tok.line} 行出现意外的记号 {tok.value!r}")这样写的最大好处是每一步都能停下来看:在mul里下一个断点,就能确认a + b * c是不是先算b * c。用 bison 时你看的是冲突数量,出了问题只能重点检查文法;手写的时候你能直接在代码里看见优先级。primary里把line也塞进 AST 节点,是给后面的语义报错留位置,变量未定义时要靠它指回源码。
2.3 抽象语法树:节点字段设计决定后面好不好写
课设规模的 AST 不建议上访问者模式,用元组最省事,打印和比对都方便。但语义分析阶段要往节点上回填类型,元组不可变,所以我一般做法是:语法分析阶段用元组,语义分析阶段建一张id -> type的字典单独存类型信息,两边不互相污染。
| 节点类型 | 字段 | 说明 |
|---|---|---|
num | 字面值 | 整数与浮点都走这个节点,类型靠字面值里有没有小数点判断 |
var | 名字、行号 | 语义分析阶段查符号表,查不到就是未定义 |
bin | 运算符、左、右 | 算术与关系运算共用,语义阶段按运算符分派检查 |
assign | 左值名字、右表达式 | 左值必须已在符号表里声明 |
if | 条件、then 语句、else 语句(可为空) | 条件表达式必须是数值或关系运算 |
while | 条件、循环体 | 循环体走一个新的作用域 |
call | 函数名、实参列表 | 实参个数与形参个数要单独校验 |
表里最后一行call是最容易漏的:很多人的语法分析只做了表达式和赋值,函数调用靠正则硬匹配,一到语义阶段就崩。把call当成正式节点,实参列表用元组存,后面生成四元式时按顺序param压栈,链路才通。
3. 语义分析与中间代码:符号表、类型检查与四元式回填
前端只保证“语法上说得通”,语义阶段才回答“这段代码有没有意义”。这一段是课设的评分重头,也是最容易写得含糊的地方:符号表做成一个全局字典、类型检查只判断相等、中间代码直接把表达式转成字符串拼接。这三种偷懒方式在简单用例上都过得去,一到嵌套作用域和混合类型就全暴露。
3.1 符号表:作用域栈 + 同层重定义检查
符号表用列表套字典实现作用域栈,栈底是全局作用域,进入{}、if、while就压一层,离开就弹一层。查找时从栈顶往下走,命中即返回,这就是内层遮蔽外层的语义。重定义只在同一层里判,外层有同名变量不算错,这一点很多人做反了,导致for循环里定义i时报“重复定义”。
class SymbolTable: def __init__(self): self.scopes = [{}] # 栈底是全局作用域 self.errors = [] def enter(self): self.scopes.append({}) def leave(self): self.scopes.pop() # 必须与 enter 成对,否则变量泄漏到外层 def declare(self, name, ty, line, kind="var"): cur = self.scopes[-1] if name in cur: # 只有同层重定义才算错 self.errors.append((line, f"变量 {name} 重复定义")) return cur[name] = {"type": ty, "kind": kind, "line": line} def lookup(self, name): for scope in reversed(self.scopes): # 由内向外查,命中即返回 if name in scope: return scope[name] return Nonekind字段用来区分变量和函数,函数名统一进全局作用域,调用时先查名字是不是函数,再比对实参个数。leave的配平是最常见的出错点:if后面没有花括号的写法、while循环体是单语句、函数体结束时提前return,这三种情况都可能让enter和leave数量对不上,表现是块内变量在外面还能被引用,或者反过来合法变量报未定义。我的习惯是在leave里加一句断言,调试阶段直接崩,比逻辑错误藏到后面强。
3.2 类型检查:隐式转换插在哪儿
课设里的类型系统通常只有 int 和 float 两种,规则不多但必须写清楚:算术运算两边同类型直接算,不同类型把 int 提升成 float;赋值要求右值类型能赋给左值,int 赋 float 要插一条转换;条件表达式接受关系运算的结果,或者接受数值(非零为真)。把“插转换”做成显式动作,而不是在生成代码时临时判断,后面的四元式生成会简单很多。
| 场景 | 规则 | 处理方式 |
|---|---|---|
int op int | 结果 int | 直接生成四元式 |
int op float | 结果 float | 在 int 那侧插入cast2f节点 |
float op float | 结果 float | 直接生成四元式 |
float = int | 允许 | 插入cast2f |
int = float | 报错或警告 | 我一般报错,逼学生写显式转换 |
if (int/float) | 允许,非零为真 | 生成条件跳转时按零比较 |
最后一行值得单独说:条件不是布尔类型的时候,四元式里要生成jne 条件, 0, 目标,而不是假设条件就是一个布尔值。很多人的实现在if (a)上直接崩,原因就在这里。
3.3 四元式生成:先留空白,再统一回填
中间代码我选四元式(op, arg1, arg2, result),结构简单、打印好看、答辩时好讲。生成过程用一张列表顺序追加,跳转指令的目标地址先用占位,等目标确定后统一回填。回填是整套流程里最容易翻车的一步,也是最能看出有没有真理解控制流的一步。
class QuadGen: def __init__(self): self.quads = [] # (op, arg1, arg2, result) self.tmp = 0 def new_tmp(self): self.tmp += 1 return f"t{self.tmp}" def emit(self, op, a1="", a2="", res=""): self.quads.append((op, a1, a2, res)) return len(self.quads) - 1 # 返回下标,供回填使用 def backpatch(self, holes, target): for h in holes: # 把之前留空的跳转目标一次性补齐 op, a1, a2, res = self.quads[h] self.quads[h] = (op, a1, a2, target) def gen_while(self, cond, body): start = len(self.quads) # 循环入口 false_jumps = self.gen_cond(cond) # 条件为假时要跳出的位置,先留空 self.gen_stmt(body) self.emit("jmp", "", "", start) # 回到条件判断 self.backpatch(false_jumps, len(self.quads))gen_cond返回的是一组“条件为假时跳转”的四元式下标,因为&&、||短路求值会生成多个跳转点,必须一起回填。new_tmp用递增编号而不是复用寄存器,是为了让中间代码可读,优化阶段再考虑临时变量压缩。如果直接跳过四元式、在语法树上边遍历边输出汇编,while的循环入口地址根本没法在生成循环体之前确定,这就是回填存在的理由。
常用的四元式集合不用多,十来个就够跑通整个子集:+ - * / %、=、< > <= >= == !=、jmp、jlt、jne、param、call、ret、label。label用来标记函数入口和循环入口,比直接用下标好读,也方便后面划分基本块。
4. 目标代码生成与局部优化:从四元式到寄存器分配
后端部分在很多课设里被砍掉,只交到四元式就结束。但只要时间够,我建议至少做到“四元式映射到伪汇编 + 基本块内三种优化”,这一块是答辩时唯一能体现你懂“编译”而不只是“翻译”的地方。目标机不用真机,一套有四五个通用寄存器、支持LD/ST/ADD/SUB/MUL/DIV/JMP/JLT的伪汇编就够了。
4.1 指令选择:寄存器描述符与变量描述符
逐个四元式翻译,遇到二元运算就先把两个操作数弄进寄存器,算完写回结果。难点在“弄进寄存器”这一步:变量可能已经在某个寄存器里,也可能还在内存里,得靠两张表判断。寄存器描述符记录每个寄存器当前存的是哪个变量,变量描述符记录每个变量当前在哪个寄存器或内存。
REGS = ["R0", "R1", "R2", "R3"] class CodeGen: def __init__(self): self.code = [] # 输出的伪汇编行 self.reg_desc = {} # 寄存器 -> 当前保存的变量 self.var_desc = {} # 变量 -> 所在寄存器,缺省表示在内存 def get_reg(self, name): for r, v in self.reg_desc.items(): if v == name: # 已经在寄存器里,直接用 return r for r in REGS: if r not in self.reg_desc: # 优先用空闲寄存器 self.reg_desc[r] = name self.var_desc[name] = r self.code.append(f" LD {r}, {name}") return r return self.spill() # 全满则淘汰一个 def spill(self): r = REGS[0] # 课设规模下按固定顺序淘汰即可 old = self.reg_desc[r] self.code.append(f" ST {r}, {old}") # 被淘汰的变量先写回内存 self.var_desc.pop(old, None) self.reg_desc.pop(r) return rspill用最简单的固定顺序淘汰,而不是 LRU 或最远下次使用,是因为课设的测试用例规模小、寄存器压力低,复杂策略带来的收益还不如代码可读性重要。真要做,可以在答辩时讲一句“理论上应该按下次使用距离淘汰,这里做了简化”,比硬写一个半吊子 LRU 更稳。图着色寄存器分配也是同理:能讲清楚原理、能说明为什么在这个规模下不划算,比硬塞一个跑不通的实现得分高。
4.2 基本块划分与三种局部优化
基本块的划分规则很固定:第一条指令是入口,跳转指令的下一条是入口,跳转目标也是入口。入口之间的连续指令就是一个基本块。块内优化只做三件事,做完效果已经很明显:常量折叠、公共子表达式删除、死代码删除。
| 优化前四元式 | 优化后 | 用到的规则 |
|---|---|---|
t1 = 3 + 4 | t1 = 7 | 常量折叠 |
t2 = a * b;t3 = a * b | t3 = t2 | 公共子表达式删除 |
x = t1;t1之后未再使用 | 删除该行 | 死代码删除 |
t = i * 8 | t = i << 3 | 强度削弱(有移位指令时) |
常量折叠要在生成四元式之后、生成目标代码之前做,位置放错了就白做。公共子表达式删除只在基本块内做,跨块做需要数据流分析,课设阶段写不动也讲不清。强度削减弱化成移位属于加分项,没有移位指令的后端直接跳过这条,不影响整体评分。
5. 避坑与排查:课设里最容易出问题的五个环节
这一节列出的五个问题,几乎每一届都会有人遇到。它们的共同点是不是“不会写”,而是“写完了跑不对,还不知道错在哪”。
5.1 扫描器卡死或切错词
现象:程序跑起来不结束,CPU 占满;或者a>=1被切成a、>、=、1四个记号。
原因:某个分支没有推进下标i,最常见的是注释分支和非法字符分支;或者双字符运算符的判断放到了单字符后面。
解决:在while循环末尾加一句断言,检查i是否严格递增,一跑就暴露。运算符表按长度倒序匹配,先查两字符再查单字符。
5.2 递归下降栈溢出
现象:解析稍微长一点的表达式就RecursionError,短表达式正常。
原因:文法没消左递归,expr -> expr + term直接翻译成函数,第一句就调自己。
解决:所有二元运算改写成term (op term)*的循环形式。改完检查一遍每个非终结符函数,第一个被调用的函数不能是自己。
5.3 符号表作用域泄漏
现象:if块里定义的变量在外面还能用;或者反过来,全局变量在函数里报未定义。
原因:enter和leave数量不匹配,异常路径上没弹栈;或者查找时只查了栈顶一层。
解决:leave里加断言检查栈深度不为 1;查找统一走reversed(self.scopes)。语法错误恢复时也要保证栈能弹回原始深度。
5.4 循环跳转地址错位
现象:while循环只执行一次就退出,或者死循环跳不出来。
原因:回填时把“条件为假跳出”的地址填成了循环入口;或者&&短路产生的多个跳转点漏填了一个。
解决:把gen_cond的返回值统一成“假跳转下标列表”,全部走backpatch,不允许在生成条件时直接算地址。打印四元式列表逐行对照源码,错误位置一眼可见。
5.5 报错即崩溃,演示做不下去
现象:答辩现场输入一个故意写错的用例,程序抛异常退出,后面准备好的演示全乱。
原因:全程用异常做错误处理,没有恢复机制;或者错误信息只有一句“语法错误”,没有行号。
解决:词法和语法阶段都改成“记录错误 + 跳过继续”,把错误收集到列表里最后统一打印。同步集合用语句起始记号(;、}、if、while),跳过直到遇到它们为止。
6. 验收与答辩:测试用例怎么分级、十分钟怎么演示
课设的分数很少由代码量决定,更多取决于“你能不能证明它对”。我一般要求学生准备三组用例,从能跑到能错,覆盖不同评分点。第一组是必过用例,只包含变量声明、赋值、四则运算,用来证明链路通;第二组是特性用例,覆盖if/else、while、嵌套作用域、函数调用、int 与 float 混算,每个特性一个文件,出问题能立刻定位到模块;第三组是错误用例,故意写未定义变量、重复定义、括号不匹配、块注释未闭合,用来证明你的报错机制不是临时拼的。
演示节奏控制在十分钟内,顺序固定:先跑一个必过用例,展示从源码到四元式到伪汇编的完整输出;再打开一个特性用例,只讲while的回填过程,因为这是控制流里最能体现理解深度的部分;最后跑一个错误用例,让评委看到程序不崩、能报出行号。剩下的时间留给提问,别把十分钟全用在念代码上。
| 评分点 | 对应演示动作 | 常见扣分原因 |
|---|---|---|
| 词法正确性 | 跑含注释、小数、双字符运算符的用例 | 只测了加减乘除 |
| 语法正确性 | 跑嵌套括号与多层if | 表达式优先级错 |
| 语义检查 | 跑未定义变量、重复定义 | 报错无行号 |
| 中间代码 | 打开四元式列表讲while回填 | 地址对不上源码 |
| 目标代码 | 展示一个基本块的优化前后对比 | 优化没做或做错 |
| 健壮性 | 跑错误用例不崩 | 一遇错误就退出 |
答辩提问里出现频率最高的三个问题,提前准备答案:为什么不用 bison、寄存器不够了怎么办、公共子表达式为什么只在块内做。这三个问题的答案都在前面的设计选择里,讲清楚“我为什么这么选”比讲“我实现了什么”更能拿分。
从那以后我每次带课设,都强制要求第一天先写扫描器的单元测试,再动语法分析;四元式一旦生成就先打印出来逐行对照源码,确认跳转地址没问题再往后端走。这套顺序看着慢,实际上省掉了后期一大半的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取