ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

拆解北航编译技术课设代码:从IR构建到MIPS寄存器分配

拆解北航编译技术课设代码:从IR构建到MIPS寄存器分配 简介北航编译技术课程设计代码2022是一份围绕编译器设计与实现的课程项目资源适用于正在学习编译原理、需要完成编译实验或课程设计的高校学生。资源覆盖词法分析、语法分析、语义分析、中间代码生成、优化及目标代码生成等关键环节以 Java 源码为主同时包含编译生成的 class 文件并配有 markdown 与 txt 格式的说明文档便于对照学习各模块的实现思路。整个压缩包共 2000 个文件其中 1437 个 Java 源文件、235 个 class 字节码、149 个 md 文档、111 个 txt 文本以及少量 xml、json、c、py 和构建配置类文件整体约 10.64MB目录结构较清晰可按模块依次查阅。目前已有 135 人学习读者可从中学到 IR 中间表示构建、MIPS 目标指令生成等实际代码方法以及课程设计报告或实验笔记的参考样例对深入掌握编译技术并提升工程实践能力很有帮助。1. 北航编译技术课程设计代码这份 2022 年的包到底值不值得拆编译技术课设的代码包拿到手最容易踩的坑是把它当成一个“能一键编译出可执行文件”的黑匣子。北航这份 2022 年的课程设计代码从文件构成看其实是一个典型的“C 子集 → 中间表示IR → MIPS 汇编”的编译后端IrInstructionBuilder 负责把指令翻译成统一 IRIrBasicBlockBuilder 负责切基本块IrGlobalVariableBuilder 处理全局变量最后 MipsInstructionBuilder 和 RegisterFile 把 IR 落到 MIPS 指令和物理寄存器上。它适合正在做编译原理大作业的人也适合想搞懂中间表示与寄存器分配到底怎么衔接的熟手。一句话这份代码的价值不在“跑通”在于它把编译器后端拆成了能逐个读懂的积木块。2. 拿到压缩包先摸清家底反编译 .class 与还原项目骨架一个课程设计代码包值不值得花时间研究先看它的文件构成。这包东西很典型两个.c文件是 C 源程序一堆.class是 Java 的编译产物类名里出现 IR 和 MIPS说明这是一个从类 C 语言到 MIPS 汇编的编译器工程。跟产品代码不一样课设的目录往往不标准不能拿“找到根目录直接 make”的惯性去搞第一步必须是反推它属于哪个模块、哪段流程然后再决定从哪个入口看代码。2.1 从文件清单反推项目结构先按名字把文件分组IrInstructionBuilder、IrBasicBlockBuilder、IrGlobalVariableBuilder 是一组负责中间表示构建MipsInstructionBuilder、RegisterFile 是另一组负责指令选择和寄存器分配。CMakeCCompilerId.c 和 main.c 放在一起但性质完全不同。文件名属于模块推测职责IrInstructionBuilder.class中端构建四元式 IR 指令IrBasicBlockBuilder.class中端切分基本块、记录跳转关系IrGlobalVariableBuilder.class中端收集全局符号、生成 .data 布局MipsInstructionBuilder.class后端把 IR 翻译成 MIPS 汇编RegisterFile.class后端物理寄存器的分配与溢出CMakeCCompilerId.c工具产物CMake 编译器探测程序不属于编译器本体main.c测试/驱动可能是被测 C 子集程序或运行时库注意 CMakeCCompilerId.c 的定位。CMake 在探测 C 编译器时会自动生成这个探针程序里面全是编译器特性检测宏。它出现在压缩包里大概率是当时工程打包时顺手带进来的它不是你该拿来测试编译器的输入文件。main.c 才是更值得关注的样本。另一件值得注意的事清单里没有 Lexer 和 Parser 类。这通常意味着两种可能要么前端由老师提供的框架 jar 封装了要么学生只提交了后端部分。对使用者来说这不影响学习价值IR 构建器之后的代码反而是课设里最难抄、最该读的部分。2.2 用 CFR 把 .class 还原成可读的 .java拿到一堆 .class 而没有 .java是课设包的常见形态。我一般用 CFR 做反编译它是一个单个 jar 就能跑完的工具。反编译单个类java -jar cfr-0.152.jar IrInstructionBuilder.class --outputdir ./decompiled参数--outputdir指定输出目录。如果多个 class 在同一个包结构下直接对整个目录反编译更省事java -jar cfr-0.152.jar ./classes --outputdir ./decompiled反编译出来的代码不是原始源码类的结构还原度很高但方法体内比较复杂的 for 循环和 switch 偶尔会走样它只适合当阅读材料不要指望反编译后直接能编译回去。如果只是想看类的方法签名和指令调用关系javap 比 CFR 更稳而且 JDK 自带javap -p -c IrBasicBlockBuilder.class block.asm参数-p把 private 成员也显示出来-c直接输出 JVM 字节码。读字节码可以看到“哪个方法调用了 addInst、什么地方 new 了一个 Label”对理解 IR 构建流程帮助很大。想偷懒的话用 JD-GUI点几下就能浏览所有方法和字段。2.3 构建入口在哪先找 Main 和测试脚本有了反编译源码下一步是找程序入口。清单里没有 Main.class不代表包内没有入口类只是没列全。我一般会在反编译目录里搜索 main 方法grep -rl public static void main ./decompiled找到入口之后按课设常规运行路径大致有两条一条是直接读 C 源文件输出.s汇编文件另一条是前端已经生成好 IR 结构后端只负责从 IR 继续往下翻译。既然这份包看起来是后端型验证时不必强求前端可以直接构造 IR 指令喂给 MipsInstructionBuilder看生成的汇编长什么样。还有一种常见做法是反编译整个包后再编译回去验证逻辑是一致的javac -source 8 -target 8 -cp ./decompiled ./decompiled/**/*.java但要有一个预期反编译代码直接编译经常因为泛型擦除、switch 还原问题报错不要死磕编译通过它只负责帮你读懂逻辑。课设代码属于个人成果反编译学习没问题交作业还是要用自己的实现。3. 中端拆解IrInstructionBuilder、IrBasicBlockBuilder 与全局变量的内存安排后端能不能写出干净的汇编先看中端把 IR 铺成什么样。IrInstructionBuilder 管的是“一条指令长什么样”IrBasicBlockBuilder 管的是“一串指令怎么分组、怎么连跳转”IrGlobalVariableBuilder 管的是“变量住内存哪块”。这三个类合起来基本上就是一个线性 IR 版本的三地址码框架。3.1 四元式为什么课设编译器都用这种 IR三地址码、四元式是课设编译器里最常见的 IR 选择。一条指令固定四个字段后端翻译时逻辑最简单也方便后续做数据流分析。典型结构长这样public class IrInstruction { public String op; // ADD, SUB, MUL, LOAD, STORE, BEQ, BR, RET public String dst; // 结果临时变量比如 %3 public String lhs; // 左操作数 public String rhs; // 右操作数 }IrInstructionBuilder 在标准实现里只干一件事维护一个ListIrInstruction对外提供addInst(op, dst, lhs, rhs)之类的接口。读代码到这里不要失望编译器后端最难的不是“往列表里塞指令”而是怎么把分支、布尔表达式和函数调用压成这种扁平的指令序列。我一般会把 op 定义成字符串常量而不是裸字符串否则后端 MipsInstructionBuilder 做 switch 时很容易拼错。反编译时如果看到的是整数字面量多半是枚举被反编译成了 int理解含义时要对照 IR 指令的原始定义。3.2 基本块切分leader 判定是核心基本块切分的规则教材里都有入口语句是 leader跳转指令的目标是 leader跳转指令的下一条是 leader。IrBasicBlockBuilder 的 build 方法只要把这三个判定写对就行。private boolean isLeader(ListIrInstruction insts, int i) { // 第一个指令必然是块入口 if (i 0) return true; IrInstruction prev insts.get(i - 1); // 上一条是跳转指令当前指令是新块的入口 if (prev.op.equals(BR) || prev.op.equals(BEQ) || prev.op.equals(BNE)) { return true; } // 当前指令是某条跳转的目标也是新块入口 return jumpTargets.contains(i); }jumpTargets需要在 build 之前先扫描一遍全部指令把所有跳转指令的 target 字段对应的指令下标加进集合否则基本块会被切散。每个基本块切好后会分配一个唯一入口 label这个 label 也是后续 MIPS 翻译时跳转指令的目标名。基本块切分的质量直接影响寄存器分配块的边界是插入保存/恢复指令最自然的位置。如果 IrBasicBlockBuilder 实现得粗糙把每个跳转都当独立块后续 MipsInstructionBuilder 输出的汇编会又长又碎。读反编译代码时重点看 leader 判定条件里是否覆盖了“跳转指令目标”这一条漏掉这条的实现在分支较多的测试用例里会明显翻车。3.3 IrGlobalVariableBuilder全局变量与 .data 段布局全局变量跟寄存器分配的关系比初学者想象的要大。IrGlobalVariableBuilder 的职责是收集所有全局符号按照类型大小和对齐规则算出偏移最终生成 .data 段。类型大小字节对齐说明int44按 4 字节对齐char11连续存放int[10]404起始地址按 4 对齐常见做法是给每个全局变量分配一个符号名比如_g_var然后生成一行.data定义。IR 层引用全局变量时用符号名真正的地址解析发生在 MIPS 层la指令取地址lw/sw访存。这里有一个容易被忽略的边界数组和结构体需要额外记录元素个数否则后端不知道要预留多少字节。IrGlobalVariableBuilder 如果实现得完整类里一般会有一个MapString, Integer记录变量名到分配大小的映射。反编译时优先看这个 Map 怎么被填充它决定了你能否看懂 .data 段的生成逻辑。3.4 顺着短路求值读懂 IR 组织要说读这份代码最有收获的地方我投短路求值。C 语言里a b并不是把两个值做完逻辑与再转分支而是先算a假就直接跳走真才继续算b。这个过程在 IR 层通常被翻译成两组分支指令L0: t0 load a beq t0, 0, L_false t1 load b beq t1, 0, L_false t2 1 br L_end L_false: t2 0 L_end:注意标号 L_false 和 L_end 都是跳转目标按前面 leader 判定它们都会变成基本块入口这段逻辑会自动切成五块。如果 IrInstructionBuilder 没有专门处理短路求值生成的指令会非常啰嗦反编译时看到一串 BEQ/BNE 往同一个 target 跳基本就是在处理布尔表达式。读这部分代码时建议把 IR 输出功能打开。许多课设编译器会提供dump IR的调试开关如果没有就在 IrInstructionBuilder 里临时加一个 println每生成一条指令就格式化输出一行。这比在 MIPS 指令堆里排错直观得多。4. 后端落地MipsInstructionBuilder 的指令映射与 RegisterFile 的寄存器分配MipsInstructionBuilder 是整条流水线的出口也是课设编译器里最容易堆代码的地方。它的输入是一串已经切好基本块的 IR 指令输出是一段带 label 的 MIPS 汇编文本。RegisterFile 是它背后的军火库负责把 IR 里的%0、%1这类虚拟寄存器翻译成$t0、$s0物理寄存器或者在某些时候直接溢出到栈上。4.1 IR opcode 到 MIPS 指令的映射后端翻译的核心是一张映射表IR op 到 MIPS 助记符基本一对对应IR opMIPS 指令说明ADDaddu $d, $s, $t无符号加避免溢出异常SUBsubu $d, $s, $t无符号减MULmult $s, $tmflo $d乘法结果在 lo 寄存器LOADlw $t, offset($base)取 4 字节地址需先用 laSTOREsw $t, offset($base)存 4 字节BEQbeq $s, $t, label相等跳转BRb label无条件跳转RETjr $ra函数返回重点讲乘除法这是新手最容易翻车的地方。MIPS 没有“一条指令把乘法结果写回寄存器”的写法mult把 64 位结果放在 hi/lo 两个寄存器里要自己用mflo取低位。课程设计一般不需要 64 位结果mflo就够但如果你做溢出检查还得补一步看 hi 是否全 0 或全 1。翻译时我一般会在 MipsInstructionBuilder 里维护一个ListString或StringBuffer每翻译一条 IR 追加几行汇编同时维护一个 label 计数器和跳转目标的映射表。所有虚拟寄存器到这一步应该已经被 RegisterFile 替换成物理寄存器如果生成的汇编里还残留%开头的名字说明寄存器分配没有跑干净QtSpim 加载后必然报错。4.2 RegisterFile朴素分配器怎么工作RegisterFile 名字听起来正式课设里它往往就是一张表加两个方法alloc() 分配空闲寄存器free() 把用完的还回去。常见实现是维护一个物理寄存器列表和一个 Map记录虚拟寄存器到物理寄存器的映射。public class RegisterFile { private ListString pool new ArrayList( Arrays.asList($t0, $t1, $t2, $t3, $t4)); private MapString, String mapping new HashMap(); private int frameOffset; // 溢出栈偏移 public String alloc(String virtualReg) { if (pool.isEmpty()) { spill(); // 池子空了溢出最久没用的寄存器 } String phys pool.remove(0); mapping.put(virtualReg, phys); return phys; } private void spill() { String victim poolForSpill(); // 按使用距离挑一个受害者 emit(sw victim , frameOffset ($sp)); frameOffset 4; } }pool 的选取决定了分配策略只放$t0-$t4意味着实现假设每个基本块很短临时活不过几条指令如果代码里出现$s0-$s7说明它支持跨块分配会保存恢复保存寄存器。溢出策略看 spill 的受害者怎么挑最简单的挑最近没用的高级一点的会按活跃区间长度挑——后者就接近线性扫描了。这里必须说一个边界如果 RegisterFile 只做临时寄存器分配跨基本块的变量必须落在保存寄存器里或者在块边界重新加载。有的课设实现偷懒全程只用$t0-$t9程序大一点就出乱子。读反编译代码时重点看 spill 函数里有没有保存寄存器列表没有就说明这份实现比较稚嫩只适合单块或极小测试用例。4.3 栈帧、调用约定与 $sp 处理MIPS 课设的调用约定是固定的前四个整数参数进$a0-$a3多余参数压栈返回值进$v0$ra保存返回地址嵌套调用前必须自己保存。函数开头一般是压栈保存$ra和被用到的保存寄存器再向下调整$sp分配局部变量区func_foo: addiu $sp, $sp, -16 sw $ra, 12($sp) sw $s0, 8($sp) # 函数体可能嵌套调用 lw $ra, 12($sp) addiu $sp, $sp, 16 jr $ra这一段基本是死模板课设翻车最多的就是在序言里没保存$ra。没保存的话函数体里有嵌套调用时内层jal会把返回地址覆盖掉返回到一个未知地址QtSpim 直接报 PC 异常。还要注意 $sp 的初始值。QtSpim 的默认栈指针不一定是你想要的稳妥做法是在汇编入口显式设置一次li $sp, 0x10008000QtSpim 的栈区常见起始地址或者在加载汇编前确认 QtSpim 的栈指针设置。很多“Address out of range”的报错其实不是访存越界而是整个程序根本没有把栈指针初始化到位。5. 避坑指南跑这份课设代码最容易翻车的六个位置下面这些坑一部分是我拆类似课设包时踩过的一部分是帮别人看代码时见过的。每条按现象、原因、解决的顺序写可以直接照着排查。5.1 反编译时抛 UnsupportedClassVersionError现象运行java -jar cfr时抛UnsupportedClassVersionError或者用 JD-GUI 打开 class 报版本不支持。原因class 是比当前 JDK 更高版本编译的。课设包的 .class 经常由学生本机 JDK 17 编译而你本地还是 JDK 8。解决先用十六进制工具看 class 文件的版本号第 6、7 字节确认是 52Java 8、55Java 11还是 61Java 17。然后换一个跟它同版本或更高版本的 JDK 来跑反编译工具。CFR 本身就支持较高版本如果你还在用老版本顺手升到最新版能省掉大半兼容性问题。5.2 QtSpim 加载汇编时报 Address out of range现象生成的 MIPS 汇编放在 QtSpim 里加载或运行直接报地址越界单步也找不到可疑指令。原因大多数时候是栈指针没有初始化或全局变量引用的符号不在 .data 段里。IR 层符号名带了_前缀但 .data 段生成时丢了前缀引用跟定义对不上la抓不到地址。解决在生成汇编的入口处显式设置栈指针例如li $sp, 0x10008000。再检查 IrGlobalVariableBuilder 生成的符号表确保 .data 段定义的名字和后续引用完全一致包括下划线和大小写。调试时可以在 MipsInstructionBuilder 里把 .data 段的每行定义先打印出来跟 IR 引用对照一遍。5.3 寄存器里的临时值被悄悄覆盖现象同一个测试程序单个函数跑很对连起来跑结果就错而且错误没有规律。原因调用前没保护调用者保存寄存器或者临时寄存器池太小后面的指令重新分配了同一个物理寄存器。RegisterFile 的 pool 里只有几个寄存器时这种情况几乎必现。解决看 RegisterFile 的 alloc 逻辑池子里放的是哪些寄存器、spill 函数有没有真把值存到栈上。如果没有 spill 而只是报错说明这个分配器不支持阻塞。更稳的做法是在嵌套调用点前面把$t0-$t9里仍然有用的临时值压栈调用后再恢复。5.4 把 CMakeCCompilerId.c 当成测试用例来喂现象尝试用编译器去编译 CMakeCCompilerId.c报出一堆语法错误。原因这个文件是 CMake 自动生成的编译器探针专门用来检测当前编译器支持哪些特性里面全是底层宏和特性检测语法不是正常的 C 程序输入。解决别碰它拿 main.c 或者自己写一个只含 int 变量、数组、if、while 的小程序做测试。说句实在话课程设计编译器能完整处理 main.c 已经算合格了。5.5 前端缺失导致全流程断档现象按 README 或课设说明准备跑完整流程发现从源码到 IR 的链路根本是断的。原因文件清单里没有词法、语法分析相关类这份压缩包很可能只给了后端或者前端由课堂框架提供打包时没放进压缩包。解决先确认边界不要四处找不存在的 Lexer。验证 MipsInstructionBuilder 和 RegisterFile就手动构造 IR 序列把后端当独立库测。真想补前端的话经典做法是先写 C 子集的词法程序再加递归下降语法分析量级不比后端小要有心理准备。5.6 汇编里符号名带前缀导致 Symbol not found现象生成的汇编在 QtSpim 里报Symbol not found指向某个全局变量。原因IR 层的全局符号带了_前缀.data 段里定义的符号名没有带或者引用的名字跟定义的大小写不一致。解决所有全局符号统一经过 IrGlobalVariableBuilder 输出生成 .data 定义和后续引用都从一个符号表取名字不要把前缀在字符串拼接时重复加。检查时可以直接打开生成的 .s 文件搜_前缀是否只出现在一处。6. 验证与进阶用 QtSpim 批量跑汇编再给自己加一个取反运算6.1 批量验证 MIPS 输出反编译看完逻辑只是完成了第一步必须把生成的汇编真正跑起来。QtSpim 有 GUI命令行下用 spim 更顺手。我一般会写一个极简的回归脚本#!/bin/bash for f in generated/*.s; do log$(mktemp) spim -file $f $log 21 if grep -q Bad address\|Unknown symbol $log; then echo FAIL: $f head -n 20 $log else echo PASS: $f fi done这个脚本不验证输出结果对不对只验证汇编能不能被 spim 加载并正常跑完。真正的语义正确性需要在 QtSpim 里比对寄存器和内存值。每次改完 RegisterFile 或 MipsInstructionBuilder把这个脚本跑一遍可以快速排除低级的符号错误和栈错误。6.2 给自己加一个“取反”运算走全流程如果只想做一个小验收我建议加一元取反。它串联前端、IR 和后端三个层次前端识别!expr时生成一个临时变量IR 层新增一条 NOT 指令MIPS 层把 NOT 翻译成xori $d, $s, 1。加完之后你会发现前中后端各改哪一行、虚拟寄存器怎么流转、物理寄存器怎么分配全部过了一遍。这个特性足够小但能逼你重新理清编译器三层之间的边界。6.3 边界感这份代码能教你的和它没教的这套课设代码里没有数据流分析没有活跃变量分析寄存器分配也只是局部贪心。但正是这种简化让你能一眼看到完整指令流水源码走完词法和语法后变成四元式四元式切基本块基本块里的虚拟寄存器经 RegisterFile 变成物理寄存器最后 MipsInstructionBuilder 吐出一段能跑的 MIPS。真实产品编译器比这复杂得多但骨架就是这份代码画出来的。所以读它的正确姿势是当“带注释的骨架”不是当“可以抄的终稿”。把每一层的输入输出画在纸上比逐行读每一个方法有用得多。从那以后我拿到任何课设代码包都会强制走这四步反编译读结构、看 IR 构建器怎么组织指令、跑一遍生成的 MIPS、加一个小特性验收。走完全套才决定要不要动代码。希望帮到你。本文还有配套的精品资源点击获取
返回列表