简介:这是一份面向北航编译技术课程设计的代码与文档合集,适合计算机专业本科生或对编译器内部实现感兴趣的开发者作为参考。资源围绕编译器构建的核心流程展开,涉及有限自动机驱动的词法分析、递归下降语法分析、中间表示(IR)构建、寄存器分配以及目标代码生成等模块,既包含可直接阅读修改的Java工程源码,也附带了实验记录、Markdown笔记与说明文档,有助于对照课程要求梳理每个阶段的实现方案。压缩包共2000个文件,其中Java源文件1437个、class字节码235个,另有Markdown笔记149个、txt文本111个、XML配置47个以及少量Python/C辅助脚本,整体体积约10.64MB,文件类型覆盖源代码、编译产物和文档,结构便于按模块检索。目前已有135人学习下载。通过该资源,读者可获得完整课设代码与配套文档,重点参考IR构建、MIPS指令生成与寄存器分配等关键代码,并借助文档中的设计思路和排错经验,为独立完成类似编译器课程设计或扩展研究提供实用支撑。
1. 北航编译技术课程设计代码(2022).zip:能把类 C 源文件变成汇编的最小编译器骨架
拿到一个名为“北航编译技术课程设计代码(2022).zip”的压缩包,不少人的第一反应是解压、找 README,然后对着几十个源文件发呆。这门课的大作业核心不是写个能打印 Hello World 的小工具,而是把一段类 C 源码依次送过词法分析、语法分析、语义分析、中间代码生成和目标代码生成,最终输出一份可以链接运行的汇编文件。对正在做课程设计的学生、接手旧代码的组员,以及想从样例代码反推编译器实现的初学者来说,这份 zip 是离“完整编译器”最近的脚手架。我按理解、运行、验证三个动作来拆它,后面的命令和参数可以直接抄。
2. 解压后的第一步:认清目录、入口和运行环境
课程设计代码包和公司项目最大的差别在于,它通常不是按“可维护性”组织的,而是按“老师看得见工作量”组织的。我见过把 lexer、parser、codegen 塞进一个 main.cpp 的,也见过用 flex/bison 生成一堆 .c 文件后不自量力地全部提交的,先保证能编译是正事。所以解压之后不要急着打开编辑器,先做三件事:看清文件布局、找到主入口、确认构建脚本。三件事做完,再花十分钟跑一个最小用例,整条链路才算在你手上转起来。
2.1 用一条命令看清压缩包的真实结构
拿到 zip 后我一般会在终端里走下面这几步,把代码放到独立目录,避免污染工作区:
mkdir -p ~/compiler-course && cd ~/compiler-course unzip 北航编译技术课程设计代码(2022).zip -d src cd src && find . -maxdepth 2 -type f | sortunzip -d src是指定解压目录,find -maxdepth 2限制只看两层,避免被.git或者tests/output下的临时文件淹没。如果解压后中文文件名变成乱码,说明这个 zip 是在 Windows 下用默认编码打包的,可以先记录,后面避坑章再展开。我更愿意在服务器上解压还有一个原因:grep 和 find 比图形界面顺手,隐藏文件也容易看见;图形解压工具常常把.gitignore、.vscode这类文件藏起来,而这些文件偶尔会影响目录结构判断。
常见的目录布局大概是下面这张表:
| 路径 | 常见内容 | 读法 |
|---|---|---|
src/或根目录 | .cpp/.h源码、lexer.l、parser.y | 主逻辑 |
include/ | 公共头文件、AST 节点定义 | 先看这里 |
tests/ | 输入用例、期望输出、回归脚本 | 验证用 |
docs/或report/ | 课程设计报告、测试说明 | 先看需求 |
Makefile或CMakeLists.txt | 构建脚本 | 决定环境 |
README.md | 编译和运行说明 | 不一定有 |
如果根目录只有main.cpp和一个Makefile,说明代码是单文件结构,读起来更省事,但扩展的时候要小心全局变量互相纠缠。如果看到lexer.l、parser.y,说明语法分析用了 flex/bison,这是另一套排错思路——你改的优先级文件会被重新生成 C 代码,还得检查生成器版本。先判断结构,再决定用 debugger 还是 dump 输出。不要一上来就期待代码规范,课程设计代码里经常出现拼音变量名和全大写宏,但这不影响读懂主干。
2.2 入口文件与命令行参数:先跑通一个最小 case
找入口文件最直接的方法是在源码里找main:
grep -rn "int main" --include=*.cpp --include=*.c . # 如果项目是 Java 写的,换成查找 public static void main grep -rn "public static void main" --include=*.java .命令行通常是课程设计老师统一的约定:从输入文件读出源码,输出.s汇编文件。入口参数解析一般不超过四个:输入路径、输出路径、优化级别、debug 开关。常见写法像下面这样:
// 课程设计编译器入口,约定用法:compiler [options] input.c int main(int argc, char* argv[]) { string srcFile = "test.c"; // 默认输入,方便在 IDE 里直接调试 string outFile = "out.s"; // 默认输出汇编文件 int optLevel = 0; // 优化级别,默认不开优化 bool dumpTokens = false; // 是否打印 token 序列 for (int i = 1; i < argc; ++i) { string arg = argv[i]; if (arg == "-o" && i + 1 < argc) { outFile = argv[++i]; } else if (arg == "-O" && i + 1 < argc) { optLevel = atoi(argv[++i]); } else if (arg == "-dump-tokens") { dumpTokens = true; } else if (arg.substr(arg.size() - 2) == ".c" || arg.substr(arg.size() - 5) == ".mini") { srcFile = arg; } } return compile(srcFile, outFile, optLevel, dumpTokens); }这个例子覆盖了大多数课程设计的约定:-o指定输出,-O0/-O1/-O2指定优化级别,-dump-tokens是调试口;把输入文件判断写在最后,是为了兼容“输入文件是最后一个位置参数”的调用习惯。如果你拿到的代码没有参数解析,而是硬编码一个文件路径,那就直接改默认字符串,先跑通再说。
跑最小用例前,确认构建脚本。Linux/macOS 下用make,Windows 下用 Visual Studio 的工程文件或者 MinGW 的mingw32-make。如果找不到 Makefile 或者它已经过时,手动编译命令一般是:
g++ -std=c++17 -Iinclude src/*.cpp -o compiler-Iinclude把include/加进头文件搜索路径,src/*.cpp把源码一次编完。这里要注意 C++ 标准:如果代码用了 C++17 特性,而 GCC 默认是 C++11,会在std::optional、std::filesystem上报错。Makefile 里如果写了-std=c++17,先确认你的 GCC 版本在 9 以上,否则编译后端那部分会翻车。
2.3 第一次端到端运行:从源码到汇编
拿到一个可以编译的程序后,我习惯先准备一个最小输入文件,例如只放一个返回常量的函数,然后再跑编译命令。把输入输出分开放在/tmp,这样翻车时不会把锅甩到测试用例上。
printf 'int main(){return 0;}\n' > /tmp/mini.c ./compiler /tmp/mini.c -o /tmp/mini.s head -30 /tmp/mini.shead -30看汇编头,确认生成的是 64 位还是 32 位汇编、有没有冒出来奇怪的.section。如果命令报错,先确认输入文件能读、输出目录能写。这一步不追求正确性,只追求“编译器完整执行完”。端到端跑通后,再回到源码里一个一个模块读;如果跑都没跑通,后面所有 AST 可视化都是空中楼阁。
3. 把词法分析和语法分析拆开:先弄懂 token 表和语法树
词法分析和语法分析是编译器前半段的主干。与其把整包源码当一个黑匣子,我更建议你按一条主线读,把它当成一份示例代码讲解:先看 token 类型定义,再看语法树节点定义,最后看 Parser 如何把 token 变成树。这条线走通,后面语义分析就只是在这棵树上加属性。
3.1 词法分析模块的读法:关键字表、最长匹配和状态机
词法分析在课程设计里通常有两种实现:手写词法分析器,或者用 flex 生成。怎么区分?看目录里有没有lexer.l、lex.yy.c。有 flex 的,关键字表在.l文件里;手写的,一般在Lexer.cpp或token.h里。两种实现都要重点看“最长匹配”和“注释/字符串”两个边界。
给一个手写词法分析核心循环的示意:
// 词法分析核心循环:跳过空白和注释,再按开头字符分类 Token Lexer::nextToken() { while (true) { if (isspace(peek())) { advance(); continue; } if (peek() == '/') { advance(); if (peek() == '/') { // 行注释 while (peek() != '\n' && !eof()) advance(); continue; } if (peek() == '*') { // 块注释 advance(); while (!(peek() == '*' && peekNext() == '/') && !eof()) advance(); advance(); advance(); // 消费掉尾部的 "*/" continue; } return makeToken(Slash); } break; } if (isalpha(peek()) || peek() == '_') { string id; while (isalnum(peek()) || peek() == '_') id += advance(); return makeToken(checkKeyword(id), id); } if (isdigit(peek())) { string num; while (isdigit(peek())) num += advance(); // 只处理十进制定点 return makeToken(Number, num); } // 单字符运算符 char op = advance(); switch (op) { case '+': return makeToken(Plus); case '-': return makeToken(Minus); default: throw LexError("unknown char"); } }这段代码的逻辑是先处理空白和注释,然后按当前字符的类型分派:字母或下划线开头走标识符,数字开头走整数,剩下的单字符运算符直接查符号表。读别人代码时重点看三件事:关键字表是否覆盖了课程要求的全部关键字;注释和字符串的结束条件是否正确;数字按几进制解析。词法层的坑集中在注释结束符、字符串转义和'\r'回车符上。Windows 换行符进入 Linux 环境时,\r会被当成非法字符,这是非常典型的踩坑点。
如果你看到checkKeyword(id)这个函数,它背后通常会有一个查找表:
// 把标识符和关键字区分开 const unordered_set<string> keywords = {"if", "else", "while", "return", "int", "void"}; Token checkKeyword(const string& id) { if (keywords.count(id)) { Token t(Keyword); t.lexeme = id; return t; } Token t(Identifier); t.lexeme = id; return t; }这里有一个读者容易忽略的点:关键字表必须和语法分析器里用到的 token 类型对齐。如果语法分析要求支持for,但关键字表里没有for,for会被当作普通标识符解析,后面语法分析报错时你根本想不到是词法层先放错了。所以读代码时,可以先搜课程设计题目要求支持哪些语法,再对着关键字表打勾。
3.2 语法分析:递归下降还是 yacc/lex,错误恢复藏在哪
语法分析器决定整套代码的主干。手写递归下降的代码读起来最顺畅:每个函数对应一个语法单元,函数名基本就是产生式的名字。如果你的包里出现Parser类、parseExpression()这种命名,说明是手写递归下降。如果出现parser.y、y.tab.c,说明是 Bison 生成。
递归下降里优先级是靠函数调用层级解决的,一个典型表达式解析函数是这样:
// 表达式解析:先调用 parseTerm,表示乘除优先级更高 Node* Parser::parseExpr() { Node* node = parseTerm(); // 先吃掉乘除 while (peekToken() == Plus || peekToken() == Minus) { Token op = nextToken(); Node* right = parseTerm(); node = new BinaryOpNode(op, node, right); // 左结合 } return node; } Node* Parser::parseTerm() { Node* node = parseFactor(); while (peekToken() == Star || peekToken() == Slash) { Token op = nextToken(); Node* right = parseFactor(); node = new BinaryOpNode(op, node, right); } return node; }这里的parseExpr只处理加减,parseTerm处理乘除,先调用parseTerm()表示乘除优先级更高。读代码时顺着parseExpr -> parseTerm -> parseFactor这条线走,就能看到完整的优先级和结合性。如果代码里出现大量if (token == x) consume(); else throw,说明错误恢复策略是“遇到错误立即抛异常”。更好的课程设计会在错误点插入“同步 token”,比如分号或右花括号,然后继续解析。你可以在error函数里看到这些同步 token 列表。
我一般会先看这个错误恢复函数,因为 Parser 的翻车通常不是在正确路径上,而是在错误路径上。如果错误信息只有parse error,后面调试语义分析时很痛苦。测试时故意漏一个分号,看编译器会不会给出“第几行”而不是一个退出码,这也是验收时会考的。从选型上说,课程设计我更推荐手写递归下降:flex/bison 需要额外安装和生成步骤,带进评测环境容易翻车,生成的 C 代码也不好读;递归下降体积小、错误信息友好,在报告里也能写清楚“我如何表达优先级”。
3.3 从最小用例反推 AST:先找 BinaryOpNode 和 FuncDeclNode
读完词法,再看一眼 AST 节点定义。找到BinaryOpNode、NumberNode、FuncDeclNode这几种节点,你就知道语法分析要在哪一步结束。用1+2*3;做一个最小用例,跟着 Parser 走一遍,比背半天产生式更有效。如果代码里有dumpAST或者toString方法,优先用它们输出一棵树,然后对比你自己的推导。这步做完,语法分析这块就过关了。
4. 语义分析、中间代码和目标代码:从语法树到可执行文件的三个关口
语法树只是骨架,语义分析负责给它加血肉。课程设计里这一阶段往往被分成两个文件:SemanticAnalyzer.cpp和CodeGen.cpp。前者管作用域、类型、return 路径;后者负责把树翻译成三地址码或汇编。读这一章的时候,不要一上来就钻汇编细节,先找“这个编译器自己定义的中间表示”。
4.1 符号表与类型检查:先看作用域嵌套和 return 路径
符号表在课程设计中可能叫SymbolTable、Scope或Table。常见结构是一个栈:进入块作用域时 push 新层,退出时 pop。读代码时注意三个必查项:变量是否先声明后使用;同一作用域下是否重复声明;函数返回值类型是否一致。
// 符号表条目:变量和函数统一用一条记录 struct Symbol { string name; // 变量名/函数名 string type; // int / void / pointer bool isFunction; // 区分变量和函数 int paramCount; // 函数参数个数,变量为 0 int blockDepth; // 所在作用域深度,用于查重和释放 }; class SymbolStack { vector<unordered_map<string, Symbol>> scopes; int depth() const { return scopes.size(); } void push() { scopes.push_back({}); } void pop() { scopes.pop_back(); } bool insert(const Symbol& s) { auto& cur = scopes.back(); if (cur.count(s.name)) return false; // 重复声明 cur[s.name] = s; return true; } const Symbol* find(const string& name) const { for (int i = (int)scopes.size() - 1; i >= 0; --i) { auto it = scopes[i].find(name); if (it != scopes[i].end()) return &it->second; } return nullptr; } };这个find从当前层向上逐层找,对应“内层变量遮蔽外层变量”的语义。读代码时要确认push/pop是否和{}配对,很多翻车点是作用域栈没有在 return 语句或异常路径上 pop,导致后面的声明被误判成重名。如果课程设计还要求布尔短路,那类型检查还要处理非零即真这类 C 语言惯例。
类型检查还有一个隐蔽问题:隐式转换。只支持int的编译器不会遇到,但一旦加入char或pointer,就必须定义“赋值兼容”的规则。课程设计里常见做法是只在赋值和函数调用参数上做类型检查,在算术表达式里不做完整推导,因为推导要写类型格。
4.2 中间代码生成与汇编输出:三地址码是救命稻草
有些课程设计要求先生成三地址码,有些直接生成 X86 汇编。前者调试起来舒服很多,因为你可以先确认指令序列对不对,再怀疑汇编模板。如果你看到Temp、Label、Quad这样的类,基本就是三地址码。示例输出长这样:
L0: t0 = 10 t1 = a t2 = t0 + t1 return t2在代码里如何打开这类输出?常有一个-emit-llvm、-dump-ir或-d参数。读后端代码时,先找“指令选择”函数。如果只有push/pop模板而没有寄存器分配函数,说明用的是最简单的“每个临时变量对应一个栈槽位”,这对课程设计完全够用。不要在这里纠结优化,先把功能跑对。
给一个汇编生成模板的示意:
// 将三地址码加法 t2 = t0 + t1 转成 x86 汇编 void CodeGen::emitAdd(const Quad& q) { // 假定临时变量已经映射到 32 位寄存器或栈槽 fprintf(out, "movl %s, %%eax\n", tempName(q.arg1)); fprintf(out, "addl %s, %%eax\n", tempName(q.arg2)); fprintf(out, "movl %%eax, %s\n", tempName(q.result)); }这种写法的优点是每一条中间代码都能在汇编里找到对应行,出了错可以直接从t2 = t0 + t1反查是寄存器分配的问题还是模板的问题。如果要支持复杂的表达式,可以用栈式机器(push/pop)避免处理寄存器分配,代价是生成的汇编更长,但正确性更容易保证。
中间代码为什么是后悔药?因为它是前端和后端的唯一契约。如果生成汇编结果不对,先 dump 三地址码,看错误是前端的 IR 错了,还是后端的翻译错了。没有这层 IR,排错就只能靠调试器单步两条路。课程设计代码里如果没有 IR,我会在 Parser 之后加一个-dump-ast,先用文本形式确认 AST,再改后端,这样至少不会把两个问题叠加在一起。
指令选择和寄存器分配是后端最容易翻车的地方。课程设计的常见要求是“能生成汇编并正确执行”,所以很多样例代码会给每个临时变量分配一个内存地址,而不是做真正的寄存器分配。你读到的代码如果有alloc之类函数,并且每次临时变量都调用它,说明用的是栈槽方案。这种方案在 32 位汇编下很好写,但要注意栈对齐,否则运行时会出现莫名其妙的数据错乱。
5. 跑通测试用例的 3 个必调参数与 5 条避坑记录
当你可以编译出一个compiler可执行文件后,接着就要面对所有课程设计代码最常出问题的环节:跑测试。这一章把三个必调参数和五条避坑记录写在一起,照做能少走不少弯路。
5.1 第一个必调参数:输入输出文件路径与编码
很多课程设计代码默认把输入文件写死在主函数里,比如"test.c",你在项目根目录跑没问题,换个目录就报No such file。改法有两个:改动主函数里的默认路径,或者调用时用绝对路径。更重要的是中文路径和空格:编译器里的文件读取一般用 C 的fopen,Windows 下路径里的中文和空格经常导致打开失败。我一般会把测试用例全放到tests/cases这种纯 ASCII 路径下,避免在词法分析之前就倒在文件系统层。
如果程序里有-dump-tokens参数,先在最小输入上跑一遍,确认 token 序列能正常输出。这一步能同时验证路径解析和词法层都通了。
5.2 第二个必调参数:链接运行时库的方式
编译器生成的.s文件并不能直接变成可执行文件。课程设计里通常会提供一个runtime.c或lib.c,里面实现了print_int、read_int之类供程序调用的辅助函数。生成汇编后需要把这个运行时库一起编译链接:
gcc out.s runtime.c -o out如果链接时报undefined reference to printf或main,先检查生成汇编里的调用名是不是和你 runtime.c 里写的一致。很多课程设计会在代码生成里硬编码调用_printf,而 C 库实际符号是printf。这时候要么改生成模板,要么在 runtime.c 里加一个同名的包装函数。不要直接去禁用优化,问题往往在符号名。
5.3 第三个必调参数:优化开关与 dump 开关
课程设计一般要求至少支持-O0,有些代码也实现了-O1或简单常数折叠。调试阶段保持-O0最重要,因为一旦开优化,临时变量可能被合并,你在 dump 里看到的 IR 和实际汇编对不上。-dump-tokens、-dump-ast、-dump-ir是三个不同层级的 dump 开关,建议全部打开并输出到文件,不要只打到 stdout。因为编译器前端报错时,stdout 和 stderr 混在一起,拿到文件里好定位。
5.4 五条避坑记录:从这套代码里学到的血泪教训
第一条,解压后文件名乱码。现象:zip 里中文目录变成麦之类;原因:Windows 默认用 GBK 编码文件名,Linux/macOS 用 UTF-8;解决:在 Linux 下用unzip -O gbk 北航编译技术课程设计代码(2022).zip,或者用 Python 的zipfile做一次转码重命名。不要手动一个个改名,文件多了会疯。
第二条,make 编译失败,头文件找不到。现象:fatal error: ast.h: No such file or directory;原因:代码用#include "include/ast.h"或者根本没加-Iinclude;解决:看 Makefile 的 CFLAGS,手动补-Iinclude。如果代码是从别处粘的,可能还缺-std=c++17,一并加上。
第三条,一跑就段错误。现象:输入一个合法测试用例,编译器直接Segmentation fault;原因:AST 节点构造时子节点指针没有初始化,或者符号表scopes.back()在空栈上调用;解决:用 gdb 跑到段错误处,看调用栈在哪个构造函数,把所有成员放到初始化列表里。也可以开 AddressSanitizer,g++ -fsanitize=address -g重新编译,它会把野指针定位到具体行。
第四条,汇编链接时找不到printf。现象:gcc out.s -o out报undefined reference to printf;原因:生成代码里输出的是_printf或printf@PLT,而运行库环境不匹配;解决:检查汇编模板的符号名,统一改成printf,并在生成文件头部声明.global main。注意 32 位汇编下符号名可能带下划线,64 位则不带。
第五条,测试用例全部超时。现象:一个 10 行的输入程序能让编译器卡死;原因:词法分析或语法分析进入了死循环,通常是advance()没有真正消费字符,或者注释匹配*/的循环在 EOF 处停了下来;解决:在关键循环里加一个消费计数器,限定最多等于输入长度加一;或者在读字符时把peek和advance配对检查,尤其注意while (!(peek() == '*' && peekNext() == '/') && !eof())这种写法在 EOF 时会先peekNext(),导致越界。
这五条里,前两条是环境问题,后三条是代码本身的问题。按照“先环境后源码”的顺序排查,大部分卡点都在编译阶段而不是运行阶段。
6. 验证正确性:给编译器加一棵能可视化的语法树
6.1 加一个 -dump-ast 参数,让语法树变成文本
在 Parser 里加一个 AST 输出函数,比猜结构靠谱:
void dumpAST(Node* n, int depth) { for (int i = 0; i < depth; ++i) cerr << " "; cerr << nodeTypeName(n->type); if (!n->lexeme.empty()) cerr << "(" << n->lexeme << ")"; cerr << "\n"; for (auto child : n->children) dumpAST(child, depth + 1); }depth把嵌套层级显示成缩进,lexeme是节点对应的原文,这样一眼能看到运算符优先级被 parser 建成了什么样。判断优先级对不对,看1+2*3的树是(+ 1 (* 2 3))还是(* (+ 1 2) 3)就行。
6.2 用一份回归脚本把全部测试用例拉起来跑
临时目录里准备tests/cases和tests/expect,然后跑一个简单脚本,对比返回值或者输出文件:
#!/usr/bin/env python3 import glob, subprocess, os for c in sorted(glob.glob("tests/cases/*.c")): res = subprocess.run(["./compiler", c, "-o", "/tmp/t.s"], capture_output=True) if res.returncode != 0: print(f"FAIL compile {c}: rc={res.returncode}") continue res2 = subprocess.run(["gcc", "/tmp/t.s", "-o", "/tmp/t"], capture_output=True) if res2.returncode != 0: print(f"FAIL link {c}: {res2.stderr.decode()}") continue r = subprocess.run(["/tmp/t"], capture_output=True) print(f"{os.path.basename(c)}: exit={r.returncode}")这里编译器和 gcc 的返回值都被检查,任一步失败都会打印对应用例名,比跑一百个用例再回头找哪个挂了要快得多。
我自己的习惯是:拿到任何编译课程设计代码,第一件事不是改功能,而是把测试用例脚本先搭好;没有回归脚本,你改了后面就会毁掉前面。有一次我把&&的右操作数求值顺序改错了,单独测试看不出来,回归脚本跑完才发现第三个用例挂了。希望帮到你。
本文还有配套的精品资源,点击获取