Ch20.11: Thread-Local Storage, Floating Point, and Startup
1. The Three Nobody Lists
P1105R0 has eight sections of features that become optional in freestanding mode. Everybody
argues about exceptions and new. Three of the remaining ones are more
interesting, because they are not about a library at all — they are about things the
runtime normally does on your behalf, and a freestanding build has no runtime.
2. Floating Point: The Kernel Does Not Want It, Even When It Has It
The obvious case is a board without an FPU. The interesting case is a kernel on a machine
with an excellent FPU, which still refuses to touch it. The reason is not correctness, it is
context switching. The FP/SIMD register file is large: 512 bytes of legacy
SSE state per thread, 2–4 KiB for AVX-512 with its opmask and ZMM registers. Saving
and restoring that is a substantial part of a context switch, and a syscall is a context
switch. A kernel that used floating point in its own code would have to save the FPU state
on every entry whether the user program had used it or not, which is why real kernels make
FP use explicit, rare, and wrapped: on Linux, kernel_fpu_begin() and
kernel_fpu_end() exist precisely because touching the FPU inside the kernel is
an exception that must be paid for deliberately.
The compiler will enforce that for you if you ask. On x86-64, telling GCC that SSE is not
available makes every double an error:
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; }
| ^~~~
On AArch64 the error names the ABI instead, and means the same thing:
$ 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; }
| ^~~~~~~~
So "floating point" as a freestanding feature is really three separate problems:
(1) there may be no FPU; (2) there is an FPU but using it costs a context save the kernel
cannot afford; (3) there is an FPU but the ABI for passing float and
double has been removed so vectors of them are passed exactly like integers.
All three end in the same place: the build forbids floating point, and code that needs it
says it needs it, at the one place where the FPU can be enabled.
This is also why so much of fast_io's numeric formatting is written to avoid floating point entirely: it prints integers, and it prints floating point by extracting digits itself rather than handing the work to a library that assumes an FPU and a locale.
3. Thread-Local Storage Is a Runtime Feature
thread_local looks like a storage class, and it is implemented as a
data structure owned by the loader and switched by the kernel. For a
variable to be thread-local, all of this must exist:
-
A thread pointer register. On x86-64 it is
%fs; the variable is reached as an offset from it, and the offset is assigned by whoever laid out the TLS block. - A TLS image per module — the initial values, copied into each new thread's block, plus a second copy for the initial thread.
-
A dynamic allocator for TLS if modules are loaded later: that is what
__tls_get_addris for, and it is why the general-dynamic model needs a function call:
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
Two extremes of the same variable. In the general-dynamic model it is a function call into
the loader; in local-exec it is three instructions off %fs. Neither works
without a runtime that (a) decides where the block is, (b) copies the image, and (c) reloads
the base register on every thread switch. A kernel has threads, but it has no loader
— so it can have thread-local storage only if it implements those three things
itself, usually by hand-rolling per-CPU data instead.
P1105R0 §4.5 makes it optional for exactly this reason. Practically: fast_io uses
thread-local buffers on hosted targets for performance and has an explicitly
tlc-versus-global distinction in its allocators
(19.9) so that a
freestanding build can drop the thread-local variant without changing a line of user code.
4. Startup: Who Runs the Global Constructors?
This is the one people forget, and it is the reason a freestanding C++ program can appear to
do nothing at all. A namespace-scope object with a constructor does not exist until
something calls that constructor. The compiler emits it into a section called
.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 is the constructor, and it is stored in a section that
nothing in the object file calls. On a hosted system, the C runtime's
start-up code walks .init_array from __init_array_start to
__init_array_end before calling main. In a freestanding program
there is no start-up code unless you write it, so the section is linked and never executed,
and table[0] stays zero. That is why "my kernel prints garbage" is such a
common first bug.
A freestanding C++ start-up routine is short, and every kernel eventually writes it:
- Zero
.bss, copy.datafrom the load image (if it is not loaded in place). - Set up the stack pointer and, if you use it, the thread pointer.
-
Walk
__init_array_startto__init_array_endand call each entry — that is the standard-compliant way to honour dynamic initialisation, and the order within the array is the order the language requires. - Enter the kernel's real entry point, or call
main. -
On the way out, still call the
.fini_arrayentries if you promised static destructors a chance to run — most kernels never return, so they never do.
P1105R0 §4.7 asks for the standard to say all of this instead of leaving it
implementation-defined, because right now the only interoperable answer is "write a crt0
and hope", and everyone's crt0 differs on whether it runs
.init_array at all.
The advice for your own code is simpler than the mechanism: do not need
start-up. Use constexpr and constinit for everything that
can be computed at compile time
(Ch7), so that there is no constructor to
run, no order to get wrong, and nothing that can be mysteriously zero. A freestanding
program that has an empty .init_array is a program that starts correctly on any
target, with any crt0.
5. The Pattern
| Feature | What it actually requires | What a freestanding build does |
|---|---|---|
| Floating point | An FPU, an ABI for passing FP values, and a context-save policy | Off by build flag; enabled only inside explicit regions |
thread_local |
A thread pointer, per-module TLS images, a loader to lay them out | Off, or replaced by per-CPU data the kernel owns |
| Dynamic initialisation | Start-up code that calls .init_array |
Your own entry code, or no dynamic initialisers at all |
All three are the same shape: a language feature whose real dependency is a piece of infrastructure that a hosted program gets from the C runtime and a freestanding program must either provide or avoid. P1105R0's contribution is to make "avoid" a specified choice with a feature-test macro, instead of an accident that makes your build non-conforming.