第20.1章:问题:C++ 不是为独立实现设计的
1. 那封仍然在开启争论的邮件
2004 年 1 月 19 日,Linus Torvalds 回复了一个关于编译 C++ 内核模块的问题。这封回信是每一次 "C++ 能不能写内核"争论里被引用得最多的一段:
In fact, in Linux we did try C++ once already, back in 1992.
It sucks. Trust me - writing kernel code in C++ is a BLOODY STUPID IDEA.
The fact is, C++ compilers are not trustworthy. They were even worse in 1992, but some fundamental facts haven't changed:
- the whole C++ exception handling thing is fundamentally broken. It's especially broken for kernels.
- any compiler or language that likes to hide things like memory allocations behind your back just isn't a good choice for a kernel.
- you can write object-oriented code (useful for filesystems etc) in C, without the crap that is C++.
In general, I'd say that anybody who designs his kernel modules for C++ is either
- (a) looking for problems
- (b) a C++ bigot that can't see what he is writing is really just C anyway
- (c) was given an assignment in CS class to do so.
Feel free to make up (d).
— Linus
中文翻译(保留原文语气):
事实上,Linux 在 1992 年就试过一次 C++。
它糟透了。相信我——用 C++ 写内核代码是他妈愚蠢透顶的主意。
事实是,C++ 编译器不可信。1992 年时它们更糟,但有些根本事实从未改变:
-
整套 C++ 异常处理从根本上就是坏的。它对内核尤其坏。
-
任何喜欢在你背后隐藏内存分配之类东西的编译器或语言,都不是写内核的好选择。
-
你可以在 C 里写面向对象代码(对文件系统等很有用),而且不用碰 C++ 那堆垃圾。
总的来说,我想说,任何用 C++ 设计内核模块的人,要么
- (a) 是自找麻烦
- (b) 是个 C++ 偏执狂,看不出自己写的东西其实还是 C
- (c) 是计算机课作业要求他这么做的
请随意补充 (d)。
— Linus
语气是 2004 年的语气。那三条论断值得检查,但更深层的问题是结构性的:C++ 是作为宿主语言设计的, freestanding 从一开始就是在它上面做减法。我们逐条对照独立实现的实际运行时代价。
2. 两类实现,一个宏
标准定义了两类实现。是哪一类由实现定义,预定义宏 __STDC_HOSTED__ 告诉你答案:
宿主实现为 1,独立实现为 0。在独立实现里,执行可以在没有操作系统的情况下发生;
程序是否必须定义 main、能否拥有多个线程,都由实现定义。标准库收缩成一份头文件清单。
这就是全部契约,而问题出在它没有说的部分。C++03 要求 C 头文件,再加上
<new>、<typeinfo> 和 <exception>
的一部分——这些头文件之所以存在,是因为语言有 new、RTTI 和异常,而这些特性需要堆、
类型信息对象和 unwinder。标准要求了一份独立目标根本无法实现的库。
语言本身并没有缩小。throw、try、catch、
typeid、dynamic_cast、new、delete、
thread_local、动态初始化和浮点全都留在语法里。于是一个符合标准的独立 C++03 程序,
可以包含在它被编译的机器上永远链接不起来的构造。实践中,每一个嵌入式和内核构建都用编译选项把这些特性关掉,
按标准的字面意义就不再符合标准。
P1105R0 给这个结果命了名:"符合标准的 C++ 工具链不适合面向内核和嵌入式领域。实践中,内核和嵌入式开发者几乎总是使用 让工具链不符合标准的编译选项。这意味着符合标准的 C++ 给更底层的语言留下了空间。"
这种倒置用两个例子就能看清。C 的独立实现不要求 malloc 和 free;
C++ 的独立实现直到 C++26 把默认堆变成可选之前,都要求 <new> 和全局分配函数。
C 的独立实现不要求 <math.h> 或 <fenv.h> 里的浮点环境;
C++ 却要求浮点类型,即使板子上没有 FPU,或者内核拒绝碰它已有的 FPU。C++ 的独立模式向运行时要得比 C 更多,
而不是更少。
3. -ffreestanding 不是一套独立工具链
这个选项只改三件事:__STDC_HOSTED__ 变成 0,假定宿主 C 库存在的内建函数被关掉,
main 不再特殊。它不改头文件搜索路径、库、启动文件和链接脚本。一套宿主编译器加上
-ffreestanding,仍然指向宿主头文件和宿主 C 库。
这种错配通常会立刻暴露。libstdc++ 用这个宏给自己的宿主头文件设了闸门:
$ g++ -std=c++26 -ffreestanding -c t.cc
.../bits/requires_hosted.h:34:4: error: #error "This header is not available in freestanding mode."
在裸机上,甚至连一个可以被拒绝的宿主头文件都没有。一套面向 aarch64-none-elf
的编译器没有为该目标安装 C++ 库,所以第一个头文件就失败了:
$ clang++ -std=c++26 --target=aarch64-none-elf -ffreestanding -c bare.cc
bare.cc:1:10: fatal error: 'cstddef' file not found
1 | #include <cstddef>
所以 freestanding 是一套安装的属性,不是一条命令行的属性。要拥有它,你需要为目标构建的编译器、
一个只包含目标头文件和库的系统根、一个 C 库或你自己的 memcpy、一个 C++ 子集、启动代码和链接脚本,
以及编译器运行时(libgcc 或 compiler-rt)。编译选项是最简单的部分。
4. 运行时代价,逐项拆开
这才是那三条论断真正指向的东西。宿主程序免费得到的每一个语言特性,在裸机上都对应一块基础设施:
| 构造 | 运行时需要什么 | 独立构建怎么做 |
|---|---|---|
throw / catch |
在 Itanium ABI 上为异常对象分配堆(Microsoft ABI 上是一大块栈帧)、一个 unwinder、
.eh_frame 和 .gcc_except_table、一个 personality routine,
以及用于匹配处理块的 RTTI
|
-fno-exceptions |
new / delete |
一个带唯一失败策略的全局堆;虚析构函数还会引入 ::operator delete
|
没有默认堆,或者手写 operator new |
typeid、dynamic_cast |
每个多态类一个 type_info 对象和一张额外的 vtable,以及
__dynamic_cast
|
-fno-rtti |
thread_local |
线程指针、每个模块一份 TLS 镜像、负责布局的加载器,以及每次线程切换时的重载 | 关掉,或者换成内核自己管理的 per-CPU 数据 |
| 动态初始化 | 遍历 .init_array 的启动代码 |
自己的 crt0,或者改用 constexpr / constinit |
| 浮点 | FPU、传递浮点值的 ABI,以及上下文保存策略 | -mgeneral-regs-only、-mno-sse,或者显式的启用区域 |
异常这一行就是 Torvalds 所说的"对内核尤其坏",理由值得说清楚。在 Itanium ABI 上,一次遗留
throw 会为异常对象分配堆(Microsoft ABI 则使用一大块栈帧),所以失败路径要么分配,
要么预留栈。它会遍历栈,所以延迟随栈深度增长。而 unwinder、帧表和 personality routine
恰恰是内核必须自己提供的那一层。一次 throw 用
-nostdlib 链接,会在这些运行时入口上失败:
undefined reference to `__cxa_allocate_exception'
undefined reference to `__cxa_throw'
undefined reference to `__cxa_begin_catch'
undefined reference to `__cxa_end_catch'
undefined reference to `_Unwind_Resume'
undefined reference to `__gxx_personality_v0'
undefined reference to `typeinfo for char const*'
其中没有一个能由用户程序可移植地提供。写它们意味着为你的目标写一个 C++ 运行时,与编译器的 ABI 兼容, 并且要在每个编译器版本上维护它。
OSDev wiki 上有一页专门讲这件事:C++
Exception Support。它不是一个小的兼容层。你需要为目标帧布局实现栈展开器、解析
.gcc_except_table 里语言相关的数据区、理解 C++ ABI 的 personality routine、
用于匹配处理块的 RTTI,以及把它们串起来的 __cxa_* 入口。这就是第二个运行时,
而且必须先证明它正确,任何会抛出的代码才谈得上可信。
即使写出来了,也不是没有代价。帧表(.eh_frame、.gcc_except_table)
随程序规模和复杂度增长,而不是随 throw 点的数量增长,所以每个函数都在贡献。
在 Itanium ABI 上,抛出的对象还要在失败路径上分配堆。这正是内核不能接受的组合:代码体积、
错误路径上的分配,以及一个难到大多数项目根本写不完的运行时。
new 那一行对应第二条论断。一次分配就会引入 operator new 的未定义引用;
失败策略、对齐和数组 cookie 都在链接时选定,并作用于进程里的每一个库。内核本来就没有"一个"堆:
它有按 zone、按 NUMA 节点、按 DMA 约束划分的池,选哪一个来源是语义决策,单个全局量表达不了。
Placement new 是例外:在已经拥有的内存里构造对象,完全不需要运行时。
第三条论断最经不起时间考验。"你可以在 C 里写面向对象代码"说的是运行时开销,不是语法。现代 C++ 保留了那些
不依赖运行时就能组织代码的部分——constexpr、模板、concepts、确定性析构——而赫布异常又加上了
一条不分配、不展开的错误通道。C 始终没有得到的,恰恰是这些不需要运行时的抽象。
5. 缺失的无需运行时特性
运行时代价只是问题的一半。另一半是那些根本不需要运行时、却仍然不在独立模式里的特性。 它们正是让泛型代码成为可能的特性,而它们的缺失很容易被忽略,因为代码在宿主工具链上照样能编译。
-
std::addressof(C++23)。在 C++23 之前,<memory>不是独立头文件,所以std::addressof没有保证。它无法手写:它必须绕过用户定义的operator&,这需要__builtin_addressof,而且它必须是constexpr。于是一个独立 C++ 程序拥有一个语言特性——取对象的地址——任何类都可以悄悄改写它, 却没有可移植的逃生口。C 从 1972 年起,&x就一直是"x的地址"。 -
迭代器 traits 与迭代器 concepts(C++23)。在 C++23 之前,
<iterator>不是独立头文件。独立程序没有保证可用的std::iterator_traits, 没有std::input_iterator,也没有std::contiguous_iterator。泛型代码无法询问 一个迭代器是输入、前向、随机访问还是连续的,因此既不能选择正确的算法,甚至不能为一个未知迭代器类型写出正确的distance。这些 traits 不需要运行时;它们只是不在清单上。 -
std::move与<utility>(C++23)。 在 C++23 之前,<utility>不是独立头文件,所以std::move、std::forward、std::swap和std::pair也没有保证。std::move只是一个转换,不需要运行时。延迟不是因为这个转换本身有什么技术困难:<utility>是一个很大、牵扯很多的头文件,把它切出一个独立子集是没人愿意接的活, 直到 C++23 才做。想要move的独立程序只能自己写这个转换,或者依赖厂商扩展。 自己写是可能的,但这不是解决方案:每家公司、每个代码库最后都有自己的xxx::move, 代码于是不再地道,也不再可移植;库代码写的是std::move,不会用你的。 在 Clang 上,std::move、std::forward、std::move_if_noexcept、std::addressof和std::as_const还被当作编译器内建 (D123345);手写的xxx::move得不到这套处理,因此不能假定它生成和标准库相同的代码。 -
std::array与<algorithm>(C++26)。 一个不分配、不抛异常的固定大小数组,是标准库里要求最低的容器;而<algorithm>大多就是循环。两者在 C++26 之前都不是独立的。如果最简单的、无需运行时依赖的容器和操作它的算法都缺失, 那问题就不在目标上,而在清单上。 -
std::vector与std::string。"默认用vector" 这条常见建议假定了一个宿主实现。标准把这两个容器设计成围绕动态分配器和异常:vector的增长调用全局分配器,失败时抛出bad_alloc;string暴露分配器、容量, 以及同样的异常保证。这些不是改几句话就能去掉的附带属性,它们就是接口本身。所以完整的vector和完整的string永远不可能成为独立的;C++26 只让string的一部分成为独立,vector则完全没有。语言里最被推荐的容器,恰恰是独立程序用不了的那一个。fast_io::containers::vector<T, Alloc>是反例:同一种容器,把分配器带在类型里, 它就是独立的。下一页展示具体做法。 -
std::string_view(C++26)。<string>的独立部分只有char_traits,不是basic_string。<string_view>在 C++26 里是独立的, 但那些会抛异常的方法被标成freestanding-deleted:at、copy、substr,以及几个compare重载。独立实现可以把它们=delete。 剩下的东西不能做带边界检查的访问,也不能取子串,所以这是一个被掏空到对很多代码都没用的视图类型。 标准对"这个方法会抛异常"的解决办法是删掉这个方法,而不是提供一个不抛异常的替代品。
前四条的模式一样:特性本身不需要运行时,但标准没有要求它的头文件,所以一个符合标准的独立实现可以把它省掉。
最后两条不同:vector 和 string 围绕分配器和异常设计,而 string_view
那些有用的、会抛异常的方法被删掉,剩下的要么与运行时绑得太死,要么被掏空到没法用。
两种情况下,代码都在开发者的桌面上编译通过,在板子上失败。这部分问题不是交叉编译器能修的, 因为缺的是标准里的保证,不是安装里的文件。
6. 哪些变了,哪些没变
头文件清单确实在改善。C++23 让 <utility>、<tuple> 和
<ratio> 完全独立,并为 <memory>、<iterator>、
<ranges> 和 <functional> 增加了部分支持。C++26 又加入了
<array>、<string>、<cmath>、
<charconv>、<system_error>、<random>
等,其中许多只是部分独立。方向是真实的。
但 C++26 不是已发布的标准。2026 年 10 月 23 日,ISO DIS 投票未通过,公开给出的原因是
契约(contracts)提案。它带来的独立实现新增项——<array>、<string>、
<cmath>、<charconv>、<system_error>、
<random>——都还在一份未批准的草案里。一个接受 -std=c++26 的编译器
实现的是草案,你需要的保证要等标准真正发布之后才存在。又是同一个模式:独立实现要等一份标准,
而这份标准在等别的东西。
没变的是那些接口本身就是隐藏的堆加抛出的异常的容器——vector、list、
deque、map——永远不会成为独立的,因为依赖是设计的一部分,不是标准里少了一段话。
7. 两条规则
于是剩下本书遵循的两条规则。第一,禁止需要操作系统的机制:-fno-exceptions -fno-rtti,
可恢复错误用 throws。第二,把分配来源和 I/O 设备做成类型的一部分,这样函数不可能从没有给它的堆里分配,
也不可能往不存在的控制台写。第二条规则就是 fast_io 的设计中心,也是下一页的内容。
8. 根因:C++ 默认就是宿主语言
这一页列出的每个缺口都是症状。根因是 C++ 作为宿主语言设计,freestanding 只是从它上面做减法。 标准没有定义一门有自己需求的独立语言;它定义的是一门宿主语言,然后说独立实现拥有更短的头文件清单。 于是每个特性都先为宿主场景设计,再(如果运气好)回头补到独立模式。
这就是为什么相关提案读起来是一份"把哪些东西变成可选"的清单——异常、RTTI、默认堆、线程局部存储、
浮点、启动——而不是从目标出发设计独立程序真正需要什么。这就是为什么 C++23 才加入
<utility> 和迭代器 traits,C++26 才加入 <array> 和
<algorithm>,而容器仍然被排除在外。方向始终是"去掉宿主依赖",
从来不是"从目标开始,把适合的加进来"。
同样的假设出现在日常讨论里。人们说"C++"时,指的是宿主 C++:有堆、有操作系统、有标准库、 有异常、有线程、有控制台。关于 C++ 是否适合内核的争论,通常就是从那个世界出发的: 装着宿主工具链,默认搜索路径上是宿主的头文件。他们描述的是 C++ 在桌面上的行为, 却把它当成语言的属性。
这些假设通常是隐含的:RAII 是免费的,异常很少发生,分配是看不见的,标准库总是在那里, 运行时总有别人提供。在独立目标上,这些每一条都是你必须自己提供或去掉的依赖。 这就是为什么这一章反复回到目标文件和未定义符号:它们是唯一不取决于论证者坐在哪个世界里的证据。