前端读完了源程序,后端要写目标机器,中间需要一个「两种语言都不在乎」的通用形态:中间表示(Intermediate Representation, IR)。IR 是编译器的主战场(优化全在这里),设计得好坏直接决定后端的难度。本章讲最经典的教学 IR(三地址码)、它的两种数据结构表示(四元式 vs 静态单赋值 SSA 的预览)、以及 AST 到 IR 的完整生成。
回顾第 1 章的流水线:IR 在前端和后端之间,职责是「表达程序的语义,但不绑任何源语言或机器」。经典三地址码(Three-Address Code, TAC)的形状:每条指令最多三个地址(两个操作数 + 一个结果):
t1 = b * 2 ← 乘法:结果 t1,操作数 b、2
t2 = a[t1] ← 数组寻址
a[t1] = t2? 不对,例子要一致:
x = y + z 二目运算
t1 = -x 一目运算
goto L1 无条件跳转
if x < 0 goto L2 条件跳转
param x, call f 函数调用(第 8 章)
「三地址」的名字来自 x = y op z 的三个地址位,复合表达式必须拆成多步(每步一个临时变量 t1、t2……),这个「拆平」正是优化的着力点:临时变量的生存期、公共子表达式都显形了。
第 5 章的后序遍历骨架,换个 emit 就成 IR 生成器:
int temp_id = 0;
char *new_temp(void) { static char buf[8]; sprintf(buf, "t%d", ++temp_id); return strdup(buf); }
char *gen_expr(const Node *n) { // 返回「存放该表达式结果的地址」
switch (n->kind) {
case ND_NUM: return emit_load(n->value);
case ND_VAR: return n->name;
case ND_ADD: {
char *l = gen_expr(n->lhs); // 先左
char *r = gen_expr(n->rhs); // 再右
char *t = new_temp();
emit("%s = %s + %s", t, l, r); // 最后自己
return t;
}
}
}
对 a = b * 2 + c 走一遍,输出的 TAC:
t1 = b * 2
t2 = t1 + c
a = t2
「先孩子后自己」的后序遍历 = 运算的操作数先就位。控制流(if/while)生成时引入标签:
if (a < b) s1 else s2 →
t1 = a < b
if !t1 goto Lelse
s1
goto Lend
Lelse:
s2
Lend:
while 是 if 的变体(条件在头,回边 goto 回条件)。生成的 IR 逐条与汇编仅一步之遥(第 10 章),这就是 IR 的「中间」含义。
TAC 有两种落地方式。线性四元式:指令数组(op, arg1, arg2, result),简单直接,经典教材与早期编译器用。静态单赋值形式(SSA):每个变量只被赋值一次,多次赋值时改名(x₁、x₂……),控制流汇合处用 φ(phi) 函数合并:
线性版: SSA 版:
x = 1 x1 = 1
if ... if ... x2 = φ(x1, x3)
x = 2 x = 2 → x3 = 2
x = x + 1 x4 = x2 + 1? 或 φ 的那个
SSA 的魔力:每个「名字」只有一处定义,使用-定义关系(du 链)变成树的形状,数据流分析(第 9 章)从「迭代求不动点」简化为「沿树传播」,大量优化(常量传播、死代码删除)变得局部而便宜。现代编译器全部以 SSA 为核心 IR(LLVM IR、V8 的 Sea of Nodes)。本课实现线性版(够教学),SSA 记住概念和「为什么单赋值这么值钱」即可,进阶读 LLVM 的 IR 文档。
几个设计轴,每条都有权衡:
设计 IR 的总原则:它要让中端的优化「写起来简单」。优化器是这个舞台的主角,IR 是为它定制的布景。
a && b 直接生成 a+b 是错的(第 3 章 Java 的短路),要用「短路求值」的跳转模式生成。while (i < n) { s = s + i; i = i + 1; } 输出完整 TAC。x = a + b * c - d 手写三地址码(注意优先级),再改成 SSA 版(每变量一次赋值),体会 t 变量的命名。t1 = 2 * 3 变成 t1 = 6)。这就是中端优化的第一口。