
简介一份来自北航2022年编译技术课程设计的完整工程代码面向高校编译原理课程学生及需要从零搭建编译器的开发者也适合准备技术面试或从事编程语言方向研究的人群。资源包共2000个文件以Java源码为主1437个java辅以class字节码235个、Markdown文档149个、文本说明111个以及XML配置等覆盖中间表示构建、指令选择、寄存器分配等关键模块压缩包仅10.64MB。已有135人学习下载。代码实现了从词法分析、语法分析、语义分析到中间代码生成、优化与MIPS目标代码生成的完整编译流程对应文档还讨论了有限自动机、递归下降分析等技术要点并配有设计说明可帮助读者理解编译器各阶段的实现细节尤其适合参照完成课程设计、撰写实验报告或进行编译后端二次开发。工程结构清晰源码与文档分离便于针对具体阶段自测与调试。1. 北航编译技术课程设计代码这套包真正值钱的是中后端模块看到压缩包名「北航编译技术课程设计代码2022」时我第一反应是又是一堆课程报告。解压后反而踏实了里面躺着的是 IrInstructionBuilder.class、MipsInstructionBuilder.class、RegisterFile.class 这一组 Java 字节码外加 main.c 和 CMakeCCompilerId.c。这套东西真正值钱的是给了你一份编译器中后端的实测参考——中间表示怎么构建、基本块怎么划分、MIPS 汇编怎么选指、寄存器怎么分配。适合正在做编译课设、被 IR 生成和寄存器分配卡住的人。它不替你写实验报告但能让你少走半个学期的弯路。2. 从文件清单逆推架构IR 构建与 MIPS 后端的职责划分拿到资源第一步我习惯先列文件清单再谈架构。这套压缩包的体积不大但文件之间的层级关系其实很清晰六个 Java 类负责编译器核心逻辑一个 C 文件是被测程序样例另一个 C 文件是 CMake 的探测器残留物。把它们分清楚整个课设的模块边界也就浮出来了。2.1 六个类文件对应编译器的哪几个阶段每个类文件都在编译流水线上有明确的位置。我按自己的理解做了一张职责对照表后面写实验报告的时候可以直接复用这套表述。类文件所属阶段核心职责IrInstructionBuilder.class中端构造三地址码形式的中间指令统一操作码与操作数格式IrBasicBlockBuilder.class中端把 IR 指令流切分为基本块构建控制流图IrGlobalVariableBuilder.class中端处理全局变量的声明、初始化和地址引用MipsInstructionBuilder.class后端把 IR 指令选择映射为 MIPS 汇编指令RegisterFile.class后端管理 MIPS 寄存器分配、占用状态和溢出值得多说一句的是文件列表里 MipsInstructionBuilder.class 和 RegisterFile.class 各出现了两次。这通常是打包时把多个实验阶段的产物叠在了一起也可能是同一个工程在 src 和 out 两个目录下各留了一份。如果你解压后发现重复文件先对比一下它们的修改时间以时间戳更新的那份为准别让旧版本干扰你反编译后的阅读。2.2 IrInstructionBuilder 与 IrBasicBlockBuilder中间表示怎么搭IrInstructionBuilder 的关键作用是生成统一格式的三地址码。「三地址」的意思是每条指令最多包含一个目标地址和两个源地址这是最经典也最好做后续优化的 IR 形式。北航这套设计里操作数的载体是虚拟寄存器或变量名而不是直接落到物理寄存器这样指令选择器在目标代码生成阶段才有自由调整的空间。我一般会把一条 IR 指令建模成下面这种结构和 IrInstructionBuilder 的职责是吻合的// IR 指令的数据结构示意 public class IrInstruction { public enum Op { ADD, SUB, MUL, DIV, LOAD, STORE, BR, BNEZ, BEQZ, LABEL, CALL, RETURN } public Op op; // 操作码 public String dst; // 目标操作数虚拟寄存器或变量名 public String src1; // 源操作数 1 public String src2; // 源操作数 2 public int line; // 源行号调试和报错要用的 }逻辑上ADD 表示 dst src1 src2LOAD 表示从 src1 指向的地址加载到 dstSTORE 反过来。这里有一个容易被新手忽略的点为什么 MUL 也要放在三地址码里因为 MIPS 的乘法在指令选择阶段可能有特殊处理比如结果放在 hi/lo 寄存器IR 阶段保留一个语义清晰的 MUL生成具体指令时再展开比在 IR 阶段就拆成多条指令要更容易调试。IrBasicBlockBuilder 做的事情是把这个 IR 指令序列切分成基本块。我反复核对过它的命名基本块划分遵循三条经典规则程序第一条 IR 指令作为一个基本块的入口任何跳转指令的目标指令作为新基本块的入口任何跳转指令的下一条指令作为新基本块的入口换句话说基本块的入口要么是「被跳转指向的地方」要么是「跳转之后继续执行的地方」。基本块的出口则是无条件跳转、条件跳转、函数返回这些指令本身。划分完基本块之后才能进一步构建控制流图做死代码删除、常量传播这些优化。很多课设做到这一层就停了因为能跑到 IR 基本块划分说明前端和语义分析已经打通了。2.3 MipsInstructionBuilder 与 RegisterFile后端如何接管后端的第一件事是选指也就是把 IR 指令映射成 MIPS 指令。MipsInstructionBuilder 这个名字暗示了这个类采用的就是逐条映射的策略映射关系不出意外是下面这张表的样子IR 操作码MIPS 指令备注ADDaddu $rd, $rs, $rt用无符号加法避免溢出异常课设阶段更省心SUBsubu $rd, $rs, $rt同上MULmul $rd, $rs, $rtSPIM 支持 MIPS32 的 mul不需要再拆 multmfloLOADlw $rt, offset($rs)从全局变量或栈上加载STOREsw $rt, offset($rs)把值写回内存BRj label 或 b label无条件跳转BNEZbnez $rs, label条件跳转配合分支延迟槽这里有个细节值得警惕MIPS 的寻址方式是「基址 偏移」没有直接把标签地址放到 lw 里的语法。全局变量访问一般需要先 la $t0, 变量名再 lw $t0, 0($t0)。如果你在映射 LOAD 时直接用 lw $t0, 变量名SPIM 虽然会报错或者行为异常。常见做法是在 IrGlobalVariableBuilder 阶段就把全局变量的访问转换成「取地址 加载」两步后端只负责处理基址寄存器不用关心变量到底落在哪一段。RegisterFile 则是后端的另一只臂膀。它管的是 MIPS 那 32 个通用寄存器谁空闲、谁被占用、某个虚拟寄存器对应到哪个物理寄存器。为什么不能省掉它因为 MIPS 的临时寄存器只有 t0-t9 这十个保存寄存器 s0-s7 有八个加起来十八个可用寄存器而一长串 IR 里的虚拟操作数可能成百上千。物理寄存器不够用是必然的必须有一套分配策略来周转。3. 跑通这套代码从 class 字节码还原接口到重新编译不少人在这一步栽了跟头。压缩包里给的是 .class 文件而不是 .java 源文件这意味着你不能直接打开 IDE 改代码得先想办法把类的接口和行为还原出来。好在 Java 字节码天生就是为反编译准备的工具链也成熟整个过程一小时左右能搞定。3.1 包里只有字节码先用 javap 看清接口我拿到 .class 文件后的第一个动作永远是跑 javap而不是直接上反编译器。javap 是 JDK 自带的反汇编工具能稳定输出类的字段、方法签名和字节码指令虽然不能还原出漂亮的源码但接口轮廓是最准确的。# 查看 RegisterFile 类的方法签名 javap -p RegisterFile.class # 带 -c 参数还能看到字节码指令级别的实现 javap -p -c RegisterFile.class-p 表示显示 private 成员不加的话只能看到 public 方法对理解寄存器分配来说远远不够。第一次跑完 javap你就能看到类似 allocateRegister、freeRegister、getRegisterByName 这样的方法名配合参数类型基本能猜出分配策略的骨架。这一步花十分钟比直接反编译然后被一坨还原后的泛型代码砸晕要划算得多。如果 javap 报错“类文件版本错误”说明这个 class 是高版本 JDK 编译的而当前命令行用的是低版本 JDK。用 java -version 看一下版本换到新一点的 JDK 再跑就行。3.2 用 CFR 反编译得到可读的 Java 源码javap 能看接口但没法直接拿来做实验。想看完整逻辑还是得靠反编译。我一般用 CFR它对现代 Java 语法支持得比较好枚举、lambda、泛型都能还原个七八成。# 将单个类反编译到指定目录 java -jar cfr.jar RegisterFile.class --outputdir ./src # 批量处理整个目录下所有 class java -jar cfr.jar ./classes_dir --outputdir ./src反编译得到的代码不是 100% 和源文件一致这是常识。CFR 输出的代码里switch 可能被还原成奇怪的 if 链注解丢失是常态但核心算法逻辑是可信的。我在看 RegisterFile 的反编译结果时重点看三个地方寄存器占用数组的初始化、分配方法的遍历顺序、溢出时把寄存器值写到哪里。这三件事看明白寄存器分配策略就掌握八成了。有一点要提醒反编译出来的代码通常没有包名和 import直接拿去 javac 编译会报一堆符号找不到的错误。常见做法是自己建一个 package 声明把散落的类文件里的引用关系理清或者干脆把反编译结果当作「带注释的参考实现」自己照着重写一遍。3.3 重新编译与 classpath 排错反编译代码修改好后重新编译这一步会让不少人翻车。归根到底就是 classpath 配置问题。我把完整流程走一遍# 1. 新建输出目录编译所有反编译后的 Java 源文件 mkdir -p out javac -d out src/*.java # 2. 运行课程设计主程序把 out 加入 classpath java -cp out 主类名如果反编译后的类分布在多个包里上面的 src/*.java 就不够用了得换成递归编译# 查找所有 java 文件并编译适用于多包结构 find src -name *.java sources.txt javac -d out sources.txt参数说明-d 指定字节码输出目录sources.txt 是 javac 支持的通配文件列表写法比手打一长串要省事。这时候最常遇到的报错是 NoClassDefFoundError十有八九是某个类不在 classpath 里。检查办法很简单用 jar tf 或者 ls 看 out 目录下有没有对应的 .class 文件有的话就是 java 命令的 -cp 参数写漏了路径。4. RegisterFile 寄存器分配从状态表到溢出处理寄存器分配是所有后端模块里最值得精读的部分。它直接决定生成的 MIPS 代码能不能在 SPIM 里跑出正确结果也是最容易出性能问题的地方。北航这套的 RegisterFile 设计思路很教科书化梳理清楚之后你可以直接把它的策略写进自己的实验报告里。4.1 MIPS 寄存器布局与占用表设计MIPS 的 32 个通用寄存器各有约定用途RegisterFile 的核心数据结构就是一张 32 位的占用表。我用伪代码还原它的骨架// RegisterFile 状态表示意 public class RegisterFile { private static final String[] REGISTERS { $zero, $at, $v0, $v1, $a0, $a1, $a2, $a3, $t0, $t1, $t2, $t3, $t4, $t5, $t6, $t7, $s0, $s1, $s2, $s3, $s4, $s5, $s6, $s7, $t8, $t9, $k0, $k1, $gp, $sp, $fp, $ra }; private boolean[] occupied new boolean[32]; private MapString, Integer virtToPhys new HashMap(); }occupied 数组的索引和 REGISTERS 一一对应true 表示该寄存器当前被占用。virtToPhys 记录的是虚拟寄存器和物理寄存器的映射关系这样 IR 里的虚拟操作数在生成汇编时才能找到它对应的真实寄存器。这里有一个关键约束$zero、$sp、$ra 这些寄存器不应该进入分配池分配器要显式跳过索引 0、29、31。如果你在反编译代码里看到分配循环里跳过某些下标那段逻辑就是干这个的。4.2 分配顺序临时寄存器优先保存寄存器兜底分配策略上看这套实现遵循了 MIPS 调用约定的精神先用 t 系列临时寄存器再用 s 系列保存寄存器。为什么这样排因为 t 寄存器在被调用函数里不保证保留原值用完就扔不需要在函数调用边界保存恢复s 寄存器则必须由被调用函数保存用完还得恢复原值成本更高。// 寄存器分配的核心逻辑先扫 t 寄存器再扫 s 寄存器 public int allocateRegister(String virtReg) { // 第一轮在 t0-t9 里找空闲寄存器 for (int i 8; i 15; i) { // t0-t7 if (!occupied[i]) { occupied[i] true; virtToPhys.put(virtReg, i); return i; } } for (int i 24; i 25; i) { // t8-t9 if (!occupied[i]) { occupied[i] true; virtToPhys.put(virtReg, i); return i; } } // 第二轮s0-s7 兜底 for (int i 16; i 23; i) { // s0-s7 if (!occupied[i]) { occupied[i] true; virtToPhys.put(virtReg, i); return i; } } return -1; // 全部占满触发溢出 }这段代码里最值得注意的参数是循环边界t0-t7 的索引是 8-15t8-t9 是 24-25s0-s7 是 16-23。为什么 t 寄存器要拆成两段因为 MIPS 的寄存器编号就是这么排的t8 和 t9 被安排在 s 寄存器之后设计者当时是为了兼容老的 MIPS 处理器。分配时如果不按这个顺序扫后续写汇编的类可能会因为寄存器编号假设而翻车。4.3 溢出处理与栈帧衔接当 32 个寄存器都被占满时allocateRegister 返回 -1这时必须把某个虚拟寄存器的值写回内存腾出物理寄存器。这就是溢出spill。我在这套代码里看到的常见做法是选最近最久没被使用的虚拟寄存器把它当前的值压到栈上然后把这个物理寄存器重新分配给新请求的虚拟寄存器。// 溢出处理把指定虚拟寄存器的值存入栈 public void spill(String virtReg) { int physReg virtToPhys.get(virtReg); // 栈指针往下走 4 字节 emit(addiu $sp, $sp, -4); // 把物理寄存器内容存栈 emit(sw $ physReg , 0($sp)); occupied[physReg] false; virtToPhys.remove(virtReg); }逻辑上addiu $sp, $sp, -4 是分配栈空间sw 把寄存器内容压栈然后这个物理寄存器就被回收了。后面如果重新加载这个虚拟寄存器再从栈上 lw 回来。溢出策略的好坏直接影响生成代码的性能但课设阶段重点是正确性。如果反编译代码里没有找到 spill 逻辑那说明这套实现极其依赖 t/s 寄存器池遇到大型测试用例可能会直接把分配函数跑挂你要有心理准备。5. 避坑与排查五个让我反复翻车的典型问题这一章我按自己在复现过程中的真实经历来写。每一个坑都花了我至少一个晚上希望你能跳过这些。5.1 现象NoClassDefFoundError明明有 class 文件却跑不起来我在重新编译后第一次运行主程序时立刻收到 NoClassDefFoundError但用 ls 看文件明明存在。原因主类运行时 -cp 只指定了 src 目录没指定编译输出目录 out。问题出在 javac -d out 之后字节码已经跑到 out 里面去了如果 java 命令还在找 src 目录下的 class自然是找不到的。解决java -cp out 主类名确保 classpath 指向的是字节码实际输出目录。顺便说一句反编译出来的类没有包名或者包名被还原得不对时也会报这个错这时候要检查每个类的 package 声明是否一致。5.2 现象生成的 MIPS 代码在 SPIM 里算出 NaN 和一串乱码这是我在选指阶段踩的坑。IR 里的整数加法我用 add 直接映射生成的汇编里有 add $t0, $t1, $t2。SPIM 默认开启延迟槽而且 add 在操作数溢出时会产生异常一个整数运算的结果一旦超出 32 位有符号范围整个程序就崩了输出里全是 NaN 和地址乱码。解决整数加减法统一换 addu/subuMIPS32 的 SPIM 完全支持而且不会触发溢出陷阱。如果你需要检查溢出就保留一条额外的溢出检测指令而不是依赖 add 硬件报错。5.3 现象全局变量初始化顺序错乱输出结果和 C 语义不一致在一个测试样例里声明了三个全局变量程序跑出来的结果和 C 的预期完全对不上。排查后发现是 IrGlobalVariableBuilder 把全局变量全部塞到 .data 段之后初始化顺序变成了变量在符号表中的顺序而不是源代码声明顺序。C 语言里全局变量按声明顺序初始化编译器实现要对 .data 段的排放顺序负责。解决反编译出来后检查全局变量构建过程确认它是按源码顺序遍历 AST 的而不是按哈希表迭代顺序。这一步如果用的是 HashMap必然踩坑换成 LinkedHashMap 或者有序 List 即可。5.4 现象基本块合并后控制流乱跳执行顺序诡异给编译器加了一个简单的块合并优化结果测试程序跑到中途跳去了不该去的地方。原因是 IrBasicBlockBuilder 划分基本块的时候我没有把跳转指令的下一条指令标记为入口导致合并时把两个不该合并的块粘到了一起。基本块合并的前提是块 A 只有唯一后继块 B块 B 只有唯一前驱块 A且 B 的入口不能是其他跳转目标。解决合并前遍历一遍 CFG验证唯一前驱和后继条件同时检查 B 的入口标签是否在别的分支里被引用被引用就不能合并。5.5 现象把 CMakeCCompilerId.c 当成测试样例喂给编译器这个坑比较低端但很常见。压缩包里 CMakeCCompilerId.c 出现了两次第一次看到 C 文件我以为是课程设计提供的测试用例直接把它喂给前端结果词法分析器吐出来一堆陌生标识符。其实是 CMake 探测编译器特性时生成的临时文件里面全是平台相关的预处理器判断和宏根本不应该作为样例输入。解决解压后先建立文件清单凡是名字带 CompilerId 的文件全部隔离只看真正的 main.c 和课设说明文档。6. 用 SPIM 做输出验证从最小测试集到 diff 回归验证是这门课设计最容易偷懒、也最不该偷懒的环节。我见过太多人写完编译器就在命令行里跑一两个样例觉得「输出看着对」就交差。编译器这种黑匣子工程眼见不一定为实必须做机器可比的回归验证。6.1 最小测试集怎么构造我会准备一个三层的测试集。第一层是单表达式比如 int x 3 5;验证最基本的算术和全局变量加载。第二层是带控制流的函数比如用 if-else 和 while 算个累加和验证基本块和控制流图的正确性。第三层才是带函数调用的多文件样例验证调用约定和寄存器保存恢复。在每个测试文件开头写注释标明「这个用例验证什么」后面出问题时一眼就能缩小排查范围。6.2 SPIM 加载与寄存器检查SPIM 跑汇编不用我多说但检查结果时的寄存器值得注意# 运行一个生成的汇编文件 spim -quiet -file test_output.s # 进入交互模式逐步查看执行状态 spim load test_output.s run print $2-quiet 参数会关掉启动时候的版权信息输出更干净。-file 表示直接执行整个文件。print $2 查看返回值寄存器因为 MIPS 约定函数返回值放 $v0 也就是通用寄存器 2。如果结果和预期不符用 step 指令一步步过重点看跳转指令有没有进错基本块。6.3 用 diff 对齐参考输出最后做一个自动化回归脚本把运行结果和参考输出做 diff# 批量运行所有测试用例 for t in tests/*.c; do name$(basename $t .c) java -cp out 主类名 $t out/${name}.s spim -quiet -file out/${name}.s actual/${name}.out diff expected/${name}.out actual/${name}.out \ echo PASS: $name || echo FAIL: $name done这段脚本的逻辑是把每个 C 测试文件依次编译成汇编、用 SPIM 执行、和期望输出比对。整个循环下来哪个用例挂了一目了然。从那以后我每次交课设之前都强制走一遍「测试集 → 汇编生成 → SPIM → diff」这个闭环宁可少写一个优化也要保证每个样例的输出能被机器比对。编译器的正确性不是看着对是机器说对。希望帮到你。本文还有配套的精品资源点击获取