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:

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:

  1. Zero .bss, copy .data from the load image (if it is not loaded in place).
  2. Set up the stack pointer and, if you use it, the thread pointer.
  3. Walk __init_array_start to __init_array_end and 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.
  4. Enter the kernel's real entry point, or call main.
  5. On the way out, still call the .fini_array entries 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

FeatureWhat it actually requiresWhat 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.