Archipelago

Lec 6代码生成

讲义完成更新于 2026-09-16

Lec 6 代码生成

配套复习课:R5 SSA(第 9 节)、R6 控制流图、R7 x86 汇编——后两者内容已大量融入本讲正文 参考:Cooper et al., Ch.6 过程抽象 / 实现过程;Ch.7 代码形态(Code Shape)

TL;DR

  • 代码生成把结构化 IR 降级为基本块和 CFG,再线性化为符合 x86-64 ABI 的汇编。
  • 短路条件、过程调用、栈帧、数组边界检查等都应通过小而可验证的降级模式实现,先保证正确性再优化。
  • SSA 通过单次定义和 φ 节点显式化 def-use 关系,为后续优化与寄存器分配提供更直接的表示。

1. 本节学习目标

结构化 IR → 控制流图 (control flow graph,CFG) → 生成的汇编代码

本讲强调未优化 代码生成:现在只做最简单的事,把优化作为独立主题留到后面。代码生成器要保持简单,宁可生成 0 + 1*x + 0*y 这样的丑代码,也让优化器后续去清理。

2. 控制流图

定义

Definition:控制流图

它是这样一种图:

  • 节点表示计算
    • 每个节点是一个基本块 。
    • 基本块是一段指令序列,满足:
      • 中间没有分支跳出
      • 中间没有分支跳入;
      • 基本块应当极大化;
    • 执行从首指令开始
    • 包含块内全部指令;
  • 边表示控制流。

2.1 结构化 IR → CFG 的模式

if-then-else:if 节点(无操作 nop)下挂 condition、then、else 的 CFG,汇合于一个 nop。

image-20260617100929028

if-then、while、语句序列 seq 各有对应模式。

image-20260617101050602 image-20260617101010582

2.2 基本块构造

定义

Definition:基本块合并算法

从逐个指令的控制流图出发,遍历所有边,当且仅当:第一个节点只有一条出边,且第二个节点只有一条入边 时,合并相邻两节点

image-20260617101359012

2.3 程序点、分裂点与汇合点

  • 每条语句前后各有一个程序点 (program point)。
  • 分裂点 (split point):有多个后继——只有条件分支语句是分裂点。
  • 汇合点 (merge point):有多个前驱。
  • 每个基本块:要么以汇合点开始、要么其前驱以分裂点结束;要么以分裂点结束、要么其后继以汇合点开始。
image-20260617101555036

3. 短路条件与"解构"

布尔条件需短路求值:((i<n) && (v[i]!=0)) || i>k 中,只在 (i<n) 为真时才求 (v[i]!=0)。

用 CFG 表示这种短路:

image-20260617101841475

另外一个例子:

image-20260617101954648

3.1 两个解构例程

定义

Define 解构与短路

destruct(n):生成结构化节点 n 的降级形式,返回 (b, e)——b 是起始节点, e 是结束节点 shortcircuit(c, t, f):生成条件 c 的短路形式;

  • c 为真则流向 t,
  • c 为假则流向 f;
  • 返回起始节点 b —— b 是条件求值的开始点
  • 引入新节点类型 nop。

destruct 各情形(关键边连接):

seq x y:   (bx,ex)=destruct(x); (by,ey)=destruct(y); next(ex)=by; return (bx,ey)
if c x y:  (bx,ex)=destruct(x); (by,ey)=destruct(y); e=new nop;
           next(ex)=e; next(ey)=e; bc=shortcircuit(c,bx,by); return (bc,e)
while c x: e=new nop; (bx,ex)=destruct(x);
           bc=shortcircuit(c,bx,e); next(ex)=bc; return (bc,e)

shortcircuit 各情形(递归结构归纳):

c1 && c2:  b2=shortcircuit(c2,t,f); b1=shortcircuit(c1,b2,f); return b1
c1 || c2:  b2=shortcircuit(c2,t,f); b1=shortcircuit(c1,t,b2); return b1
! c1:      b=shortcircuit(c1,f,t); return b           // 交换 t/f
e1 < e2:   b=new cbr(e1<e2, t, f); return b           // 计算型条件,发出 cmp/jl

注意 && 把 f 同时传给两支(任一假即假);|| 把 t 同时传给两支(任一真即真);! 交换真假目标。

3.2 nop 的消除

解构会留下 nop 节点,最后用窥孔优化 (peephole optimization) 消除(如 jmp .L0; .L0: 中的多余 jmp)。


4. 线性化 CFG 为汇编(Linearizing)

  • 在分支处为边目标生成标签 (labels)——对应跳转目标。
  • 发出过程入口代码 → 各基本块代码(语句/条件表达式,适当线性化,用 jmp/条件 jmp 连接)→ 过程出口代码。

5. 现代 ISA 与内存布局

ISA 四要素:内存、寄存器、ALU、控制。计算模型:从内存 load 到寄存器 → 在寄存器上计算 → store 回内存;控制流决定执行什么。编译器的角色:编排寄存器使用、生成与机器交互的低层代码。

5.1 典型内存布局

高地址  ┌─────────────┐
        │  Stack      │ 局部变量 / 临时值 / 部分参数(向下增长)
        ├─────────────┤
        │  (Unmapped) │
        ├─────────────┤
        │  Heap       │ 动态分配(向上增长,sbrk 扩展)
        ├─────────────┤
        │  Data       │ 全局变量
        │  RO Data    │ 只读常量
        │  Text       │ 程序代码
低地址  └─────────────┘

5.2 x86-64 寄存器与调用约定

定义(System V x86-64 调用约定)

  • 16 个 64 位寄存器:%rax,%rbx,%rcx,%rdx,%rdi,%rsi,%rbp,%rsp,%r8–%r15。

  • 前 6 个整型/指针参数依次在 %rdi,%rsi,%rdx,%rcx,%r8,%r9,其余压栈。

  • 返回值(≤64 位)在 %rax,更长的经栈返回。

  • %rbp 标记当前帧起点,%rsp 标记栈顶。

  • 被调用者保存 (callee-save):%rsp,%rbx,%rbp,%r12–%r15(过程前后须一致);调用者保存 (caller-save):%rax,%rcx,%rdx,%rsi,%rdi,%r8–%r11。 :::

5.3 调用栈帧

        ┌────────────────────┐  ← 高地址
        │ argument n …7       │  16(%rbp), 24(%rbp)…
        │ return address      │  8(%rbp)
%rbp →  │ previous %rbp       │  0(%rbp)
        │ local 1 … local m   │  -8(%rbp)…
%rsp →  │ (栈顶)              │  ← 低地址
        └────────────────────┘

5.4 过程联接(Procedure Linkage)

::: definition 定义(标准过程联接四段)

  • Pre-call(调用方):保存 caller-save 寄存器;设置参数(1–6 进寄存器,7–N 压栈)。

  • Prolog(被调方入口):压旧帧指针;保存 callee-save;为参数/临时/局部腾空间。enter $size, $0 一次完成压 %rbp、复制 %rsp→%rbp、下移 %rsp。

  • Epilog(被调方出口):恢复 callee-save;弹旧帧指针;存返回值。leave; ret。

  • Post-return(调用方):恢复 caller-save;弹出参数。 :::


6. 生成过程体代码(Generate Procedure Body)

6.1 表达式扁平化(Flatten Expressions)

把表达式化为三地址形式:所有运算形如 temp1 = temp2 op temp3、temp1 = temp2[temp3]、if (temp1 op temp2)、while (temp1 op temp2)。

求值表达式树的两种模型:

  • 栈模型 (Stack Model):求左子树压栈、求右子树压栈、取栈顶两值运算再压栈——非常低效。
  • 平铺列表模型 (Flat List Model):左到右深度优先遍历表达式树,为每个中间结点分配临时变量(暂存栈上),每个表达式是单条三地址运算 x = y op z:把 y 载入 %rax、op z, %rax、%rax 存回 x。

降级表达式的问题:寄存器有限,树大时不够 ⇒ 临时值放栈上;代价是拷贝过多——但别担心,优化趟会处理;保持代码生成器极简。

6.2 未优化代码生成模板(Templates)

temp = var:        mov var(%rbp), %rax ; movq %rax, temp(%rbp)
temp = temp op temp: mov t2, %rax ; add t3, %rax ; movq %rax, t1
temp = arr[idx]:   mov idx, %r10 ; mov arr(,%r10,8), %rax ; movq %rax, t1
  • %rax 作工作寄存器;%r10 作数组下标寄存器;临时值都在栈上。

6.3 数组访问与边界检查(Bounds Check)

数组访问前先检查下标合法:

cmp $0, idx         ; idx < 0 ?
jl  .boundsbad
mov idx, %rax
cmp $bound, %rax    ; idx >= bound ?
jge .boundsbad
jmp .boundsgood
.boundsbad:
    mov idx, %rdx   ; 参数1=下标
    mov $elemSize, %rcx
    call .boundserror
.boundsgood:
    mov idx, %r10
    mov arr(,%r10,8), %rax

全局数组声明 int values[20]; 对应汇编伪指令 .comm values,160,8(名、大小字节、对齐)。

6.4 控制流模板

::: example 例题(if-then-else 模板)

if (ax > bx) dx = ax - bx; else dx = bx - ax;
————————————————————————————————
    movq 16(%rbp), %r10        ; ax
    movq 24(%rbp), %r11        ; bx
    cmpq %r10, %r11
    jg  .L0                    ; 条件真跳 then
    movq 24(%rbp), %r10        ; FALSE: dx = bx - ax
    movq 16(%rbp), %r11
    subq %r10, %r11
    movq %r11, -8(%rbp)
    jmp .L1
.L0:                           ; TRUE: dx = ax - bx
    movq 16(%rbp), %r10
    movq 24(%rbp), %r11
    subq %r10, %r11
    movq %r11, -8(%rbp)
.L1:

while 循环优化模板(把测试放循环末尾,减少一次 jmp):

lab_cont:
    <do the test> ; joper lab_end   // 条件不满足跳出
    <body>
    jmp lab_cont
lab_end:

do-while 模板:lab_begin: <body> <test> joper lab_begin。


7. 从汇编到目标文件(Object File)

汇编语言优点:符号指令与符号名简化代码生成、提供逻辑抽象层、一种汇编可描述多架构。缺点:需额外汇编与链接、汇编器有开销。

  • 可重定位机器码 (relocatable):地址用符号表示,链接/加载期映射到内存地址,支持分离编译。
  • 绝对机器码 (absolute):地址硬编码,简单但不灵活,用于中断处理与设备驱动。

目标文件含多个段(Global Offset Table、Procedure Linkage Table、Text、Data、RO Data)、符号信息、重定位信息。OS 读目标文件→在内存建可执行进程→运行。


8. 代码生成器编写准则(Guidelines)

定理(代码生成器工程准则)

  • 缓慢降级抽象层:多趟、每趟只做少量(或一件)事,便于分解与调试。

  • 保持抽象层一致:IR 应始终有"正确"语义,趟与趟之间可插入部分优化。

  • 编写并经常运行理智/一致性检查 (sanity checks)。

  • 做最简单(甚至笨)的事,把优化交给优化器;面向优化器需要的形态来组织代码。

  • 建立良好测试基础设施:回归测试(把触发 bug 的输入加入回归集)、二分查找 / delta debugging 找 bug。 :::

为什么用栈分配活动记录(而非静态预分配或堆分配)?支持递归与可重入、按调用动态伸缩、后进先出与调用嵌套自然吻合、回收开销 O(1)。


9. 静态单赋值形式(SSA)

该节为理论补充,项目中不要求实现 SSA。基于 SSA-based Compiler Design(Rastello 等)第 1–3 章。

9.1 什么是 SSA

一种低层 IR,其中每个变量恰好被定义一次(静态性质)。等价直觉:变量不可变;同名变量的每次出现都有相同的值;"SSA 即函数式编程"(Appel 1998)。

基本块内转 SSA:给每次定义一个新下标名。

a ← 1            a1 ← 1
b ← a + 1        b1 ← a1 + 1
a ← a + b   ⟹   a2 ← a1 + b1
c ← a + 1        c1 ← a2 + 1
a ← b + c        a3 ← b1 + c1

9.2 φ 函数(Phi Function)

在 CFG 的汇合点,不同控制流路径带来不同定义,需用 φ 函数合并:

定义(φ 函数)

a5←ϕ(a3,a4)a_5 \leftarrow \phi(a_3, a_4) 表示根据所走的控制流路径,从 a3a_3 或 a4a_4 中选一个值。φ 节点放在汇合点(基本块开头),保证"每个变量仍只定义一次"

9.3 为什么 SSA 有用

SSA 让程序分析更简单更快——把 def-use 链这一关键环节一次性算好:

  • def-use 链数量:无 SSA 时,N 个定义、N 个使用,每个使用可能来自任一定义 → 最坏 O(N2)O(N^2);有 SSA 时每个使用只来自唯一定义 → O(N)O(N)。
  • 到达定义 (reaching definitions):有 SSA 只需看定义与使用是否同名。
  • 可用表达式 (available expressions):原需"求值后 x、y 未被重新赋值"的条件,SSA 下自动满足(不可变)。
  • 活跃性 (liveness):变量在其定义点活跃 ⟺ 它有使用。
  • 据此可直接驱动常量传播(唯一定义且为常量)、复制传播(唯一定义且为拷贝)、归纳变量识别等。

9.4 SSA 构造(Construction)

朴素法:每个基本块开头给所有变量加 φ 节点,逐块转 SSA 并把"最后定义"传播到后继块的 φ;问题是 φ 节点过多(事后用复制传播 + 死代码消除清理)。

标准(高效)法:

定义(基于支配的 SSA 构造)

  1. 计算支配树 (dominator tree)。
  2. 对变量 xx 在基本块 BB 的每次赋值,计算迭代支配边界 DF+(B)DF^+(B),在 DF+(B)DF^+(B) 的每个块放 xx 的 φ 节点。
  3. 按支配树的 DFS 顺序遍历各块,重命名变量。

支配关系:

定义(支配 / 严格支配 / 直接支配 / 支配边界)

  • 若从入口到 mm 的每条路径都经过 nn,则 nn 支配 (dominate) mm。

  • m≠nm \ne n 时为严格支配。

  • 若不存在 xx 使 nn 严格支配 xx 且 xx 严格支配 mm,则 nn 直接支配 (immediately dominate) mm;除入口外每个节点有唯一直接支配者,构成支配树。

  • 支配边界 DF(n)DF(n):nn 所支配 CFG 区域的"边界"——精确地,满足"nn 支配 mm 的某个直接前驱、但不支配 mm"的节点 mm 的集合。

  • 迭代支配边界:DF0(n)={n}DF^0(n)=\{n\},DFi+1(n)=DF({n}∪DFi(n))DF^{i+1}(n)=DF(\{n\}\cup DF^i(n)) 的极限 DF+(n)DF^+(n)。 :::

9.5 SSA 析构(Destruction)

最简单法:在 φ 节点各前驱块末尾插入相应赋值(如 a5 ← a3 / a5 ← a4)。这会产生额外拷贝,但合并式寄存器分配器 (coalescing register allocator) 能消掉它们(注意环的特例)。


可靠的 lowering 应每次只显式化一类约束:先把控制流变成 CFG,再把值变成三地址操作,最后适配 ABI 和 ISA。一次跨越太多语义层,就会让验证和调试同时失去落点。

10. CFG、x86-64 与 SSA 小结

  • 流程:结构化 IR → CFG → 线性化汇编;强调先做未优化的最简单版本。
  • CFG = 基本块(极大、无中途跳入跳出)+ 控制流边;合并相邻单入单出节点构造。
  • 短路条件用 destruct/shortcircuit 按结构归纳生成,&&/||/! 分别处理真假目标;nop 由窥孔消除。
  • x86-64:前 6 参数入寄存器、%rax 返回、caller/callee-save 之分、enter/leave 管理帧、四段过程联接。
  • 代码生成:表达式扁平化为三地址 + 模板套用 + 数组边界检查;保持简单,把效率交给优化器。
  • SSA:每变量定义一次 + 汇合点 φ;把 def-use 链从 O(N2)O(N^2) 降到 O(N)O(N);标准构造靠支配树 + 迭代支配边界 + 重命名。