跳到内容

8.2 指令选择、ABI 与寄存器分配:把 IR 放进真实机器

走到高塔底层,无限虚拟值要塞进有限寄存器,抽象指令也要服从真实 CPU 和 ABI 的约束。

IR 可以假设无限虚拟值,目标 CPU 却只有有限寄存器、受约束的指令编码和固定调用约定。代码生成不是把每条 IR 指令查表替换,而是在多项相互影响的约束中选择一组合法机器操作。

本课目标

  • 理解 legalization、指令选择和调度的职责;
  • 说明 ABI 对参数、返回值、栈与寄存器的约束;
  • 用 liveness 构造寄存器冲突;
  • 比较图着色、线性扫描、spill 与 coalescing。

1. 目标描述不只是一张 opcode 表

目标后端需要知道:

  • 寄存器类别、别名与保留寄存器;
  • 指令操作数形状和立即数范围;
  • 支持的数据类型和合法位宽;
  • 寻址模式;
  • 指令延迟、吞吐与代码大小;
  • 调用约定、栈对齐与重定位;
  • 原子和内存序映射。

同一 IR add i64 在 64 位目标上可能一条指令完成,在较窄目标上可能拆成多条带进位操作。

2. Legalization 先让操作可表示

IR 操作不一定被硬件原生支持。legalizer 可以:

  • 扩展或拆分类型;
  • 把操作展开成指令序列或运行库调用;
  • 把非法立即数物化到寄存器;
  • 将高层原子操作映射为目标序列。

例如目标没有整数除法指令时,可能调用 helper;目标没有 i128 加法时,可拆成两个 i64 与进位。

合法化时必须维持符号扩展、溢出、异常和原子性语义。能“算出差不多结果”的序列不够。

3. 指令选择是带成本的覆盖

IR:

text
t0 = mul x, 4
t1 = add base, t0
t2 = load t1

某些目标可用带 scale 的寻址模式把乘法、加法和 load 合并。选择器可通过:

  • 树/DAG 模式匹配;
  • 动态规划最小成本覆盖;
  • 表驱动重写;
  • 全局或局部搜索。

成本不只看指令条数。代码大小、关键路径、微架构、寄存器压力和调度机会都可能改变最佳选择。

4. ABI 让独立编译单元能够协作

调用约定规定:

  • 参数放在哪些寄存器或栈槽;
  • 返回值怎样传递;
  • 哪些寄存器由 caller/callee 保存;
  • 栈指针、帧指针和对齐要求;
  • 可变参数和聚合类型布局;
  • 异常展开与调试元数据。

函数内部生成得再快,若破坏 callee-saved 寄存器或栈对齐,也会在跨函数调用时损坏状态。ABI 是外部可观察契约,不属于可自由优化的内部细节。

5. Liveness 是后向数据流

对基本块 $B$:

$$ LIVEOUT[B]=\bigcup_{S\in succ(B)}LIVEIN[S], $$

$$ LIVEIN[B]=USE[B]\cup(LIVEOUT[B]-DEF[B]). $$

若某值当前值会在未来路径使用,且在此之前没有重新定义,它就是 live。对 SSA,value 单定义使 def-use 清楚,但经过 φ、调用约束和机器指令 tied operands 后,物理分配仍复杂。

指令级逆向扫描可从块 live-out 开始:先移除当前定义,再加入当前使用。若两个虚拟寄存器的 live range 重叠,它们通常不能占同一物理寄存器。

6. 冲突图与图着色

每个虚拟寄存器是节点,同时活跃的值之间连边。$K$ 个可用寄存器对应 $K$ 色着色。

一般图着色困难,分配器使用启发式:

  1. 简化低度数节点;
  2. 选择潜在 spill 候选;
  3. 弹栈并尝试着色;
  4. 无色可用时插入 spill/reload,再重算。

真实机器还有预着色节点、寄存器类别、子寄存器别名、固定操作数和调用 clobber,远比普通 $K$ 色图丰富。

7. Spill 不是简单“放到栈上”

spill 会增加 load/store、栈帧与内存流量。选择候选时常考虑:

  • 使用频率与循环深度;
  • live range 长度;
  • 重新计算是否比 reload 便宜;
  • 可拆分 live range 的位置;
  • 调用点跨越成本。

插入 spill 后会产生新临时值和活跃区间,可能需要再次分配。spill slot 也可在生命周期不重叠时复用。

8. Coalescing 与线性扫描

复制:

text
v2 = copy v1

v1v2 不冲突,可分到同一物理寄存器并删除 copy。过度 coalesce 会合并 live range、增加节点度数,反而导致 spill,所以要用保守准则。

线性扫描按 live interval 顺序快速分配,常用于编译时间敏感场景。它实现简单、速度快,但质量取决于区间构造、拆分和 spill 策略。图着色也不自动优于线性扫描;应对具体目标和工作负载衡量。

9. 指令调度与寄存器压力互相影响

调度把相互独立的指令重排,以隐藏延迟并遵守数据、内存与资源依赖。提前计算可能增加并行度,也会让结果活得更久,提升寄存器压力。

因此选择、调度、分配并非严格单向:后端可能在不同阶段重调度、拆分 live range,或使用压力感知成本。删除一条指令也不保证更快,关键路径和端口竞争可能更重要。

常见误区

  • 一条 IR 指令对应一条机器指令:类型、寻址和目标能力会导致合并或展开。
  • 寄存器不够就挑任意变量 spill:选择会显著影响运行成本和后续压力。
  • 指令越少越快:延迟、吞吐、代码布局和缓存同样重要。
  • 函数内部正确就够了:ABI、unwind 和调试信息也是代码生成契约。

练习

  1. 给一段直线 IR 计算逐指令 liveness 并画冲突图。
  2. 用 2 个物理寄存器分配三个 live range,比较两种 spill 选择。
  3. 为目标不支持 i128 加法设计合法化序列。
  4. 列出一次函数调用前后 caller-saved 与 callee-saved 值的处理责任。

小结

目标代码生成要同时满足类型合法化、指令约束、ABI 和成本模型。liveness 把未来使用变成冲突关系,分配器再用有限物理寄存器、spill 与复制合并实现可执行布局。

下一课比较 AOT 与 JIT,重点不是“运行时编译更聪明”,而是画像、推测、守卫与反优化如何组成一份可撤销的性能决策。

Built with VitePress | Software Systems Atlas