汇编:分配可变大小对象,并在读取前检查
本节阅读量:汇编 emitter 只消费结构化 IrProgram。对于:
|
|
它接收的是:
|
|
这一层才把逻辑操作落实为 tagged word、malloc、header、payload 写入、种类检查、边界检查和内存读取。
运行时常量集中在一个地方
src/runtime/value.h 定义所有表示公式:
|
|
辅助函数把通用公式写成可复用接口:
|
|
record 的三个辅助函数只是给通用协议加上 HeapKind::record:
|
|
后续 closure 可以复用 heap_header、heap_object_size 和 heap_payload_offset,但拥有自己的 header kind 与 payload 解释。本章不提前实现 closure。
tag 与 header 必须分开理解
一个 record 在运行时同时有两层标记:
|
|
低位 4 不是 record 专用 tag。只检查 4,未来会把 closure 或 cell 也误认为 record;因此 record?、size 和 get 还必须检查 header kind。
旧值操作适配 tagged integer
统一表示后,整数 operand 在汇编中写成:
|
|
所以 IR 的 40 变成汇编立即数 321。
加法对两个 8n + 1 编码相加后减去多余的一个 tag:
|
|
sub1 减少一个源语言整数,机器编码应减 8:
|
|
eq? 比较完整机器字。相同整数编码相等,同一个 heap object 的 tagged pointer 也相等;分别分配的对象地址不同。CPU 产生的裸 0/1 还要编码回源语言整数:
|
|
函数值仍是 code_address | 3,调用前用 andq $-8 恢复代码地址。这部分沿用第五章。
栈槽保护跨 malloc 的字段 Value
malloc 遵循 System V ABI,可以改写 caller-saved 寄存器。record op 的字段 operand 在 IR 中已经准备好:复杂字段结果位于临时栈槽,简单字段是立即数。
emitter 因此先调用 malloc,再从 operand 的稳定位置逐个装入 %rcx 并写入新对象。它不会要求字段值跨调用一直留在 %rax 或 %rcx。
局部栈大小继续向 16 字节对齐,确保发出 call malloc、call exit 或函数调用时满足 ABI 对齐要求。
根据字段数发出分配
record emitter 不再使用固定 24 和 513。它直接读取 op.fields.size():
|
|
这里的循环发生在编译器运行时:字段数量已写在 IR 中,所以 emitter 为每个字段生成一条固定偏移 store。目标程序运行时不需要循环创建这个特定 record。
空 record 仍然调用 malloc
IR:
|
|
生成的核心汇编是:
|
|
字段循环执行零次,但 header、分配和 tag 都保留。两次 (record) 会执行两次 malloc(8),得到两个不同对象身份。
不能用常量 4 直接表示空 record:那不是有效的 tagged heap address,record? 随后的 header 读取也会访问错误位置。
三字段 record 的生成过程
对于:
|
|
字段数为三,因此:
|
|
核心汇编形状是:
|
|
81、161、241 分别是整数 10、20、30 的 tagged 表示。header 中的 count 是裸 3,payload 中的字段则必须是完整 tagged Value。
record? 先看 tag,再看 header kind
predicate 对任意 Value 都应该安全返回,因此不能直接清 tag 并解引用。生成逻辑是:
|
|
对应关键汇编:
|
|
源语言真值 1 编码为 9,假值 0 编码为 1。内部标签带递增编号,避免一个程序中出现多个 record? 时重名。
即使当前只有 record 一种 heap kind,第二层检查也不是多余工作;它把接口固定在未来能够安全扩展的形式上。
size 与 get 共用 record guard
二者都要求输入一定是 record,因此 emitter 提取共用检查:
|
|
检查成功后形成一个小约定:
|
|
后续两个 emitter 可以直接继续使用。
最关键的安全顺序是先检查低位 tag,再读取 0(%rax)。整数 42 的机器编码不是有效堆地址,绝不能先解引用再判断 header。
size 解出 count,再编码成 Value
header 的高位保存裸 payload count:
|
|
但源语言 size 要返回 tagged integer,因此完整过程是:
|
|
对于空 record,header 是 1:右移八位得到裸 0,再编码得到机器字 1。主函数最终解码后返回退出状态 0。
get 检查 index 小于 count
guard 成功后,%rcx 保存 header。field emitter 先取得 count:
|
|
AT&T 语法的 cmpq %rdx, %rcx 计算 %rcx - %rdx。这里 %rcx 是 count,jbe 在 count <= index 时跳转,所以只有 index < count 才能继续。
这条比较使用无符号条件,因为 index 和 count 都是非负数量。
检查通过后,用基址、比例索引和 header 偏移一次完成地址计算:
|
|
地址公式是:
|
|
这与 heap_payload_offset(index) = (index + 1) * 8 完全一致。读取结果已经是字段中保存的 tagged Value,不应再次添加 tag。
统一失败出口返回 70
size 类型错误、get 类型错误和 get 越界都跳到:
|
|
这里调用 C 库 exit(70),而不是简单把 %rax 设为 70 后从当前位置 ret。错误可能发生在任意生成函数中,直接 ret 只会返回调用者并继续执行;exit 能从任何深度终止整个进程。
ud2 表明 exit 理论上不应返回,也防止控制流意外落入后续内容。
解释器仍提供具体错误文字;生成程序只用状态 70 表示本章这几种运行时失败。课程没有在这里引入异常对象或完整诊断 runtime。
guard 同时把 runtime_error_used_ 设为 true。emit() 只有看到这个标志时才追加上述共享错误出口;因此这个判断也覆盖生成的 lambda 函数体,而只含整数、record? 或 record 创建的程序不会携带一段永远用不到的 exit 调用。
平台符号差异
生成代码会按程序实际用到的操作引用 C 库函数:
|
|
只创建 record 的程序只需要 malloc;出现 size 或 get 时,emitter 才生成共享的错误出口并引用 exit。这样没有运行时检查的程序不会凭空多出一个外部符号依赖。
Linux 目标使用 malloc、exit,macOS 目标使用 _malloc、_exit。main 同样分别生成 main 与 _main。
CLI 只生成汇编:
|
|
手动链接时,cc 才从 C 标准库解析这些外部符号:
|
|
Apple Silicon Mac 使用:
|
|
用退出状态验证成功与失败
成功读取:
|
|
record_get.lang 得到 2,退出状态应为 2。
对越界或非 record 的 size/get 示例做相同操作时,退出状态应为 70。shell 只保留较小的进程退出状态,因此本书仍使用小整数作为最终可观察结果。
若顶层直接返回 record,main 检查结果 tag 后返回 0,不会打印对象。完整的 (record ...) 文本只由解释器的 format_value 提供。
嵌套 record 是多个独立分配
对于:
|
|
IR 先发出内层 record,再发出外层 record,所以生成代码执行两次 malloc。外层 field 1 保存的是:
|
|
不是把内层 header 与字段复制到外层 payload。对象图在机器层仍由一个机器字引用连接。
本章汇编边界
第六章后端现在实现:
|
|
它仍然不检查 malloc 是否返回空指针,不回收对象,也没有 GC。对旧的算术、函数调用和函数相等,也没有在本章补齐所有动态类型错误路径。
这些边界不妨碍本章目标:解释器和生成代码已经对 record 的创建、判断、长度、安全读取、嵌套与身份实现同一套行为,并把通用 heap object 协议准备好供后续章节扩展。