The Thread Control Block

Every thread needs a private scratchpad of bookkeeping the runtime can reach instantly and without knowing the thread’s identity — its kernel thread ID, its list of held robust locks, its thread-local-storage pointers, its cancellation state, its stack canary. That scratchpad is the Thread Control Block (TCB), which glibc implements as struct pthread (defined in nptl/descr.h). It is anchored by the thread pointer: the %fs segment base on x86-64, which the code reaches with a single segment-relative memory access. The first bytes of the block are the ABI-defined tcbhead_t — holding the self-pointer, the DTV pointer, and, at fixed offsets, the security cookies stack_guard and pointer_guard. The rest is glibc’s private per-thread state. This note dissects that structure and how the thread pointer is set; the storage it anchors is Thread-Local Storage and the addressing that reaches it is TLS Models and Access.

Scope boundary, authorities, and what was measured

Where the line between this note and its siblings falls. Thread-Local Storage owns the mechanism and the ABI — .tdata/.tbss, PT_TLS, the DTV’s semantics, static versus dynamic allocation, the static-TLS surplus. TLS Models and Access owns the addressing — the four access models, their instruction sequences and relocations. This note owns the per-thread structure those two hang off: what is actually in struct pthread, at which offsets, who else reads those offsets, and how the %fs base that points at it gets installed. If you are asking “where does a __thread variable live?”, read the first. If you are asking “what instructions reach it?”, read the second. If you are asking “what is the thing %fs points at, and what else is in there?”, you are in the right place.

Which authority governs which claim. The existence and position of the header fields the compiler and loader depend on (tcb, dtv, self, stack_guard, pointer_guard) is an ABI matter — fixed by Drepper’s ELF Handling For Thread-Local Storage Version 0.21, 22 August 2013 (the revision read here, fetched from akkadia.org) plus the System V ABI AMD64 Processor Supplement, Version 1.0, 12 March 2025. Everything past the header — every field described under “glibc’s Private State” below — is glibc’s private layout, pinned here to glibc 2.43 and explicitly not stable across releases; the source says so itself. The thread pointer’s installation is the kernel’s narrow contribution, pinned to Linux v6.12 (arch/x86/kernel/process_64.c, arch/x86/kernel/process.c).

Measured on this machine, 2026-09-11: Fedora 44, kernel 7.1.13, glibc 2.43-8.fc44, GCC 16.2.1, binutils 2.46.1. glibc source is read at the upstream tag glibc-2.43 from sourceware.org — note that the bminor/glibc GitHub mirror had not picked up that tag at the time of writing, which is why the previous revision of this note, citing that mirror’s master branch, was reading an unpinned moving target.

Mental Model

Picture the thread pointer as a this pointer for the running thread. Just as an object method reaches its fields through this, every thread reaches its own descriptor through %fs — and because %fs is set per thread by the kernel, the same instruction in shared code lands on a different descriptor in each thread. On x86-64 (a Variant II layout, see TLS Models and Access) the TCB sits at the top of the per-thread block with the TLS blocks below it, and the thread pointer points at the TCB itself. The very first word, %fs:0, is a self-pointer back to the descriptor, so code that needs the descriptor’s address (not just to index off it) can load it in one instruction.

flowchart TB
  subgraph BLOCK["Per-thread memory block (x86-64, Variant II)"]
    direction TB
    TLSN["…other modules' TLS blocks…"]
    TLS1["executable's TLS block<br/>(negative offsets from TP)"]
    subgraph PTHREAD["struct pthread (the TCB)"]
      HEAD["tcbhead_t (ABI header):<br/>[0x00] tcb  [0x08] dtv → DTV<br/>[0x10] self  [0x18] multiple_threads/gscope<br/>[0x20] sysinfo  [0x28] stack_guard (canary)<br/>[0x30] pointer_guard (PTR_MANGLE)"]
      PRIV["glibc private fields:<br/>list, tid, robust_head,<br/>cancelhandling, stackblock,<br/>specific[] (pthread_key TSD),<br/>start_routine, result, joinstate…"]
    end
  end
  FS["%fs base (thread pointer)"] --> HEAD
  FS -. "%fs:0 = self-pointer" .-> HEAD
  HEAD -->|dtv| DTV["Dynamic Thread Vector"]
  TLS1 -.->|"%fs:x@tpoff (negative)"| PTHREAD

The x86-64 thread control block. What it shows: the %fs base points at struct pthread; its ABI header tcbhead_t exposes the DTV pointer and the security cookies at fixed offsets (stack_guard at %fs:0x28, pointer_guard at %fs:0x30), with static TLS blocks living at negative offsets below and glibc’s private per-thread fields following. The insight to take: the fixed offsets are a contract — the compiler emits %fs:0x28 to read the stack canary and %fs:(dtv) to reach TLS without ever computing the descriptor’s address, which is why TLS and stack-protector accesses are single instructions.

The ABI Header: tcbhead_t

The head of the block is fixed by the ABI so that compiler-generated code and the dynamic linker can find well-known fields at well-known offsets, independent of glibc’s private layout. On x86-64 it is (sysdeps/x86_64/nptl/tls.h, verbatim):

typedef struct
{
  void *tcb;            /* Pointer to the TCB.  Not necessarily the
                           thread descriptor used by libpthread.  */
  dtv_t *dtv;
  void *self;           /* Pointer to the thread descriptor.  */
  int multiple_threads;
  int gscope_flag;
  uintptr_t sysinfo;
  uintptr_t stack_guard;
  uintptr_t pointer_guard;
  unsigned long int unused_vgetcpu_cache[2];
  unsigned int feature_1;
  int __glibc_unused1;
  void *__private_tm[4];
  void *__private_ss;
  unsigned long long int ssp_base;
  __128bits __glibc_unused2[8][4] __attribute__ ((aligned (32)));
  void *__padding[8];
} tcbhead_t;

Walking the load-bearing fields:

  • tcb (offset 0x00) and self (0x10) both point back at the descriptor. self is “Pointer to the thread descriptor,” so %fs:0 (which is tcb) is the self-pointer that makes mov %fs:0, %rax load the TP into a register. The comment “For now the thread descriptor is at the same address” reflects that on x86-64 the TCB and struct pthread coincide.
  • dtv (0x08) is the pointer to this thread’s Dynamic Thread Vector — the array __tls_get_addr and the dynamic TLS models index to find module blocks.
  • multiple_threads (0x18) is a fast single-threaded flag: glibc checks it to skip locking and atomic overhead while a process is still single-threaded (see the mutex fast path in pthread Synchronization and Futexes).
  • sysinfo (0x20) historically cached the vDSO/__kernel_vsyscall entry point for fast system calls.
  • stack_guard (0x28) and pointer_guard (0x30) are the two security cookies, dissected below.

The tcb/self split and the placement of stack_guard at exactly %fs:0x28 are ABI promises the compiler hard-codes: GCC’s stack-protector reads %fs:0x28 directly, so this offset cannot change without breaking every stack-protected binary.

The complete offset map

Quoting the struct is not the same as knowing the offsets, and the offsets are what matters — they are literal constants in compiled code. Compiling the definition above verbatim and printing offsetof for every member gives the full map. glibc ships two of these offsets as its own _Static_asserts inside sysdeps/x86_64/nptl/tls.h, which makes them a built-in check on the reproduction:

/* morestack.S in libgcc uses offset 0x70 to access __private_ss,   */
_Static_assert (offsetof (tcbhead_t, __private_ss) == 0x70,
                "offset of __private_ss != 0x70");
_Static_assert (offsetof (tcbhead_t, __glibc_unused2) == 0x80,
                "offset of __glibc_unused2 != 0x80");
$ ./off
  0x00  tcb                     8 bytes
  0x08  dtv                     8 bytes
  0x10  self                    8 bytes
  0x18  multiple_threads        4 bytes
  0x1c  gscope_flag             4 bytes
  0x20  sysinfo                 8 bytes
  0x28  stack_guard             8 bytes
  0x30  pointer_guard           8 bytes
  0x38  unused_vgetcpu_cache   16 bytes
  0x48  feature_1               4 bytes
  0x4c  __glibc_unused1         4 bytes
  0x50  __private_tm           32 bytes
  0x70  __private_ss            8 bytes
  0x78  ssp_base                8 bytes
  0x80  __glibc_unused2       512 bytes
  0x280 __padding              64 bytes
sizeof(tcbhead_t) = 704 (0x2c0)
static_assert __private_ss == 0x70 : PASS
static_assert __glibc_unused2 == 0x80 : PASS

Both of glibc’s own assertions hold on the reproduction, so the rest of the map can be trusted. In full:

OffsetFieldSizeWho reads it, and why it cannot move
0x00tcb8The self-pointer. mov %fs:0, %rax loads the thread pointer into a register in one instruction — the only way to get the TP’s value, since a segment base is not otherwise readable in user mode without a syscall. Drepper fixes this: “the content of the word addressed via %fs:0 is the address of the very same location” (Drepper 2013 §3.4.6).
0x08dtv8The Dynamic Thread Vector pointer (see Thread-Local Storage). Read by every general-dynamic and local-dynamic TLS access, via __tls_get_addr’s THREAD_DTV().
0x10self8Second pointer to the descriptor. Distinct from tcb only on architectures where the TCB and struct pthread are not the same object; on x86-64 glibc sets both to the same value with the comment “For now the thread descriptor is at the same address.” THREAD_SELF is defined as a read of this field.
0x18multiple_threads4Set once the process has spawned a thread (or self-cancelled). glibc tests it to skip lock prefixes and cancellation checks in single-threaded processes. Exported to assembly as MULTIPLE_THREADS_OFFSET in tcb-offsets.sym.
0x1cgscope_flag4The global-scope flag — glibc’s asymmetric lock protecting lazy symbol resolution against a concurrent dlclose. Dissected below.
0x20sysinfo8Historically the cached vDSO __kernel_vsyscall entry point for i386-style fast syscalls; vestigial on x86-64, guarded by NEED_DL_SYSINFO.
0x28stack_guard8The stack canary. GCC’s -fstack-protector emits mov %fs:0x28, … inline in every protected function prologue. This is the single most load-bearing constant in the whole structure.
0x30pointer_guard8The PTR_MANGLE/PTR_DEMANGLE cookie. Exported to assembly as POINTER_GUARD in tcb-offsets.sym because setjmp/longjmp are written in assembly and mangle in place.
0x38unused_vgetcpu_cache[2]16Dead weight, kept only because removing it would move everything after it.
0x48feature_14Intel CET state. The source comments the bits: “Bit 0: X86_FEATURE_1_IBT. Bit 1: X86_FEATURE_1_SHSTK” — indirect-branch tracking and shadow stack. Exported as FEATURE_1_OFFSET.
0x4c__glibc_unused14padding
0x50__private_tm[4]32Reserved for the Transactional Memory ABI.
0x70__private_ss8GCC’s split-stack support. libgcc’s morestack.S hard-codes 0x70, which is why glibc asserts it.
0x78ssp_base8The base of the current shadow stack under CET. Exported as SSP_BASE_OFFSET.
0x80__glibc_unused2[8][4]512Dead, and explicitly frozen: “Must be kept even if it is no longer used by glibc since programs, like AddressSanitizer, depend on the size of tcbhead_t.” A 512-byte hole preserved for a third-party tool.
0x280__padding[8]64tail padding
—total704sizeof(tcbhead_t) = 0x2c0
packet-beta
0-7: "0x00 tcb — self-pointer, read by mov %fs:0"
8-15: "0x08 dtv — Dynamic Thread Vector"
16-23: "0x10 self — thread descriptor"
24-27: "0x18 multiple_threads"
28-31: "0x1c gscope_flag"
32-39: "0x20 sysinfo (vestigial on x86-64)"
40-47: "0x28 stack_guard — the canary, read by -fstack-protector"
48-55: "0x30 pointer_guard — PTR_MANGLE cookie"
56-71: "0x38 unused_vgetcpu_cache[2] (dead)"
72-75: "0x48 feature_1 — CET: bit0 IBT, bit1 SHSTK"
76-79: "0x4c __glibc_unused1"
80-111: "0x50 __private_tm[4] — reserved for the TM ABI"
112-119: "0x70 __private_ss — split-stack (libgcc hard-codes 0x70)"
120-127: "0x78 ssp_base — shadow-stack base"

The first 128 bytes of tcbhead_t on x86-64 at glibc 2.43. Drawn with mermaid packet-beta, whose cells normally count bits; here each cell is one byte, so the ranges read directly as the hexadecimal offsets in the table above. What it shows: every byte offset the compiler, the dynamic linker, libgcc and third-party tooling hard-code, in one picture; the two security cookies sit adjacent at 0x28 and 0x30, immediately after the three pointers and the two flag words. The insight to take: the first six fields are the hot ones — self-pointer, DTV, self, flags, and the canary — packed into the first 48 bytes so that a single cache line covers everything a function prologue or a TLS access needs to touch. The remaining 576 bytes (__glibc_unused2 and __padding, not drawn) are frozen reservations, not live state.

Why 704 bytes of header for ~48 bytes of live fields

Over 80% of tcbhead_t is reservations that glibc itself no longer uses: 512 bytes of __glibc_unused2 kept alive solely because AddressSanitizer depends on sizeof (tcbhead_t), 64 bytes of __padding, 32 bytes for a Transactional Memory ABI that never shipped broadly on x86-64, and 16 bytes of dead vgetcpu cache. This is what an ABI looks like after two decades: you cannot shrink a structure whose size has become a dependency, only stop using the middle of it.

Reading the header at runtime

None of this needs a debugger. A short program can read its own header through %fs and check the claims against each other:

static void *read_tp(void) { void *p; __asm__ volatile("mov %%fs:0, %0" : "=r"(p)); return p; }
...
void *tp = read_tp();
unsigned long fsbase; syscall(SYS_arch_prctl, ARCH_GET_FS, &fsbase);
void *self  = *(void **)((char *)tp + 0x10);
void *dtv   = *(void **)((char *)tp + 0x08);
unsigned long guard  = *(unsigned long *)((char *)tp + 0x28);
unsigned long pguard = *(unsigned long *)((char *)tp + 0x30);
$ ./tcb
pthread_self()=0x7f10334ac780
main     tp(%fs:0)=0x7f10334ac780  ARCH_GET_FS=0x7f10334ac780  self(%fs:0x10)=0x7f10334ac780
main     dtv(%fs:0x08)=0x7f10334ad120  stack_guard(%fs:0x28)=0x57ef3a8673b2eb00  pointer_guard(%fs:0x30)=0xf9305cb1cbbbf845
main     &tls_a=0x7f10334ac778  (tp -8)   &tls_b=0x7f10334ac77c  (tp -4)   tls_a=0xaaaa
 
child    tp(%fs:0)=0x7f10334ab6c0  ARCH_GET_FS=0x7f10334ab6c0  self(%fs:0x10)=0x7f10334ab6c0
child    dtv(%fs:0x08)=0x3f043030  stack_guard(%fs:0x28)=0x57ef3a8673b2eb00  pointer_guard(%fs:0x30)=0xf9305cb1cbbbf845
child    &tls_a=0x7f10334ab6b8  (tp -8)   &tls_b=0x7f10334ab6bc  (tp -4)   tls_a=0xbbbb

Five claims confirmed at once. pthread_self(), %fs:0 and %fs:0x10 are all the same value — the self-pointer identity, and the proof that a pthread_t is the descriptor’s address. The kernel agrees: arch_prctl(ARCH_GET_FS) returns the identical number, so the thread pointer is the hardware FS base, not a library abstraction. The two threads have different thread pointers but identical negative TLS offsets (-8 and -4 in both), which is the variant II layout doing its job. The child’s dtv is a low heap address (0x3f043030) while the main thread’s is a high mapped one — the main thread’s DTV comes from the loader’s early allocation, the child’s from ordinary malloc. And — the one that overturns a common misconception — both threads carry the exact same stack_guard and pointer_guard.

The Security Cookies

Two words in the header are secrets the loader randomizes per process to harden against memory-corruption exploits.

stack_guard (%fs:0x28) is the stack canary used by -fstack-protector. GCC’s prologue copies this value between a function’s locals and its saved return address; the epilogue re-reads %fs:0x28 and compares. A linear buffer overflow that smashes the return address must first overwrite the canary, and since the attacker cannot know the randomized value, the mismatch is caught and __stack_chk_fail aborts the process (Wikipedia: Buffer overflow protection). Here is GCC 16.2.1 doing it, on a function whose only sin is a fixed-size buffer:

void copy(const char *s) { char buf[64]; strcpy(buf, s); }
$ gcc -O2 -fstack-protector-strong -c canary.c && objdump -d --no-show-raw-insn canary.o
0000000000000000 <copy>:
   0:  sub    $0x58,%rsp
   4:  mov    %fs:0x28,%rsi        <-- load the canary straight from the TCB
   d:  mov    %rsi,0x48(%rsp)      <-- store it above the locals, below the return address
  12:  mov    %rdi,%rsi
  15:  mov    %rsp,%rdi
  18:  call   1d <copy+0x1d>       <-- strcpy
  1d:  mov    0x48(%rsp),%rax      <-- reload the stored copy
  22:  sub    %fs:0x28,%rax        <-- compare against the TCB's value
  2b:  jne    32 <copy+0x32>
  2d:  add    $0x58,%rsp
  31:  ret
  32:  call   37 <copy+0x37>       <-- __stack_chk_fail

Two instructions — mov %fs:0x28,%rsi and sub %fs:0x28,%rax — are the entire reason the offset 0x28 is frozen forever. There is no indirection, no function call, and no way to relocate the field: the constant is baked into every stack-protected function in every binary on the system. Storing the canary in the TCB rather than in a global is what makes it a single instruction to read; details of the mechanism, and the contrast with the hardware guard page, are in Thread Stacks and Stack Guards.

The canary is per-process, not per-thread — a very common misreading

The storage is per-thread (every thread has its own %fs:0x28), but the value is identical in every thread of a process. nptl/pthread_create.c copies it from the creating thread into the new descriptor before the thread starts:

/* Copy the stack guard canary.  */
#ifdef THREAD_COPY_STACK_GUARD
  THREAD_COPY_STACK_GUARD (pd);
#endif
/* Copy the pointer guard value.  */
#ifdef THREAD_COPY_POINTER_GUARD
  THREAD_COPY_POINTER_GUARD (pd);
#endif

and the measured output above shows main and child carrying the identical 0x57ef3a8673b2eb00. It has to work this way: a longjmp or a __stack_chk_fail comparison must be meaningful across a stack frame that may have been set up by another thread, and a per-thread canary would make cross-thread stack inspection incoherent. The security consequence is real — an information leak that discloses the canary in any thread discloses it for all threads — and it is why this design is only a mitigation, not a boundary.

The randomization and its exact shape are worth reading, because the value has a tell. elf/rtld.c derives both cookies from _dl_random, which is the kernel’s 16 bytes from the auxiliary vector entry AT_RANDOM:

  uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard (_dl_random);
  THREAD_SET_STACK_GUARD (stack_chk_guard);
  ...
  uintptr_t pointer_chk_guard = _dl_setup_pointer_guard (_dl_random, stack_chk_guard);
  ...
  /* We do not need the _dl_random value anymore.  The less
     information we leave behind, the better. */
  _dl_random = NULL;

The two derivations differ in one crucial way (sysdeps/unix/sysv/linux/dl-osinfo.h):

_dl_setup_stack_chk_guard (void *dl_random)
{
  union { uintptr_t num; unsigned char bytes[sizeof (uintptr_t)]; } ret;
  memcpy (ret.bytes, dl_random, sizeof (ret));
#if BYTE_ORDER == LITTLE_ENDIAN
  ret.num &= ~(uintptr_t) 0xff;          /* <-- zero the LOW byte */
#endif
  return ret.num;
}
 
_dl_setup_pointer_guard (void *dl_random, uintptr_t stack_chk_guard)
{
  uintptr_t ret;
  memcpy (&ret, (char *) dl_random + sizeof (ret), sizeof (ret));   /* second 8 bytes, unmasked */
  return ret;
}

The canary takes the first 8 bytes of AT_RANDOM and forces the low byte to zero; the pointer guard takes the second 8 bytes untouched. The zeroed byte is the classic terminator canary: on little-endian x86-64 the low byte is the first byte in memory, so a canary that begins with \0 cannot be reproduced by an overflow driven by strcpy, sprintf, gets or any other NUL-terminated string primitive — the attacker’s write stops at the very byte they would need to forge. It costs 8 bits of entropy (56 random bits remain) and defeats an entire class of attack. It is also a free self-check on the measurement: every canary this machine produced ends in 00.

$ for i in 1 2 3; do ./tcb | grep -m1 stack_guard; done
main  stack_guard(%fs:0x28)=0x86a811ad4d30df00  pointer_guard(%fs:0x30)=0xd35c14e71c5495d6
main  stack_guard(%fs:0x28)=0x958c301ee5662e00  pointer_guard(%fs:0x30)=0x2e62f836730c2380
main  stack_guard(%fs:0x28)=0x6e5d3ffe13b65900  pointer_guard(%fs:0x30)=0xb466c8a809e19e9b

Three runs, three different canaries, all ending 00; three different pointer guards with no such pattern. That is the source code’s arithmetic, visible from the outside.

pointer_guard (%fs:0x30) backs glibc’s PTR_MANGLE/PTR_DEMANGLE macros. Sensitive function pointers stored in writable memory — atexit handlers, longjmp targets, some GOT-adjacent state — are scrambled with this cookie before being stored and unscrambled before use. On x86-64 the operation is exactly two instructions (sysdeps/unix/sysv/linux/x86_64/pointer_guard.h):

#  define PTR_MANGLE(reg)	xor %fs:POINTER_GUARD, reg;   \
				rol $2*LP_SIZE+1, reg
#  define PTR_DEMANGLE(reg)	ror $2*LP_SIZE+1, reg;        \
				xor %fs:POINTER_GUARD, reg

LP_SIZE is 8 on LP64, so the rotation is rol $17. The XOR alone would be reversible by an attacker who can observe one mangled/plaintext pair; the rotation by a non-multiple of 8 additionally smears the bytes so that a partial overwrite — the common case, where only the low bytes of a pointer are controlled — cannot be reasoned about byte-wise. The POINTER_GUARD name in that macro is a constant emitted from sysdeps/x86_64/nptl/tcb-offsets.sym, which exists precisely so hand-written assembly (setjmp, longjmp, _dl_start_user) can name the offset symbolically. One wrinkle: inside ld.so itself, where %fs is not yet set up, the same header falls back to xor __pointer_chk_guard_local(%rip), reg against a global, which security_init in elf/rtld.c sets unconditionally alongside the TCB copy.

stack_guardpointer_guard
Offset%fs:0x28%fs:0x30
Source bytesAT_RANDOM[0..7]AT_RANDOM[8..15]
Post-processinglow byte forced to 0x00 (terminator canary)none
Effective entropy56 bits64 bits
Scopeper process (copied to each new thread)per process (copied to each new thread)
Emitted byGCC -fstack-protector*, inline in prologue/epilogueglibc’s PTR_MANGLE/PTR_DEMANGLE, including in assembly
Protects againstlinear stack buffer overflow overwriting a return addressoverwriting a stored function pointer with an attacker-chosen address
Failure action__stack_chk_fail → *** stack smashing detected ***, abort()jump to a garbage address → typically SIGSEGV
Global fallback__stack_chk_guard, set by security_init only in the #else branch — i.e. on architectures with no THREAD_SET_STACK_GUARD, which excludes x86-64__pointer_chk_guard_local, set always, and used by ld.so’s own mangling before %fs is live

The CET fields: feature_1 and ssp_base

Two of the newer header fields belong to Intel Control-flow Enforcement Technology (CET). feature_1 at 0x48 carries two bits the source names explicitly — “Bit 0: X86_FEATURE_1_IBT. Bit 1: X86_FEATURE_1_SHSTK” — for indirect branch tracking (every indirect call must land on an endbr64 instruction) and shadow stack (the CPU keeps a second, write-protected copy of return addresses and faults if it disagrees with the ordinary stack). ssp_base at 0x78 records the base of the current thread’s shadow stack, which glibc needs when a longjmp or a cancellation unwind has to pop the shadow stack in step with the real one.

These are per-thread state by necessity — each thread has its own shadow stack — and so they live in the TCB rather than in a global. On this machine they are inert:

$ ./cet
feature_1(%fs:0x48)=0x0  (bit0 IBT=0, bit1 SHSTK=0)
__private_ss(%fs:0x70)=(nil)  ssp_base(%fs:0x78)=0x0
$ readelf -n ./cet | grep -A2 Properties
      Properties: x86 ISA needed: x86-64-baseline
        x86 feature used: x86

A CET-enabled binary carries IBT and SHSTK in its GNU_PROPERTY_X86_FEATURE_1_AND note and the loader sets the corresponding bits in feature_1; this binary carries neither, and the kernel on this host has shadow stacks disabled, so both fields read zero. The ARCH_SHSTK_ENABLE / ARCH_SHSTK_STATUS sub-functions of arch_prctl exist at Linux v6.12 (arch/x86/include/uapi/asm/prctl.h) — the plumbing is in place, the feature is simply off here.

struct pthread: glibc’s Private State

Following the ABI header, struct pthread in nptl/descr.h carries glibc’s per-thread bookkeeping. The transition between the two halves is a union, and the union is worth reading before the fields:

struct pthread
{
  union
  {
#if !TLS_DTV_AT_TP
    /* This overlaps the TCB as used for TLS without threads (see tls.h).  */
    tcbhead_t header;
#else
    struct { int multiple_threads; int gscope_flag; } header;
#endif
    /* This extra padding has no special purpose, and this structure layout
       is private and subject to change without affecting the official ABI.  */
    void *__padding[24];
  };
  ...

Two things are being said here. On a variant II architecture like x86-64 the whole tcbhead_t is the first member, which is why %fs:0x28 reaches the canary and %fs:0x08 reaches the DTV — the TCB and struct pthread are literally the same object at the same address. And the comment is an explicit disclaimer: everything below is private and subject to change without affecting the official ABI. That disclaimer is not decorative, as the version-skew example at the end of this section shows.

FieldTypeWhat it is forWho else touches it
headertcbhead_t (union)the ABI header dissected aboveGCC, ld.so, libgcc, ASan
listlist_tlinks the descriptor onto GL(dl_stack_used) or GL(dl_stack_user)__thread_gscope_wait, setxid, teardown
tidpid_tthe kernel thread ID, written by the kernel at clone time via CLONE_PARENT_SETTIDpthread_kill → tgkill, gettid caching
robust_prev / robust_headstruct robust_list_headhead of the robust-futex list, registered with the kernel by set_robust_listthe kernel, which walks it on thread death to mark held mutexes owner-dead (pthread Synchronization and Futexes)
cleanup_jmp_bufstruct pthread_unwind_buf *the unwind buffer cancellation longjmps topthread_cancel, forced unwind
cleanupstruct _pthread_cleanup_buffer *stack of pthread_cleanup_push handlerscancellation and pthread_exit
cancelhandlingintbitfield: CANCELSTATE_BIT (0), CANCELTYPE_BIT (1), CANCELING_BIT (2), CANCELED_BIT (3), EXITING_BIT (4), TERMINATED_BIT (5), SETXID_BIT (6)every cancellation point
specific_1stblock[32]struct pthread_key_data[]first 32 POSIX thread-specific data slots, inlinepthread_getspecific / pthread_setspecific
specific[32]struct pthread_key_data *[]second level of the sparse TSD array, lazily allocated 32 keys at a time (PTHREAD_KEYS_MAX = 1024)same
report_eventsboolwhether libthread_db wants creation/exit eventsGDB
setxid_futexunsigned intserializes setuid-family calls across all threadssetxid broadcast
joinstateunsigned intthe thread lifecycle state — THREAD_STATE_EXITED (0), EXITING, JOINABLE, DETACHEDthe kernel (see below), pthread_join, pthread_detach
resultvoid *the thread function’s return valuepthread_join
schedparam / schedpolicystruct sched_param / intscheduling parameters, copied from the creator unless ATTR_FLAG_NOTINHERITSCHEDpthread_setschedparam
start_routine / argfunction pointer / void *what the thread runs and what it is passedstart_thread
eventbuf / nexteventtd_eventbuf_t / struct pthread *the thread-event slot and its queuelibthread_db, hence GDB
stackblock / stackblock_size / guardsize / reported_guardsizepointers and sizesthe thread’s stack allocation including its guard regionglibc’s stack cache (Thread Stacks and Stack Guards)
exitingboolset during the exit path__call_tls_dtors, teardown
tls_statestruct tls_internal_tper-thread scratch for libc functions with static-buffer legacy APIs (strsignal, strerror)libc internals

The kernel writes into this structure — and where it writes changed in glibc 2.43

Two of those fields are written by the kernel, not by glibc, which is what makes their offsets quietly load-bearing across the syscall boundary. pthread_create passes their addresses in struct clone_args:

  const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM
			   | CLONE_SIGHAND | CLONE_THREAD
			   | CLONE_SETTLS | CLONE_PARENT_SETTID
			   | CLONE_CHILD_CLEARTID
			   | 0);
  ...
  struct clone_args args =
    {
      .flags       = clone_flags,
      .parent_tid  = (uintptr_t) &pd->tid,        /* CLONE_PARENT_SETTID  */
      .child_tid   = (uintptr_t) &pd->joinstate,  /* CLONE_CHILD_CLEARTID */
      .stack       = (uintptr_t) stackaddr,
      .stack_size  = stacksize,
      .tls         = (uintptr_t) tp,              /* CLONE_SETTLS         */
    };

CLONE_PARENT_SETTID makes the kernel write the new thread’s TID into pd->tid. CLONE_CHILD_CLEARTID makes the kernel zero the word at child_tid and FUTEX_WAKE on it when the thread exits (clone(2)) — and that word is pd->joinstate, not pd->tid. Because THREAD_STATE_EXITED is defined as 0, “the kernel zeroes it” and “the kernel sets the state to EXITED” are the same event. pthread_join then simply futex-waits on it:

  while ((state = atomic_load_acquire (&pd->joinstate)) != THREAD_STATE_EXITED)
    { ...
      int ret = __futex_abstimed_wait64 (&pd->joinstate, state, clockid, abstime, LLL_SHARED);

Version skew: this was pd->tid through glibc 2.42

The older and very widely repeated description — “pthread_join waits on the tid field, which the kernel clears via CLONE_CHILD_CLEARTID” — was correct up to and including glibc 2.42 and became wrong in glibc 2.43. Verified by fetching nptl/pthread_create.c at each tag: glibc-2.34 through glibc-2.42 all carry .child_tid = (uintptr_t) &pd->tid, while glibc-2.43 carries .child_tid = (uintptr_t) &pd->joinstate. The joinstate field does not exist at all in glibc-2.42’s nptl/descr.h; its predecessor was struct pthread *joinid, with detachment encoded as the self-referential IS_DETACHED(pd) ((pd)->joinid == (pd)).

The rationale is on record. The change is patch 3/6 of Adhemerval Zanella’s series “nptl: Do not use pthread set_tid_address as state synchronization (BZ #19951)” (libc-alpha, 11 December 2025), which fixes a use-after-free: “The use-after-free described in BZ#19951 is due to the use of two different PD fields, joinid and cancelhandling, to describe the thread state and to synchronise the calls of pthread_join, pthread_detach, pthread_exit, and normal thread exit. Any state change may require checking both fields atomically to handle partial state.” The fix collapses both into one word with four states, and — point 4 of the commit message — “The clear_tid_field on clone call is changed to set the new state field on thread exit (EXITED).” The payoff is stated in point 5: “The pthread_join implementation is now simpler: the futex wait is done directly on thread state, and there is no need to reset it in case of timeout.”

This is the private-layout disclaimer in the union comment doing exactly what it warns about, in a single release, to the most frequently described field in the structure. Any tool that hard-codes struct pthread offsets — and libthread_db is one — must be matched to the glibc it is inspecting.

A crucial identity: the opaque pthread_t handle is simply the address of this struct pthread. Drepper’s NPTL paper is explicit — “the thread handle is simply the pointer to the thread descriptor,” which is why “successively created threads will get the same handle” when glibc reuses a cached stack (Drepper & Molnar 2005 §5.6). That is the concrete reason POSIX forbids comparing pthread_t with == (use pthread_equal) and why using a handle after join/detach is undefined.

The gscope Flag and Safe dlclose

One header field deserves more than a line because it solves a genuinely hard problem: gscope_flag (offset 0x1c, sharing space with multiple_threads). “gscope” is glibc’s global scope lock, and it exists to make [[dlopen and Runtime Loading|dlclose]] safe in the presence of lazy PLT resolution.

The problem: when the dynamic linker resolves a symbol lazily, it walks the global lookup scope — the ordered list of loaded objects (see Symbol Resolution and Lookup Scope). If another thread calls dlclose and unmaps an object mid-walk, the resolving thread reads freed memory and crashes. glibc cannot take a heavyweight lock around every lazy resolution — that would serialize all symbol lookups — so it uses a per-thread flag instead, with a deliberately asymmetric cost.

The flag has three values (sysdeps/x86_64/nptl/tls.h):

# define THREAD_GSCOPE_FLAG_UNUSED 0
# define THREAD_GSCOPE_FLAG_USED   1
# define THREAD_GSCOPE_FLAG_WAIT   2

Entering a scope walk is genuinely free — THREAD_GSCOPE_SET_FLAG() is a plain store of 1 through %fs, with no atomic and no barrier. Leaving is almost free, and the “almost” is the interesting part:

# define THREAD_GSCOPE_RESET_FLAG() \
  do									      \
    { int __res;							      \
      asm volatile ("xchgl " FS_ASM "%1, %0"				      \
		    : "=r" (__res)					      \
		    : "m" (((struct pthread __seg_fs *)0)->header.gscope_flag), \
		      "0" (THREAD_GSCOPE_FLAG_UNUSED));			      \
      if (__res == THREAD_GSCOPE_FLAG_WAIT)				      \
	lll_futex_wake (&THREAD_SELF->header.gscope_flag, 1, LLL_PRIVATE);    \
    }									      \
  while (0)

The exit is an xchgl against %fs-relative memory — and xchg with a memory operand on x86 is implicitly lock-prefixed, so this is a full atomic read-modify-write, not a plain store. That is the minimum needed: the exiting thread must learn atomically whether a dlclose upgraded its flag from USED to WAIT in the interval, because if it did, the exiting thread owes it a FUTEX_WAKE. One locked exchange per lazy symbol resolution is the entire fast-path cost of dlclose safety.

The slow side is __thread_gscope_wait (sysdeps/nptl/dl-thread_gscope_wait.c), and it is not a spin:

void
__thread_gscope_wait (void)
{
  lll_lock (GL (dl_stack_cache_lock), LLL_PRIVATE);
  struct pthread *self = THREAD_SELF;
 
  list_t *runp;
  list_for_each (runp, &GL (dl_stack_used))     /* and again for dl_stack_user */
    {
      struct pthread *t = list_entry (runp, struct pthread, list);
      if (t == self || t->header.gscope_flag == THREAD_GSCOPE_FLAG_UNUSED)
        continue;
 
      int *const gscope_flagp = &t->header.gscope_flag;
 
      /* We have to wait until this thread is done with the global
         scope.  First tell the thread that we are waiting and
         possibly have to be woken.  */
      if (atomic_compare_and_exchange_bool_acq (gscope_flagp,
                                                THREAD_GSCOPE_FLAG_WAIT,
                                                THREAD_GSCOPE_FLAG_USED))
        continue;
 
      do
        futex_wait_simple ((unsigned int *) gscope_flagp,
                           THREAD_GSCOPE_FLAG_WAIT, FUTEX_PRIVATE);
      while (*gscope_flagp == THREAD_GSCOPE_FLAG_WAIT);
    }
  lll_unlock (GL (dl_stack_cache_lock), LLL_PRIVATE);
}

It walks both thread lists — dl_stack_used (glibc-allocated stacks) and dl_stack_user (stacks the application supplied via pthread_attr_setstack) — which is the concrete purpose of the list member of struct pthread described above. For each other thread it skips the common UNUSED case with a plain read, and for a thread actually in a walk it CASes USED → WAIT and then futex-waits, blocking rather than burning CPU. The continue on CAS failure is the race being handled: the thread finished between the read and the CAS, so there is nothing to wait for.

sequenceDiagram
    autonumber
    participant W as worker thread<br/>(lazy PLT resolution)
    participant F as its gscope_flag<br/>at %fs:0x1c
    participant D as dlclose thread
    W->>F: THREAD_GSCOPE_SET_FLAG — plain store of 1 (USED)<br/>no atomic, no barrier
    Note over W: walks the global scope,<br/>reading link maps that dlclose wants to free
    D->>D: remove the objects from every scope,<br/>then call __thread_gscope_wait
    D->>D: lll_lock(dl_stack_cache_lock), walk<br/>dl_stack_used and dl_stack_user
    D->>F: read flag — it is USED, so CAS USED to WAIT (2)
    D->>D: futex_wait on the flag while it reads WAIT
    Note over W: finishes the walk
    W->>F: THREAD_GSCOPE_RESET_FLAG — xchgl to 0,<br/>implicitly locked, returns the OLD value
    W->>W: old value was WAIT, so owe a wake
    W->>D: lll_futex_wake(flag, 1)
    D->>D: wakes, flag is UNUSED, moves to the next thread
    D->>D: all threads clear — munmap the objects for real

The gscope handshake. What it shows: the worker pays one plain store on entry and one locked exchange on exit, with no knowledge of whether any dlclose is in flight; all the bookkeeping — walking the thread list, upgrading flags, blocking — is on the dlclose side. The insight to take: this is textbook asymmetric synchronization, and it is only possible because the flag lives at a fixed %fs offset inside a structure that a different thread can also reach through the global thread list. A per-thread variable that were only reachable through %fs could not be polled by dlclose at all.

Finding the Descriptor from Outside: Debuggers

The descriptor is not only reached by the thread itself. libthread_db — the helper library GDB and other debuggers load to understand a live or core-dumped multithreaded process — walks these structures from outside the process, where %fs is not available at all: the debugger is a different process, and the inferior’s segment base is not something it can simply read.

glibc solves this by exporting the recipe. sysdeps/x86_64/nptl/tls.h carries, right next to the ordinary THREAD_SELF definition, a second one labelled for the debugger:

/* Magic for libthread_db to know how to do THREAD_SELF.  */
# define DB_THREAD_SELF_INCLUDE  <sys/reg.h> /* For the FS constant.  */
# define DB_THREAD_SELF CONST_THREAD_AREA (64, FS)

CONST_THREAD_AREA (64, FS) tells libthread_db: on a 64-bit target, the thread descriptor’s address is the FS base, which you can fetch with ptrace(PTRACE_PEEKUSER) at the FS offset in the target’s user_regs_struct. The same macro on AArch64 says something different and equally revealing:

# define DB_THREAD_SELF \
  CONST_THREAD_AREA (64, sizeof (struct pthread))

— because AArch64 is variant I, the descriptor sits sizeof (struct pthread) bytes below the thread pointer, and the debugger must subtract. The entire architecture-specific “how do I find a thread descriptor from outside” question is thus reduced to one constant per port.

From that anchor libthread_db walks the list members through GL(dl_stack_used) and GL(dl_stack_user) — the same two lists __thread_gscope_wait walks — and watches td_eventbuf_t eventbuf and nextevent, which glibc updates on thread creation and death so a debugger can breakpoint “new thread” and “thread exit” rather than poll.

This is the concrete reason the “private” layout is not entirely free to change: libthread_db is a separately compiled binary that hard-codes offsets into struct pthread, so glibc ships and versions the two together. Mixing them is the classic Cannot find new threads: generic error — most often hit when debugging a core file or container image whose glibc differs from the host’s, which makes matching the debug infrastructure to the target’s glibc a prerequisite, not a nicety. It is also why a corrupted TCB makes GDB’s info threads misbehave: the debugger is chasing pointers through the very structure the bug damaged.

How the Thread Pointer Gets Set

For the TCB to work, %fs must be pointed at the descriptor before the thread runs any TLS-using code. There are two mechanisms, one for the main thread and one for new threads.

The main thread is set up during process startup. glibc allocates the initial descriptor and calls its TLS_INIT_TP macro, which “sets _head->tcb and _head->self to the thread descriptor” and then “invokes arch_prctl(ARCH_SET_FS, thrdescr)… setting the x86-64 FS segment register to point to the TCB base” (sysdeps/x86_64/nptl/tls.h). arch_prctl(ARCH_SET_FS, addr) is the x86-64 system call that writes the %fs base MSR; before it existed, this required a costly GDT/LDT descriptor manipulation, which was one of the pain points the NPTL redesign eliminated.

That description is second-hand; the macro itself (glibc 2.43, sysdeps/x86_64/nptl/tls.h) shows there is no libc wrapper involved at all — it is a raw syscall instruction, because errno does not exist yet at this point in startup:

# define TLS_INIT_TP(thrdescr) \
  ({ void *_thrdescr = (thrdescr);					      \
     tcbhead_t *_head = _thrdescr;					      \
     int _result;							      \
									      \
     _head->tcb = _thrdescr;						      \
     /* For now the thread descriptor is at the same address.  */	      \
     _head->self = _thrdescr;						      \
									      \
     /* It is a simple syscall to set the %fs value for the thread.  */	      \
     asm volatile ("syscall"						      \
		   : "=a" (_result)					      \
		   : "0" ((unsigned long int) __NR_arch_prctl),		      \
		     "D" ((unsigned long int) ARCH_SET_FS),		      \
		     "S" (_thrdescr)					      \
		   : "memory", "cc", "r11", "cx");			      \
									      \
    _result == 0;							      \
  })

Note the ordering: tcb and self are written before the syscall, so that the instant %fs becomes live, %fs:0 and %fs:0x10 already read back the descriptor’s own address. The comment “It is a simple syscall” is doing quiet historical work — this used to require manipulating an LDT descriptor via modify_ldt, which was one of the concrete pain points the NPTL redesign eliminated.

For a new thread, glibc avoids a second arch_prctl by folding the TP setup into thread creation: pthread_create allocates the descriptor, then issues clone with CLONE_SETTLS, whose tls argument on x86-64 “is the new value to be set for the %fs base register” (clone(2)). The kernel installs the %fs base as part of creating the task, so the new thread wakes up already able to reach its TCB and TLS — no window in which TLS is unusable. This is exactly the “signal-safe loading of the thread register” the NPTL kernel work added: “the new thread must be started by the kernel with the thread register already loaded… what… the CLONE_TLS flag” provides (Drepper & Molnar 2005 §6).

What the kernel actually does — Linux v6.12

The kernel’s whole contribution to TLS is installing one 64-bit register base, and the two entry points converge almost immediately. CLONE_SETTLS is handled in arch/x86/kernel/process.c, where copy_thread calls:

static int set_new_tls(struct task_struct *p, unsigned long tls)
{
	struct user_desc __user *utls = (struct user_desc __user *)tls;
 
	if (in_ia32_syscall())
		return do_set_thread_area(p, -1, utls, 0);
	else
		return do_set_thread_area_64(p, ARCH_SET_FS, tls);
}

So on 64-bit, CLONE_SETTLS is literally the ARCH_SET_FS path — the same code arch_prctl reaches. That path, in arch/x86/kernel/process_64.c, is short:

	case ARCH_SET_FS: {
		/*
		 * Not strictly needed for %fs, but do it for symmetry
		 * with %gs
		 */
		if (unlikely(arg2 >= TASK_SIZE_MAX))
			return -EPERM;
 
		preempt_disable();
		/*
		 * Set the selector to 0 for the same reason
		 * as %gs above.
		 */
		if (task == current) {
			loadseg(FS, 0);
			x86_fsbase_write_cpu(arg2);
			/*
			 * On non-FSGSBASE systems, save_base_legacy() expects
			 * that we also fill in thread.fsbase.
			 */
			task->thread.fsbase = arg2;
		} else {
			task->thread.fsindex = 0;
			x86_fsbase_write_task(task, arg2);
		}
		preempt_enable();
		break;
	}

Line by line. The arg2 >= TASK_SIZE_MAX check returns -EPERM for a base in kernel address space — cheap, and the reason arch_prctl(ARCH_SET_FS, ...) cannot be used to point %fs at kernel memory. loadseg(FS, 0) zeroes the selector, decoupling the base from any GDT/LDT entry: on x86-64 the segment base is an independent MSR, and a non-zero selector would make the base reloadable from a descriptor table on the next __switch_to. The task == current split matters because CLONE_SETTLS runs in the parent’s context configuring a not-yet-running child — that takes the else branch, which only records the value in thread.fsbase for the scheduler to install later. arch_prctl from a thread setting its own base takes the if branch and writes the CPU register immediately.

The write itself is where the modern hardware shows up (arch/x86/include/asm/fsgsbase.h):

static inline void x86_fsbase_write_cpu(unsigned long fsbase)
{
	if (boot_cpu_has(X86_FEATURE_FSGSBASE))
		wrfsbase(fsbase);
	else
		wrmsrl(MSR_FS_BASE, fsbase);
}

FSGSBASE is an Intel/AMD instruction-set extension (WRFSBASE, RDFSBASE, WRGSBASE, RDGSBASE) that lets the base registers be written without an MSR access. On a pre-FSGSBASE CPU, writing %fs base means wrmsr — a serializing, ~100-cycle instruction. With FSGSBASE it is a single unprivileged instruction, which matters far more on the context-switch path than at thread creation, since __switch_to must restore %fs base on every switch between threads of different processes:

static __always_inline void x86_fsgsbase_load(struct thread_struct *prev,
					      struct thread_struct *next)
{
	if (static_cpu_has(X86_FEATURE_FSGSBASE)) {
		/* Update the FS and GS selectors if they could have changed. */
		if (unlikely(prev->fsindex || next->fsindex))
			loadseg(FS, next->fsindex);
		...
		/* Update the bases. */
		wrfsbase(next->fsbase);
		__wrgsbase_inactive(next->gsbase);
	} else {
		load_seg_legacy(prev->fsindex, prev->fsbase,
				next->fsindex, next->fsbase, FS);
		...
	}
}

The unlikely(prev->fsindex || next->fsindex) is the payoff from ARCH_SET_FS having zeroed the selector: because every glibc thread has fsindex == 0, the selector reload is skipped and the switch costs one wrfsbase. This is the concrete, measurable cost of the thread pointer — one instruction per context switch — and it is the reason the whole TLS design puts the pointer in a register base rather than in a table the kernel would have to reload.

sequenceDiagram
    autonumber
    participant App as application
    participant PC as glibc pthread_create
    participant K as kernel (Linux v6.12)
    participant Child as the new thread
    Note over PC: allocate the per-thread block —<br/>_dl_allocate_tls_storage: malloc of<br/>static TLS + struct pthread, then allocate_dtv
    PC->>PC: copy the parent's canary and pointer guard<br/>THREAD_COPY_STACK_GUARD / THREAD_COPY_POINTER_GUARD
    PC->>PC: tls_setup_tcbhead(pd) — fill the ABI header
    App->>PC: pthread_create(&t, attr, fn, arg)
    PC->>K: clone3 with CLONE_SETTLS, CLONE_PARENT_SETTID,<br/>CLONE_CHILD_CLEARTID, tls = pd
    K->>K: copy_thread → set_new_tls(p, tls)
    K->>K: do_set_thread_area_64(p, ARCH_SET_FS, tls)<br/>task != current, so fsindex = 0 and thread.fsbase = tls
    K->>PC: returns the new TID, written into pd->tid<br/>by CLONE_PARENT_SETTID
    K->>Child: schedule it — __switch_to runs<br/>x86_fsgsbase_load, wrfsbase(next->fsbase)
    Note over Child: %fs is live on the very first instruction.<br/>There is no window in which TLS is unusable.
    Child->>Child: start_thread → fn(arg)
    Child->>K: exit
    K->>K: CLONE_CHILD_CLEARTID — zero pd->joinstate<br/>(THREAD_STATE_EXITED) and FUTEX_WAKE on it
    K-->>App: pthread_join's futex wait on pd->joinstate returns

Thread creation from the thread pointer’s point of view, glibc 2.43 on Linux v6.12. What it shows: glibc builds the entire descriptor — TLS blocks, DTV, ABI header, copied security cookies — before the clone, then hands the kernel three separate jobs in one call: install %fs (CLONE_SETTLS), report the TID (CLONE_PARENT_SETTID), and publish thread death (CLONE_CHILD_CLEARTID). The insight to take: the kernel never allocates or interprets the TCB; it only stores one pointer value into a register base and later zeroes one word inside it. Everything else about the structure is glibc’s, which is exactly why its layout is free to change between releases while the %fs contract is not.

arch_prctl is not a user-facing API here

The arch_prctl(2) man page states the constraint plainly: “FS may be already used by the threading library. Programs that use ARCH_SET_FS directly are very likely to crash.” Reading the base with ARCH_GET_FS is safe and useful (it is what the measurement above does); writing it in a glibc program destroys the thread’s access to its own TCB, its TLS, and its stack canary in one instruction.

Failure Modes and Common Misunderstandings

  • Overwriting %fs:0x28 breaks stack protection silently. Inline assembly or a wild write that clobbers the TCB header can corrupt the canary or DTV pointer; the symptom is a spurious *** stack smashing detected *** abort or a TLS segfault far from the real bug.
  • Assuming pthread_t is a small integer. It is a pointer into this structure; printing it as an ID or hashing it across process boundaries is meaningless. Stale handles alias reused descriptors (see the == warning above).
  • tcbhead_t layout is arch-specific. The offsets quoted here (stack_guard at 0x28, pointer_guard at 0x30) are x86-64. i386, AArch64, and others have different tcbhead_t layouts; the concepts transfer but the numeric offsets do not.
  • DTV pointer corruption after dlopen. If the header’s dtv field is stale relative to the global generation (see Thread-Local Storage), a TLS access triggers a DTV resize; a bug in a dlopened module that corrupts the DTV pointer manifests as garbage TLS reads.
  • Believing the canary is per-thread. Covered at length above: the slot is per-thread, the value is per-process, copied by THREAD_COPY_STACK_GUARD at pthread_create. A leak in one thread compromises the mitigation for all of them.
  • Citing glibc’s master branch as a source for the layout. struct pthread is explicitly private and moves. The tid-versus-joinstate change above landed within one release and invalidated the most commonly repeated description of pthread_join. Always pin a tag, and say which. (This note’s previous revision cited raw.githubusercontent.com/bminor/glibc/master/..., which is unreproducible by construction.)
  • Assuming %fs:0 works before TLS_INIT_TP. In very early startup — before security_init, before the self-pointer is written — the %fs base is whatever the kernel left it as. Any code running there (an ifunc resolver, an unusually early constructor, a LD_AUDIT module) that touches TLS or a stack-protected function will fault or read garbage. This is why ld.so itself is built with restricted TLS models and its own mangling fallback against __pointer_chk_guard_local rather than %fs:POINTER_GUARD.
  • Expecting %fs to survive a longjmp across threads. setjmp/longjmp mangle the saved stack and instruction pointers with pointer_guard, which is process-wide and therefore will demangle correctly in another thread — so the mangling offers no protection against this mistake. The result is a thread running on another thread’s stack with its own %fs, which corrupts both. longjmp across threads is undefined and the TCB will not stop you.
  • Reading tcbhead_t offsets from the wrong architecture. The offsets quoted here are x86-64 only, and this is worth restating because they get copied into portable code. Compare sysdeps/aarch64/nptl/tls.h at the same glibc 2.43 tag — the entire header is two words:
    /* The TP points to the start of the thread blocks.  */
    # define TLS_DTV_AT_TP	1
    # define TLS_TCB_AT_TP	0
     
    typedef struct { dtv_t *dtv; void *private; } tcbhead_t;
     
    # define TLS_PRE_TCB_SIZE	sizeof (struct pthread)
    # define THREAD_SELF  ((struct pthread *)__builtin_thread_pointer () - 1)
    There is no stack_guard field on AArch64 at all — no THREAD_SET_STACK_GUARD, so security_init takes its #else branch and the canary lives in the global __stack_chk_guard. And struct pthread sits below the thread pointer (THREAD_SELF subtracts one whole descriptor from __builtin_thread_pointer()), the exact inverse of x86-64. The concepts transfer; not one number does.
x86-64AArch64
Layout variantII (TLS_TCB_AT_TP 1)I (TLS_DTV_AT_TP 1)
Thread pointer%fs segment base (MSR, or wrfsbase)TPIDR_EL0 system register
Read the TPmov %fs:0, %rax via the self-pointer__builtin_thread_pointer(), a direct mrs
tcbhead_t704 bytes, 16 members16 bytes: { dtv, private }
struct pthread isat the TP — the header is its first memberbefore the TP, THREAD_SELF = (struct pthread *)tp - 1
Static TLS blocksbelow the TP, negative @tpoffabove the TP, positive offsets
Stack canary lives inthe TCB, at %fs:0x28the global __stack_chk_guard
TCB_ALIGNMENT64 (cache-line, for segment-override performance)generic 32
flowchart LR
  TP(["thread pointer<br/>%fs base"])
  subgraph TCB["struct pthread (x86-64)"]
    H["tcbhead_t — the ABI half"]
    P["glibc private half"]
  end
  TP --> H
  GCC["GCC-generated code"] -->|"mov %fs:0x28 in every<br/>stack-protected prologue"| H
  GCC -->|"%fs:0 / %fs:x@tpoff<br/>for TLS access"| H
  LD["ld.so — dynamic linker"] -->|"writes stack_guard, pointer_guard<br/>in security_init at startup"| H
  LD -->|"reads/updates dtv on<br/>dlopen (_dl_update_slotinfo)"| H
  LD -->|"CAS gscope_flag USED→WAIT<br/>during dlclose"| H
  LIBGCC["libgcc morestack.S"] -->|"hard-codes offset 0x70<br/>for __private_ss"| H
  ASAN["AddressSanitizer"] -.->|"depends on sizeof(tcbhead_t)<br/>— hence 512 frozen bytes"| H
  KERN["Linux kernel"] -->|"CLONE_SETTLS installs the base;<br/>CLONE_PARENT_SETTID writes tid"| P
  KERN -->|"CLONE_CHILD_CLEARTID zeroes<br/>joinstate on exit + FUTEX_WAKE"| P
  KERN -->|"walks robust_head on<br/>thread death"| P
  TDB["libthread_db / GDB"] -.->|"walks dl_stack_used from<br/>OUTSIDE the process, by<br/>compiled-in offsets"| P
  LIBC["glibc internals"] -->|"tid, cancelhandling,<br/>specific[], stackblock…"| P

Everyone who reads the Thread Control Block, and through which door. What it shows: the ABI half (tcbhead_t) is touched by four independent parties — compiler-generated code, the dynamic linker, libgcc, and a sanitizer that depends on its size — while the private half is touched by glibc, by the kernel (which writes three different fields), and by libthread_db reading the process from outside. The insight to take: “private” means “glibc may change the layout”, not “only glibc reads it”. Three of these consumers cannot be recompiled in lockstep with libc — the kernel writes through pointers glibc hands it, libgcc’s assembly is already in every binary, and libthread_db is a separate file — which is the real reason the header’s offsets are frozen and the reason a mismatched libthread_db breaks GDB’s thread support.

Alternatives and When to Choose Them

“The TCB” is one point in a design space, and the alternatives are instructive because each of them is in production somewhere.

A kernel-managed table instead of a register base (the historical x86-32 design). Before arch_prctl and the %fs-base MSR, i386 reached TLS through %gs pointing at a GDT/LDT descriptor that the kernel had to install with modify_ldt or set_thread_area and reload on every context switch. The register-base design replaced a descriptor-table entry with a single MSR (and later a single wrfsbase instruction), which is why TLS_INIT_TP can be a bare syscall and why __switch_to costs one instruction. The old design’s only advantage was working on hardware with no per-thread base register — an advantage that no longer exists on any relevant target.

musl’s smaller, simpler struct __pthread. musl uses the same %fs thread pointer and its own much smaller descriptor, but the load-bearing difference is that it reserves no static-TLS surplus at all. Where glibc quietly satisfies a dlopened initial-exec module out of a 1,664-byte reservation until that runs out, musl refuses on the spot (ldso/dynlink.c at musl v1.2.5):

		if ((type == REL_TPOFF || type == REL_TPOFF_NEG)
		    && def.dso->tls_id > static_tls_cnt) {
			error("Error relocating %s: %s: initial-exec TLS "
				"resolves to dynamic definition in %s",
				dso->name, name, def.dso->name);
			longjmp(*rtld_fail, 1);
		}

static_tls_cnt is the number of modules that were present at startup; any module with a higher tls_id is by definition dynamically loaded, and an initial-exec relocation in it is an error regardless of size. That is a legitimate and arguably better design choice — deterministic failure over best-effort success — and it is exactly why a plugin that loads fine on a glibc distribution can fail on Alpine with a different and more informative message. The corollary is that musl’s per-thread overhead is markedly lower, since there is no reservation to carry in every thread.

A per-CPU base instead of a per-thread one (the kernel’s own %gs). Inside the kernel, %gs points at per-CPU data, not per-task data, and the swapgs instruction switches between the user and kernel bases at the syscall boundary. The trade is exactly inverted: per-CPU state needs no update on a context switch but requires preemption discipline to use safely, while per-thread state is safe to use anywhere but must be reloaded on every switch. Userspace has no swapgs equivalent, which is one reason there is no per-CPU userspace analogue of the TCB — the nearest thing is Restartable Sequences.

Green threads / user-level scheduling, where the TCB must move. Any runtime that multiplexes many logical threads onto one OS thread — Go’s goroutines, a fiber library, a C++ coroutine scheduler — breaks the assumption that %fs identifies the current unit of execution. Go solves it by not using the C TCB for its own state at all (goroutine-local data lives in the g structure, reached through a register the Go compiler reserves) and only touching %fs when it must call into C. A C fiber library that switches stacks without switching %fs gets a genuinely nasty class of bug: errno, the malloc tcache, and every __thread variable silently belong to the OS thread, not the fiber.

Uncertain

Verify: the specific claim that Go keeps goroutine-local state in a g structure reached through a compiler-reserved register rather than through the C TCB, and what exactly Go does with %fs when crossing into C via cgo. Reason: this paragraph is from general knowledge; no Go runtime source was read during this task, unlike every other claim in this note. The glibc, kernel and musl comparisons here are verified against pinned source. To resolve: read runtime/go_tls.h, runtime/asm_amd64.s (get_tls/g macros) and runtime/cgocall.go at a pinned Go release, and cross-check against GMP Scheduler Model. uncertain

DesignThread pointerSwitch costFails how when a module wants more static TLSWhere it is used
glibc on x86-64%fs base MSR / wrfsbaseone instruction in __switch_tobest-effort: succeeds until the 1,664-byte surplus is spent, then dlopen failsevery mainstream Linux distribution
i386, pre-set_thread_area%gs selector into the LDTdescriptor reloadsame, plus an LDT entry per threadhistorical
musl%fs base, same mechanismsamedeterministic: fails at once, no surplusAlpine, static-linking-focused systems
kernel per-CPU (%gs + swapgs)per-CPU, not per-taskzero on task switch, one swapgs per syscalln/athe Linux kernel itself
language runtime with its own descriptora reserved register (Go’s g)runtime-managedn/a — does not use the C TLS machineryGo, some JITs

Production Notes

A corrupted TCB presents as a bug somewhere else. This is the single most useful thing to know about the structure operationally. Because the header packs the DTV pointer, the canary, and the gscope flag into the first 64 bytes, a wild write of a few words near %fs produces symptoms with no obvious common cause: *** stack smashing detected *** in a function with no buffer, a segfault inside __tls_get_addr, a hang in dlclose, or info threads in GDB printing nonsense. The diagnostic move is to dump the header early and compare it against its invariants — %fs:0, %fs:0x10 and pthread_self() must all be equal, and on a little-endian build the canary at %fs:0x28 must end in a zero byte:

(gdb) p/x $fs_base
(gdb) x/8gx $fs_base        # tcb, dtv, self, flags, sysinfo, stack_guard, pointer_guard

If self does not equal the base, the header is damaged and every subsequent theory about the bug is unreliable.

The per-thread cost is real at scale. Everything described here is allocated per thread: sizeof(struct pthread) plus the whole static TLS block plus the surplus. Measured on this machine, that is 4,224 bytes before the thread’s own stack, for a program with 8 bytes of its own TLS — the number _dl_get_tls_static_info reports. At 10,000 threads that is roughly 42 MB of descriptor and reservation, entirely separate from stacks. glibc mitigates the allocation cost by caching stacks (and with them the descriptors) rather than the memory cost; see Thread Stacks and Stack Guards. If thread counts are in the thousands, this is one of several reasons to prefer a pool (see Thread Pools and Executor Design).

The surplus arithmetic is still moving. As of September 2026 there is active work on libc-alpha extending the tunables described in Thread-Local Storage — DJ Delorie’s glibc.rtld.optional_static_tls_alignment (August 2026) and a separate glibc.rtld.audit_static_tls, motivated because “when audit modules are used the loader can’t compute TLS space before the auditors run” (August 2026). The 1,664-byte figure itself is a deliberately preserved historical constant — glibc carries a regression test, elf/tst-tls-surplus.c, added in 2020 with the commit “elf: Change TLS static surplus default back to 1664” specifically to pin it. Treat any surplus number, including the ones measured in these notes, as dated.

The descriptor is cache-line aligned, and for a documented microarchitectural reason. _dl_get_tls_static_info reports an alignment of 64 on this machine, which is not the generic value: nptl/descr.h defines TCB_ALIGNMENT as 32 only #ifndef, and sysdeps/x86/nptl/pthreaddef.h overrides it with an explanation worth quoting, because it is the rare case of an ABI constant justified by a hardware erratum-adjacent behaviour:

/* ... access using a segment override if that segment's base is not
   aligned to the size of a cache line.  (See Intel 64 and IA-32
   Architectures Optimization Reference Manual, section 13.3.3.3,
   "Segment Base".)  On such machines, a cache line is 64 bytes.  */
#define TCB_ALIGNMENT		64

Every %fs:-relative access in the process — every TLS read, every stack-canary check — is a segment-override access, and on the affected parts those are penalised unless the segment base sits on a cache-line boundary. So struct pthread carries __attribute ((aligned (TCB_ALIGNMENT))), the loader rounds the whole static TLS block up to 64, and that rounding is the reason the measured dlopen surplus ceiling in Thread-Local Storage can exceed the arithmetic by a few tens of bytes.

See Also