章节目录

值表示:一个机器字怎样引用不同的堆对象

本节阅读量:

解释器里的 Value 可以使用 C++ 枚举、数字成员和智能指针。生成的 x86-64 程序没有这些 C++ 类型信息;函数参数、返回值、栈槽和 record 字段最终都只是 64 位机器字。

这一节建立一套统一表示,使一个机器字能够区分:

1
2
3
整数
函数引用
堆对象引用

record 是本书遇到的第一种堆对象,但不会是最后一种。低位 tag 因此表示“这是 heap object”,具体是哪一种对象则由对象 header 决定。这个分层会让后续 closure、cell 和 GC 复用同一协议。

为什么原来的裸整数不够用

第一章到第五章的大部分计算都可以把整数 n 直接放进 %rax。但加入 record 后,同一个栈槽有时保存整数,有时保存堆地址:

1
2
3
(if condition
    42
    (record 40 2))

如果二者都只是未经编码的机器字,运行时无法判断 42 是整数还是地址。更危险的是,record? 可能把整数误当作地址并读取内存。

解决办法是在机器字的低位写入种类标记,也就是 tag。

利用对齐留下的低三位

x86-64 上,本章得到的代码地址和 malloc 返回地址都至少按 8 字节对齐,因此原始地址的低三位是 000:

1
...xxxxxxxx000

我们可以在这三位中编码值类别:

1
2
3
integer        ...001
function       ...011
heap object    ...100

运行时常量集中放在 src/runtime/value.h,而不是散落在汇编 emitter 中。概念上相当于:

1
2
3
4
5
constexpr std::uint64_t kTagMask = 0b111;
constexpr std::uint64_t kIntTag = 0b001;
constexpr std::uint64_t kFunctionTag = 0b011;
constexpr std::uint64_t kHeapObjectTag = 0b100;
constexpr std::size_t kWordSize = 8;

这里的 4 不叫 record tag。它只说明机器字引用某种堆对象。当前唯一的 heap kind 是 record;后续章节加入别的 heap kind 时,不需要再消耗一个低位 tag。

整数编码为 8n + 1

整数使用:

1
tagged_integer(n) = (n << 3) | 1

常见值是:

1
2
3
4
源语言 0    -> 1
源语言 1    -> 9
源语言 2    -> 17
源语言 40   -> 321

解码时做算术右移:

1
decoded_integer = tagged_word >> 3

算术右移会保留负号,因此负整数也能沿用前文语义。

统一表示会改变算术 emitter。若两个输入分别是 8a + 1 与 8b + 1:

1
(8a + 1) + (8b + 1) = 8(a + b) + 2

结果多出一个 tag,所以加法要再减 1。sub1 则减 8,因为相邻两个源语言整数的编码相差八。

IR 仍保存逻辑整数 40,不会提前把它改成 321。tag 编码属于最终机器表示,只在汇编阶段完成。

函数引用仍是代码地址加 tag

第五章中的函数值由对齐后的代码地址生成:

1
function_value = code_address | 3

调用前清除低三位:

1
code_address = function_value & -8

本章保留这套表示。函数不是 heap object,也没有 record header;第七章加入捕获环境后,函数值的实现才会迁移到真正的 closure object。

函数拥有机器 tag,不代表语言自动获得函数相等。第六章仍拒绝用 eq? 比较函数值。

heap-object tag 与 header 各管一层

假设 malloc 返回对齐地址 p。语言层 heap object 引用是:

1
heap_value = p | 4

准备访问对象内容时,先清除 tag:

1
raw_address = heap_value & -8

但 4 只说明它是堆对象。raw object 的第一个机器字是 header,负责记录更具体的信息:

1
header = (payload_count << 8) | heap_kind

本章定义:

1
HeapKind::record = 1

于是 header 可以拆成:

1
2
heap_kind     = header & 0xff
payload_count = header >> 8

为什么 kind 放在低 8 位,而 count 从第 8 位开始?这样一种对象最多有 256 个 kind 编号,同时 payload count 可以利用剩下的大部分机器字。课程当前只用到很少的 kind,公式却已经足够稳定。

record? 必须依次检查两层:

  1. 机器字低三位是否为 heap-object tag 4;
  2. 清 tag 后读取 header,确认 kind 是否为 HeapKind::record。

不能只检查 tag。以后 closure 和 cell 也会携带 heap-object tag,但它们不是 record。

可变长度 record 的统一布局

一个含 n 个字段的 record 使用 n + 1 个机器字:

1
2
3
4
5
offset 0           header
offset 8           field 0
offset 16          field 1
...
offset 8 * n       field n - 1

通用公式是:

1
2
object_size(n) = 8 * (1 + n)
field_offset(i) = 8 * (1 + i)

几个实例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
(record)
  header = (0 << 8) | 1 = 1
  size   = 8 bytes

(record 40 2)
  header = (2 << 8) | 1 = 513
  size   = 24 bytes

(record 10 20 30)
  header = (3 << 8) | 1 = 769
  size   = 32 bytes

二字段对象的 513 不再是全局 record 常量,只是通用 header 公式在 n = 2 时的结果。

payload 保存完整 tagged Value

每个字段槽都是一个机器字,里面保存完整 Value 编码,而不是裸整数:

1
(record 40 (record) (lambda x x))

概念布局是:

1
2
3
4
5
outer raw object
offset 0     header: kind record, count 3
offset 8     tagged integer 40
offset 16    tagged heap-object pointer to empty record
offset 24    tagged function address

因此 record 可以自然形成嵌套和共享对象图。读取字段只是复制一个机器字;如果它是另一个对象引用,内层对象本身不会被复制。

这也是后续 GC 必须扫描 payload 的原因:某些字段可能继续指向堆对象。

空 record 不需要特殊表示

(record) 的 payload count 是零,但对象仍有 header:

1
2
raw object
offset 0     1

每次求值 (record) 都执行一次独立分配并返回新的 tagged pointer。它不需要占用新的立即数 tag,也不需要引入全局 singleton。于是创建、身份相等、record? 和 size 都沿用同一套一般规则。

size 从 header 取 count

size 收到 Value 后不能直接读取 offset 0。正确顺序是:

1
2
3
4
5
6
检查 low tag == heap object
清除 low tag
读取 header
检查 header kind == record
payload_count = header >> 8
把 count 编码回 tagged integer

最后一步很容易漏掉。header 中的 count 是后端内部使用的裸整数,而源语言看到的 size 结果必须仍是 8n + 1。

get 在访问前完成两类检查

get 的 index 已经是 AST 和 IR 中的非负整数元数据。生成代码仍必须检查对象本身:

1
2
3
4
5
6
检查 low tag == heap object
清 tag并读取 header
检查 header kind == record
取 payload_count
检查 index < payload_count
读取 field_offset(index)

类型或边界检查失败都会跳到统一失败出口,使进程返回状态 70。尤其要注意,边界检查必须发生在字段 load 之前;先读取再检查已经太晚,可能访问对象之外的内存。

record? 与它们不同:收到任意值都应正常返回 0 或 1,所以检查失败不是运行时错误。

解释器表示与机器表示不要混在一起

两条执行路线表达相同语义,但具体材料不同:

语言值 解释器中的 C++ 表示 生成代码中的机器表示
整数 Kind::number + long 8n + 1
函数 Kind::function + 智能指针 `code_address
record Kind::record + shared_ptr<RecordValue> `object_address

解释器中的 RecordValue 可以用 std::vector<Value> 保存字段。这里的 vector 只服务 C++ 实现;源程序仍然只认识 record。

解释器的 shared_ptr 会在引用消失时回收 C++ 对象。生成代码调用 malloc 后暂时不 free,第九章才会建立受管理堆和垃圾回收。这一差异属于内存管理策略,不改变可观察的 record 语义。

表示不等于完整动态类型系统

本章会为 record?、size 和 get 发出与 record 直接相关的检查,因为它们正是理解 heap tag、header kind 和长度的最佳入口。

此前的算术、函数调用和函数相等并不会因此自动获得全部机器错误诊断。编译路径仍只承诺这些旧操作收到合适种类的值。统一 tagged Value 建立的是可扩展表示基础;完整覆盖每种动态类型错误仍是更大的运行时工作。

下一节回到前端。AST 只保存字段表达式、操作数子树和字面量 index,不保存 tag、header、对象大小或 malloc。这些实现决定必须留在后端边界之内。


6.1 语言:一组值,也可以成为一个值

上一节

6.3 AST 与 parser:拥有数量不定的字段子树

下一节