章节目录

汇编:把位置落成 cell 对象

本节阅读量:

解释器可以用 Store 中的下标表示位置。生成机器代码时,我们还需要回答一个更具体的问题:这个位置放在哪里,闭包又怎样在函数返回以后继续找到它?

第八章的答案是把位置实现成堆上的 cell 对象。IR 中的三种操作会直接落到这个对象上:

1
2
3
x0.cell = cell 1
store x0.cell 41
t.0 = load x0.cell

它们分别表示创建位置、写入位置和读取位置。

cell 不是源语言新增的一类值

这里先划清一个容易混淆的边界。

源程序仍然只能观察整数、closure 和 record。读者不能在源语言里直接写出一个 cell,也没有 cell? 或“读取 cell 地址”的语法。下面的 x 求值得到的是 cell 中保存的 41,而不是 cell 指针:

1
2
3
4
(let x 1
  (begin
    (set! x 41)
    x))

cell 是解释器和编译器用来实现“位置”的内部对象。IR 能看到它,是因为 IR 正在描述实现细节;表面语言并没有因此增加一种可操作的数据。

所有堆对象继续共用一个 pointer tag

第六、七章已经让 record 和 closure 共用 heap-object pointer tag。cell 继续加入同一协议,而不是再占用一种新的 pointer tag:

1
2
integer       low tag = 1
heap object   low tag = 4

指针的低三位只能说明“这是一个堆对象”。对象究竟是哪一种,要看原始地址 offset 0 处 header 的 kind:

HeapKind kind
record 1
closure 2
cell 3

header 仍使用统一公式:

1
header = (payload_count << 8) | kind

这样做有一个直接好处:record、closure 和 cell 的地址表示、header 读取、对象大小计算以及 payload 偏移都遵守同一套规则。

cell 的布局

cell 只有一个 payload word,用来保存当前位置的内容:

1
2
offset 0   header
offset 8   current tagged Value

所以它的三个常量可以直接算出:

1
2
3
4
payload_count = 1
header = (1 << 8) | 3 = 259
size = 8 * (1 + 1) = 16 bytes
value offset = 8

offset 8 保存完整的 tagged Value。变量先后保存整数、closure 或 record 时,cell 的布局都不需要改变,只需替换这个 word。

runtime/value.h 用三个小函数集中表达这些事实:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
inline constexpr std::size_t cell_header() {
    return heap_header(HeapKind::cell, 1);
}

inline constexpr std::size_t cell_size() {
    return heap_object_size(1);
}

inline constexpr std::size_t cell_value_offset() {
    return heap_payload_offset(0);
}

汇编 emitter 使用这些函数,不在不同操作里重复写裸常数。

从一段 IR 看三种操作

examples/set.lang 是:

1
2
3
4
(let x 1
  (begin
    (set! x 41)
    (+ x 1)))

查看 IR:

1
2
cd code/08_state
./mini ir examples/set.lang

实际输出是:

1
2
3
4
5
x0.cell = cell 1
store x0.cell 41
t.0 = load x0.cell
t.1 = t.0 + 1
return t.1

源程序中的 let 变成一次 cell;set! 变成 store;再次读取 x 时则生成 load。

cell:分配并写入初始值

生成一份 Linux 符号拼写的汇编,便于和下面片段逐行核对:

1
./mini compile examples/set.lang -o out.s --target linux

其中创建 x0.cell 的真实汇编是:

1
2
3
4
5
6
7
movq $16, %rdi
call malloc
movq $259, 0(%rax)
movq $9, %rcx
movq %rcx, 8(%rax)
orq $4, %rax
movq %rax, -8(%rbp)

这里依次发生了几件事:

  1. malloc(16) 返回对齐的原始地址;
  2. offset 0 写入 cell header 259;
  3. offset 8 写入整数 1 的机器表示 1 * 8 + 1 = 9;
  4. 原始地址与 4 做或运算,得到统一的 tagged heap pointer;
  5. 这个 cell 指针保存到 x0.cell 对应的栈槽。

注意 %rax 在 orq $4 之前是原始地址,之后才是能够放进 IR local 和 closure payload 的 tagged pointer。

store:替换 offset 8 的 Value

(set! x 41) 对应:

1
2
3
4
movq -8(%rbp), %rax
andq $-8, %rax
movq $329, %rcx
movq %rcx, 8(%rax)

329 是整数 41 的 tagged 表示。andq $-8 只作用于 cell 指针的副本,用来恢复原始地址;栈槽中的 tagged pointer 不会被改坏。

store 自身不需要产生新的目标 local。lowering 仍把写入的 operand 作为整个 set! 的结果,因此:

1
(let x 0 (+ (set! x 40) 2))

结果是 42。

load:读取当前位置的 Value

再次读取 x 时生成:

1
2
3
4
movq -8(%rbp), %rax
andq $-8, %rax
movq 8(%rax), %rax
movq %rax, -16(%rbp)

最后得到的 %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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(let make
  (lambda start
    (lambda step
      (begin
        (set! start (+ start step))
        start)))
  (let counter (make 40)
    (begin
      (counter 1)
      (counter 1))))

内层 closure 的关键 IR 是:

1
2
3
4
5
6
7
8
9
lambda1(step3, self-cell: -, captures: [start2.cell]):
  step4.cell = cell step3
  t.0 = load start2.cell
  t.1 = load step4.cell
  t.2 = t.0 + t.1
  store start2.cell t.2
  t.3 = load start2.cell
  return t.3
end lambda1

start2.cell 是 cell 指针。外层函数返回以后,内层 closure 仍保存着这个指针;两次调用因而读写同一个 offset 8,先得到 41,再得到 42。

普通 closure 的布局保持:

1
2
3
offset 0       closure header
offset 8       raw code pointer
offset 16+8i   capture i

现在 capture word 保存的是 tagged cell pointer。以只捕获 start2.cell 的内层 closure 为例:

1
2
3
4
offset 0    header 514
offset 8    raw address of lambda1
offset 16   tagged pointer to start2.cell
total       24 bytes

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 内层函数开头的真实汇编是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
movq %rsi, -8(%rbp)
movq %rdi, %rax
andq $-8, %rax
movq 16(%rax), %rcx
movq %rcx, -16(%rbp)
movq $16, %rdi
call malloc
movq $259, 0(%rax)
movq -8(%rbp), %rcx
movq %rcx, 8(%rax)
orq $4, %rax
movq %rax, -24(%rbp)

第一行先保存传入的参数;接下来的四行从 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 写入其中:

1
2
3
loop0.cell = cell 0
f.0 = closure lambda0 [loop0.cell]
store loop0.cell f.0

对于递归 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。

1
2
./mini run examples/recursive_binding_set.lang
./mini ir examples/recursive_binding_set.lang

参数 cell 在词法环境中最后加入,所以参数和递归名字同名时,参数仍然正确遮蔽 self。可以用下面的例子检查:

1
./mini run examples/self_param_shadow.lang

结果是 42。

call 仍先检查 callee,再求 argument

状态使求值顺序变得更容易观察。若 callee 根本不是 closure,argument 中的 set! 不应先发生。因此结构化 IR 保持以下顺序:

1
2
3
4
callee ops
check-closure callee
argument ops
call callee argument

closure_check 先检查共同 heap tag 4,再检查 header kind 2。通过后,真正的间接调用只在 scratch register 中清 tag:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
movq -24(%rbp), %rax
movq %rax, %rcx
andq $7, %rcx
cmpq $4, %rcx
jne .Lruntime_error
andq $-8, %rax
movq 0(%rax), %rdx
andq $255, %rdx
cmpq $2, %rdx
jne .Lruntime_error
movq -24(%rbp), %rdi
movq %rdi, %rcx
andq $-8, %rcx
movq $321, %rsi
call *8(%rcx)

%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 目标的结尾是:

1
2
3
4
.Lruntime_error:
movl $70, %edi
call exit
ud2

macOS 目标会使用 _exit。compile 本身仍然只生成汇编,不替读者调用链接器。

编译、链接并验证

在 Linux、WSL 或 Intel Mac 上:

1
2
3
4
5
6
cd code/08_state
make
./mini compile examples/counter.lang -o out.s
cc out.s -o out
./out
echo $?

Apple Silicon Mac 需要让系统编译器链接 x86-64 程序:

1
2
3
4
./mini compile examples/counter.lang -o out.s
cc -arch x86_64 out.s -o out
./out
echo $?

两种情况下都应看到退出状态 42。

再检查一个受控错误:

1
2
3
4
./mini compile examples/call_integer_error.lang -o out.s
cc out.s -o out
./out
echo $?

Apple Silicon Mac 的 cc 命令同样加入 -arch x86_64。最后的状态应为 70,而不是段错误。

当前快照仍通过 malloc 分配 record、closure 和 cell,也不主动释放。随着共享位置和返回 closure 增多,对象的存活时间已经不能只看创建它的词法块;下一章会接着处理这条自然出现的内存管理问题。


8.4 结构化 IR:把值和位置分开

上一节

8.6 边界与验收:第八章真正承诺什么

下一节