第20.11章:线程局部存储、浮点与程序启动
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 看起来像一个存储类,而它是作为一个由加载器拥有、由内核切换的数据结构实现的。要让一个变量成为线程局部的,下面这些必须全部存在:
-
一个线程指针寄存器。在 x86-64 上是
%fs;变量是相对它的一个偏移,而那个偏移由布局 TLS 块的人分配。 - 每个模块一份 TLS 映像 —— 初始值,拷进每个新线程的块,另外为初始线程再留一份。
-
一个 TLS 的动态分配器,如果之后还要加载模块的话:这就是
__tls_get_addr的用途,也是为什么 general-dynamic 模型需要一次函数调用:
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++ 启动例程很短,而每个内核最终都会写它:
-
清零
.bss,如果映像不是原地加载的,从加载映像拷贝.data。 - 建立栈指针,如果你要用,还有线程指针。
-
从
__init_array_start遍历到__init_array_end并逐个调用 —— 这是符合标准地兑现动态初始化的方式,而数组内部的顺序正是语言要求的顺序。 - 进入内核真正的入口点,或者调用
main。 -
在退出的路上,如果你承诺过给静态析构函数一次机会,还要调用
.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 的贡献在于把"避开"变成一个有规范、有特性测试宏的选择,而不是一个让你的构建变得不符合标准的事故。