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.
-
std::addressof(C++23).<memory>was not a freestanding header before C++23, sostd::addressofwas not guaranteed. It cannot be written by hand: it has to defeat a user-definedoperator&, which needs__builtin_addressof, and it isconstexpr. So a freestanding C++ program had a language feature — taking the address of an object — that any class could silently redefine, with no portable escape hatch. C has had&xmean "the address ofx" since 1972. -
Iterator traits and iterator concepts (C++23).
<iterator>was not freestanding before C++23. A freestanding program had no guaranteedstd::iterator_traits, nostd::input_iterator, and nostd::contiguous_iterator. Generic code could not ask whether an iterator is input, forward, random-access or contiguous, so it could not select the right algorithm — or even write a correctdistancefor an unknown iterator type. The traits need no runtime; they were simply not on the list. -
std::moveand<utility>(C++23).<utility>was not a freestanding header before C++23, sostd::move,std::forward,std::swapandstd::pairwere not guaranteed either.std::moveis a cast; it needs no runtime. The delay was not technical in that sense:<utility>is a large, entangled header, and carving out a freestanding subset was work nobody wanted to take on until C++23. A freestanding program that wantedmovehad to write the cast itself or depend on a vendor extension. Writing it yourself is possible, but it is not a solution: every company and codebase ends up with its ownxxx::move, so the code is no longer idiomatic or portable, and library code that spellsstd::movewill not use yours. On Clang,std::move,std::forward,std::move_if_noexcept,std::addressofandstd::as_constare also treated as compiler builtins (D123345); a hand-writtenxxx::movedoes not get that handling, so it cannot be assumed to produce the same code as the standard library's. -
std::arrayand<algorithm>(C++26). A fixed-size array with no allocation and no exceptions is the least demanding container in the library, and<algorithm>is mostly loops. Both were not freestanding before C++26. If the simplest container with no runtime dependency and the algorithms that operate on it are missing, the problem is not the target; it is the list. -
std::vectorandstd::string. The common advice to usevectorby default assumes a hosted implementation. The standard designs both containers around a dynamic allocator and exceptions:vectorgrows by calling the global allocator and reports failure by throwingbad_alloc;stringexposes allocator, capacity and the same exception guarantees. These are not incidental properties that a wording change can remove. They are the interface. So the fullvectorand the fullstringcan never be freestanding; C++26 makes only a partialstringfreestanding, andvectornot at all. The most recommended container in the language is exactly the one a freestanding program cannot use.fast_io::containers::vector<T, Alloc>is the counterexample: the same kind of container, with the allocator carried in the type, and it is freestanding. The next page shows how. -
std::string_view(C++26). The freestanding part of<string>ischar_traits, notbasic_string.<string_view>is freestanding in C++26, but the methods that can throw are markedfreestanding-deleted:at,copy,substr, and severalcompareoverloads. A freestanding implementation may=deletethem. What is left cannot do bounds-checked access or produce a substring, so it is a view type gutted to the point of being useless for a lot of code. The standard's answer to "this method throws" is to delete the method, not to provide a non-throwing alternative.
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.