Ch20.1: The Problem: C++ Was Not Designed for Freestanding

中文(简体)

1. The Message That Still Starts the Argument

On 19 January 2004, Linus Torvalds answered a question about compiling a C++ kernel module. The reply is the most-quoted message in every discussion of C++ in kernels:

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

The tone is from 2004. The three bullets are the argument, but the deeper problem is structural: C++ was designed as a hosted language, and freestanding has been a subtraction from it ever since. Check the bullets against what a freestanding build actually requires.

2. Two Implementations, One Macro

The standard defines two kinds of implementation. The kind is implementation-defined, and the predefined macro __STDC_HOSTED__ tells you which one you have: 1 for hosted, 0 for freestanding. In a freestanding implementation, execution may happen without an operating system; whether a program must define main, and whether it can have more than one thread, are both implementation-defined. The standard library shrinks to a set of headers.

That is the whole contract, and the gap is in what it does not say. C++03 required the C headers plus parts of <new>, <typeinfo> and <exception> — headers that exist because the language has new, RTTI and exceptions, and those features need a heap, type-information objects and an unwinder. The standard required a library that a freestanding target could not implement.

The language itself did not shrink. throw, try, catch, typeid, dynamic_cast, new, delete, thread_local, dynamic initialisation and floating point all remained in the grammar. A conforming freestanding C++03 program could therefore contain constructs that could never link on the machine it was compiled for. In practice, every embedded and kernel build turned those features off with flags and became non-conforming by the letter of the standard.

P1105R0 named the result: "Conforming C++ toolchains are ill-suited to target kernel and embedded domains. In practice, kernel and embedded developers almost always use compiler switches that make the toolchain non-conforming. This means that conforming C++ has left room for a lower-level language."

The inversion is easiest to see in two examples. C freestanding does not require malloc or free; C++ freestanding required <new> and the global allocation functions until C++26 made the default heap optional. And C freestanding does not require the floating-point environment in <math.h> or <fenv.h>; C++ required the floating-point types even on a board with no FPU, or in a kernel that refuses to touch the one it has. C++'s freestanding mode demanded more runtime than C's, not less.

3. -ffreestanding Is Not a Freestanding Toolchain

The flag changes exactly three things: __STDC_HOSTED__ becomes 0, builtins that assume a hosted C library are turned off, and main stops being special. It does not change the include paths, the library, the start files or the linker script. A hosted compiler with -ffreestanding still points at the host's headers and the host's C library.

The mismatch usually appears immediately. libstdc++ gates its hosted headers on the macro:

$ g++ -std=c++26 -ffreestanding -c t.cc
.../bits/requires_hosted.h:34:4: error: #error "This header is not available in freestanding mode."

On a bare target there is not even a hosted header to reject. A compiler aimed at aarch64-none-elf has no C++ library for that target installed, so the first header fails:

$ 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 is therefore a property of an installation, not of a command line. To have one you need a compiler built for the target, a sysroot that contains only that target's headers and libraries, a C library or your own memcpy, a C++ subset, start-up code and a linker script, and the compiler runtime (libgcc or compiler-rt). The switches are the easy part.

4. The Runtime Bill, Feature by Feature

This is what the three bullets are really about. Every language feature that a hosted program gets for free is a piece of infrastructure on a bare target:

Construct What it needs at run time What a freestanding build does
throw / catch Heap for the exception object on the Itanium ABI (a large stack frame on the Microsoft ABI), an unwinder, .eh_frame and .gcc_except_table, a personality routine, and RTTI for handler matching -fno-exceptions
new / delete One global heap with one failure policy; a virtual destructor also pulls in ::operator delete No default heap, or a hand-written operator new
typeid, dynamic_cast A type_info object and a second vtable per polymorphic class, plus __dynamic_cast -fno-rtti
thread_local A thread pointer, a TLS image per module, a loader to lay it out, and a reload on every thread switch Disabled, or replaced by per-CPU data the kernel owns
Dynamic initialisation Start-up code that walks .init_array Your own crt0, or constexpr / constinit instead
Floating point An FPU, an ABI for passing FP values, and a context-save policy -mgeneral-regs-only, -mno-sse, or explicit regions

The exception row is the one Torvalds called "especially broken for kernels", and the reasons are worth spelling out. A legacy throw allocates the exception object on the Itanium ABI (the Microsoft ABI uses a large stack frame instead), so the failure path allocates or reserves stack. It walks the stack, so its latency grows with stack depth. And the unwinder, frame tables and personality routine are exactly the layer a kernel would have to provide for itself. A -nostdlib link of a single throw fails on the runtime entry points:

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*'

None of those can be supplied portably by a user program. Writing them means writing a C++ runtime for your target, ABI-compatible with the compiler, and maintaining it against every compiler release.

The OSDev wiki has a page that shows what "implement them yourself" actually means: C++ Exception Support. It is not a small shim. You need a stack unwinder for your target's frame layout, a parser for the language-specific data area in .gcc_except_table, a personality routine that understands the C++ ABI, RTTI for matching handlers, and the __cxa_* entry points that tie them together. That is a second runtime, and it has to be correct before anything that throws can be trusted.

Even when it works, it is not free. The frame tables (.eh_frame, .gcc_except_table) grow with the size and complexity of the program, not with the number of throw sites, so every function contributes. On the Itanium ABI the thrown object is heap-allocated at failure time. That is the combination a kernel cannot accept: code size, an allocation on the error path, and a runtime that is hard enough to implement that most projects never finish it.

The new row is the second bullet. A single allocation adds an undefined reference to operator new; the policy for failure, alignment and array cookies is chosen at link time and applies to every library in the process. A kernel does not have "a" heap anyway: it has per-zone, per-NUMA-node and DMA-constrained pools, and choosing one is a semantic decision that a single global cannot express. Placement new is the exception: constructing an object in memory you already own needs no runtime at all.

The third bullet has aged the worst. "You can write OO code in C" was a claim about runtime cost, not about syntax. Modern C++ keeps the parts that structure code without a runtime — constexpr, templates, concepts, deterministic destruction — and herbceptions add an error channel that does not allocate or unwind. What C never got is exactly those abstractions that need no runtime.

5. The Features That Need No Runtime

The runtime bill is only half the problem. The other half is features that need no runtime at all and were still missing from freestanding mode. They are the features that make generic code possible, and their absence is easy to miss because the code compiles on a hosted toolchain.

The pattern in the first four bullets is the same: the feature needs no runtime, but the standard did not require its header, so a conforming freestanding implementation could omit it. The last two are different: vector and string are designed around the allocator and exceptions, and string_view's useful throwing methods are deleted, so what remains is either too coupled to the runtime or too gutted to use.

In both cases the code compiled on the developer's desktop and failed on the board. That is the part of the problem that a cross compiler cannot fix, because the guarantee is missing from the standard, not from the installation.

6. What Has Changed, and What Has Not

The header list has improved. C++23 made <utility>, <tuple> and <ratio> fully freestanding and added partial support for <memory>, <iterator>, <ranges> and <functional>. C++26 adds <array>, <string>, <cmath>, <charconv>, <system_error>, <random> and others, many of them partial. The direction is real.

But C++26 is not a published standard. On 23 October 2026 the ISO DIS ballot was disapproved; the stated reason was the contracts proposal. The freestanding additions — <array>, <string>, <cmath>, <charconv>, <system_error>, <random> — are in that unapproved draft. A compiler that accepts -std=c++26 is implementing a draft, and the guarantee you need does not exist until the standard is published. That is the same pattern again: freestanding waits for a standard, and the standard is waiting for something else.

The thing that has not changed is the containers whose interface is a hidden heap and a thrown exception — vector, list, deque, map — will never be freestanding, because the dependency is the design, not a missing paragraph in the standard.

7. The Two Rules

That leaves the two rules this book follows. Ban the mechanisms that need an operating system: -fno-exceptions -fno-rtti, and use throws for recoverable errors. Then make the allocation source and the I/O device part of the type, so a function cannot allocate from a heap it was not given and cannot write to a console that is not there. The second rule is what fast_io is built around, and it is the next page.

8. The Root Cause: C++ Is Hosted by Default

The individual gaps on this page are symptoms. The cause is that C++ was designed as a hosted language, and freestanding was added as a subtraction from it. The standard does not define a freestanding language with its own requirements; it defines a hosted language and then says that a freestanding implementation has a shorter list of headers. Every feature is therefore designed for the hosted case first and retrofitted to freestanding later, if at all.

This is why the proposals read as a list of things to make optional — exceptions, RTTI, the default heap, thread-local storage, floating point, startup — rather than a design of what a freestanding program actually needs. It is why C++23 added <utility> and iterator traits, C++26 added <array> and <algorithm>, and the containers are still excluded. The direction is always "remove the hosted dependency", never "start from the target and add what fits".

The same assumption appears in ordinary discussion. When people say "C++", they mean hosted C++: a heap, an operating system, a standard library, exceptions, threads, a console. Arguments about whether C++ is suitable for a kernel are usually made from that world, with the hosted toolchain installed and the host's headers on the search path. They describe what C++ does on a desktop and call it a property of the language.

The assumptions are usually implicit: RAII is free, exceptions are rare, allocation is invisible, the standard library is always there, and someone else provides the runtime. On a freestanding target each of those is a dependency you have to supply or remove. That is why this chapter keeps coming back to object files and undefined symbols: they are the only claims that do not depend on which world the person making the argument is sitting in.