章节目录

三种 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 是

最容易混淆的是后两项:它们都写成内存操作数,但属于两块完全不同的内存。

1
2
3
4
machine stack                         shadow root stack
由 %rbp 定位                          由 %r15 定位
保存 spill 和寄存器现场               保存 collector 必须更新的 Value
不会被 collector 扫描                 collection 时逐槽扫描

对象本体在托管堆上;root slot 保存的是带 tag 的对象引用。对象被 Cheney collector 搬走时,collector 会把 root slot 中的旧引用改成新引用。普通 spill slot 没有这项能力,所以可能为堆引用的值绝不能放到 %rbp spill 中。

HomeKind 把三类位置写进类型

src/compile/register_alloc.h 没有用带特殊前缀的字符串猜测 home 类型,而是显式记录 kind:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
enum class HomeKind {
    machine_register,
    spill_slot,
    root_slot,
};

struct Home {
    HomeKind kind = HomeKind::root_slot;
    std::string register_name;
    int offset = 0;

    std::string dump() const;
};

最终 emitter 仍用 switch 分派:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
std::string home_text(const std::string& name) const {
    const Home& home = body_allocation_->home(name);
    switch (home.kind) {
    case HomeKind::machine_register:
        return home.register_name;
    case HomeKind::spill_slot:
        return std::to_string(home.offset) + "(%rbp)";
    case HomeKind::root_slot:
        return std::to_string(home.offset) + "(%r15)";
    }
    throw std::runtime_error("unknown home kind");
}

这样,表示分析、分配器、调试输出和汇编 emitter 对三类位置使用同一份事实。

为什么只把四个寄存器交给染色器

本章的可分配寄存器是:

1
%rbx  %r12  %r13  %r14

它们在 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 帧的什么位置

生成函数先保存五个寄存器:

1
2
3
4
5
6
7
pushq %rbp
movq %rsp, %rbp
pushq %rbx
pushq %r12
pushq %r13
pushq %r14
pushq %r15

因此 %rbp 以下的前 40 字节已经有用途:

1
2
3
4
5
-8(%rbp)   saved %rbx
-16(%rbp)  saved %r12
-24(%rbp)  saved %r13
-32(%rbp)  saved %r14
-40(%rbp)  saved %r15

第一个普通 spill 从 -48(%rbp) 开始,之后每个 slot 再减 8:

1
int offset = -48 - static_cast<int>(next_spill * kWordSize);

prologue 根据 spill_count 一次留出整块机器栈空间。即使没有 spill,也可能留一个 8 字节 padding;这样五次寄存器保存之后,每个 call 前的 %rsp 仍满足 16 字节对齐要求。

返回时不需要逐个释放 spill。epilogue 直接回到保存寄存器的位置:

1
2
3
4
5
6
7
8
leaq -40(%rbp), %rsp
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
leave
retq

这里恢复 %r15 同时也恢复了 caller 的 root top。callee 为自己扩出的 root frame 因而整体退出,不会残留在调用者的扫描范围内。

root frame 仍沿用第九章协议

每个 root Value 都得到固定的 %r15 相对槽位。函数进入时,emitter 先检查新 top 是否越过 C++ runtime 通过 C ABI 导出的 mini_gc_root_end,再把全部槽初始化为 tagged 0:

1
2
3
4
5
6
7
leaq ROOT_BYTES(%r15), %rax
cmpq mini_gc_root_end(%rip), %rax
ja .Lmini_gc_abort
movq %rax, %r15

movq $1, -8(%r15)
movq $1, -16(%r15)

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:

1
2
3
t.0 = load box0.cell
check-integer t.0
t.1 = t.0 + 1

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 故意先算出很多彼此同时活跃的整数:

1
2
3
4
(+ (+ 1 20)
  (+ (+ 2 19)
    (+ (+ 3 18)
      ...)))

运行分配观察命令:

1
./mini alloc examples/register_spill.lang

输出开头会同时出现寄存器和普通 spill:

1
2
3
4
5
6
7
t.0 : integer -> register %rbx
t.1 : integer -> register %r12
t.2 : integer -> register %r13
t.3 : integer -> register %r14
t.4 : integer -> spill -48(%rbp)
...
root-bytes=0 spill-count=11

这个程序没有堆引用,所以 root-bytes=0。当前朴素染色结果会产生 11 个 spill;这不是语言语义的一部分,却是本章代码快照的一个有用验收点。

解释执行结果是:

1
./mini run examples/register_spill.lang
1
210

生成程序也应以状态 210 返回。发生 spill 只改变值住在哪里,不能改变计算结果。

内存到内存为什么经过 %rax

x86-64 的普通 movq 不能同时把源和目标都写成内存操作数。若一次 copy 恰好从普通 spill 移到另一个内存 home,emitter 必须拆成两步:

1
2
movq -48(%rbp), %rax
movq %rax, -56(%rbp)

本章统一让运算结果先到 %rax,再写入分配好的 home。它不是最省指令的实现,却让寄存器、spill 和 root 三种目标遵循同一条清楚的生成规则。

本章刻意没有继续做什么

当前实现不做 move coalescing、spill cost 估计、spill rewrite、caller-saved register 分配或 root slot 复用。它已经形成完整且安全的路径:

1
2
3
4
5
6
7
structured IR
  -> representation analysis
  -> CFG liveness
  -> integer interference graph
  -> coloring
  -> register / ordinary spill / root home
  -> one assembly emitter

先把三种 home 的职责分清,比立刻追求少一两条指令更重要。


10.4 冲突图与染色:谁不能共用寄存器

上一节

10.6 用 `alloc` 看见整个分配过程

下一节