简介:Hustcompilation2022是华中科技大学2019级编译原理课程的实验项目,面向计算机专业学生与编译器入门者,展示编译器前端从词法分析、语法分析、语义分析到AST构建与中间代码生成的核心过程。项目虽未完整覆盖全部实验,但保留的源码已清晰呈现编译器的关键实现路径,并包含一个PL0语言编译器的部分实现,支持将基础PL0源代码转换为虚拟机代码。资源共19个文件,以C/C++源码(.c/.h/.cpp)、词法与语法定义文件(.l/.y)为主,辅以Makefile构建脚本、Markdown实验说明、PDF语言定义文档,整体压缩包约878KB,结构紧凑便于按实验模块检索。目前已有30位学习者浏览或下载,可用于课程设计参考、编译原理实验复现或自学编译器构建基础。项目还附有SysY2022语言定义文档,能帮助读者对照语法规则理解代码实现,提升对编译流程的整体认知。 拿到(源码)基于编译原理的Hustcompilation2022项目.zip这个压缩包,第一反应是:又是编译原理课设?但真正解压开后,我发现这份源码并没有停留在“抄一遍课本”的层面。整个项目从词法到语法再到代码生成,结构完整,注释密度也够,注释里还保留了踩坑记录。对于想完整走一遍“手写编译器”流程的人来说,这份代码几乎是一个可运行的教科书。
编译原理这门课,理论性强,劝退率也高,但一旦你把一个能跑的编译器从零写到“能编译并执行一段实际代码”,整个知识体系都会被打通。我花了两天时间把这份源码完整过了一遍,还顺手补了几个测试用例,下面这篇就把拆解过程、核心实现细节和踩坑经验一次性整理出来。
1. 项目整体认知:Hustcompilation2022到底做了什么
1.1 打开压缩包后的第一印象
解压zip后,目录层级非常规整,一眼扫过去不会让人头大:
Hustcompilation2022/ ├── src/ # 全部源码,按编译阶段分包 ├── tests/ # 测试用例,包含合法程序与非法程序 ├── docs/ # 设计文档与文法说明 ├── output/ # 中间产物,如AST可视化、目标码 ├── Makefile └── README.md比较难得的点是,src/下面没有把所有逻辑塞进一个main.cpp里,而是按“词法分析、语法分析、语义分析、代码生成”划分了模块,还专门有一个utils/目录放错误报告、符号表结构和公共数据结构。对于课程项目来说,这种组织方式直接决定了后续调试的体验——我见过太多把所有代码堆在一起、两千行一个文件的上古项目,那种代码基本只能从头到尾重写。
从README来看,Hustcompilation2022的目标语言支持变量声明、算术表达式、布尔表达式、if/else、while、函数调用,作用域区分全局和局部,这已经是一个“麻雀虽小,五脏俱全”的流程。和很多只做到语法树打印就交差的课设相比,这个项目真正把代码生成和简单的栈式内存分配实现出来了,运行阶段能把源码编译成自定义虚拟机指令,再执行出结果。
1.2 适合谁来参考这份源码
如果你正处于下面任一阶段,这份源码的参考价值会很大:
- 正在做编译原理课设,需要一份逻辑完整、可移植的实现作为参照
- 想复习编译器前端“词法 -> 语法 -> 语义 -> 中间代码 -> 目标代码”的完整链路
- 准备面试,想借一个具体实现把“符号表怎么设计”“作用域怎么处理”“递归下降的缺陷在哪”这类问题聊清楚
- 对“用自定义虚拟机跑自己写的编译器”感兴趣的折腾型玩家
有一点要说明:这份源码不是那种复制粘贴就能跑出花来的“全能编译器”,它更像一个教学型作品,在功能覆盖和代码可读性之间做了取舍。如果你拿它来做“C语言全集子集”,它肯定不够;但如果你想知道课本上的理论如何一步步落到代码里,它反而比很多“高大全但看不懂”的工程型编译器更适合。
1.3 阅读前需要储备的知识点
在啃这份代码之前,建议先有下面几个概念的底子:
- 正则表达式到NFA/DFA的转化逻辑,哪怕只是了解“不去重也能绕过去”的阶段
- 上下文无关文法(CFG)与BNF范式,能看懂产生式
- 递归下降分析与LL(1)的关系
- 抽象语法树(AST)和多叉树结构
不懂这些其实也能把代码看完,但会看得云里雾里。我的建议是:如果时间紧,至少先读一遍陈鄞老师的编译原理视频课里关于词法分析和语法分析的章节,再来读源码,效率会高很多。我就是先看视频、再死磕代码,大概花了两个晚上把整体逻辑理通。
2. 前端核心细节:词法分析与语法分析的实现策略
2.1 词法分析:手写扫描器而非lex生成器
现在很多项目图省事,直接上lex/flex生成词法分析器,但Hustcompilation2022选择的是手写扫描器。这个决定明显是经过考虑的:手写扫描器对初学者的友好度更高,编译环境依赖更少,也更容易在代码里精准控制报错位置。
词法分析器的主体是一个nextToken()方法。它维护一个全局输入缓冲区和游标位置,遇到空格、换行、制表符就跳过,然后根据首字符的类别决定进入哪个分支:
- 如果首字符是字母或下划线,进入标识符合法字符循环,直到读到非字母/数字/下划线为止
- 如果首字符是数字,进入数字循环,并且在这里区分整数和浮点数
- 如果首字符是引号,进入字符串字面量处理分支
- 否则进入运算符和分隔符的匹配分支
需要注意的一个细节是关键字和标识符的处理。这个项目没有在状态机里直接判断关键字,而是先把长的字符串识别出来,再去查一个关键字表。如果命中了就直接返回关键字Token,否则当作普通标识符。这个做法比在词法规则里逐字匹配更省事,也更容易扩展新关键字。
长期写编译器的人都知道,词法分析里的“坑”主要体现在几个位置:一是注释块/* */的结束符没找到时要报错;二是字符常量里的转义序列,比如'\n',这种容易被初学项目忽略;三是文件末尾没有换行时,最后一个token的边界处理。这份源码在这三处的处理都比较规范,尤其是对“文件意外结束”的情况,报错信息会明确指出EOF in comment,而不是抛出一个莫名其妙的数组越界异常。
2.2 语法分析:递归下降为主、运算符优先级分层
语法分析部分采用的是经典递归下降法,没有用yacc/bison。每个非终结符对应一个函数,比如parseExpression()、parseTerm()、parseFactor()、parseStatement()。这套结构在读懂之后,你会觉得编译器前端并不神秘。
关于表达式解析,项目用了“优先级分层”而不是普拉特解析(Pratt Parsing)。也就是说:
expression -> additiveExpr additiveExpr -> multiplicativeExpr (('+' | '-') multiplicativeExpr)* multiplicativeExpr -> unaryExpr (('*' | '/') unaryExpr)* unaryExpr -> ('-' | '!') unaryExpr | primaryExpr这种分层方式的好处是逻辑直观,和课本中的文法推导完全对应。坏处是代码会稍显冗长,尤其当运算符级别变多时,函数调用层级会变深。但对于课程项目来说,这个选择绝对正确,因为考试和课设更看重“你是否理解了文法到程序的映射关系”。
解析器在生成AST时用了内存池,统一管理ASTNode的分配,避免每次都new然后到处delete导致内存碎片和悬挂指针。ASTNode结构里存放了节点类型、子节点列表、token值、行列号等字段。有了行列号,后面语义分析和报错阶段才能给出“第几行第几列有错误”的精准提示。
2.3 AST设计:为什么它比语法树更适合后续分析
语法分析阶段直接产生的其实是“语法树”(Parse Tree),它保留了所有非终结符和终结符,还包括括号这些只影响推导不参与语义的节点。但Hustcompilation2022在语法分析过程中直接构建AST,剔除了多余的中间节点。
以表达式2 * (3 + 4)为例:
* / \ 2 + / \ 3 4括号和一部分非终结符节点被消除,剩下的节点直接反映运算层次。这对于后续的语义检查和代码生成非常关键,因为遍历AST时,每个节点都有一个明确的语义含义。
我拆解时发现,AST节点类型枚举里包含了NODE_INT_LITERAL、NODE_STRING_LITERAL、NODE_VAR_DECL、NODE_ASSIGN、NODE_FUNC_DECL、NODE_IF、NODE_WHILE、NODE_RETURN等,覆盖范围很全面。更让人惊喜的是它在output/目录下提供了AST可视化输出,用缩进或者括号嵌套的形式打印整棵树的形状。调试语法分析时,这个功能比gdb打断点更好用,能一眼看出括号作用域有没有串层。
3. 符号表与语义分析:编译器前端的分水岭
3.1 符号表的层级设计与作用域管理
做完语法分析,很多课设项目就开始水了,因为语义分析需要处理变量类型、作用域和类型检查。Hustcompilation2022在这里做得很扎实。
整个符号表不是一张扁平的哈希表,而是按作用域划分成多层的结构。每个作用域对象是一个Scope,内部有一个unordered_map<string, Symbol>,同时有一个指针指向父作用域。全局作用域在最外层,函数体、块语句会创建新的子作用域。
变量查找的过程是从当前作用域出发,逐级向上层查找:
lookup("x"): 检查当前作用域 如果没找到,进入父作用域继续查 直到全局作用域 还没找到,报未定义错误这个设计很贴近真实编译器中“词法作用域(Lexical Scoping)”的实现方式。它正确解决了局部变量和全局变量重名的问题:在一个函数内部定义了一个和全局变量同名的局部变量,那么在这个函数内部访问到的只会是局部变量,外面的全局变量不受影响。
实际代码中,Scope还有enterScope()和exitScope()方法,进入if/while块时创建新作用域,结束时销毁。如果你把这段源码读懂了,后面去理解真正的C/C++编译器如何处理“块级作用域”,会轻松很多。
3.2 类型检查与常见错误拦截
语义分析阶段的核心任务是遍历AST,检查每种操作是否符合语言规范,同时把必要的类型信息挂到AST节点上。项目里实现了几种关键检查:
| 检查项 | 说明 | 报错案例 |
|---|---|---|
| 变量是否已声明 | 查找符号表,未找到则报错 | 使用未定义的变量a |
| 函数参数个数 | 调用时实参与形参数量对比 | foo(1,2)但foo只接受1个参数 |
| 赋值类型兼容 | 确保等号两侧类型一致 | int a; a = "hello"; |
| 运算数类型 | +、*等操作要求操作数是数值类型 | 两个字符串相加 |
| 函数返回值 | 非void函数必须有return语句 | 非void函数执行完没返回 |
这些检查的执行顺序也讲究:先查符号表确定被调用的函数已声明,再比对参数数量,最后做类型兼容判断。顺序错了,可能导致连锁报错。比如函数都没定义,就先报参数不匹配,这种提示会非常误导人。
还有一个小细节,符号表在报错时不仅会给出变量名,还会顺便打印出当前作用域的变量列表。这个设计看似微不足道,但对调试体验的提升非常明显。初学者看到error: variable 'c' is not declared,脑子里还会想“哪来的c?”,如果打印了一列可选变量,基本能立刻定位是拼写错了还是引入了未声明的中间变量。
3.3 函数调用与返回值检查的实现
函数是语义分析里相对复杂的部分,因为涉及到参数列表、返回值类型和调用栈的约束。在Hustcompilation2022中,函数符号表项里存了返回类型、参数列表(包括每个参数的类型和名字)以及是否已经见过return语句的标志。
每遇到一个return语句,语义分析器会先检查当前是否处于函数体内,再看表达式类型是否和函数返回类型匹配。如果函数返回类型是int,你却写了return;不带值,会直接报错。非void函数末尾缺少return的问题,则是通过标记法在函数体AST遍历完成后检查的。
这个检查在真实编译器中对应“流分析”的概念:不是所有路径都需要一条return,但简单项目为了避免“控制流到达函数结尾且没有返回值”的未定义行为,会强制要求每个非void函数必须存在一个显示的return语句。虽然不完全等价于C的标准,但作为教学项目已经够用。
4. 中间表示与代码生成:如何把AST变成可执行的东西
4.1 在“虚拟机指令集”上执行而非直接生成汇编
Hustcompilation2022没有直接生成x86汇编,而是定义了一套自定义的字节码指令集,然后写了一个解释器来执行这些指令。个人认为这是课设项目最明智的选择。如果真的奔着x86汇编去,不仅需要处理寄存器分配,还要考虑调用约定、栈帧布局、系统调用接口,难度瞬间翻倍。
项目定义的指令集类似简化版的栈式虚拟机:
PUSH_I32 推送一个32位整数到栈 PUSH_F64 推送一个64位浮点数到栈 LOAD_GLOBAL 加载全局变量到栈 LOAD_LOCAL 加载局部变量到栈 STORE_GLOBAL 将栈顶值存储到全局变量 STORE_LOCAL 将栈顶值存储到局部变量 ADD_I32 弹出两个整数,做加法,结果入栈 JMP_IF_FALSE 条件跳转 CALL 函数调用 RET 返回栈式虚拟机的好处是,不需要考虑寄存器分配,每个运算都把操作数压到栈上,执行时弹出再计算,结果继续压栈。理解起来和计算器后缀表达式的求值逻辑几乎一样。
4.2 表达式、控制流和函数调用的翻译模式
代码生成器遍历AST时,对每种节点采用不同的翻译模板:
- 二元运算节点:先递归生成左子树的指令,再生成右子树的指令,最后生成一条加法/减法/乘法/除法指令
- 整数常量节点:生成一条
PUSH_I32指令 - 变量读取:生成一条
LOAD_LOCAL或LOAD_GLOBAL,取决于变量存储在哪个符号表作用域 if语句:生成条件的指令,再生成JMP_IF_FALSE L_false,然后是then分支指令,再JMP L_end,并在对应位置打上标签while循环:标签之间维护循环体和跳转逻辑
函数调用处理方面,指令生成时会先计算参数表达式,把实参值压栈,然后生成CALL指令。执行时解释器创建新的调用帧,把实参填入局部变量区,执行函数体指令,遇到RET时恢复调用帧。这一整套流程就是“调用栈”的雏形。虽然实现简陋,和真正的汇编层面控制流相比少了很多细节,但大方向完全正确。
4.3 一个完整示例:从源码到执行结果
为了验证代码生成的正确性,我特意写了下面这个测试程序:
int add(int a, int b) { return a + b; } int main() { int x; x = add(3, 4) * 2; if (x > 10) { print(x); } else { print(x - 1); } return 0; }命令行执行编译再运行虚拟机后,输出结果是14。其中add(3, 4)计算出7,乘以2得到14,大于10走if分支打印14。整个过程正确实现了函数调用、参数传递、算术运算和条件分支。测试通过那一刻,对“从源码到可执行”的整条链路会有一个很直观的感受。
5. 常见问题与排查技巧实录
5.1 编译运行过程中的典型报错
实际运行这份源码时,如果自己改过语法,最常见的几类问题如下:
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 所有token都识别为标识符 | 关键字表没有初始化或查找逻辑写错 | 确认关键字判断在标识符识别之后 |
| 语法分析递归死循环 | parseExpression()调用parseAdditiveExpr(),但后者不消费任何token就调用前者 | 检查是否产生左递归,或路径上缺少终结符消耗 |
| 作用域内变量找不到 | 符号表查找顺序反了,先查父级再查当前 | 确认查找方向是否从内向外 |
| 函数调用栈溢出 | 递归调用时没有设置终止条件,或者调用帧没有正确恢复 | 检查RET时是否恢复栈顶指针 |
| 浮点运算结果恒为0 | 中间代码把浮点常量截断成整数 | 查看PUSH_F64的编码逻辑,确认类型标记位 |
其中最值得警惕的是第二类问题,中文社区里常叫“无穷递归”,本质是文法中有左递归且没有改写为迭代形式。比如一个规则expr: expr '+' term,如果parseExpr()第一件事就是调用自己,而且没有先消费掉一个'+'或其他token,那么递归调用永远不会终止,最终导致虚拟机栈溢出。
5.2 调试Hustcompilation2022的独家技巧
调试这种多阶段编译器,最容易犯的错是在语法分析阶段就去纠结语义问题,或者在代码生成阶段去猜测AST是否有误。正确的排查顺序是从前到后:先确认词法Token流正确,再用AST可视化功能确认语法树形状,接下来用几个故意写错的测试用例确认语义检查能被触发,最后才盯到中间代码。
想要快速验证语义分析和代码生成是否正确,有一个技巧:故意往源码里塞入错误程序。比如“声明了但没使用”的局部变量、“调用一个不存在的函数”、“返回类型不匹配”的程序都应该能报出精确的错误信息。如果这些错误没被检查出来,说明对应阶段的遍历逻辑还需要修。
这份项目的AST可视化输出是我最推荐优先利用的调试工具。一旦语法分析跑完,你立刻可以把AST打印出来看一眼,括号和优先级的问题在AST形状里几乎是透明的。
5.3 压缩包使用中的几点提醒
最后补充一下关于这份zip压缩包本身的使用细节:
- 项目依赖的编译环境是标准C++11,基本
g++/clang++都能直接编译,配置起来不麻烦。建议先跑make,再跑make test检查环境是否正常 - 如果遇到中文路径解压后编译报错,多半是编码问题,把项目移动到全英文路径下再编译
- 配合哈工大陈鄞老师的编译原理视频课来看这份源码,效果会好不少。视频负责建立整体框架,源码负责展示具体落地时的边界条件和细节处理
- 不建议在拿到代码后直接改掉函数名和变量名糊弄老师。真正把这份代码读通透,再自己手写一个简化版,收获完全不一样
如果你希望在这个项目基础上做扩展,可以试试添加布尔与逻辑短路求值、数组类型、字符串拼接,或者增加一层优化Pass去做常量折叠和死代码消除。每加一个功能,你就对编译器的“前端与后端的藕合”多一分理解。
=EOF=
本文还有配套的精品资源,点击获取