收官章把前九章的零件拼成一台真的编译器:IR 进,汇编出, gcc 能链接,程序能跑。本章先讲代码生成的核心问题(指令选择、寄存器分配),然后给出完整实战:一个算术语言(变量、赋值、if、while)从源码到 x86-64 的全程,最后是学习路线图。
从 IR 到汇编,三个子问题:
指令选择:每种 IR 操作选哪条机器指令。朴素做法一对一(tac 的 t = a+b → mov+add),高级做法用树模式匹配(一撮 IR 树匹配一条寻址模式最强的指令,如 lea rax,[rdi+rsi*4] 一条顶四条,计组课第 10 章见过的编译器魔法)。x86 的 CISC 指令集让指令选择成为「选最短最快等价序列」的搜索。
寄存器分配:IR 假设无限寄存器,真机器只有 16 个(计组课第 4 章)。经典算法图着色:把「同一时刻活跃的变量」连边(活跃变量分析,第 9 章),给图着色(变量→寄存器),颜色数不够就把某些变量**溢出(spill)**到栈上(用前 load、用后 store)。
指令调度:利用流水线(计组课第 6 章),把会 stall 的指令隔开,-load-use 冒泡的填法(数据结构课没有,计组第 6 章有)。
三件套的顺序通常是指令选择 → 寄存器分配 → 调度,每次变换都可能给下一步新的机会(所以现代编译器跑多轮)。
目标语言 mini:变量、算术、赋值、if/else、while、print。前九章的成果全部接上:
词法(第 2 章)→ 语法(第 3 章)→ AST(第 5 章)→ 语义检查(第 6 章)
→ IR 生成(第 7 章)→ 常量折叠(第 9 章)→ 指令选择(本章)→ x86-64
代码生成的落点(简化版,每变量一个栈槽,不搞寄存器分配 —— 但第 9 章活跃变量分析的结论可以指导「哪些变量值得进寄存器」的朴素分配):
// 表达式:结果放在 rax(计组课第 10 章的返回值约定)
void gen_expr(const Node *n) {
switch (n->kind) {
case ND_NUM: emit("mov eax, %d", n->value); break;
case ND_VAR: emit("mov eax, [rbp-%d]", slot(n)); break;
case ND_ADD:
gen_expr(n->lhs);
emit("push rax"); // 左值入栈保护
gen_expr(n->rhs);
emit("mov ecx, eax");
emit("pop rax"); // 取回左值
emit("add eax, ecx");
break;
}
}
// 语句:while → 标签 + 条件跳转(第 7 章 IR 的汇编直译)
void gen_stmt(const Node *n) {
switch (n->kind) {
case ND_IF: {
int id = new_label();
gen_cond(n->cond, id); // 条件不成立跳 else
gen_stmt(n->then);
emit("jmp .Lend%d", id);
emit(".Lelse%d:", id);
gen_stmt(n->els);
emit(".Lend%d:", id);
break;
}
}
}
编译主流程(全程串起九章):
int main(int argc, char **argv) {
tokenize(read_file(argv[1])); // 第 2 章
Node *ast = program(); // 第 3/5 章
check(ast); // 第 6 章:符号表 + 类型
gen_data(); gen_code(ast); // 第 7/10 章:IR → 汇编
return 0;
}
产出的汇编文件交给 gcc 汇编链接:gcc out.s -o mini,然后跑起来。看着自己写的语言跑通第一个程序,是这门课最值得珍藏的时刻。
编译器是「吃代码吐代码」的程序,测试策略:golden test——对每个样例程序,存一份期望输出,CI 里逐个编译运行对比。调试顺序从上往下:token 对不对(打印 token 流)→ AST 对不对(打印树/DOT,第 5 章)→ IR 对不对(打印 TAC)→ 汇编对不对(和 gcc -O0 的输出对照思路)。每层有独立的可检查产物,这是编译器作为管道的可调试性红利;哪层断的,看哪层的打印,不要隔着三层猜。
走出本课后,进阶的三条路:
*.mini + *.expect,脚本逐个编译运行对比输出,故意改坏一个 case 验证框架会报警。