简介这是北航2022年编译技术课程设计的完整代码资源包面向正在学习编译原理、需要独立完成编译器实战项目的计算机专业高年级学生。压缩包共2000个文件整体大小10.64MB主体为1437个Java源文件覆盖词法分析、语法分析、语义处理、中间表示生成与MIPS目标代码生成等核心编译阶段另有235个class编译产物便于对照验证构建结果149个Markdown文档和111个txt文本承载课程笔记、设计说明与实验报告47个XML与7个JSON文件则承担工程配置与数据组织。此外还有少量C代码、Python脚本、JAR依赖与License说明等辅助内容目录划分清晰目前已有135人学习。通过研读源码与文档可以掌握北航在LLVM-IR构建、MIPS指令选择、寄存器分配、基本块与全局变量管理等编译后端环节的具体教学实现也可结合class文件重现编译流程适合作为系统进阶编译器设计与工程实践的参考范例。1. 这个zip不是拿来交差就算完的编译技术课程设计到底在考什么北航编译技术课程设计2022这个代码包光看标题就知道不是给你复制粘贴跑通就行的——这门课历年都是计算机系“挂科率排行榜”常客核心原因不是代码量大而是你要在一个学期内徒手造出一个能跑完真实代码的编译器。词法分析、语法分析、语义分析、中间代码生成、目标代码生成五个子系统环环相扣任何一环偷懒最后测试用例一跑就原形毕露。这个zip存在的意义是给你一个能照着拆解的完整工程框架而不是让你当成提交物直接上传。适合什么人两类。第一类是选了这个课、正在为“从哪开始写”发愁的学生你需要的是看懂整体架构知道每个模块该干什么、接口怎么设计第二类是工作和编译原理打交道、想快速回忆一遍“一个迷你编译器到底有哪些组成部分”的工程师你需要的是一个不过度复杂的参考实现。这两类人读这篇文章的目标是一样的把压缩包里那堆源码变成自己能讲清楚、能改得动、能应付答辩的东西。先泼一盆冷水历年翻车最惨的不是代码写得最烂的而是拿了一份代码但根本说不清“为什么这里要用递归下降而不是LR”的人。老师一眼就能看出你是不是自己写的。所以下面所有章节都按“先立框架、再拆实现、后讲避坑”的顺序来每个模块都会给出参数和设计理由让你照着讲也能站得住脚。2. 拿到zip先别急着解压整体框架与模块边界怎么划分2.1 从十个文件里认出编译器的“五段式”骨架常见的课程设计代码包目录结构差异很大但无论你怎么组织编译器的五个阶段一定会在文件组织上留下痕迹。我一般拿到这种zip第一件事不是看代码而是先把文件按阶段归类。一个典型的划分方式是词法分析模块通常叫lexer.c / token.h负责把源代码字符串切成token流同时记录行号和列号语法分析模块parser.c / ast.h负责把token流按文法规则组装成抽象语法树语义分析模块semantic.c负责符号表管理、类型检查、作用域处理中间代码生成ir.c生成三地址码或类似IR目标代码生成codegen.c把IR翻译成目标平台汇编很多课程设计会简化成“解释执行”或者“输出汇编文本”先理清这个框架有两个实际好处。第一你知道每个文件在测试时应该怎么单独验证——词法分析器可以只喂一小段代码看token输出语法分析器可以只喂表达式看AST打印不用每次都要编译整个工程。第二答辩时老师常问的一句话是“你改了哪个模块影响会波及哪些文件”你对模块边界越清楚这个问题越容易答。2.2 符号表和作用域整个编译器的心脏藏在最不显眼的文件里很多人在拿到代码时会先去看parser或者codegen因为听起来“有技术含量”。但我建议你先找符号表相关的实现——它往往是决定一个编译器能不能支持复杂语言的真正瓶颈。符号表不只是“变量名到类型”的映射它要处理三件事作用域嵌套、同名遮蔽、类型完整性检查。以最常见的C语言子集为例符号表条目通常这么设计typedef struct Symbol { const char *name; // 变量/函数名 Type type; // 类型枚举INT, FLOAT, CHAR, ARRAY... int scope_depth; // 作用域深度进入{}加1退出减1 int is_const; // 是否const限定 union { int int_val; // 常量值用于编译期求值 int array_size; // 数组长度当type为ARRAY时 } value; struct Symbol *next; // 同一作用域下的哈希链 } Symbol;这里最容易被忽略的是scope_depth字段。很多人第一版设计符号表时只用了“栈式”思路进入作用域push、退出pop看起来没问题但一旦遇到C语言里那种“内层定义同名变量、退出内层后外层变量恢复可见”的语义就出bug了。保留scope_depth的好处是符号表不必物理弹出条目查找时只需跳过比当前作用域深的旧条目这在debug时会友好很多——因为历史记录还在你可以打印出每一个曾出现过的同名符号。查找逻辑一般是这样从当前作用域开始逐层向外层搜索找到第一个匹配name的符号就返回。这个策略和C语言的词法作用域完全一致也是课程设计里最容易实现且不容易被答辩老师挑刺的方案。3. 词法分析与语法分析把字符串变成树的两道工序3.1 手工写出一个tokenizer状态机比正则库更靠谱课程设计里词法分析有两种常见路线用flex等代码生成器或者纯手工写状态机。我强烈建议你选后者——不是因为它更简单而是因为它更好debug、更好答辩。我们的课程设计用的是手写tokenizer核心是一个逐字符扫描的循环配合几个状态标志位。一个简化但完整的词法分析核心长这样typedef enum { TOK_IDENT, TOK_NUMBER, TOK_STRING, TOK_KEYWORD, TOK_OP, TOK_EOF, TOK_ERROR } TokenKind; typedef struct { TokenKind kind; char text[256]; // 当前token的文本 int line; // 起始行号报错用 int col; // 起始列号报错用 int int_val; // 当kind为TOK_NUMBER时有效 } Token; Token next_token(const char **src) { const char *p *src; // 跳过空白空格、\t、\n、\r while (*p || *p \t || *p \n || *p \r) p; Token tk {0}; tk.line current_line; // current_line在扫描\n时更新 tk.col (int)(p - line_start); // 标识符和关键字首字母必须是字母或下划线 if (is_alpha(*p) || *p _) { const char *start p; while (is_alnum(*p) || *p _) p; int len (int)(p - start); memcpy(tk.text, start, len); tk.text[len] \0; tk.kind is_keyword(tk.text) ? TOK_KEYWORD : TOK_IDENT; } // 数字字面量十进制整数 else if (isdigit(*p)) { const char *start p; while (isdigit(*p)) p; // 如果下一个字符是字母说明这不是一个合法数字比如123abc if (is_alpha(*p) || *p _) { tk.kind TOK_ERROR; sprintf(tk.text, invalid number %s%.*s, start, 8, p); *src p; return tk; } tk.kind TOK_NUMBER; tk.int_val atoi_len(start, (int)(p - start)); memcpy(tk.text, start, (int)(p - start)); // 记录错误上下文 } // 运算符注意吃透双字符运算符比如 ! else if (is_op_char(*p)) { ... } else if (*p \0) { tk.kind TOK_EOF; strcpy(tk.text, EOF); } *src p; return tk; }这段代码有几个细节值得在答辩或者写报告时专门提。一是错误处理数字后面直接跟字母这个错误绝大多数同学做不出来但“123abc”这种输入在真实代码里太常见了而早期的词法分析器往往会直接忽略或者当作合法token。二是在next_token里维护行号列号而不是在语法分析阶段再补——后者会丢失很多报错信息因为你定位到的位置是语法错误的位置而不是词法错误的位置。三是用数组而不是char*来做token文本存储避免指针指向源代码缓冲区的悬空风险这在做“带缓冲区的文件读取”时特别重要。3.2 递归下降词法不到底怎么设计从文法开始而不是从代码开始语法分析是课程设计里最容易“看别人代码觉得简单、自己写就崩”的部分。问题出在绝大多数人拿到源码包之后第一反应是看parser.c里的函数而不是先去找文法文件。但如果你不理解文法代码里那些parse_expression、parse_term、parse_factor之间的互相调用看起来就是一团乱麻。一句话文法先行。一个支撑简单表达式和赋值语句的文法长这样program - stmt_list stmt_list - stmt { stmt } stmt - var_decl | assign_stmt | if_stmt | while_stmt | block var_decl - type IDENT [ [ NUMBER ] ] ; type - int | float | char assign_stmt - IDENT expr ; if_stmt - if ( expr ) stmt [ else stmt ] while_stmt - while ( expr ) stmt block - { stmt_list } expr - term { (|-) term } term - factor { (*|/) factor } factor - NUMBER | IDENT | ( expr )对应到递归下降代码模式是“每个非终结符一个函数”。expr匹配term再循环匹配/-term匹配factor再循环匹配*//factor依据当前token类型决定走哪个分支。这里面最大的一个坑是“左递归消除”。如果文法写成expr - expr term递归下降直接掉进死循环——你必须手工把文法改写成上面的左递归消除版本。很多第一次独立写parser的人debug一整个晚上都是“程序stack overflow”原因就在这。所以拿到别人的代码时先找文法定义通常写在注释或者README里再对着文法读代码效率会高一个数量级。3.3 AST节点的设计树形结构里的“类型”和你学的C语言struct强相关AST是语法分析阶段的输出也是后面语义分析和代码生成的唯一输入。AST节点设计得好不好直接决定后面两个阶段要写多少补丁。常见的偷懒错误是“每个节点都用一个通用结构体字段特别多很多字段在不同节点类型下不适用”这样代码是少写了但语义分析阶段要不断用if-else判断“这个节点到底是什么”代码会非常丑且容易出现未初始化字段崩溃。更好的做法是用C语言模拟继承每类节点一个struct但第一字段都是节点类型标记后面各自带自己的数据。比如typedef enum { NODE_NUMBER, NODE_IDENT, NODE_BINARY_OP, NODE_VAR_DECL, NODE_ASSIGN, NODE_IF, NODE_WHILE, NODE_BLOCK } NodeKind; typedef struct Node { NodeKind kind; int line; // 所有节点都记录行号报错定位 } Node; typedef struct { Node base; int value; // 数字字面量的值 } NumberNode; typedef struct { Node base; char name[256]; // 变量名 } IdentNode; typedef struct { Node base; int op; // OP_ADD, OP_SUB, OP_MUL, OP_DIV Node *left; Node *right; } BinaryOpNode;用这种“第一字段是NodeKind”的设计你在语义分析里可以用一个switch(kind)做分发每种节点类型都有明确的字段访问方式不容易踩未初始化内存。答辩时如果老师问你“为什么这么设计而不把所有字段塞进一个结构体”你可以回答提高类型安全性减少无效内存占用同时每个节点只保留自己需要的数据遍历时cache更友好。4. 语义分析与中间代码从“树”到“序列”的关键一跳4.1 类型检查的3个必查项和错误上报机制语义分析是在AST上做的常见做法是对AST做一次或两次深度遍历。第一次遍历建立符号表、登记所有变量和函数第二次遍历做实际的类型检查和代码生成。为什么要分两遍C语言允许“先调用后声明”的函数如果你一遍遍历同时做登记和检查遇到后一种函数调用会报“未定义”。分两遍可以在第一遍收集所有声明第二遍再检查函数体。必查的类型错误至少有三个赋值兼容性int变量不能被赋值为float数组、运算数类型两侧不能是数组、函数参数个数与形参匹配。每个错误都要带上行号和列号并且最好同时输出“当前得到的类型”和“期望的类型”这样写测试用例的时候一眼就能看到哪一行代码触发了问题。很多课程设计的评分标准里有一条“错误信息要准确到行号”这不是可选的是硬性要求。错误输出格式建议统一为[error] line 27: incompatible type: int[4] assigned to int variable这个格式的意义在于不仅人眼好看后续如果你写自动化测试脚本可以用grep精准断言某个错误是否出现。很多测试框架里用到的“断言编译器的报错信息”原理就是这个。4.2 三地址码生成临时变量命名与表达式树后序遍历中间代码最常用的形式是三地址码TAC每条指令最多有三个操作数形如t1 a b。从AST生成TAC的方式本质上是后序遍历先递归处理左右子树得到两个临时变量再产生一条新指令把二者运算结果存到新临时变量。typedef struct { char result[32]; // 目标临时变量名如 t1 char op[8]; // 运算符如 ADD, SUB, MUL, DIV, ASSIGN char arg1[32]; // 第一个操作数 char arg2[32]; // 第二个操作数 } TacIns; int temp_count 0; char *new_temp() { char *name malloc(16); sprintf(name, t%d, temp_count); return name; } // 生成表达式树的三地址码返回保存结果的临时变量 char *gen_expr(Node *node, TacIns **out, int *out_len) { if (node-kind NODE_NUMBER) { char *t new_temp(); sprintf(t, t%d, temp_count); // 这里要统一用new_temp (*out)[(*out_len)] (TacIns){.result t, .op ASSIGN, .arg1 int_to_str(node-value)}; return t; } if (node-kind NODE_IDENT) { return node-name; // 变量自身就是操作数无需新临时变量 } if (node-kind NODE_BINARY_OP) { char *left gen_expr(node-left, out, out_len); char *right gen_expr(node-right, out, out_len); char *result new_temp(); char op_str[8]; switch (node-op) { case OP_ADD: strcpy(op_str, ADD); break; case OP_SUB: strcpy(op_str, SUB); break; case OP_MUL: strcpy(op_str, MUL); break; case OP_DIV: strcpy(op_str, DIV); break; } (*out)[(*out_len)] (TacIns){.result result, .op op_str, .arg1 left, .arg2 right}; return result; } // 其他节点类型赋值节点、函数调用节点等 return NULL; }这里有三个容易踩的细节。第一生成临时变量时很多初学者不自觉地用局部数组名存返回值函数返回后指针失效这是经典的“悬垂指针”bug。解决方式是统一用new_temp分配内存或者把临时变量名放在调用方传入的缓冲区。第二变量节点直接返回变量名本身不要为它新造临时变量——这是没必要的开销也会让三地址码长一倍老师看到“每个变量引用都带一条mov指令”会觉得你对编译优化完全没有概念。第三如果表达式里混入了赋值运算符需要特别小心赋值运算符的求值方向和加法的结合律不同gen_expr里的递归顺序要单独处理不能简单复用binary_op分支。4.3 符号表在IR生成阶段的重要作用寄存器分配前的“伪寄存器”设计很多人以为符号表在语义分析之后就完成了使命到了IR生成阶段就不需要了。这是误解。IR生成时需要知道每个变量当前作用域里对应的是哪个“伪寄存器”也就是IR层面的变量编号。否则生成的代码里会直接引用变量名在后续目标代码生成时你又要额外做一遍名字查内存地址的转换非常绕。常见做法是在符号表里加一个字段ir_name在语义分析阶段分配每遇到一个变量声明就分配一个全局唯一的伪寄存器名比如%0、%1。这样IR里所有读写都只用%N编译流程变成词法阶段源码字符串 - token流语法阶段token流 - AST语义阶段AST 符号表 - 带类型标注的ASTIR生成带类型AST - 三地址码只在伪寄存器层面目标代码生成三地址码 - 真实寄存器/栈地址这个分层最容易被忽视的好处是IR不依赖于原始变量名所以在后续做活跃变量分析、寄存器分配时不用反复做字符串比较直接对比编号即可。如果课程设计只要求“能生成IR就行”而不强制做优化这一步可以简化但保留ir_name字段是一个面向扩展的好习惯答辩时能加印象分。5. 避坑手册编译技术课程设计最常见的5个翻车现场5.1 解压后中文乱码先检查编码别急着改代码现象Windows上解压zip后所有.c和.h文件里的中文注释全部变成“锟斤拷”乱码。原因压缩包里的文件很可能是UTF-8编码而Windows记事本默认用ANSIGBK读取。解决不要用记事本打开换VS Code或者Sublime右下角选择“通过编码重新打开 - UTF-8”。另外读文件内容的代码里如果fopen没有指定二进制模式rb而不是r在Windows上遇到\r\n会出诡异问题——这个问题和乱码不是一个根因但经常被误诊成同一个需要区分。5.2 “unexpected token at line 3”指的位置根本不对现象语法报错的信息指向的行号和列号跟实际错误差好几行。原因你的词法分析器在跳空白时把换行符的计数逻辑放错了位置——常见错误是跳过空白时如果遇到\n就更新行号但NUMBER和IDENT分支里如果字符串中间夹着换行符行号同步就乱了。解决行号更新只放在唯一一个“处理换行符”的地方不要在三个分支里各写一遍。这类问题用“喂一个每个分行只有一个token的小文件”很快就能复现。老手的做法是写一个只含5行代码的测试用例把每行长度都弄成不一样然后对照报错的行号。5.3 多层作用域下变量名“不消失”现象在{}里声明了一个变量闭括号之后变量还在符号表里导致后面的语句错误地引用了它。原因符号表设计成“只加不减”或者减的时候没处理好嵌套关系。解决在语义分析的Block节点退出时遍历该块声明过的所有变量从符号表中移除并校验当前作用域深度确实回到了进入Block之前的深度。如果按2.2节的scope_depth方案还有一个更简单的办法查找时只搜scope_depth小于等于当前深度的条目这样即使符号表里残留旧条目也不会被错误匹配到。5.4 除法运算扔了余数测试用例全挂在“整数除法”上现象表达式5 / 2结果不是2而是2.5或者反过来期望浮点结果但输出整型截断。原因目标代码生成阶段对IR指令的翻译过于简单没有区分整数除法指令和浮点除法指令。解决在TAC指令里加上类型信息比如DIV_INT和DIV_FLOAT两个操作码IR生成阶段检查两个操作数的类型。这个坑看起来小但在答辩测试时极其致命——因为测试用例里必然有除法而且必然有int和float混合的边界用例。在代码里不要用同一个op字符串“DIV”然后指望后端自动判断类型后端类型判断通常是要么全部当作int要么全部当作float这个“默认选择”一定会挂掉一半测试。5.5 代码能编译但一跑就段错误Segmentation Fault现象编译自己的编译器时一切正常但用编译器编译测试程序时直接崩溃或者报错“stack overflow”。原因很多但最常见的是递归下降解析器的递归深度过大——尤其是表达式嵌套超过几十层时。解决先跑一个小表达式12*3确认基础路径没问题再逐步加大表达式嵌套深度确认递归深度上限。如果测试程序里有很多层括号嵌套可以考虑给解析器加一个显式的“递归深度计数器”超过阈值直接报“expression too complex”而不是崩溃。这个错误在Linux下特别难排查因为没有任何报错信息只有一行Segmentation fault所以提前加防护参数是血泪换来的经验。6. 进阶玩法从“能交差”到“能答辩”——三件值得做的事第一个进阶方向是把你原始的字符数组tokenizer升级成“跨行字符串和注释支持”。很多课程设计在文档里写的支持范围只有单行注释//但你测试时会发现几乎所有稍微真实一点的C程序里都有/* ... */块注释而且块注释里经常夹着换行和星号。不处理块注释你用自己编译器编译教材附录里的示例代码时会在第20行突然报错。实现块注释的核心是状态机里加一个“在注释中”的状态遇到/*进入遇到*/退出期间所有字符包括换行都跳过。这个功能代码量很小但能把你的编译器从“能跑玩具”变成“能跑真实代码”。第二个值得做的事是做一个简单的测试脚本把你的用例和期望输出放到一个目录里#!/bin/bash # 测试脚本编译并运行测试用例比对输出 for test_file in tests/*.c; do echo $test_file ./compiler $test_file /tmp/ir.txt 21 if [ $? -ne 0 ]; then echo FAIL: compiler exited with $? continue fi # 如果你的编译器直接生成可执行或IR解释执行这里可以继续运行并比对结果 done这个脚本的价值不在脚本本身而在于它逼你把错误输出格式稳定下来——脚本里grep stderr报错信息你的报错格式一旦不统一脚本就跑不通。答辩时老师问“你测试了哪些用例”你直接把测试目录翻给他看比口头说“我测了很多”可信得多。第三个进阶方向是把IR从“只输出到stdout”改成“输出到文件并支持简单的死代码消除”。你不需要做复杂的寄存器分配只需在IR列表上做一次“活跃变量扫描”如果一个临时变量的定义之后从未被引用那这条指令可以删除。这个优化在编译原理课上讲了理论你亲手实现一次才算真正理解“数据流分析”四个字的具体含义。实现方式是在三地址码序列上从后往前扫描维护一个“被引用过”的临时变量集合如果某条指令的result不在被引用集合里就标记删除。代码量大约30行但对答辩和理解的提升远超30行的价值。最后说一个我自己的习惯每完成一个模块就提交一次gitcommit message写成“feat: add lexical analyzer supporting block comments”这种格式。不是为了好看而是当你在凌晨两点把parser改成递归下降、结果原来的测试用例挂了三个时你能用一条git revert回到十分钟前的可用状态。这个“后悔药”在课程设计的冲刺周能救命。编译器的代码是五个模块互相咬合的一旦断链检查顺序一定是“词法token对不对 - AST打印对不对 - 符号表对不对 - IR对不对”逐层缩小范围不要在一个层级里翻遍所有代码。希望这四件事能帮你在同样的代码量上做出多一倍的验收效果。本文还有配套的精品资源点击获取