章节目录

本章边界:不改语言,只改变值的家

本节阅读量:

寄存器分配常常和“类型”“优化”“机器调用约定”一起出现,很容易让人误以为这一章也要给源语言加静态类型。这里先把边界说清楚:

1
2
源语言仍然是动态类型语言。
第十章没有类型标注,也不会在编译期拒绝类型不匹配的程序。

整数、let、条件、函数、闭包、record、set!、begin 和第九章的 GC 全都保留。parser、AST 和解释器仍然实现同一套语言语义;改变的是 IrProgram 之后的机器位置选择。

运行时的 Value 没有变化

前几章用一个 64 位 word 表示源语言的动态值。低三位 tag 告诉运行时这个 word 是整数还是堆对象引用:

1
2
tagged integer:  n * 8 + 1
heap reference:  raw_address | 4

record、closure 和 cell 继续共享 heap tag 4,再通过对象 header 的 HeapKind 区分。eq?、函数调用、get 和算术等操作仍然在运行时执行相应的动态检查;类型错误仍然走结束码 70 的错误路径。

例如:

1
(+ (record) 1)

编译器不会因为看见 record 就拒绝生成汇编。lowering 在求完左操作数后立即生成 integer_check;运行时发现这个值不是整数,才进入语言错误路径。这和给语言增加静态类型检查是两回事。

integer_check 之所以是独立 IR 操作,不是 add emitter 中的隐式前置代码,是因为检查本身也有可观察的顺序:

1
2
3
4
5
lower lhs
check lhs
lower rhs
check rhs
add

解释器也是先求左边、检查左边,再求右边。如果把两次检查都拖到 rhs 求值之后,rhs 里的 set!、函数调用或分配就可能在本应立即报错时抢先发生。显式 check 让 IR 自身完整保存源语言的求值顺序。

编译器只做两类保守判断

为了决定 home,register_alloc.h 暴露了两个表示类别:

1
2
3
4
enum class ValueClass {
    integer,
    root,
};

它们回答的不是“这个源码表达式具有什么语言类型”,而是一个更窄的机器问题:

1
2
integer  编译器已经证明:只要执行继续,这个临时值一定是 tagged integer。
root     编译器不能排除这个临时值是堆引用,因此保守地给它 root home。

第二行尤其重要。root 的含义是“需要按可能的堆引用处理”,不是“已经证明一定是堆对象”。一个运行时实际为整数的值,也可能被保守分类为 root。

这条规则宁可少用一个寄存器,也不能漏掉一个 GC root:

1
2
误把整数放进 root slot     安全,只是更保守
误把堆引用当成普通整数     不安全,GC 搬移后会留下旧地址

哪些结果可以确定为整数

当前实现先在内部使用三态 AnalysisClass:

1
2
3
4
5
enum class AnalysisClass {
    unknown,
    integer,
    root,
};

unknown 只用于分析还没有收敛时。分析结束后,所有剩余的 unknown 都会变成 root,对外只保留 integer 和 root 两类。

定义一个 IR 临时值的操作按下面的规则分类:

IR 结果来源 分类 原因
整数字面量 integer tag 在编译期已知
add、sub1 integer 动态操作数由前置 integer_check 验证,整数立即数已知合法,正常结果必为整数
eq?、record? integer 谓词结果是 tagged 0 或 1
size integer 若 record 检查通过,字段数必为整数
copy 跟随源操作数 条件分支汇合时,同一目标可能有多个来源
cell、closure、record root 结果是堆对象引用
load root cell 里可以保存任意动态 Value
函数 call root 函数可以返回任意动态 Value
get root record 字段可以保存任意动态 Value
函数参数、self cell、capture root 调用或捕获边界上没有静态类型保证

注意 add 的两个输入不一定都被分类为 integer。它们可能来自 load 或函数调用,因此属于 root;lowering 会在每个这样的操作数产生后插入 integer_check。只要检查通过并继续执行,add 定义的结果就一定是整数,可以拥有寄存器 home。integer_check 自身只使用值、不定义新值,所以没有自己的 ValueClass 或 home。

一个运行时是整数、分类却是 root 的例子

第八章起,每个词法绑定都用 cell 表示。考虑:

1
2
(let a (+ 1 2)
  (+ a 39))

右侧 (+ 1 2) 的结果可以确定为整数,但绑定 a 时,这个结果会被写入 cell。以后读取 a 对应的是一条 load:

1
2
3
4
5
6
t.0 = 1 + 2
a0.cell = cell t.0
t.1 = load a0.cell
check-integer t.1
t.2 = t.1 + 39
return t.2

因此:

1
2
3
4
t.0       integer
a0.cell   root
t.1       root
t.2       integer

人当然看得出 t.1 在这段程序里是整数,但当前章节没有实现跨 cell 的值流分析。把 load 一律归为 root,能让规则保持局部、清楚而可靠。

这也是教程刻意保留的优化空间:保守不等于错误,只表示编译器尚未证明更多事实。

分支汇合要合并两个来源

examples/register_mixed_join.lang 展示了为什么分类不能只看某一条赋值:

1
(+ (record? (if 0 42 (record))) 41)

它的 IR 是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
if 0 goto then0 else else0
then0:
t.0 = 42
goto end0
else0:
t.1 = record
t.0 = t.1
end0:
t.2 = record? t.0
check-integer t.2
t.3 = t.2 + 41
return t.3

t.0 在 then 分支收到整数,在 else 分支收到 record 引用。表示分析把同名目标的所有定义合并:

1
2
3
integer join integer = integer
root    join root    = root
integer join root    = root

运行:

1
./mini alloc examples/register_mixed_join.lang

可以看到:

1
2
3
4
t.0 : root -> root -8(%r15)
t.1 : root -> root -16(%r15)
t.2 : integer -> register %rbx
t.3 : integer -> register %rbx

即使当前执行必定走 else 分支,分类规则也不依赖常量折叠;它保守地覆盖 CFG 上所有可能到达汇合点的定义。

一套后端,不再检查 AST 子集

第十章的 CLI 入口直接展示了正式编译路径。alloc 命令先 lowering,再分配:

1
2
3
4
mini::IrProgram program = mini::lower_to_ir(*expr);
mini::ProgramAllocation allocation =
    mini::allocate_registers(program);
std::cout << mini::dump_register_allocation(program, allocation);

compile 同样先得到 IrProgram:

1
2
3
write_file(
    output_path,
    mini::compile_to_assembly(mini::lower_to_ir(*expr), target));

这里没有 supports_register_backend,也没有 AST fallback。main body 和所有 IrFunction 都进入 allocate_registers,之后交给同一个 AssemblyEmitter。

这个边界很重要:

1
2
3
4
前端负责语言结构与语义
lowering 负责得到统一 IR
表示分析与分配器负责选择 home
emitter 只消费 IR 和分配结果

后端不再重新解释一遍 AST,自然也不会出现“整数子集和完整语言各维护一份行为”的问题。

三种 home 对应三种责任

公开的数据结构把 home 明确分成三类:

 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;
};

它们的安全边界是:

  • machine_register 和 spill_slot 只分给确定为整数的值,collector 不扫描它们。
  • root_slot 分给所有 root 值,位置相对于 %r15,collector 可以扫描并更新它。
  • %rax、%rcx、%rdx 和 %r11 等 scratch register 可以短暂保存任意 word,但不能成为可能堆引用跨 safepoint 时唯一的位置。

当前实现没有复用 root slots。每个 root 名字仍然拥有固定 root home,safepoint 前再利用 CFG liveness 清除已经死亡的 slots。这延续了第九章已经验证过的 root 生命周期规则,把本章的新困难集中在整数寄存器分配上。

为什么选择四个 callee-saved 寄存器

整数候选寄存器固定为:

1
%rbx  %r12  %r13  %r14

它们在 System V x86-64 ABI 中属于 callee-saved register。生成函数在入口保存它们,在返回前恢复它们,所以已分配的整数可以安全跨过源语言函数调用和 mini_alloc 调用。

其他重要寄存器已经有职责:

寄存器 当前后端中的职责
%r15 shadow root stack 的当前 top
%rbp、%rsp 机器栈 frame
%rdi、%rsi 函数参数与分配器参数
%rax 结果与主要 scratch
%rcx、%rdx、%r11 tag 检查、地址计算和间接调用 scratch

使用四个候选寄存器不是硬件上限,而是本章为了让 ABI、GC 和图染色同时保持清楚而选择的教学范围。寄存器不够时,确定的整数会 spill 到机器栈,正确性不依赖“所有值都必须进寄存器”。

本章有意不做的事

为了让核心路径足够清楚,本章不实现:

  • 给源语言增加静态类型系统或类型标注;
  • 跨 cell、record 字段或任意函数调用的精细类型推断;
  • 让可能的堆引用驻留寄存器并配套精确 register root map;
  • root slot 复用;
  • move coalescing、复杂 spill 重写或基于代价的全局优化;
  • 基本块重排或独立窥孔优化。

条件分支继续遵守全书既定布局:条件为假时跳到 else,条件为真时顺序进入紧随其后的 then。CFG 分析覆盖两条后继边,汇编 emitter 保持这一布局约定。

本章真正增加的只有一件核心能力:在完整动态语言和现有 GC 不变量之上,把已经证明安全的整数交给寄存器分配。


10.0 寄存器分配

上一节

10.2 统一 pseudo 指令:分配器真正看到什么

下一节