第20.11章:线程局部存储、浮点与程序启动

English

1. 没人列出来的那三个

P1105R0 有八节关于在独立模式里变成可选的特性。所有人都去争异常和 new。剩下几个里的三个更有意思,因为它们根本不是关于库的 —— 而是关于运行时通常替你做的事,而独立构建没有运行时。

2. 浮点:即使有,内核也不想要它

明显的情形是没有 FPU 的板子。有意思的情形是一台拥有极好 FPU 的机器上的内核,它仍然拒绝碰它。理由不是正确性,而是上下文切换。FP/SIMD 寄存器组很大:每个线程 512 字节的经典 SSE 状态,带 opmask 和 ZMM 寄存器的 AVX-512 则是 2 到 4 KiB。保存和恢复这些是上下文切换中相当大的一部分,而一次系统调用就是一次上下文切换。一个在自己代码里用了浮点的内核,将不得不在每次进入时保存 FPU 状态,不管用户程序有没有用过它 —— 这就是为什么真实内核把浮点使用做成显式的、罕见的、有包裹的:在 Linux 上,kernel_fpu_begin() 和 kernel_fpu_end() 之所以存在,正是因为在内核里碰 FPU 是一个必须刻意付出代价的例外。

如果你要求,编译器会替你强制执行这一点。在 x86-64 上,告诉 GCC 没有 SSE,就让每个 double 都变成错误:

double half(double x) noexcept { return x * 0.5; }
$ g++ -std=c++26 -O2 -mno-sse -mno-sse2 -mno-80387 -c fp2.cc
fp2.cc: In function 'double half(double)':
fp2.cc:1:8: error: SSE register return with SSE disabled
    1 | double half(double x) noexcept { return x * 0.5; }
      |        ^~~~

在 AArch64 上报错改而指明 ABI,意思一样:

$ clang++ --target=aarch64-none-elf -mgeneral-regs-only -O2 -c fp.cc
fp.cc:1:8: error: 'half' requires 'double' type support, but ABI 'aapcs' does not support it
    1 | double half(double x) noexcept { return x * 0.5; }
      |        ^~~~~~~~

所以"浮点"作为一个独立特性其实是三个不同的问题:(1) 可能没有 FPU;(2) 有 FPU,但用它要付一次内核负担不起的上下文保存;(3) 有 FPU,但传 float 和 double 的 ABI 被去掉了,于是它们的向量就像整数一样传递。三者都落在同一个地方:构建禁止浮点,而需要它的代码在那个可以打开 FPU 的唯一位置上声明它需要。

这也是为什么 fast_io 的数字格式化大量地刻意避开浮点:它打印整数,而打印浮点是自己去提取数字,而不是把活儿交给一个假定有 FPU 和 locale 的库。

3. 线程局部存储是一个运行时特性

thread_local 看起来像一个存储类,而它是作为一个由加载器拥有、由内核切换的数据结构实现的。要让一个变量成为线程局部的,下面这些必须全部存在:

thread_local int counter;

int bump() noexcept { return ++counter; }
$ g++ -std=c++26 -O2 -fPIC -ftls-model=global-dynamic -c tls.cc && nm -u tls.o
                 U _GLOBAL_OFFSET_TABLE_
                 U __tls_get_addr
$ g++ -std=c++26 -O2 -ftls-model=local-exec -c tls.cc && objdump -d t2.o
0000000000000000 <_Z4bumpv>:
   0:	mov    %fs:0x0,%eax
   8:	add    $0x1,%eax
   b:	mov    %eax,%fs:0x0
  13:	ret

同一个变量的两个极端。在 general-dynamic 模型里它是一次进入加载器的函数调用;在 local-exec 里它是三条基于 %fs 的指令。两者都离不开一个运行时来 (a) 决定块在哪里、(b) 拷贝映像、(c) 在每次线程切换时重载基址寄存器。内核有线程,但没有加载器 —— 所以它只有在自己实现这三件事时才能拥有线程局部存储,通常的做法是手工滚动一套 per-CPU 数据。

P1105R0 第 4.5 节正是因为这个理由才把它做成可选的。实践上:fast_io 在宿主目标上使用线程局部缓冲区来提升性能,并且在其分配器里有一个显式的 tlc 与全局之分(19.9),好让独立构建不改用户代码一行就能丢掉线程局部变体。

4. 启动:谁来运行全局构造函数?

这是人们最容易忘的一个,也是为什么一个独立 C++ 程序可能看起来什么也没做的原因。一个命名空间作用域的带构造函数对象,在有人调用那个构造函数之前并不存在。编译器把它放进一个叫 .init_array 的段:

int table[4];

struct setup { setup() noexcept { table[0] = 7; } };
setup const global_setup{};
$ g++ -std=c++26 -O2 -fno-exceptions -c init.cc && nm init.o
  0000000000000000 t _GLOBAL__sub_I_table
  0000000000000000 B table
$ objdump -h init.o | grep init_array
    4 .init_array   00000008  0000000000000000  0000000000000000  2**3

_GLOBAL__sub_I_table 就是那个构造函数,而它被放在一个目标文件里没有任何东西会调用的段里。在宿主系统上,C 运行时的启动代码会从 __init_array_start 走到 __init_array_end 遍历 .init_array,然后才调用 main。在独立程序里,除非你写,否则没有启动代码,于是这个段被链接进去却从不执行,而 table[0] 一直是零。这就是为什么"我的内核打印出垃圾数据"是如此常见的第一个 bug。

一个独立的 C++ 启动例程很短,而每个内核最终都会写它:

  1. 清零 .bss,如果映像不是原地加载的,从加载映像拷贝 .data。
  2. 建立栈指针,如果你要用,还有线程指针。
  3. 从 __init_array_start 遍历到 __init_array_end 并逐个调用 —— 这是符合标准地兑现动态初始化的方式,而数组内部的顺序正是语言要求的顺序。
  4. 进入内核真正的入口点,或者调用 main。
  5. 在退出的路上,如果你承诺过给静态析构函数一次机会,还要调用 .fini_array 里的条目 —— 大多数内核永不返回,所以它们从不这么做。

P1105R0 第 4.7 节提出,标准应该把这些都说清楚,而不是留成由实现定义,因为目前唯一可互操作的答案是"写一个 crt0 然后祈祷",而每个人的 crt0 在"到底跑不跑 .init_array"上都不一样。

给你自己代码的建议比这个机制简单:不要需要启动。凡是能在编译期算出来的都用 constexpr 和 constinit(第7章),这样就没有构造函数要跑、没有顺序会搞错、也没有东西会莫名其妙为零。一个 .init_array 为空的独立程序,是一个在任何目标、任何 crt0 上都能正确启动的程序。

5. 这个模式

特性它实际要求什么独立构建怎么做
浮点 一个 FPU、传浮点值的 ABI,以及一套上下文保存策略 用构建选项关掉;只在显式区域里打开
thread_local 一个线程指针、每模块一份 TLS 映像,以及一个把它们布局出来的加载器 关掉,或者换成内核自己拥有的 per-CPU 数据
动态初始化 会调用 .init_array 的启动代码 你自己的入口代码,或者干脆没有动态初始化

三者形状相同:一个语言特性,其真正的依赖是一块基础设施,宿主程序从 C 运行时拿到它,而独立程序必须自己提供或者避开它。P1105R0 的贡献在于把"避开"变成一个有规范、有特性测试宏的选择,而不是一个让你的构建变得不符合标准的事故。