编译原理
把前端友好的 IR Tree 改写成对后端友好的 canonical form:消除 ESEQ、切分基本块、再用 traces 把跳转重排为顺序流,为指令选择铺路。
Chapter 7 把 typed AST 翻成了 IR Tree——ESEQ、CJUMP(o, e1, e2, t, f)、CALL(f, args) 这些节点表达力极强,前端写起来很顺手。但前端方便不等于后端方便:这些节点在真实机器上根本找不到对应物。Chapter 8 接着问:
怎么把"前端友好"的 IR Tree 改造成"后端友好"的 IR list,让指令选择能直接落地?
答案是三阶段规整(canonicalization):
Chapter 8 的三个主题:
┌──────────────────────┐
│ Abstract Syntax Tree │
└──────────┬───────────┘
↓ Chapter 7: IR Generation
┌──────────────────────┐
│ Intermediate Repr. │
└──────────┬───────────┘
↓ Chapter 8: Canonical Trees / Basic Blocks / Traces ← 你在这里
┌──────────────────────┐
│ Canonical IR │
└──────────┬───────────┘
↓ Chapter 9: Instruction Selection
┌──────────────────────┐
│ Abstract Assembly │
└──────────┬───────────┘
↓ Chapter 10/11: Register Allocation
┌──────────────────────┐
│ Assembly Code │
└──────────────────────┘
Tree 语言虽然故意挑了接近大多数机器的算子,但仍然有四个 mismatch:
IR 的 CJUMP(e, t, f) 可以跳两个地方;真实机器的条件跳转只能指定 true label,假就 fall through 到下一条。
IR Tree Machine
CJUMP(e, t, f) evaluate e
... JNZ t
LABEL(t) if-false code
if-true code t: ...
LABEL(f)
if-false code
想让机器直接执行 CJUMP,必须保证 CJUMP 后面紧跟 false label——这是 Stage 3 要做的事。
BINOP
╱ │ ╲
op e1 ESEQ
╱ ╲
s e2
s 有副作用,先算 e1 还是先算 s 结果不一样。例子:e1 = a + 5、s 是 a := 5——左右子树先后顺序决定结果。
但好的指令选择 / 调度器希望能自由调度子表达式。混杂在表达式里的语句剥夺了这种自由。
CALL 节点出现在表达式里和 ESEQ 同病相怜,而且 CALL 还有额外问题。
CALL(f, [e1, CALL(g, [e2, ...])])
每个函数都把返回值放在同一个 TEMP(RV)——内层 g 的结果会覆盖外层 e1 已经放好的东西。
1. A tree → list of canonical trees (无 SEQ/ESEQ) ← 解决 ESEQ + CALL
2. list → set of basic blocks (无内部 jumps/labels)
3. blocks → set of traces (CJUMP 后必跟 false) ← 解决 CJUMP
Stage 2 + 3 一起保证 CJUMP(cond, lt, lf) 后面总是紧跟 LABEL(lf),从而能直接翻成机器条件分支。
Canonical tree 满足两个性质:
EXP(...) 要么是 MOVE(TEMP t, ...)Property 1 ⇒ 每棵 canonical tree 最多一个 statement 节点(根),其余都是 expression。 Property 2 ⇒ 一个 canonical tree 里最多一个 CALL——因为 EXP/MOVE 只装一个 CALL。
1. eliminate ESEQs ← 拎 ESEQ 上浮直到变成 SEQ
2. move CALLs to top level ← 把 CALL 立刻搬到 fresh temp
3. eliminate SEQs ← 把 SEQ 树拍扁成线性 list
核心思路:不停往上抬 ESEQ,直到它能变成 SEQ。
ESEQ(s1, ESEQ(s2, e)) = ESEQ(SEQ(s1, s2), e)
BINOP(op, ESEQ(s, e1), e2) = ESEQ(s, BINOP(op, e1, e2))
MEM(ESEQ(s, e1)) = ESEQ(s, MEM(e1))
JUMP(ESEQ(s, e1)) = SEQ(s, JUMP(e1))
CJUMP(op, ESEQ(s, e1), e2, l1, l2) = SEQ(s, CJUMP(op, e1, e2, l1, l2))
规律:
ESEQ(s, e)出现在某节点的第一个子表达式里 —— 直接把s抽出来放外层就行,因为 s 本来就该最先算。
BINOP(op, e1, ESEQ(s, e2))
CJUMP(op, e1, ESEQ(s, e2), l1, l2)
直接搬不行——s 可能改写 e1 依赖的内存。比如:
s = MOVE(MEM(x), y)
e1 = MEM(x)
要是不管不顾把 s 提到 e1 前面,e1 读到的就是写过之后的值,语义错了。
把 e1 先求出来塞进一个新 temp t:
BINOP(op, e1, ESEQ(s, e2))
↓
ESEQ(MOVE(TEMP t, e1),
ESEQ(s,
BINOP(op, TEMP t, e2)))
这样 e1 已经"凝固"在 t 里,s 怎么折腾都不影响。CJUMP 同理。
如果 s 和 e1 commute(可交换求值顺序),就能直接搬:
若 s 和 e1 commute:
BINOP(op, e1, ESEQ(s, e2)) = ESEQ(s, BINOP(op, e1, e2))
多一个 MOVE 不致命,但能省就省——实际机器 instruction scheduling 也是这种思路。
commute(s, e):保守近似什么时候 s 和 e 一定可换序?真实判断不可能(要做精确别名分析),所以用保守估计:
EXP(CONST x))和任何 expression 都 commutestatic bool isNop(T_stm x) {
return x->kind == T_EXP && x->u.EXP->kind == T_CONST;
}
static bool commute(T_stm x, T_exp y) {
return isNop(x) || y->kind == T_NAME || y->kind == T_CONST;
}保守 = "宁可多塞 temp,也不破坏语义"。会多生成些 IR,但后续 dead code elimination 能扫掉。
reorder / do_stm / do_exptypedef struct expRefList_ *expRefList;
struct expRefList_ { T_exp *head; expRefList tail; };
struct stmExp { T_stm s; T_exp e; };
static T_stm reorder(expRefList rlist); // 关键
static T_stm do_stm(T_stm stm); // 处理 stmt
static struct stmExp do_exp(T_exp exp); // 处理 expr,返回 (s, e)expRefList:指针的 list,每个元素指向一个 T_exp 槽位(便于原地 update)stmExp:do_exp 返回 (s, e'),使得 e ≡ ESEQ(s, e') 且 e' 已无 ESEQreorder 的语义输入:一组子表达式 [e1, e2, ESEQ(s, e3)]
输出:把里面的 statement 抽出来,把子表达式槽位原地改成 ESEQ-clean 版本
[e1, e2, ESEQ(s, e3)]
│
├── s commute with e1, e2:
│ 抽出 s,槽位变成 [e1, e2, e3]
│
├── e2 不和 s commute:
│ 抽出 SEQ(MOVE(t1,e1), SEQ(MOVE(t2,e2), s)),槽位变成 [TEMP(t1), TEMP(t2), e3]
│
└── e2 和 s commute,但 e1 不:
抽出 SEQ(MOVE(t1,e1), s),槽位变成 [TEMP(t1), e2, e3]
do_stm:每种 statement 怎么处理(节选)static T_stm do_stm(T_stm stm){
switch (stm->kind) {
case T_SEQ: // SEQ(s1, s2)
return seq(do_stm(stm->u.SEQ.left),
do_stm(stm->u.SEQ.right));
case T_JUMP: // JUMP(e)
return seq(reorder(ExpRefList(&stm->u.JUMP.exp, NULL)), stm);
case T_CJUMP: // CJUMP(o, e1, e2, t, f)
return seq(reorder(ExpRefList(&stm->u.CJUMP.left,
ExpRefList(&stm->u.CJUMP.right, NULL))), stm);
case T_MOVE: ... // 见下面
case T_EXP:
if (stm->u.EXP->kind == T_CALL)
return seq(reorder(get_call_rlist(stm->u.EXP)), stm);
else
return seq(reorder(ExpRefList(&stm->u.EXP, NULL)), stm);
}
}MOVE 要分四种情况:
case T_MOVE:
if (dst==TEMP && src==CALL) // MOVE(TEMP, CALL(...)) ← 已 canonical
return seq(reorder(get_call_rlist(src)), stm);
else if (dst==TEMP) // MOVE(TEMP, e)
return seq(reorder([&src]), stm);
else if (dst==MEM) // MOVE(MEM(e1), e2)
return seq(reorder([&dst.MEM, &src]), stm);
else if (dst==ESEQ) { // MOVE(ESEQ(s, e1), e2)
// 拆成 SEQ(s, MOVE(e1, e2)) 再递归
return do_stm(T_Seq(dst.ESEQ.stm,
T_Move(dst.ESEQ.exp, src)));
}do_exp:每种 expression 怎么处理static struct stmExp do_exp(T_exp exp){
switch(exp->kind) {
case T_BINOP:
return StmExp(reorder([&L, &R]), exp);
case T_MEM:
return StmExp(reorder([&exp->u.MEM]), exp);
case T_ESEQ: {
struct stmExp x = do_exp(exp->u.ESEQ.exp); // {x.s, x.e}
return StmExp(seq(do_stm(exp->u.ESEQ.stm), x.s), x.e);
}
case T_CALL:
return StmExp(reorder(get_call_rlist(exp)), exp);
default:
return StmExp(reorder(NULL), exp); // CONST/NAME/TEMP 没子表达式
}
}
T_ESEQ那一支是关键:把内部s和do_exp(e)的 statement 部分串起来,作为新的 statement 抛出去;e 部分留作返回的 expression。
为什么 CALL 要单独处理?——所有函数都把返回值放在 RV。
BINOP(PLUS, CALL(...), CALL(...))
↑
第二个 CALL 会覆盖第一个的 RV
做法:一遇到 CALL 就立刻把返回值搬进新 temp:
CALL(f, args)
↓
ESEQ(MOVE(TEMP t, CALL(f, args)), TEMP t)
经过 ESEQ 上浮 + 这条规则,所有 CALL 最后都成 MOVE(TEMP t, CALL(...)) 或 EXP(CALL(...)),符合 canonical tree property 2。
ESEQ 拎光后,所有 SEQ 都聚在树顶,长成左偏深树:
SEQ
├── s1
└── SEQ
├── s2
└── SEQ
├── s3
└── ...
反复用结合律 SEQ(SEQ(a, b), c) = SEQ(a, SEQ(b, c)),把树掰成右展开的链:
SEQ(s1, SEQ(s2, ..., SEQ(s_{n-1}, s_n)...))
这跟一个简单 list [s1, s2, ..., sn] 没区别——SEQ 本身没语义信息,直接扔掉。
Tiger compiler 的
C_linearize(T_stm stm)一步到位返回T_stmList。
| 模式 | 改写 |
|---|---|
ESEQ(s1, ESEQ(s2, e)) | ESEQ(SEQ(s1, s2), e) |
BINOP(op, ESEQ(s, e1), e2) | ESEQ(s, BINOP(op, e1, e2)) |
MEM(ESEQ(s, e1)) | ESEQ(s, MEM(e1)) |
JUMP(ESEQ(s, e1)) | SEQ(s, JUMP(e1)) |
CJUMP(op, ESEQ(s, e1), e2, l1, l2) | SEQ(s, CJUMP(op, e1, e2, l1, l2)) |
BINOP(op, e1, ESEQ(s, e2)) | ESEQ(MOVE(TEMP t, e1), ESEQ(s, BINOP(op, TEMP t, e2))) |
CJUMP(op, e1, ESEQ(s, e2), l1, l2) | SEQ(MOVE(TEMP t, e1), SEQ(s, CJUMP(op, TEMP t, e2, l1, l2))) |
MOVE(ESEQ(s, e1), e2) | SEQ(s, MOVE(e1, e2)) |
CALL(f, a) | ESEQ(MOVE(TEMP t, CALL(f, a)), TEMP t) |
Control flow = 指令的执行顺序,忽略寄存器/内存里的具体数值,忽略算术。
把任意一段非分支指令捏成一坨(basic block),然后只分析 block 之间的控制流——这是 control-flow analysis 的核心简化。
一个 basic block 是一段只能从头进、只能从尾出的语句序列:
LABEL XX ← 第一条必是 LABEL
... # 中间没有 LABEL/JUMP/CJUMP
JUMP / CJUMP ← 最后一条必是 JUMP 或 CJUMP
扫一遍 statement list:
1. 遇到 LABEL → 上一块结束,从 LABEL 这里开新块
2. 遇到 JUMP/CJUMP → 当前块结束,下一条开新块
3. 若某块没以 JUMP/CJUMP 结尾 → 追加 JUMP(下一块的 label)
4. 若某块没以 LABEL 开头 → 造一个新 label 塞进去
Step 3 / 4 是为了统一形式:每块都"label 开头 + jump 结尾",后续阶段不用考虑特殊情况。
(1) x := input
(2) y := x - 1
(3) z := x * y
(4) if z < x goto (7)
(5) p := x / y
(6) q := p + y
(7) a := q
(8) b := x + a
(9) c := a - b
(10) if p == q goto (12)
(11) goto (3)
(12) return
分块:
得到 6 块:{1,2}、{3,4}、{5,6}、{7,8,9,10}、{11}、{12}。
切完 (5)(6)(7)(8)(9)(10) 这两块后,要补:
LABEL(five) LABEL(seven)
MOVE(p, x/y) MOVE(a, q)
MOVE(q, p+y) MOVE(b, x+a)
JUMP(NAME seven) ← 补 MOVE(c, a-b)
CJUMP(EQ, p, q, twelve, eleven)
Basic block 内部已经规整,但 block 之间的顺序还没定。注意:
Block 顺序怎么排,不影响程序语义——因为每块都靠显式 JUMP/CJUMP 跳转。
利用这一点,我们刻意挑一种排法:让每个 CJUMP(cond, lt, lf) 在最终序列里紧挨着 LABEL(lf):
CJUMP(op, a, b, lt, lf)
LABEL(lf) ← 紧跟 false label
这样 CJUMP 就能直接翻成机器的"条件跳真,假就 fall through"。
附带好处:也能让很多 unconditional JUMP(NAME next) 紧挨 LABEL(next),这种 jump 可以直接删掉——程序更快。
Trace = 一段在执行中可能连续执行的 statement 序列(可以含 CJUMP)。
挑一组 trace 把整个程序正好覆盖一次:
把所有 block 放进队列 Q
while Q 非空:
新建空 trace T
从 Q 取出头部 block b
while b 未标记:
标记 b;把 b 追加到 T 末尾
看 b 的后继(b 跳到的 block 们)
if 有未标记的后继 c:
b ← c
(b 的所有后继都已标记 → trace 走到尽头)
结束当前 trace T
直觉:从一个 block 开始,沿着 jump 链一路走,标记一个走一个,撞到死路就开新 trace。
LABEL(b1)
...
JUMP(NAME b4)
↓
LABEL(b4)
...
JUMP(NAME b6)
↓
LABEL(b6)
...
CJUMP(cond, b7, b3) ← 取 false 后继 b3 拼到 trace 后面
↓ 这样 CJUMP 后面就是 LABEL(b3) ✓
LABEL(b7) LABEL(b3)
顺着 b1 → b4 → b6 → b3 跑成一条 trace,b7 留到下一条 trace。
把所有 trace 拼回一条长 list,处理三种 CJUMP:
| 情况 | 处理 |
|---|---|
| CJUMP 后紧跟 false label | 不动(我们追求的就是这个,绝大多数都是) |
| CJUMP 后紧跟 true label | 交换 lt / lf 并取反条件 |
| CJUMP 后既不是 lt 也不是 lf | 造一个新 label lf',改写为:CJUMP(cond, a, b, lt, lf') LABEL lf'JUMP(NAME lf) |
频繁执行的代码段(如循环体)应独占一条 trace,以减少 unconditional jump。
考虑 while (i ≤ N) body,有三种可能排法:
(a) prologue (b) prologue (c) prologue
JUMP test JUMP test JUMP test
LABEL test LABEL test LABEL body ← 循环体先
CJUMP > done body CJUMP ≤ body done body
LABEL body LABEL done JUMP test ← (可省)
body epilogue LABEL test
JUMP test LABEL body CJUMP ≤ body done
LABEL done body LABEL done
epilogue JUMP test epilogue
每次循环要执行的跳转数:
(c) 把循环体做成一条独立 trace,test block 排在循环体后面——少一条 unconditional jump 看起来微小,但循环跑成千上万次会非常划算。register allocation 和 instruction scheduling 也偏爱长 trace。
typedef struct C_stmListList_ *C_stmListList;
struct C_block { C_stmListList stmLists; Temp_label label; };
struct C_stmListList_ { T_stmList head; C_stmListList tail; };
T_stmList C_linearize(T_stm stm); // Stage 1
struct C_block C_basicBlocks(T_stmList stmList); // Stage 2
T_stmList C_traceSchedule(struct C_block b); // Stage 3下游(Ch9 instruction selection)从这里拿到的是:一个干净的、CJUMP 后必跟 false label 的 T_stmList。
IR Tree 写起来漂亮,但有四个跟机器对不上的地方:
Stage 1(Canonical Trees)解决 ESEQ 和 CALL,Stage 2 + 3(Basic Blocks + Traces)联手解决 CJUMP。
原则:不停把 ESEQ 抬高,直到它能转成顶层 SEQ。语义不允许直接搬时,先把会被打乱的子表达式塞进 fresh temp。commute 用最保守的常量/空 stmt 规则估,宁滥勿缺。
CALL → ESEQ(MOVE(TEMP t, CALL), TEMP t),确保下游每个 CALL 的父节点都是 EXP 或 MOVE(TEMP, ...)。
"LABEL 开头 + JUMP/CJUMP 结尾,中间无跳转"——切块时缺 LABEL 就造一个,缺 JUMP 就补一条到下一块。
Trace 内部沿可能的执行路径连成一串,CJUMP 后主动接它的 false 后继。剩下的少数 CJUMP 用"取反 / 加跳板"两招收尾。
test 排在循环体之后,使每轮迭代少一条 unconditional jump——循环执行次数越多收益越大。
| Chapter | 问 | 答 |
|---|---|---|
| 5 | AST 语义对吗? | Symbol table + type checking |
| 6 | 运行时变量放哪? | Activation record + Frame |
| 7 | typed AST → IR 怎么翻? | IR Tree + Translate (Ex/Nx/Cx) |
| 8 | IR 怎么整理才能落到机器? | Canonical Trees → Basic Blocks → Traces |
| 9 | IR → 目标机器? | Instruction selection (maximal munch) |
到这里 IR 已经"机器友好"了:
typed AST + frame info → IR Tree fragments
→ Canonical IR list → Basic Blocks → Traces ← 你在这里
→ Abstract Assembly → Real Assembly
下一章 Chapter 9 会用 maximal munch 把 canonical IR 一棵棵地匹配到目标指令集。
按以下顺序推进,效率最高:
commute(s, e) 的保守近似:常量 + 空 stmt,其他全否reorder 的工作:输入指针 list,抽出 statement,原地更新成 ESEQ-cleanCALL → ESEQ(MOVE(TEMP t, CALL), TEMP t) 一定要记住——这是 RV 冲突的统一解法C_linearize / C_basicBlocks / C_traceSchedule 的输入输出C_basicBlocks 返回 C_block(里面是 C_stmListList,即 list of basic blocks)Written by
Comments