第20.1章:问题:C++ 不是为独立实现设计的

English

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. 缺失的无需运行时特性

运行时代价只是问题的一半。另一半是那些根本不需要运行时、却仍然不在独立模式里的特性。 它们正是让泛型代码成为可能的特性,而它们的缺失很容易被忽略,因为代码在宿主工具链上照样能编译。

前四条的模式一样:特性本身不需要运行时,但标准没有要求它的头文件,所以一个符合标准的独立实现可以把它省掉。 最后两条不同: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 是免费的,异常很少发生,分配是看不见的,标准库总是在那里, 运行时总有别人提供。在独立目标上,这些每一条都是你必须自己提供或去掉的依赖。 这就是为什么这一章反复回到目标文件和未定义符号:它们是唯一不取决于论证者坐在哪个世界里的证据。