值表示:一个机器字怎样引用不同的堆对象
本节阅读量:解释器里的 Value 可以使用 C++ 枚举、数字成员和智能指针。生成的 x86-64 程序没有这些 C++ 类型信息;函数参数、返回值、栈槽和 record 字段最终都只是 64 位机器字。
这一节建立一套统一表示,使一个机器字能够区分:
|
|
record 是本书遇到的第一种堆对象,但不会是最后一种。低位 tag 因此表示“这是 heap object”,具体是哪一种对象则由对象 header 决定。这个分层会让后续 closure、cell 和 GC 复用同一协议。
为什么原来的裸整数不够用
第一章到第五章的大部分计算都可以把整数 n 直接放进 %rax。但加入 record 后,同一个栈槽有时保存整数,有时保存堆地址:
|
|
如果二者都只是未经编码的机器字,运行时无法判断 42 是整数还是地址。更危险的是,record? 可能把整数误当作地址并读取内存。
解决办法是在机器字的低位写入种类标记,也就是 tag。
利用对齐留下的低三位
x86-64 上,本章得到的代码地址和 malloc 返回地址都至少按 8 字节对齐,因此原始地址的低三位是 000:
|
|
我们可以在这三位中编码值类别:
|
|
运行时常量集中放在 src/runtime/value.h,而不是散落在汇编 emitter 中。概念上相当于:
|
|
这里的 4 不叫 record tag。它只说明机器字引用某种堆对象。当前唯一的 heap kind 是 record;后续章节加入别的 heap kind 时,不需要再消耗一个低位 tag。
整数编码为 8n + 1
整数使用:
|
|
常见值是:
|
|
解码时做算术右移:
|
|
算术右移会保留负号,因此负整数也能沿用前文语义。
统一表示会改变算术 emitter。若两个输入分别是 8a + 1 与 8b + 1:
|
|
结果多出一个 tag,所以加法要再减 1。sub1 则减 8,因为相邻两个源语言整数的编码相差八。
IR 仍保存逻辑整数 40,不会提前把它改成 321。tag 编码属于最终机器表示,只在汇编阶段完成。
函数引用仍是代码地址加 tag
第五章中的函数值由对齐后的代码地址生成:
|
|
调用前清除低三位:
|
|
本章保留这套表示。函数不是 heap object,也没有 record header;第七章加入捕获环境后,函数值的实现才会迁移到真正的 closure object。
函数拥有机器 tag,不代表语言自动获得函数相等。第六章仍拒绝用 eq? 比较函数值。
heap-object tag 与 header 各管一层
假设 malloc 返回对齐地址 p。语言层 heap object 引用是:
|
|
准备访问对象内容时,先清除 tag:
|
|
但 4 只说明它是堆对象。raw object 的第一个机器字是 header,负责记录更具体的信息:
|
|
本章定义:
|
|
于是 header 可以拆成:
|
|
为什么 kind 放在低 8 位,而 count 从第 8 位开始?这样一种对象最多有 256 个 kind 编号,同时 payload count 可以利用剩下的大部分机器字。课程当前只用到很少的 kind,公式却已经足够稳定。
record? 必须依次检查两层:
- 机器字低三位是否为 heap-object tag
4; - 清 tag 后读取 header,确认 kind 是否为
HeapKind::record。
不能只检查 tag。以后 closure 和 cell 也会携带 heap-object tag,但它们不是 record。
可变长度 record 的统一布局
一个含 n 个字段的 record 使用 n + 1 个机器字:
|
|
通用公式是:
|
|
几个实例:
|
|
二字段对象的 513 不再是全局 record 常量,只是通用 header 公式在 n = 2 时的结果。
payload 保存完整 tagged Value
每个字段槽都是一个机器字,里面保存完整 Value 编码,而不是裸整数:
|
|
概念布局是:
|
|
因此 record 可以自然形成嵌套和共享对象图。读取字段只是复制一个机器字;如果它是另一个对象引用,内层对象本身不会被复制。
这也是后续 GC 必须扫描 payload 的原因:某些字段可能继续指向堆对象。
空 record 不需要特殊表示
(record) 的 payload count 是零,但对象仍有 header:
|
|
每次求值 (record) 都执行一次独立分配并返回新的 tagged pointer。它不需要占用新的立即数 tag,也不需要引入全局 singleton。于是创建、身份相等、record? 和 size 都沿用同一套一般规则。
size 从 header 取 count
size 收到 Value 后不能直接读取 offset 0。正确顺序是:
|
|
最后一步很容易漏掉。header 中的 count 是后端内部使用的裸整数,而源语言看到的 size 结果必须仍是 8n + 1。
get 在访问前完成两类检查
get 的 index 已经是 AST 和 IR 中的非负整数元数据。生成代码仍必须检查对象本身:
|
|
类型或边界检查失败都会跳到统一失败出口,使进程返回状态 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。这些实现决定必须留在后端边界之内。