本章边界:不改语言,只改变值的家
本节阅读量:寄存器分配常常和“类型”“优化”“机器调用约定”一起出现,很容易让人误以为这一章也要给源语言加静态类型。这里先把边界说清楚:
|
|
整数、let、条件、函数、闭包、record、set!、begin 和第九章的 GC 全都保留。parser、AST 和解释器仍然实现同一套语言语义;改变的是 IrProgram 之后的机器位置选择。
运行时的 Value 没有变化
前几章用一个 64 位 word 表示源语言的动态值。低三位 tag 告诉运行时这个 word 是整数还是堆对象引用:
|
|
record、closure 和 cell 继续共享 heap tag 4,再通过对象 header 的 HeapKind 区分。eq?、函数调用、get 和算术等操作仍然在运行时执行相应的动态检查;类型错误仍然走结束码 70 的错误路径。
例如:
|
|
编译器不会因为看见 record 就拒绝生成汇编。lowering 在求完左操作数后立即生成 integer_check;运行时发现这个值不是整数,才进入语言错误路径。这和给语言增加静态类型检查是两回事。
integer_check 之所以是独立 IR 操作,不是 add emitter 中的隐式前置代码,是因为检查本身也有可观察的顺序:
|
|
解释器也是先求左边、检查左边,再求右边。如果把两次检查都拖到 rhs 求值之后,rhs 里的 set!、函数调用或分配就可能在本应立即报错时抢先发生。显式 check 让 IR 自身完整保存源语言的求值顺序。
编译器只做两类保守判断
为了决定 home,register_alloc.h 暴露了两个表示类别:
|
|
它们回答的不是“这个源码表达式具有什么语言类型”,而是一个更窄的机器问题:
|
|
第二行尤其重要。root 的含义是“需要按可能的堆引用处理”,不是“已经证明一定是堆对象”。一个运行时实际为整数的值,也可能被保守分类为 root。
这条规则宁可少用一个寄存器,也不能漏掉一个 GC root:
|
|
哪些结果可以确定为整数
当前实现先在内部使用三态 AnalysisClass:
|
|
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) 的结果可以确定为整数,但绑定 a 时,这个结果会被写入 cell。以后读取 a 对应的是一条 load:
|
|
因此:
|
|
人当然看得出 t.1 在这段程序里是整数,但当前章节没有实现跨 cell 的值流分析。把 load 一律归为 root,能让规则保持局部、清楚而可靠。
这也是教程刻意保留的优化空间:保守不等于错误,只表示编译器尚未证明更多事实。
分支汇合要合并两个来源
examples/register_mixed_join.lang 展示了为什么分类不能只看某一条赋值:
|
|
它的 IR 是:
|
|
t.0 在 then 分支收到整数,在 else 分支收到 record 引用。表示分析把同名目标的所有定义合并:
|
|
运行:
|
|
可以看到:
|
|
即使当前执行必定走 else 分支,分类规则也不依赖常量折叠;它保守地覆盖 CFG 上所有可能到达汇合点的定义。
一套后端,不再检查 AST 子集
第十章的 CLI 入口直接展示了正式编译路径。alloc 命令先 lowering,再分配:
|
|
compile 同样先得到 IrProgram:
|
|
这里没有 supports_register_backend,也没有 AST fallback。main body 和所有 IrFunction 都进入 allocate_registers,之后交给同一个 AssemblyEmitter。
这个边界很重要:
|
|
后端不再重新解释一遍 AST,自然也不会出现“整数子集和完整语言各维护一份行为”的问题。
三种 home 对应三种责任
公开的数据结构把 home 明确分成三类:
|
|
它们的安全边界是:
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 寄存器
整数候选寄存器固定为:
|
|
它们在 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 不变量之上,把已经证明安全的整数交给寄存器分配。