汇编:把位置落成 cell 对象
本节阅读量:解释器可以用 Store 中的下标表示位置。生成机器代码时,我们还需要回答一个更具体的问题:这个位置放在哪里,闭包又怎样在函数返回以后继续找到它?
第八章的答案是把位置实现成堆上的 cell 对象。IR 中的三种操作会直接落到这个对象上:
|
|
它们分别表示创建位置、写入位置和读取位置。
cell 不是源语言新增的一类值
这里先划清一个容易混淆的边界。
源程序仍然只能观察整数、closure 和 record。读者不能在源语言里直接写出一个 cell,也没有 cell? 或“读取 cell 地址”的语法。下面的 x 求值得到的是 cell 中保存的 41,而不是 cell 指针:
|
|
cell 是解释器和编译器用来实现“位置”的内部对象。IR 能看到它,是因为 IR 正在描述实现细节;表面语言并没有因此增加一种可操作的数据。
所有堆对象继续共用一个 pointer tag
第六、七章已经让 record 和 closure 共用 heap-object pointer tag。cell 继续加入同一协议,而不是再占用一种新的 pointer tag:
|
|
指针的低三位只能说明“这是一个堆对象”。对象究竟是哪一种,要看原始地址 offset 0 处 header 的 kind:
| HeapKind | kind |
|---|---|
| record | 1 |
| closure | 2 |
| cell | 3 |
header 仍使用统一公式:
|
|
这样做有一个直接好处:record、closure 和 cell 的地址表示、header 读取、对象大小计算以及 payload 偏移都遵守同一套规则。
cell 的布局
cell 只有一个 payload word,用来保存当前位置的内容:
|
|
所以它的三个常量可以直接算出:
|
|
offset 8 保存完整的 tagged Value。变量先后保存整数、closure 或 record 时,cell 的布局都不需要改变,只需替换这个 word。
runtime/value.h 用三个小函数集中表达这些事实:
|
|
汇编 emitter 使用这些函数,不在不同操作里重复写裸常数。
从一段 IR 看三种操作
examples/set.lang 是:
|
|
查看 IR:
|
|
实际输出是:
|
|
源程序中的 let 变成一次 cell;set! 变成 store;再次读取 x 时则生成 load。
cell:分配并写入初始值
生成一份 Linux 符号拼写的汇编,便于和下面片段逐行核对:
|
|
其中创建 x0.cell 的真实汇编是:
|
|
这里依次发生了几件事:
malloc(16)返回对齐的原始地址;- offset
0写入 cell header259; - offset
8写入整数1的机器表示1 * 8 + 1 = 9; - 原始地址与
4做或运算,得到统一的 tagged heap pointer; - 这个 cell 指针保存到
x0.cell对应的栈槽。
注意 %rax 在 orq $4 之前是原始地址,之后才是能够放进 IR local 和 closure payload 的 tagged pointer。
store:替换 offset 8 的 Value
(set! x 41) 对应:
|
|
329 是整数 41 的 tagged 表示。andq $-8 只作用于 cell 指针的副本,用来恢复原始地址;栈槽中的 tagged pointer 不会被改坏。
store 自身不需要产生新的目标 local。lowering 仍把写入的 operand 作为整个 set! 的结果,因此:
|
|
结果是 42。
load:读取当前位置的 Value
再次读取 x 时生成:
|
|
最后得到的 %rax 是 cell 内容,而不是 cell 地址。后续加法、比较、调用或 record 操作看到的仍然是统一的 tagged Value。
cell 由编译器内部直接创建,lowering 也保证 load 和 store 的位置 operand 确实是 cell,所以 emitter 不为这些内部操作重复生成动态 kind 检查。需要动态检查的是源程序能够传入任意 Value 的 call、size 和 get 等操作。这个边界只是在说明 cell 操作为何可信,并不表示旧有的 +、sub1 等操作已经补齐所有动态类型检查。
闭包捕获的是 cell 指针
可变状态真正影响闭包的地方,不是 closure 外壳,而是 capture payload 中保存的内容。
考虑 counter.lang:
|
|
内层 closure 的关键 IR 是:
|
|
start2.cell 是 cell 指针。外层函数返回以后,内层 closure 仍保存着这个指针;两次调用因而读写同一个 offset 8,先得到 41,再得到 42。
普通 closure 的布局保持:
|
|
现在 capture word 保存的是 tagged cell pointer。以只捕获 start2.cell 的内层 closure 为例:
|
|
closure header 的 payload_count 仍包含 code pointer,所以这里是 2,即一个 code word 加一个 capture word。
函数入口先保存参数,再把参数装进 cell
第七章的调用约定保持不变:
| register | 内容 |
|---|---|
%rdi |
完整的 tagged closure Value |
%rsi |
完整的 tagged argument Value |
%rax |
完整的 tagged result Value |
函数入口先把 %rsi 保存成尚未装箱的参数 local,然后函数 IR 的第一条 cell op 为参数创建位置。counter.lang 内层函数开头的真实汇编是:
|
|
第一行先保存传入的参数;接下来的四行从 closure offset 16 取出 captured start2.cell。后面的分配把参数 step3 装进自己的 step4.cell。因此参数也能被 set!,但每次调用都会得到一个新的参数 cell。
这里复制并清 tag 的是 %rax。进入函数时的 %rdi 仍然携带完整 tagged closure,并没有被调用方永久改成原始地址。
递归函数的 capture 0 是 self cell
可变状态让 letrec 与第七章出现一个有意的区别。递归名字本身也能被 set!,所以函数体不能永远把“本次实际 callee”当作递归名字的值;它必须通过一个共享位置读取递归名字当前保存的内容。
lowering 为 letrec 先创建 self cell,再把初始 closure 写入其中:
|
|
对于递归 closure,capture 槽约定是:
| offset | 内容 |
|---|---|
| 0 | closure header |
| 8 | raw code pointer |
| 16 | self cell |
| 24 | ordinary capture 0 |
| 32 | ordinary capture 1 |
IrFunction::self_cell 对应 offset 16。IrFunction::captures[0] 则从 offset 24 开始。普通 lambda 没有 self cell,它的 ordinary capture 0 仍在 offset 16。
recursive_binding_set.lang 专门验证这个约定:程序先保存旧 closure,再把递归名字改成一个返回 42 的新 closure。调用旧 closure 时,它通过共享 self cell 发起的递归调用会看到新值,最终结果是 42。
|
|
参数 cell 在词法环境中最后加入,所以参数和递归名字同名时,参数仍然正确遮蔽 self。可以用下面的例子检查:
|
|
结果是 42。
call 仍先检查 callee,再求 argument
状态使求值顺序变得更容易观察。若 callee 根本不是 closure,argument 中的 set! 不应先发生。因此结构化 IR 保持以下顺序:
|
|
closure_check 先检查共同 heap tag 4,再检查 header kind 2。通过后,真正的间接调用只在 scratch register 中清 tag:
|
|
%rdi 收到的是原来的 tagged closure;只有 %rcx 被临时清 tag,用来读取 offset 8 的 code pointer。321 是参数 40 的 tagged 表示。
栈对齐和错误出口
emitter 先为一个函数体中的参数 local、self cell、captures 和所有有结果的 op 分配固定栈槽,再把总大小向上对齐到 16 字节。函数 prologue 只需一次性减去这个大小,后续的 malloc、exit 和间接 call 都能遵守 x86-64 System V ABI 的调用对齐要求。
本章继续沿用统一的运行时错误状态 70。这些情况会进入 .Lruntime_error:
- call 收到整数、record 或其他非 closure;
- closure 参与
eq?; size或get收到非 record;get的字面量 index 越界。
错误块按需生成:程序中没有相关检查时,不会无缘无故引入 exit 符号。需要时,Linux 目标的结尾是:
|
|
macOS 目标会使用 _exit。compile 本身仍然只生成汇编,不替读者调用链接器。
编译、链接并验证
在 Linux、WSL 或 Intel Mac 上:
|
|
Apple Silicon Mac 需要让系统编译器链接 x86-64 程序:
|
|
两种情况下都应看到退出状态 42。
再检查一个受控错误:
|
|
Apple Silicon Mac 的 cc 命令同样加入 -arch x86_64。最后的状态应为 70,而不是段错误。
当前快照仍通过 malloc 分配 record、closure 和 cell,也不主动释放。随着共享位置和返回 closure 增多,对象的存活时间已经不能只看创建它的词法块;下一章会接着处理这条自然出现的内存管理问题。