C++ runtime:让汇编只负责交接
本节阅读量:前几节已经把 GC 的两个部分分开了:
- 编译器知道每个程序点有哪些值仍然活着,因此它负责建立和维护 shadow root stack;
- runtime 知道半区、对象布局和搬移算法,因此它负责分配、forwarding 与 Cheney 扫描。
第二部分没有必要手写成汇编。本章把它放在 src/runtime/gc.cpp 中,用普通 C++ 实现;生成代码只在 ABI 边界调用几个固定符号。
|
|
这个边界很重要。GC 算法不再埋在一大段由 C++ 字符串拼出的汇编里;读者可以直接阅读、调试和测试 collector,同时仍能看清编译器必须承担的 root 协议。
三个文件各自负责什么
本章与运行时有关的文件是:
|
|
value.h 同时被 emitter 和 runtime 包含,因此双方使用同一组 tag、kind、word size 和对象大小公式。gc.h 只描述边界;gc.cpp 才保存半区状态并实现算法。
编译器本身不执行 gc.cpp。make 会生成两个不同产物:
|
|
这也解释了为什么 mini compile 仍然只需要输出 out.s:编译和链接仍是两个阶段,只是第九章起链接输入不再只有汇编文件。
extern "C" 固定链接边界
C++ 编译器通常会把参数类型编码进符号名。生成的汇编既不认识这种 name mangling,也不应该依赖某个 C++ 编译器的私有规则。因此 gc.h 使用 extern "C":
|
|
这四项就是生成代码与 runtime 的完整协议:
| C ABI 名字 | 生成代码怎样使用 |
|---|---|
mini_gc_init |
main 启动时调用,取得 root stack 起点 |
mini_gc_root_end |
每个生成函数建立 root frame 前检查上界 |
mini_alloc |
创建 cell、closure 或 record 时请求 raw memory |
mini_gc_abort |
root 越界或 runtime 协议失败时终止进程 |
collect 和 forward 没有出现在头文件里。它们位于 gc.cpp 的匿名名字空间,只是 runtime 内部的 C++ 函数。生成汇编无须知道 collector 被拆成几步。
extern "C" 也不等于“用 C 实现”。函数体仍可以使用 std::size_t、std::uintptr_t、std::memcpy 和 C++ 辅助函数;它只让链接名称与调用约定保持清楚。
Makefile 为什么单独生成 gc_runtime.o
如果仍把 gc.cpp 放进构建 mini 的通配列表,它只会被链接进编译器进程;生成程序仍然拿不到可以参与链接的 runtime object。Makefile 因此把它单独排除并建立 object target:
|
|
-c 表示只编译、不链接。于是 gc_runtime.o 还不是可执行文件,正适合稍后与 out.s 合并。
Apple Silicon Mac 的宿主 mini 可以是 arm64,但课程生成的汇编固定为 x86-64。因此 Makefile 在 Darwin 上只给 gc_runtime.o 加 -arch x86_64;最终它才能与 x86-64 的 out.s 链接。这里必须让两个目标文件属于同一种架构。
C++ 中的堆状态
runtime 把地址统一看作 byte pointer,把一个机器 word 看作 std::uintptr_t:
|
|
这些名字和前文的图一一对应。它们属于 gc.cpp 的匿名名字空间,不会成为生成汇编可以随意改写的公共变量。唯一必须暴露的是 root stack 上界,因为生成函数要在写入新 frame 前检查容量:
|
|
runtime 还用 static_assert 检查它理解的机器 word、tag、kind 与 value.h 完全一致。若以后修改对象表示却忘记同步 collector,构建会直接失败,而不是等到某轮 GC 才悄悄损坏对象图。
mini_gc_init:准备三块内存
初始化函数分别申请两个半区和一块 root stack:
|
|
返回值是 root stack 的起点。System V x86-64 会把指针返回在 %rax 中,所以 Linux 目标的 main 只需:
|
|
从此 %r15 保存第一个空闲 root slot。macOS 的外部符号是 _mini_gc_init,但 C++ 源码和算法完全相同;差别只来自目标文件格式(Linux 的 ELF、macOS 的 Mach-O)对符号的拼写约定。
root 边界仍由生成代码检查
runtime 拥有 root memory,编译器却知道当前函数需要多少 slot。因此生成函数负责计算新的 top:
|
|
mini_gc_root_end 是外部 C ABI 变量;.Lmini_gc_abort 则是当前汇编文件内部的局部标签。这个局部入口很小:
|
|
真正调用 std::abort() 的仍是 C++ runtime。这样 emitter 不需要知道宿主 C 库在 Linux 和 macOS 上怎样拼写 abort,同时也不会在越界之后继续写 root memory。
mini_alloc:先快走,必要时回收
外部入口先检查请求和 root top:
|
|
快速路径只是返回旧 from_free 并向后移动。空间不足时才调用内部的 collect,然后重试一次。第二次仍放不下,说明活对象加新请求已经超过固定半区,runtime 只能 abort。
生成代码按 System V ABI 传参:
|
|
| register | 内容 |
|---|---|
%rdi |
请求字节数 |
%rsi |
当前 root top |
%rax |
返回的未加 tag raw address |
C++ 编译器负责让 mini_alloc 保存 ABI 规定的 callee-saved registers。生成代码不用手写 collector prologue,也不用知道 collect 使用了哪些 C++ 局部变量。
forward:搬一次并留下新地址
forward 首先排除整数和不在当前 from-space 中的值:
|
|
若旧 header 已经是 forwarding word,就复用第一次搬家得到的地址:
|
|
否则验证 kind、payload count、旧对象边界和 to-space 容量,再复制整个对象:
|
|
这里仍然遵守“先复制并安装 forwarding,再扫描内部边”的顺序。空 record 也至少有一个 header word,所以 new_raw | 7 总有位置可写。
collect:roots 加 Cheney scan
collection 的第一阶段把每个 root 原地更新:
|
|
第二阶段让 scan 从 to_start 追赶 to_free:
|
|
三种对象的扫描边界仍是:
| kind | 扫描哪些 payload |
|---|---|
| record | 全部字段 |
| closure | 跳过 raw code pointer,只扫描 captures |
| cell | 唯一的 current Value |
forward 可能把新对象追加到 to_free,因此循环条件不能提前保存一个固定终点。只有 scan == to_free,才说明所有刚发现的对象也已经扫描完。
最后交换两个半区:
|
|
旧 from-space 整体成为下一轮 to-space,不需要逐对象调用 free。没有复制的对象自然被丢弃;复制后的对象则从新 from_start 起连续排列。
为什么 runtime 失败走 abort
下面两类错误的归属不同:
|
|
exit(70) 是语言实现承诺的可观察错误协议;abort() 表示这套固定容量 runtime 已无法安全继续。后者通常由信号结束,不承诺固定 shell 状态。
mini run 也不会调用 mini_alloc。解释器继续使用宿主 C++ 对象给出语言语义基准;只有把 out.s 与 gc_runtime.o 链接后运行,才真正验证 root stack 和 moving collector。
生成、链接与平台符号
在 Linux、WSL 或 Intel Mac 上:
|
|
Apple Silicon Mac 上,Makefile 已把 gc_runtime.o 编成 x86-64;最终链接也要明确目标架构:
|
|
两边都应得到状态 42。
Linux 汇编引用 main、mini_gc_init、mini_gc_root_end、mini_alloc 和 mini_gc_abort;出现受控动态检查时还会引用 exit。macOS 汇编引用对应的 _main、_mini_gc_init、_mini_gc_root_end、_mini_alloc、_mini_gc_abort 和 _exit。malloc 和 abort 现在是 gc.cpp 的实现细节,由 C++ 编译器处理,不再出现在 emitter 拼写的平台符号表里。
--target 只改变生成汇编的外部符号拼写,不能把已有的 runtime object 转换成另一种架构或 object format。汇编和 gc_runtime.o 必须面向同一个目标,链接器才能把 C ABI 符号接起来。
到这里,边界可以压缩成一句话:编译器在 safepoint 前提供一段准确、可写回的 roots;C++ runtime 从这些 roots 出发搬移对象;返回以后,生成代码重新读取已经更新的 homes。collector 的实现语言变了,这条正确性协议没有变。