三种 home:寄存器、普通 spill 与 GC root
本节阅读量:图着色结束后,每个有名字的 IR Value 都要得到一个可以长期保存它的位置。这个位置叫作它的 home。
第九章只有 root slot。第十章不能简单地把其中一部分 root slot 换成寄存器,因为源语言里既有整数,也有可能指向堆对象的动态 Value。正式的后端因此有三种 home:
| home | 形式 | 保存什么 | GC 是否扫描 |
|---|---|---|---|
| 机器寄存器 | %rbx、%r12、%r13、%r14 |
确定为整数且没有 spill 的值 | 否 |
| 普通 spill slot | -48(%rbp)、-56(%rbp)…… |
寄存器放不下的确定整数 | 否 |
| GC root slot | -8(%r15)、-16(%r15)…… |
堆引用或无法排除堆引用的 Value | 是 |
最容易混淆的是后两项:它们都写成内存操作数,但属于两块完全不同的内存。
|
|
对象本体在托管堆上;root slot 保存的是带 tag 的对象引用。对象被 Cheney collector 搬走时,collector 会把 root slot 中的旧引用改成新引用。普通 spill slot 没有这项能力,所以可能为堆引用的值绝不能放到 %rbp spill 中。
HomeKind 把三类位置写进类型
src/compile/register_alloc.h 没有用带特殊前缀的字符串猜测 home 类型,而是显式记录 kind:
|
|
最终 emitter 仍用 switch 分派:
|
|
这样,表示分析、分配器、调试输出和汇编 emitter 对三类位置使用同一份事实。
为什么只把四个寄存器交给染色器
本章的可分配寄存器是:
|
|
它们在 x86-64 System V ABI 中属于 callee-saved register:被调函数如果使用它们,必须在返回前恢复原值。生成函数的 prologue 保存这四个寄存器,epilogue 再恢复,因此一个整数可以安全地跨越 mini_alloc 或源语言函数调用。
其余常见寄存器各有明确任务:
%rax保存操作结果和函数返回值;%rdi、%rsi传递分配器参数或源语言调用参数;%rcx、%rdx、%r11是 emitter 使用的 scratch register;%r15专门保存 shadow root stack 的当前 top。
当然也可以把 caller-saved register 加入分配器,但那就必须把每个 call 的 clobber 约束加入冲突图,或者在 call 周围保存仍然活跃的值。本章选择四个 callee-saved register,让第一次实现把注意力放在活跃性、冲突和 GC 边界上。
spill 放在 %rbp 帧的什么位置
生成函数先保存五个寄存器:
|
|
因此 %rbp 以下的前 40 字节已经有用途:
|
|
第一个普通 spill 从 -48(%rbp) 开始,之后每个 slot 再减 8:
|
|
prologue 根据 spill_count 一次留出整块机器栈空间。即使没有 spill,也可能留一个 8 字节 padding;这样五次寄存器保存之后,每个 call 前的 %rsp 仍满足 16 字节对齐要求。
返回时不需要逐个释放 spill。epilogue 直接回到保存寄存器的位置:
|
|
这里恢复 %r15 同时也恢复了 caller 的 root top。callee 为自己扩出的 root frame 因而整体退出,不会残留在调用者的扫描范围内。
root frame 仍沿用第九章协议
每个 root Value 都得到固定的 %r15 相对槽位。函数进入时,emitter 先检查新 top 是否越过 C++ runtime 通过 C ABI 导出的 mini_gc_root_end,再把全部槽初始化为 tagged 0:
|
|
tagged 0 的机器表示是 1。collector 会把它当作整数而不是堆指针。第一次 safepoint 之前完成初始化,可以避免扫描尚未写入的内存。
allocation 前仍按当前操作的 live_in 清理死 root;源语言 call 则在暂存 callee、argument 和 raw code address 后,按 live_out 清理 caller roots。寄存器分配没有替换这条协议,只是让确定整数退出了 root 集合。
integer_check 使用 home,但不创建 home
算术的动态类型检查现在是独立的 OpKind::integer_check:
|
|
check 读取 t.0 已有的 register、spill 或 root home,却不定义新 Value,所以它没有自己的 home。活跃性分析把它记作一次 use,dst 为空;emitter 在这里生成 tag guard,后面的 add 或 sub1 只负责已经检查通过的运算。
把检查从算术 emitter 中拆出来还有一个更重要的作用:lowering 可以先求 lhs,立即发出 lhs check,再开始求 rhs。若 lhs 不是整数,运行时会在执行任何 rhs 操作之前进入错误出口。rhs 求完后同样先检查,再生成最终 add;sub1 则在唯一操作数之后立即检查。整数字面量的 tag 在编译期已知,可以不生成 check。
这项顺序约束不会改变三类 home 的边界。被检查值仍住在它原来的位置;检查只是从那个位置读取一次。
强制观察 spill
examples/register_spill.lang 故意先算出很多彼此同时活跃的整数:
|
|
运行分配观察命令:
|
|
输出开头会同时出现寄存器和普通 spill:
|
|
这个程序没有堆引用,所以 root-bytes=0。当前朴素染色结果会产生 11 个 spill;这不是语言语义的一部分,却是本章代码快照的一个有用验收点。
解释执行结果是:
|
|
|
|
生成程序也应以状态 210 返回。发生 spill 只改变值住在哪里,不能改变计算结果。
内存到内存为什么经过 %rax
x86-64 的普通 movq 不能同时把源和目标都写成内存操作数。若一次 copy 恰好从普通 spill 移到另一个内存 home,emitter 必须拆成两步:
|
|
本章统一让运算结果先到 %rax,再写入分配好的 home。它不是最省指令的实现,却让寄存器、spill 和 root 三种目标遵循同一条清楚的生成规则。
本章刻意没有继续做什么
当前实现不做 move coalescing、spill cost 估计、spill rewrite、caller-saved register 分配或 root slot 复用。它已经形成完整且安全的路径:
|
|
先把三种 home 的职责分清,比立刻追求少一两条指令更重要。