编译原理课程设计实战:从词法分析到中间代码生成全解析
简介北邮编译原理课程设计是一份可供下载的完整工程资源面向北邮学生及编译方向学习者完整展示了一个从词法分析、语法分析、语义分析到代码生成的Pascal编译器实现。资源以C源程序为骨架包含.h头文件与.cpp实现文件同时配合.pas源码、.asm汇编输出、.bin二进制目标及.aps、.rc等工程配置共83个文件压缩包仅123KB轻量且结构紧凑。已有1191人学习浏览是理解编译器前后端流程的优质参考资料。通过研读代码可掌握词法单元识别、AST构建、类型检查与代码生成的具体实现方式还能看到符号表、错误处理、中间代码如三地址码等模块的落地写法适合作为课程设计参考或自学编译原理的实践范本。 北邮的编译原理课程设计做完之后我最大的感受就是这门课设计得真值。如果你现在正被词法分析、语法分析、中间代码生成这些东西搞得头皮发麻或者刚领到题目还没想好用什么技术栈这篇文章应该能帮你省下不少摸索的时间。我会结合自己做课设的经历把整个流程、工具选型、核心模块的实现要点还有那些文档里不会写的坑一次说清楚。这个课设适合谁看主要是在校生尤其是北邮或者教学体系类似的同学其次是自己想动手写一个迷你编译器、但对完整流程没什么概念的自学者。它本质上是把一个学期学的编译原理理论落地成代码做完你会对“高级语言到底怎么变成机器能跑的东西”有一个非常具体的认知而不是停留在课本的流程图里。1. 课程设计的整体脉络与工具选型1.1 课设到底要你做什么北邮的编译原理课程设计核心任务一般是用一学期或者半个学期的时间实现一个可运行的编译器。它跟期末考试不一样考试考的是概念和推导过程课设考的是你能不能把那些概念变成真实存在的代码。我当时拿到的要求是自己定义一个简化版的高级语言写词法分析器、语法分析器再生成中间代码最后解释执行或者生成目标代码。语言不用太复杂但必须完整支持变量声明、赋值、表达式、条件分支、循环、函数调用这些基本功能。整个项目的代码量按不同实现方式差别很大纯手写大概要两千行以上用工具生成的话核心代码可能只有六七百行。但要注意代码行数不代表难度关键在于你有没有真的理解那几条产生式、那张符号表、那个属性栈是怎么协同工作的。课设验收的时候老师通常会现场改测试用例看你能不能跑通还会追问一些设计细节比如“这里遇到类型不匹配你怎么报错”“函数参数压栈的顺序是什么”所以动手做和抄代码的差距在验收时一眼就能看出来。1.2 三条技术路线怎么选做编译器有大方向的差异选错了后面会越写越难受。我梳理一下常见的三条路你可以根据自己的编程基础和未来方向来挑。第一条路是Flex Bison这是经典组合。Flex负责词法分析Bison负责语法分析两者配合生成C代码。优点是社区资料极其丰富编译原理教材里的例子基本都用这套工具遇到问题在StackOverflow一搜就能找到答案缺点是写的是C语言对指针、内存管理不熟的人容易踩段错误的坑。第二条路是ANTLR特别是用Java作为目标语言。这条路在热门词里跟“Java编译原理”的关联度很高。ANTLR用LL(*)文法写起来比LALR(1)直观很多生成的代码是Java或Python的调试起来比C友好报错信息也人性化很多。缺点是如果教学体系里讲的是LR分析你用它可能看不懂生成的解析器内部状态答辩时容易露怯。第三条路是纯手写递归下降分析器。这是最能加深理解的做法不用外部工具用自己熟悉的语言我当时用的C从头写。递归下降对应的是LL(1)文法代码逻辑非常直观函数套函数每个非终结符对应一个函数。缺点是对文法要求比较严格左递归要消除、公共前缀要提取手动处理不够小心的话很容易写出歧义文法。我个人建议如果你目标是快速拿分、少折腾选FlexBison如果你Java底子好、不想碰C的指针选ANTLR如果你确实想把这门课学透而且有足够的调试时间可以尝试手写递归下降。北邮课设其实不限制工具所以“最优解”是适合你自己的那条路。1.3 为什么我推荐优先确定中间表示很多同学拿到课设题目后第一反应是写词法分析器这其实顺序有点问题。我更建议你先想清楚中间代码长什么样再倒推词法和语法部分。因为中间代码格式决定了语法分析时要把哪些信息保存下来也决定了后面解释器或目标代码生成器怎么读取这些信息。当时我采用的三地址码Three-Address Code作为中间表示每条指令最多三个操作数比如t1 b * c、t2 a t1、a t2。三地址码的好处是线性、紧凑、贴近汇编但又不依赖具体机器调试时也用文本格式直接输出肉眼就能检查生成是否正确。如果你选栈式中间代码类JVM字节码也可以但我觉得对课设来说三地址码更容易验证逻辑正确性。2. 核心模块拆解与关键实现细节2.1 词法分析不要小看这个“翻译助手”词法分析器的任务是把源代码字符串切成一个个token相当于把一整句“你好世界”拆成“你”“好”“世”“界”这些有意义的词。用Flex写的话核心就是一个.l文件里面写了各种正则规则Flex会生成一个yylex()函数每次调用就返回下一个token。写词法规则有几点需要注意。第一关键字的优先级必须高于标识符不然if会被识别成普通变量名。解决办法是把关键字规则写在标识符规则前面Flex默认取最长匹配但长度相同时取最先出现的规则所以关键字的规则必须先声明。第二注释和空白要单独处理读到了就跳过不生成token。第三字符串和数字的边界情况要处理好比如转义字符\n、十六进制数0xFF规则写不好很容易出错。下面是我当时的一个Flex片段注释里的规则顺序很关键%{ #include tokens.h %} %% if { return TK_IF; } else { return TK_ELSE; } while { return TK_WHILE; } int { return TK_INT; } return { return TK_RETURN; } [a-zA-Z_][a-zA-Z0-9_]* { yylval.str strdup(yytext); return TK_IDENT; } [0-9] { yylval.num atoi(yytext); return TK_NUM; } [ \t\n] { /* 忽略空白 */ } //[^\n]* { /* 忽略行注释 */ } { return TK_ASSIGN; } { return TK_PLUS; } * { return TK_STAR; } ( { return TK_LPAREN; } ) { return TK_RPAREN; } . { fprintf(stderr, 非法字符: %s\n, yytext); } %%注意这里结尾有一个.规则它匹配任意不在前面规则里的单个字符起到报错兜底的作用。没有这个规则的话遇到非法字符Flex会直接卡住。2.2 语法分析Bison里的优先级和冲突处理词法分析做完接下来是语法分析。用Bison写.y文件本质上就是把文法产生式写进去每个产生式关联一段动作代码在规约时执行。Bison默认使用LALR(1)对于常见的表达式文法不需要手工消除左递归因为它内部能处理左递归。最容易踩坑的地方是运算符优先级和结合性。比如表达式2 3 * 4如果文法写成expr : expr PLUS expr | expr STAR expr | NUM这里其实是有歧义的Bison会告诉你产生了冲突。解决办法是用%left和%right声明优先级。和-是左结合*和/优先级更高也是左结合。如果你不加这些声明Bison会默认按移进优先处理生成的分析表可能不符合我们想要的运算顺序。给一个示例片段%left TK_PLUS TK_MINUS %left TK_STAR TK_DIV %% expr : expr TK_PLUS expr { $$ new_ast_binary(OP_ADD, $1, $3); } | expr TK_STAR expr { $$ new_ast_binary(OP_MUL, $1, $3); } | TK_LPAREN expr TK_RPAREN { $$ $2; } | TK_NUM { $$ new_ast_number($1); } ;这里我用$$表示产生式左部非终结符的属性值$1、$3表示右部符号的属性值。Bison默认只持有整型属性想传结构体指针得用%union声明联合体这一步很多人会漏结果编译报错半天不知道为什么。悬空else问题也很经典。比如if (a) if (b) c1; else c2;这个else到底匹配内层if还是外层ifC系语言的规则是匹配最近的ifBison默认用移进优先恰好符合这个规则所以通常不会报错但你心里得清楚这个机制答辩时老师可能会问。2.3 语义分析符号表里存的不只是名字语法分析通过之后还需要语义分析核心数据结构是符号表。符号表用来记录每个变量的类型、作用域、还有中间代码中对应的临时变量名。很多同学的符号表就只存了个变量名和类型等到生成中间代码时发现信息不够又回头改结构非常浪费时间。我设计的符号表结构大概是这样typedef struct Symbol { char *name; // 变量名 Type type; // 类型枚举INT, VOID 等 int scope_depth; // 作用域深度全局是0 char *ir_name; // 中间代码中的名字如 t1 或全局变量名 int initialized; // 是否已初始化 struct Symbol *next; // 哈希表链式地址法的next指针 } Symbol;做符号表要区分作用域。函数内定义的局部变量和全局变量重名时按作用域优先级查找这样才能正确处理变量的遮蔽问题。我当时用一个简单的链表栈实现作用域进入一个{}块就压入一个作用域退出就弹出查找时从栈顶往下找。这样做简单直接功能也够用。语义检查主要做这些事变量是否声明过就使用、函数参数个数和类型是否匹配、赋值语句左值是否可写、运算数的类型是否一致。对于简化语言见到类型不匹配时直接打印错误并终止编译也是可以的课设一般不会要求你实现类型自动转换。2.4 中间代码生成如何把语法树变成线性指令中间代码生成是整份课设里最体现理解深度的一环。我选择在语法分析阶段直接边规约边输出三地址码没有显式构建AST。这种方式简单直接代码量少但对属性传递的要求比较高——你在一条产生式里需要同时知道子表达式的类型、地址和中间变量名。另一种方式是先构建语法树再对AST遍历生成三地址码结构更清晰调试更容易但代码量会大一些。如果你工期充足我推荐先建AST再生成中间代码因为这样后面如果要加优化会好做很多。中间代码前面的临时变量命名用t1, t2, ...递增就可以。但要注意把临时变量也记录到一个计数器里避免跟用户变量冲突。实际生成时每遇到一个二元运算就生成一个新的临时变量把结果放进去。我举一个简单例子。输入语句a b c * 2;中间代码应该长这样t1 c * 2 t2 b t1 a t2这里有一个隐含的运算顺序问题因为c * 2先于b t1被计算所以生成中间代码时也要保证临时变量的顺序正确也就是深度优先遍历语法树先算子树再算父节点。如果这里顺序反了后面的解释器逐条执行时会用到未初始化变量。2.5 目标代码或解释器让程序真正跑起来中间代码生成完项目还不能算完你还得让它能执行。常见的做法有三种解释执行、生成C代码再交给gcc编译、生成简单虚拟机的字节码。解释执行最简单就是写一个循环逐条读取三地址码根据操作符执行对应的操作。比如t1 c * 2就去环境里查c的值乘2存入t1的空间。变量存储就用一个哈希表变量名到整数的映射。这样做的好处是不需要处理寄存器分配、栈帧这些底层细节课设验收时也能清晰地展示运行结果。我当时选的是生成C代码再交给gcc编译。这样不用自己写解释器只要把三地址码逐条翻译成C语句就行。比如t1 c * 2变成int t1 c * 2;条件跳转变成if (条件) goto label;。C代码生成好之后调用gcc编译成可执行文件就算是完整走通了“从源代码到可执行文件”的全流程。当然这个方案被老师问过一个问题“你不觉得自己只是换了个语言输出吗”但我回答核心的语法分析、符号表、中间代码都是自己写的只是最后一步借用C编译器做后端老师也就没说什么。3. 实操流程与核心环节的落地3.1 项目目录结构怎么组织我建议从上手第一天就按下面的目录结构来组织代码不要把所有文件都堆在根目录不然后面改起来会想哭compiler/ ├── include/ # 头文件 │ ├── lexer.h # 词法分析相关定义 │ ├── parser.h # 语法分析相关定义 │ ├── ast.h # 语法树节点定义 │ ├── symbol.h # 符号表定义 │ └── ir.h # 中间代码指令结构 ├── src/ │ ├── lexer.l # Flex词法规则 │ ├── parser.y # Bison语法规则 │ ├── ast.c # 语法树构建函数 │ ├── symbol.c # 符号表实现 │ ├── ir.c # 中间代码生成 │ └── main.c # 主流程读文件、调用各阶段 ├── tests/ │ ├── test01.src # 测试用例四则运算 │ ├── test02.src # 测试用例条件分支 │ └── test03.src # 测试用例循环和函数调用 └── Makefile这种结构的好处是每部分职责清晰调试时能用gdb直接定位到具体模块。如果通篇都写在两个文件里虽然也能跑但课设后期想加功能、想查一个bug都会非常痛苦。3.2 从空文件到完整可用的推进顺序我建议按下面的顺序做每个阶段都留下可运行、可测试的小里程碑不要试图一口气写完所有功能再调试。第一步先搭骨架。创建好目录结构让Flex和Bison能生成C代码Makefile能顺利编译链接。此时词法和语法都是空的main.c只要能调用yylex()读取token并打印就够。第二步只做词法分析。把语言里所有关键字、运算符、数字、标识符的规则写全每读到一个token就打印出来然后用几个测试用例验证切分是否准确。这一步要确保不会把while1识别成while加1不会把1.2.3一号识成合法数字。第三步做表达式语法分析。只支持数字、加减乘除、括号用Bison构建语法树打印后缀表达式验证分析结果。这一步是核心中的核心因为几乎所有高级结构最后都会归结为表达式。第四步加入语句级结构。变量声明、赋值、if、while、return把语法树从单表达式扩展到完整程序。第五步做符号表和语义检查。声明时登记符号使用时查找符号类型不匹配直接报错。第六步生成中间代码。从语法树生成三地址码先把a b c * 2这类简单语句跑通再处理条件跳转、循环回跳。第七步做后端执行。选解释器或生成C代码然后用综合测试用例验证。每完成一个阶段记得提交一次git保存这样改崩了还能回退。我见过太多同学改到半夜改挂了之后连最后一个能跑的版本都找不回来只能连夜重写。3.3 一个完整的算例从源码到运行我们用一个非常小的程序来看全流程效果假设输入源码是int main() { int a; int b; a 10; b a * 2 5; if (b 20) { return 1; } else { return 0; } }词法分析会得到INT, IDENT(main), LPAREN, RPAREN, LBRACE, INT, IDENT(a), SEMI ...这样一长串token。语法分析后会被规约成function_definition、declaration、assignment、if_statement这些语法结构。接着符号表里会登记main这个函数以及局部变量a、b。语义检查没问题后生成中间代码大致如下main: t1 10 a t1 t2 a * 2 t3 t2 5 b t3 if b 20 goto L1 t4 0 return t4 goto L2 L1: t5 1 return t5 L2: end再往下如果选择生成C代码就是把每一行翻译成等价的C语句输出。如果选择解释器就把这些指令逐条执行。看到这个流程完整跑通你的课设基本就稳了。4. 常见问题与排查技巧实录4.1 最高频的几个坑我把课设过程中、以及帮同学看项目时遇到最多的问题整理成了表格供你快速对照现象可能原因解决方法Flex报重复定义yywrap没有提供yywrap函数在.l文件里加%option noyywrapBison报shift/reduce冲突运算符优先级未声明用%left、%right声明或用bison -v查看冲突详情生成的代码段错误语义值类型不对或符号表指针未初始化检查%union声明确保$n的类型一致关键字被当成标识符词法规则顺序错关键字规则放在标识符规则之前Windows下读文件多出\r换行符差异文件读入后做字符串预处理去掉\r注释里出现*/导致死循环注释正则没写好用/([^]中间代码临时变量编号溢出计数器未初始化或每次重置导致同号覆盖全局变量计数器只增不减打印中间代码乱码字符串未拷贝就赋值给tree节点使用strdup并记得释放内存4.2 调试工具别只靠printf我理解很多人调试编译器就是到处加printf这招确实有效但效率太低。推荐你把Bison的调试输出打开在parser.y里加上外部声明和调试开关extern int yydebug; int main() { yydebug 1; // 打开语法分析器调试输出 // ... }这样运行时Bison会打印整个移进、规约、状态的流程你能清楚地看到分析器根据当前的栈顶符号和下一个输入token做了什么决策。配合Flex的--debug选项词法层面的转移过程也能显示出来。还有一个必学的操作是bison -v parser.y它会生成一个parser.output文件里面列出所有状态、项集、冲突详情。如果看到shift/reduce冲突打开这个文件它会明确告诉你冲突发生在哪个状态、需要看文法里的哪两个产生式。这比自己瞪着代码猜要快得多。4.3 经验技巧先跑通再优化课设周期有限千万不要走入“完美主义”的误区。我当时一开始想做一个带数组、结构体、作用域嵌套、局部寄存器分配的大语言结果写了三周才到语法分析阶段后面差点没做完。后来我自己反思效率最高的策略是先做一个最小可运行的编译器只支持整数、加减乘除、赋值、if、while让整个流水线跑通。然后在这个基础上逐步加功能。每加一个功能就写一个测试用例验证。这种增量式开发方式能极大减少debug时“不知道是语法错了还是语义错了还是生成错了”这种三线作战的崩溃感。另外测试用例要刻意覆盖边界除零、大数溢出、嵌套括号特别深、长变量名、空程序、缺失右括号、类型不匹配、未声明变量。老师验收时最喜欢干的事就是拿一个你没见过的边界输入来试你的编译器。如果你自己提前把这些都验证过了现场就不会慌。注意如果验收时程序崩溃或者死循环千万不要直接说“我的编译器不兼容这个输入”。应先用正常用例证明主体功能可用再针对崩溃输入分析问题。诚实地面给老师看你正在排查比慌张找借口要得体得多。5. 结课后的几个扩展方向课程设计交完如果你还有余力我建议别急着把代码删掉它其实可以做很多有意思的扩展。这也是我想重点推荐再做一步的原因。第一个方向是加优化pass。比如常数折叠——a 2 * 3直接优化成a 6死代码删除——if (1) ...的false分支可以去掉。这些优化的原理都很简单但对中间代码的遍历和改写能力很有锻炼价值。第二个方向是做一个简单的函数调用栈。目前很多课设的函数其实没有真正的栈帧概念只是把参数按顺序压到一个全局变量表里。如果你能实现栈帧分配、活动记录、返回地址保存你就理解了程序运行时的内存模型这对以后学操作系统、计算机体系结构都有好处。第三个方向是写一个可视化前端。用Python把语法树画出来或者用Web页面显示分析过程拿去做展示效果会非常惊艳。我记得有人把中间代码生成了动画演示从源码到三地址码的变化过程看得清清楚楚看起来很厉害。第四个方向是定向生成某一种架构的汇编代码。如果你学过体系结构可以尝试生成RISC-V汇编然后用模拟器跑一遍。这一步会涉及寄存器分配比较麻烦但做完后你对编译后端的理解会有质的提升。说真的编译原理课程设计是少数几个做完之后有明显“能力增长感”的项目。它不像普通作业那样照猫画虎每一步都需要你真正理解原理才能推进。如果你能把课设完整做下来并且理清每个环节的取舍那这门课学分之外的东西你就已经拿到了。本文还有配套的精品资源点击获取