章节目录

C++ runtime:让汇编只负责交接

本节阅读量:

前几节已经把 GC 的两个部分分开了:

  • 编译器知道每个程序点有哪些值仍然活着,因此它负责建立和维护 shadow root stack;
  • runtime 知道半区、对象布局和搬移算法,因此它负责分配、forwarding 与 Cheney 扫描。

第二部分没有必要手写成汇编。本章把它放在 src/runtime/gc.cpp 中,用普通 C++ 实现;生成代码只在 ABI 边界调用几个固定符号。

1
2
3
4
5
6
7
8
generated out.s                         gc_runtime.o

建立 root frame                        管理 from/to-space
清理 dead roots                        bump allocation
保存跨 safepoint 的 Value       call   forward
------------------------------->       Cheney scan
重新读取更新后的 roots          <---   返回 raw address
写入对象 header 和 payload

这个边界很重要。GC 算法不再埋在一大段由 C++ 字符串拼出的汇编里;读者可以直接阅读、调试和测试 collector,同时仍能看清编译器必须承担的 root 协议。

三个文件各自负责什么

本章与运行时有关的文件是:

1
2
3
src/runtime/value.h    tagged Value、header kind 与布局公式
src/runtime/gc.h       生成代码可调用的 C ABI
src/runtime/gc.cpp     分配器与 copying collector 的 C++ 实现

value.h 同时被 emitter 和 runtime 包含,因此双方使用同一组 tag、kind、word size 和对象大小公式。gc.h 只描述边界;gc.cpp 才保存半区状态并实现算法。

编译器本身不执行 gc.cpp。make 会生成两个不同产物:

1
2
mini             在宿主进程中解析、解释并生成汇编
gc_runtime.o     链接进生成程序,在目标进程中管理托管堆

这也解释了为什么 mini compile 仍然只需要输出 out.s:编译和链接仍是两个阶段,只是第九章起链接输入不再只有汇编文件。

extern "C" 固定链接边界

C++ 编译器通常会把参数类型编码进符号名。生成的汇编既不认识这种 name mangling,也不应该依赖某个 C++ 编译器的私有规则。因此 gc.h 使用 extern "C":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
extern "C" {

extern std::uintptr_t* mini_gc_root_end;

std::uintptr_t* mini_gc_init() noexcept;

std::uintptr_t* mini_alloc(
    std::size_t bytes,
    std::uintptr_t* root_top) noexcept;

[[noreturn]] void mini_gc_abort() noexcept;

}

这四项就是生成代码与 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
CXX ?= c++
CXXFLAGS ?= -std=c++17 -Wall -Wextra -pedantic

RUNTIME_SOURCE := src/runtime/gc.cpp
SOURCES := $(filter-out $(RUNTIME_SOURCE),$(wildcard src/*/*.cpp))
HEADERS := $(wildcard src/*/*.h)

ifeq ($(shell uname -s),Darwin)
RUNTIME_ARCH_FLAGS := -arch x86_64
endif

all: mini gc_runtime.o

mini: $(SOURCES) $(HEADERS)
	$(CXX) $(CXXFLAGS) -Isrc $(SOURCES) -o mini

gc_runtime.o: $(RUNTIME_SOURCE) src/runtime/gc.h src/runtime/value.h
	$(CXX) $(CXXFLAGS) $(RUNTIME_ARCH_FLAGS) -Isrc -c $< -o $@

-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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
using Byte = unsigned char;
using Word = std::uintptr_t;

constexpr std::size_t kSemispaceBytes = 4096;
constexpr std::size_t kRootStackBytes = 1024 * 1024;
constexpr Word kForwardingTag = 7;

Byte* from_start = nullptr;
Byte* from_free = nullptr;
Byte* from_end = nullptr;
Byte* to_start = nullptr;
Byte* to_free = nullptr;
Byte* to_end = nullptr;
Word* root_start = nullptr;

这些名字和前文的图一一对应。它们属于 gc.cpp 的匿名名字空间,不会成为生成汇编可以随意改写的公共变量。唯一必须暴露的是 root stack 上界,因为生成函数要在写入新 frame 前检查容量:

1
2
3
extern "C" {
std::uintptr_t* mini_gc_root_end = nullptr;
}

runtime 还用 static_assert 检查它理解的机器 word、tag、kind 与 value.h 完全一致。若以后修改对象表示却忘记同步 collector,构建会直接失败,而不是等到某轮 GC 才悄悄损坏对象图。

mini_gc_init:准备三块内存

初始化函数分别申请两个半区和一块 root stack:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
std::uintptr_t* mini_gc_init() noexcept {
    from_start = allocate_bytes(kSemispaceBytes);
    from_free = from_start;
    from_end = from_start + kSemispaceBytes;

    to_start = allocate_bytes(kSemispaceBytes);
    to_free = to_start;
    to_end = to_start + kSemispaceBytes;

    root_start = reinterpret_cast<Word*>(
        allocate_bytes(kRootStackBytes));
    mini_gc_root_end =
        root_start + kRootStackBytes / mini::kWordSize;
    return root_start;
}

返回值是 root stack 的起点。System V x86-64 会把指针返回在 %rax 中,所以 Linux 目标的 main 只需:

1
2
call mini_gc_init
movq %rax, %r15

从此 %r15 保存第一个空闲 root slot。macOS 的外部符号是 _mini_gc_init,但 C++ 源码和算法完全相同;差别只来自目标文件格式(Linux 的 ELF、macOS 的 Mach-O)对符号的拼写约定。

root 边界仍由生成代码检查

runtime 拥有 root memory,编译器却知道当前函数需要多少 slot。因此生成函数负责计算新的 top:

1
2
3
4
leaq 152(%r15), %rax
cmpq mini_gc_root_end(%rip), %rax
ja .Lmini_gc_abort
movq %rax, %r15

mini_gc_root_end 是外部 C ABI 变量;.Lmini_gc_abort 则是当前汇编文件内部的局部标签。这个局部入口很小:

1
2
3
.Lmini_gc_abort:
    call mini_gc_abort
    ud2

真正调用 std::abort() 的仍是 C++ runtime。这样 emitter 不需要知道宿主 C 库在 Linux 和 macOS 上怎样拼写 abort,同时也不会在越界之后继续写 root memory。

mini_alloc:先快走,必要时回收

外部入口先检查请求和 root top:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
std::uintptr_t* mini_alloc(
    std::size_t bytes,
    std::uintptr_t* root_top) noexcept {
    if (bytes == 0 || bytes > kSemispaceBytes ||
        bytes % mini::kWordSize != 0) {
        fail();
    }
    validate_root_top(root_top);

    if (!has_room(from_free, bytes, from_end)) {
        collect(root_top);
    }
    if (!has_room(from_free, bytes, from_end)) {
        fail();
    }

    Byte* result = from_free;
    from_free += bytes;
    return reinterpret_cast<Word*>(result);
}

快速路径只是返回旧 from_free 并向后移动。空间不足时才调用内部的 collect,然后重试一次。第二次仍放不下,说明活对象加新请求已经超过固定半区,runtime 只能 abort。

生成代码按 System V ABI 传参:

1
2
3
movq $72, %rdi
movq %r15, %rsi
call mini_alloc
register 内容
%rdi 请求字节数
%rsi 当前 root top
%rax 返回的未加 tag raw address

C++ 编译器负责让 mini_alloc 保存 ABI 规定的 callee-saved registers。生成代码不用手写 collector prologue,也不用知道 collect 使用了哪些 C++ 局部变量。

forward:搬一次并留下新地址

forward 首先排除整数和不在当前 from-space 中的值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
Word forward(Word value) {
    if ((value & static_cast<Word>(mini::kTagMask)) !=
        static_cast<Word>(mini::kHeapObjectTag)) {
        return value;
    }

    const Word raw_address =
        value & ~static_cast<Word>(mini::kTagMask);
    if (raw_address < address(from_start) ||
        raw_address >= address(from_free)) {
        return value;
    }

    auto* object = reinterpret_cast<Word*>(raw_address);
    // 接下来检查 forwarding word 或复制这个对象。
}

若旧 header 已经是 forwarding word,就复用第一次搬家得到的地址:

1
2
3
4
5
6
7
8
const Word header = object[0];
if ((header & static_cast<Word>(mini::kTagMask)) ==
    kForwardingTag) {
    const Word forwarded_address =
        header & ~static_cast<Word>(mini::kTagMask);
    return forwarded_address |
           static_cast<Word>(mini::kHeapObjectTag);
}

否则验证 kind、payload count、旧对象边界和 to-space 容量,再复制整个对象:

1
2
3
4
5
6
7
Byte* destination = to_free;
std::memcpy(destination, object, bytes);
to_free += bytes;

object[0] = address(destination) | kForwardingTag;
return address(destination) |
       static_cast<Word>(mini::kHeapObjectTag);

这里仍然遵守“先复制并安装 forwarding,再扫描内部边”的顺序。空 record 也至少有一个 header word,所以 new_raw | 7 总有位置可写。

collect:roots 加 Cheney scan

collection 的第一阶段把每个 root 原地更新:

1
2
3
4
5
6
7
void collect(Word* root_top) {
    validate_root_top(root_top);
    to_free = to_start;

    for (Word* root = root_start; root != root_top; ++root) {
        *root = forward(*root);
    }

第二阶段让 scan 从 to_start 追赶 to_free:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Byte* scan = to_start;
while (scan != to_free) {
    const Word header = *reinterpret_cast<Word*>(scan);
    const auto kind = mini::heap_kind(header);
    const std::size_t payload_count =
        mini::heap_payload_count(header);

    // 按 kind 决定第一个 Value payload 和扫描数量。
    // 对每个 Value slot 执行 forward,并原地写回。

    scan += mini::heap_object_size(payload_count);
}

三种对象的扫描边界仍是:

kind 扫描哪些 payload
record 全部字段
closure 跳过 raw code pointer,只扫描 captures
cell 唯一的 current Value

forward 可能把新对象追加到 to_free,因此循环条件不能提前保存一个固定终点。只有 scan == to_free,才说明所有刚发现的对象也已经扫描完。

最后交换两个半区:

1
2
3
4
5
6
7
8
Byte* old_from_start = from_start;
Byte* old_from_end = from_end;
from_start = to_start;
from_free = to_free;
from_end = to_end;
to_start = old_from_start;
to_free = old_from_start;
to_end = old_from_end;

旧 from-space 整体成为下一轮 to-space,不需要逐对象调用 free。没有复制的对象自然被丢弃;复制后的对象则从新 from_start 起连续排列。

为什么 runtime 失败走 abort

下面两类错误的归属不同:

1
2
3
4
5
6
7
源语言动态错误
  例如调用整数、非法 get
  -> generated code 调用 exit(70)

runtime 无法维持内部协议
  例如 malloc 失败、root 越界、非法 header、固定堆耗尽
  -> mini_gc_abort() -> std::abort()

exit(70) 是语言实现承诺的可观察错误协议;abort() 表示这套固定容量 runtime 已无法安全继续。后者通常由信号结束,不承诺固定 shell 状态。

mini run 也不会调用 mini_alloc。解释器继续使用宿主 C++ 对象给出语言语义基准;只有把 out.s 与 gc_runtime.o 链接后运行,才真正验证 root stack 和 moving collector。

生成、链接与平台符号

在 Linux、WSL 或 Intel Mac 上:

1
2
3
4
5
make
./mini compile examples/gc_stress.lang -o out.s
c++ out.s gc_runtime.o -o out
./out
echo $?

Apple Silicon Mac 上,Makefile 已把 gc_runtime.o 编成 x86-64;最终链接也要明确目标架构:

1
2
3
4
5
make
./mini compile examples/gc_stress.lang -o out.s --target macos
c++ -arch x86_64 out.s gc_runtime.o -o out
./out
echo $?

两边都应得到状态 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 的实现语言变了,这条正确性协议没有变。


9.5 编译器变化:让活值拥有可更新的 root home

上一节

9.7 本章完成后的能力与验收

下一节