C11’s _Thread_local, C++’s thread_local, GCC’s __thread and MSVC’s __declspec(thread) all declare a variable with one copy per thread. An ordinary global has one address that the linker can put into the code; a thread-local variable has as many addresses as there are threads, so the code has to find the one of the running thread.

x86 has a register made for that. In 64-bit mode the segments are flat, all but FS and GS, whose base address the operating system sets for each thread. An instruction with an FS or GS prefix adds that base to its address, so the same instruction reaches a different copy in each thread.

The segment register of thread data in user space.
SystemRegisterIts base points to
Linux, FreeBSD and the other psABI systems, x86-64 and x32FSthe thread control block (TCB)
The same systems on i386GSthe TCB
macOS x86-64GSthe thread-specific data of the thread’s pthread
Windows x64GSthe thread environment block (TEB)
Windows x86FSthe TEB

Some compilers leave the register aside for thread-local variables and emulate them instead: MinGW-w64 GCC on Windows and Clang for OpenBSD.

Each kernel uses the other register for itself: 64-bit Linux reaches its per-CPU data through GS, which it swaps with the user value on entry (SWAPGS), and 32-bit Linux through FS.

The thread pointer

The x86-64 psABI makes FS the thread pointer, and on i386 Drepper’s document on ELF TLS does the same with GS. The TCB starts with its own address, so reading offset 0 through the register gives the thread pointer as an ordinary address, which code needs as soon as it takes the address of a thread-local variable.

__builtin_thread_pointer() reads the first word of the TCB: through FS for x86-64 and x32, through GS for i386, on Linux and FreeBSD alike.

tls-pointer.c

/* The thread pointer: the address of the running thread's control
   block, read through the segment register of thread data. */
void *thread_pointer(void)
{
	return __builtin_thread_pointer();
}

GCC 14.2.0 (Debian 14.2.0-19) x86_64-linux-gnu

AT&T syntax gcc-14 -O2 -fno-asynchronous-unwind-tables -S tls-pointer.c

thread_pointer:
        movq    %fs:0, %rax
        ret

Intel syntax gcc-14 -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-pointer.c

thread_pointer:
        mov     rax, QWORD PTR fs:0
        ret

GCC 14.2.0 (Debian 14.2.0-19) x86_64-linux-gnux32

AT&T syntax gcc-14 -mx32 -O2 -fno-asynchronous-unwind-tables -S tls-pointer.c

thread_pointer:
        movl    %fs:0, %eax
        ret

Intel syntax gcc-14 -mx32 -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-pointer.c

thread_pointer:
        mov     eax, DWORD PTR fs:0
        ret

GCC 14.2.0 (Debian 14.2.0-19) i386-linux-gnu

AT&T syntax gcc-14 -m32 -march=i686 -mtune=generic -O2 -fno-asynchronous-unwind-tables -S tls-pointer.c

thread_pointer:
        movl    %gs:0, %eax
        ret

Intel syntax gcc-14 -m32 -march=i686 -mtune=generic -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-pointer.c

thread_pointer:
        mov     eax, DWORD PTR gs:0
        ret

Clang 23.1.2 x86_64-unknown-freebsd14

AT&T syntax clang --target=x86_64-unknown-freebsd14 -O2 -fno-asynchronous-unwind-tables -S tls-pointer.c

thread_pointer:
        pushq   %rbp
        movq    %rsp, %rbp
        movq    %fs:0, %rax
        popq    %rbp
        retq

Intel syntax clang --target=x86_64-unknown-freebsd14 -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-pointer.c

thread_pointer:
        push    rbp
        mov     rbp, rsp
        mov     rax, qword ptr fs:[0]
        pop     rbp
        ret

Clang 23.1.2 i686-unknown-freebsd14

AT&T syntax clang --target=i686-unknown-freebsd14 -march=i686 -O2 -fno-asynchronous-unwind-tables -S tls-pointer.c

thread_pointer:
        pushl   %ebp
        movl    %esp, %ebp
        movl    %gs:0, %eax
        popl    %ebp
        retl

Intel syntax clang --target=i686-unknown-freebsd14 -march=i686 -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-pointer.c

thread_pointer:
        push    ebp
        mov     ebp, esp
        mov     eax, dword ptr gs:[0]
        pop     ebp
        ret

The C library sets the base for each thread it creates. On 64-bit Linux a program can read and write it with arch_prctl() (ARCH_GET_FS, ARCH_SET_FS), or with the FSGSBASE instructions, such as RDFSBASE and WRFSBASE, where the kernel has enabled them and says so with HWCAP2_FSGSBASE in the auxiliary vector. A new thread gets its base from the tls argument of clone, with the flag CLONE_SETTLS: the FS base itself for a 64-bit program, and for a 32-bit one a struct user_desc, the segment descriptor that set_thread_area also takes.

What glibc keeps in the TCB

The TCB belongs to the C library. glibc starts it with a header that holds, among other things, the TCB’s own address at offset 0, a pointer to the dynamic thread vector, and the canary of the stack protector: at offset 0x28 on x86-64 and 0x14 on i386. Each new thread copies the canary from the thread that creates it.

The stack protector on each system. Linux code reads the canary through the thread register, from %fs:40 or %gs:20. Clang for Windows XORs the global __security_cookie with RSP; MinGW-w64 GCC and Clang for macOS load a global __stack_chk_guard.

tls-guard.c

/* With -fstack-protector-strong, a function with an array on the
   stack checks a canary before it returns. */
extern void fill(char *buf);

int first(void)
{
	char buf[64];

	fill(buf);
	return buf[0];
}

GCC 14.2.0 (Debian 14.2.0-19) x86_64-linux-gnu

AT&T syntax gcc-14 -O2 -fno-asynchronous-unwind-tables -fstack-protector-strong -S tls-guard.c

first:
        subq    $88, %rsp
        movq    %fs:40, %rdi
        movq    %rdi, 72(%rsp)
        movq    %rsp, %rdi
        call    fill@PLT
        movsbl  (%rsp), %eax
        movq    72(%rsp), %rdx
        subq    %fs:40, %rdx
        jne     .L5
        addq    $88, %rsp
        ret
.L5:
        call    __stack_chk_fail@PLT

Intel syntax gcc-14 -O2 -fno-asynchronous-unwind-tables -fstack-protector-strong -masm=intel -S tls-guard.c

first:
        sub     rsp, 88
        mov     rdi, QWORD PTR fs:40
        mov     QWORD PTR 72[rsp], rdi
        mov     rdi, rsp
        call    fill@PLT
        movsx   eax, BYTE PTR [rsp]
        mov     rdx, QWORD PTR 72[rsp]
        sub     rdx, QWORD PTR fs:40
        jne     .L5
        add     rsp, 88
        ret
.L5:
        call    __stack_chk_fail@PLT

GCC 14.2.0 (Debian 14.2.0-19) i386-linux-gnu

AT&T syntax gcc-14 -m32 -march=i686 -mtune=generic -O2 -fno-asynchronous-unwind-tables -fstack-protector-strong -S tls-guard.c

first:
        pushl   %ebx
        call    __x86.get_pc_thunk.bx
        addl    $_GLOBAL_OFFSET_TABLE_, %ebx
        subl    $100, %esp
        movl    %gs:20, %eax
        movl    %eax, 88(%esp)
        leal    24(%esp), %eax
        pushl   %eax
        call    fill@PLT
        movsbl  28(%esp), %eax
        addl    $16, %esp
        movl    76(%esp), %edx
        subl    %gs:20, %edx
        jne     .L5
        addl    $88, %esp
        popl    %ebx
        ret
.L5:
        call    __stack_chk_fail_local

__x86.get_pc_thunk.bx:
        movl    (%esp), %ebx
        ret

Intel syntax gcc-14 -m32 -march=i686 -mtune=generic -O2 -fno-asynchronous-unwind-tables -fstack-protector-strong -masm=intel -S tls-guard.c

first:
        push    ebx
        call    __x86.get_pc_thunk.bx
        add     ebx, OFFSET FLAT:_GLOBAL_OFFSET_TABLE_
        sub     esp, 100
        mov     eax, DWORD PTR gs:20
        mov     DWORD PTR 88[esp], eax
        lea     eax, 24[esp]
        push    eax
        call    fill@PLT
        movsx   eax, BYTE PTR 28[esp]
        add     esp, 16
        mov     edx, DWORD PTR 76[esp]
        sub     edx, DWORD PTR gs:20
        jne     .L5
        add     esp, 88
        pop     ebx
        ret
.L5:
        call    __stack_chk_fail_local

__x86.get_pc_thunk.bx:
        mov     ebx, DWORD PTR [esp]
        ret

Clang 23.1.2 x86_64-pc-windows-msvc

AT&T syntax clang --target=x86_64-pc-windows-msvc -O2 -fno-asynchronous-unwind-tables -fstack-protector-strong -S tls-guard.c

first:
        subq    $104, %rsp
        movq    __security_cookie(%rip), %rax
        xorq    %rsp, %rax
        movq    %rax, 96(%rsp)
        leaq    32(%rsp), %rcx
        callq   fill
        movsbl  32(%rsp), %eax
        movq    96(%rsp), %rcx
        xorq    %rsp, %rcx
        movq    __security_cookie(%rip), %rdx
        cmpq    %rcx, %rdx
        jne     .LBB0_2
        addq    $104, %rsp
        retq
.LBB0_2:
        movq    96(%rsp), %rcx
        xorq    %rsp, %rcx
        callq   __security_check_cookie

Intel syntax clang --target=x86_64-pc-windows-msvc -O2 -fno-asynchronous-unwind-tables -fstack-protector-strong -masm=intel -S tls-guard.c

first:
        sub     rsp, 104
        mov     rax, qword ptr [rip + __security_cookie]
        xor     rax, rsp
        mov     qword ptr [rsp + 96], rax
        lea     rcx, [rsp + 32]
        call    fill
        movsx   eax, byte ptr [rsp + 32]
        mov     rcx, qword ptr [rsp + 96]
        xor     rcx, rsp
        mov     rdx, qword ptr [rip + __security_cookie]
        cmp     rdx, rcx
        jne     .LBB0_2
        add     rsp, 104
        ret
.LBB0_2:
        mov     rcx, qword ptr [rsp + 96]
        xor     rcx, rsp
        call    __security_check_cookie

MinGW-w64 GCC 14.2.0 (Debian 14.2.0-19+27+b1) x86_64-w64-mingw32

AT&T syntax x86_64-w64-mingw32-gcc -O2 -fno-asynchronous-unwind-tables -fstack-protector-strong -S tls-guard.c

first:
        pushq   %rbp
        movq    %rsp, %rbp
        pushq   %rbx
        andq    $-16, %rsp
        subq    $112, %rsp
        movq    .refptr.__stack_chk_guard(%rip), %rbx
        movq    (%rbx), %rcx
        movq    %rcx, 104(%rsp)
        leaq    32(%rsp), %rcx
        call    fill
        movsbl  32(%rsp), %eax
        movq    104(%rsp), %rdx
        subq    (%rbx), %rdx
        jne     .L5
        movq    -8(%rbp), %rbx
        leave
        ret
.L5:
        call    __stack_chk_fail

.refptr.__stack_chk_guard:
        .quad   __stack_chk_guard

Intel syntax x86_64-w64-mingw32-gcc -O2 -fno-asynchronous-unwind-tables -fstack-protector-strong -masm=intel -S tls-guard.c

first:
        push    rbp
        mov     rbp, rsp
        push    rbx
        and     rsp, -16
        sub     rsp, 112
        mov     rbx, QWORD PTR .refptr.__stack_chk_guard[rip]
        mov     rcx, QWORD PTR [rbx]
        mov     QWORD PTR 104[rsp], rcx
        lea     rcx, 32[rsp]
        call    fill
        movsx   eax, BYTE PTR 32[rsp]
        mov     rdx, QWORD PTR 104[rsp]
        sub     rdx, QWORD PTR [rbx]
        jne     .L5
        mov     rbx, QWORD PTR -8[rbp]
        leave
        ret
.L5:
        call    __stack_chk_fail

.refptr.__stack_chk_guard:
        .quad   __stack_chk_guard

Clang 23.1.2 x86_64-apple-macos11

AT&T syntax clang --target=x86_64-apple-macos11 -O2 -fno-asynchronous-unwind-tables -fstack-protector-strong -S tls-guard.c

_first:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $80, %rsp
        movq    ___stack_chk_guard@GOTPCREL(%rip), %rax
        movq    (%rax), %rax
        movq    %rax, -8(%rbp)
        leaq    -80(%rbp), %rdi
        callq   _fill
        movsbl  -80(%rbp), %eax
        movq    ___stack_chk_guard@GOTPCREL(%rip), %rcx
        movq    (%rcx), %rcx
        cmpq    -8(%rbp), %rcx
        jne     LBB0_2
        addq    $80, %rsp
        popq    %rbp
        retq
LBB0_2:
        callq   ___stack_chk_fail

Intel syntax clang --target=x86_64-apple-macos11 -O2 -fno-asynchronous-unwind-tables -fstack-protector-strong -masm=intel -S tls-guard.c

_first:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 80
        mov     rax, qword ptr [rip + ___stack_chk_guard@GOTPCREL]
        mov     rax, qword ptr [rax]
        mov     qword ptr [rbp - 8], rax
        lea     rdi, [rbp - 80]
        call    _fill
        movsx   eax, byte ptr [rbp - 80]
        mov     rcx, qword ptr [rip + ___stack_chk_guard@GOTPCREL]
        mov     rcx, qword ptr [rcx]
        cmp     rcx, qword ptr [rbp - 8]
        jne     LBB0_2
        add     rsp, 80
        pop     rbp
        ret
LBB0_2:
        call    ___stack_chk_fail

The ELF access models

The ELF systems that use FS and GS lay thread-local data out the same way on x86, variant II of Drepper’s document. Every module, the program and each shared library, has a TLS block for each thread. The blocks of the modules loaded at startup sit just below the TCB, at offsets from the thread pointer that are fixed once the program runs; the program’s own block is right under the TCB, so its offset is known when the program is linked. A library loaded later with dlopen gets its blocks elsewhere, and only the C library knows where, through the dynamic thread vector of each thread.

How much the code can know in advance therefore depends on where the variable is defined and where the code runs, and there are four access models:

The four TLS access models of ELF.
ModelCode inVariable inThe code finds the variable
Local execthe programthe programat an offset from the thread pointer fixed at link time
Initial execany module loaded at startupany module loaded at startupat an offset that the dynamic linker stores in the GOT
General dynamicany moduleany moduleby calling __tls_get_addr with the module and the offset
Local dynamicany modulethe same moduleby one call for the module’s block, then fixed offsets

In a program: local exec and initial exec

Code of a program knows two things: its own variables are in its own block, and every library it links against is loaded at startup. GCC and Clang in the image build position-independent executables by default, which only changes how i386 code reaches the GOT.

counter is defined by the program: one instruction with a fixed offset (@tpoff, @ntpoff on i386), a negative number since the block lies below the TCB. errcode may be in a library, so its offset comes from the GOT (@gottpoff). For the address of counter, the code reads the thread pointer from offset 0 and adds the offset.

tls-models.c

/* A thread-local variable of the program itself, and one that a
   shared library may define. Each thread has its own copies. */
_Thread_local int counter;
extern _Thread_local int errcode;

int next(void)
{
	return ++counter;
}

int last_error(void)
{
	return errcode;
}

int *counter_addr(void)
{
	return &counter;
}

GCC 14.2.0 (Debian 14.2.0-19) x86_64-linux-gnu

AT&T syntax gcc-14 -O2 -fno-asynchronous-unwind-tables -S tls-models.c

next:
        movl    %fs:counter@tpoff, %eax
        addl    $1, %eax
        movl    %eax, %fs:counter@tpoff
        ret

last_error:
        movq    errcode@gottpoff(%rip), %rax
        movl    %fs:(%rax), %eax
        ret

counter_addr:
        movq    %fs:0, %rax
        addq    $counter@tpoff, %rax
        ret

counter:
        .zero   4

Intel syntax gcc-14 -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-models.c

next:
        mov     eax, DWORD PTR fs:counter@tpoff
        add     eax, 1
        mov     DWORD PTR fs:counter@tpoff, eax
        ret

last_error:
        mov     rax, QWORD PTR errcode@gottpoff[rip]
        mov     eax, DWORD PTR fs:[rax]
        ret

counter_addr:
        mov     rax, QWORD PTR fs:0
        add     rax, OFFSET FLAT:counter@tpoff
        ret

counter:
        .zero   4

Clang 23.1.2 x86_64-linux-gnu

AT&T syntax clang --target=x86_64-linux-gnu -O2 -fno-asynchronous-unwind-tables -S tls-models.c

next:
        movl    %fs:counter@TPOFF, %eax
        incl    %eax
        movl    %eax, %fs:counter@TPOFF
        retq

last_error:
        movq    errcode@GOTTPOFF(%rip), %rax
        movl    %fs:(%rax), %eax
        retq

counter_addr:
        movq    %fs:0, %rax
        leaq    counter@TPOFF(%rax), %rax
        retq

counter:
        .long   0

Intel syntax clang --target=x86_64-linux-gnu -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-models.c

next:
        mov     eax, dword ptr fs:[counter@TPOFF]
        inc     eax
        mov     dword ptr fs:[counter@TPOFF], eax
        ret

last_error:
        mov     rax, qword ptr [rip + errcode@GOTTPOFF]
        mov     eax, dword ptr fs:[rax]
        ret

counter_addr:
        mov     rax, qword ptr fs:[0]
        lea     rax, [rax + counter@TPOFF]
        ret

counter:
        .long   0

GCC 14.2.0 (Debian 14.2.0-19) i386-linux-gnu

AT&T syntax gcc-14 -m32 -march=i686 -mtune=generic -O2 -fno-asynchronous-unwind-tables -S tls-models.c

next:
        movl    %gs:counter@ntpoff, %eax
        addl    $1, %eax
        movl    %eax, %gs:counter@ntpoff
        ret

last_error:
        call    __x86.get_pc_thunk.ax
        addl    $_GLOBAL_OFFSET_TABLE_, %eax
        movl    errcode@gotntpoff(%eax), %eax
        movl    %gs:(%eax), %eax
        ret

counter_addr:
        leal    counter@ntpoff, %eax
        addl    %gs:0, %eax
        ret

counter:
        .zero   4

__x86.get_pc_thunk.ax:
        movl    (%esp), %eax
        ret

Intel syntax gcc-14 -m32 -march=i686 -mtune=generic -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-models.c

next:
        mov     eax, DWORD PTR gs:counter@ntpoff
        add     eax, 1
        mov     DWORD PTR gs:counter@ntpoff, eax
        ret

last_error:
        call    __x86.get_pc_thunk.ax
        add     eax, OFFSET FLAT:_GLOBAL_OFFSET_TABLE_
        mov     eax, DWORD PTR errcode@gotntpoff[eax]
        mov     eax, DWORD PTR gs:[eax]
        ret

counter_addr:
        lea     eax, counter@ntpoff
        add     eax, DWORD PTR gs:0
        ret

counter:
        .zero   4

__x86.get_pc_thunk.ax:
        mov     eax, DWORD PTR [esp]
        ret

In a shared library: general and local dynamic

Code of a shared library, compiled with -fPIC, cannot assume that it was loaded at startup. For a variable that may be defined anywhere it calls __tls_get_addr with a pointer to two GOT entries, the module’s number and the variable’s offset in its block, which the dynamic linker fills in. The function returns the variable’s address, and allocates the block of a module loaded with dlopen the first time a thread uses it. For the library’s own variables one call finds the block, and each variable is at a fixed offset in it (@dtpoff).

next uses the general dynamic model and total the local dynamic one. On x86-64 the data16 and rex64 prefixes, which GCC partly writes as .value 0x6666, make the sequence 16 bytes long. i386 passes the argument in EAX to ___tls_get_addr, with three underscores; for the one variable that record needs, GCC for i386 makes a general dynamic call.

tls-dynamic.c

/* Code of a shared library, compiled with -fPIC: counter may be
   defined by another module, while hits and misses are this
   library's own. */
_Thread_local int counter;
static _Thread_local int hits, misses;

int next(void)
{
	return ++counter;
}

int total(void)
{
	return hits + misses;
}

void record(int hit)
{
	if (hit)
		hits++;
	else
		misses++;
}

GCC 14.2.0 (Debian 14.2.0-19) x86_64-linux-gnu

AT&T syntax gcc-14 -O2 -fno-asynchronous-unwind-tables -fPIC -S tls-dynamic.c

next:
        subq    $8, %rsp
        data16  leaq    counter@tlsgd(%rip), %rdi
        .value  0x6666
        rex64
        call    __tls_get_addr@PLT
        movq    %rax, %rdx
        movl    (%rax), %eax
        addl    $1, %eax
        movl    %eax, (%rdx)
        addq    $8, %rsp
        ret

total:
        subq    $8, %rsp
        leaq    hits@tlsld(%rip), %rdi
        call    __tls_get_addr@PLT
        movl    hits@dtpoff(%rax), %edx
        addl    misses@dtpoff(%rax), %edx
        addq    $8, %rsp
        movl    %edx, %eax
        ret

record:
        subq    $8, %rsp
        testl   %edi, %edi
        je      .L7
        leaq    hits@tlsld(%rip), %rdi
        call    __tls_get_addr@PLT
        addl    $1, hits@dtpoff(%rax)
        addq    $8, %rsp
        ret
.L7:
        leaq    hits@tlsld(%rip), %rdi
        call    __tls_get_addr@PLT
        addl    $1, misses@dtpoff(%rax)
        addq    $8, %rsp
        ret

misses:
        .zero   4

hits:
        .zero   4

counter:
        .zero   4

Intel syntax gcc-14 -O2 -fno-asynchronous-unwind-tables -fPIC -masm=intel -S tls-dynamic.c

next:
        sub     rsp, 8
        data16  lea     rdi, counter@tlsgd[rip]
        .value  0x6666
        rex64
        call    __tls_get_addr@PLT
        mov     rdx, rax
        mov     eax, DWORD PTR [rax]
        add     eax, 1
        mov     DWORD PTR [rdx], eax
        add     rsp, 8
        ret

total:
        sub     rsp, 8
        lea     rdi, hits@tlsld[rip]
        call    __tls_get_addr@PLT
        mov     edx, DWORD PTR hits@dtpoff[rax]
        add     edx, DWORD PTR misses@dtpoff[rax]
        add     rsp, 8
        mov     eax, edx
        ret

record:
        sub     rsp, 8
        test    edi, edi
        je      .L7
        lea     rdi, hits@tlsld[rip]
        call    __tls_get_addr@PLT
        add     DWORD PTR hits@dtpoff[rax], 1
        add     rsp, 8
        ret
.L7:
        lea     rdi, hits@tlsld[rip]
        call    __tls_get_addr@PLT
        add     DWORD PTR misses@dtpoff[rax], 1
        add     rsp, 8
        ret

misses:
        .zero   4

hits:
        .zero   4

counter:
        .zero   4

Clang 23.1.2 x86_64-linux-gnu

AT&T syntax clang --target=x86_64-linux-gnu -O2 -fno-asynchronous-unwind-tables -fPIC -S tls-dynamic.c

next:
        pushq   %rax
        data16
        leaq    counter@TLSGD(%rip), %rdi
        data16
        data16
        rex64
        callq   __tls_get_addr@PLT
        movl    (%rax), %ecx
        incl    %ecx
        movl    %ecx, (%rax)
        movl    %ecx, %eax
        popq    %rcx
        retq

total:
        pushq   %rax
        leaq    hits@TLSLD(%rip), %rdi
        callq   __tls_get_addr@PLT
        movq    %rax, %rcx
        movl    misses@DTPOFF(%rax), %eax
        addl    hits@DTPOFF(%rcx), %eax
        popq    %rcx
        retq

record:
        pushq   %rbx
        movl    %edi, %ebx
        leaq    misses@TLSLD(%rip), %rdi
        callq   __tls_get_addr@PLT
        testl   %ebx, %ebx
        leaq    misses@DTPOFF(%rax), %rcx
        leaq    hits@DTPOFF(%rax), %rax
        cmoveq  %rcx, %rax
        incl    (%rax)
        popq    %rbx
        retq

counter:
        .long   0

hits:
        .long   0

misses:
        .long   0

Intel syntax clang --target=x86_64-linux-gnu -O2 -fno-asynchronous-unwind-tables -fPIC -masm=intel -S tls-dynamic.c

next:
        push    rax
        data16
        lea     rdi, [rip + counter@TLSGD]
        data16
        data16
        rex64
        call    __tls_get_addr@PLT
        mov     ecx, dword ptr [rax]
        inc     ecx
        mov     dword ptr [rax], ecx
        mov     eax, ecx
        pop     rcx
        ret

total:
        push    rax
        lea     rdi, [rip + hits@TLSLD]
        call    __tls_get_addr@PLT
        mov     rcx, rax
        mov     eax, dword ptr [rax + misses@DTPOFF]
        add     eax, dword ptr [rcx + hits@DTPOFF]
        pop     rcx
        ret

record:
        push    rbx
        mov     ebx, edi
        lea     rdi, [rip + misses@TLSLD]
        call    __tls_get_addr@PLT
        test    ebx, ebx
        lea     rcx, [rax + misses@DTPOFF]
        lea     rax, [rax + hits@DTPOFF]
        cmove   rax, rcx
        inc     dword ptr [rax]
        pop     rbx
        ret

counter:
        .long   0

hits:
        .long   0

misses:
        .long   0

GCC 14.2.0 (Debian 14.2.0-19) i386-linux-gnu

AT&T syntax gcc-14 -m32 -march=i686 -mtune=generic -O2 -fno-asynchronous-unwind-tables -fPIC -S tls-dynamic.c

next:
        pushl   %ebx
        call    __x86.get_pc_thunk.bx
        addl    $_GLOBAL_OFFSET_TABLE_, %ebx
        subl    $8, %esp
        leal    counter@tlsgd(,%ebx,1), %eax
        call    ___tls_get_addr@PLT
        movl    (%eax), %ecx
        leal    1(%ecx), %edx
        movl    %edx, (%eax)
        addl    $8, %esp
        movl    %edx, %eax
        popl    %ebx
        ret

total:
        pushl   %ebx
        call    __x86.get_pc_thunk.bx
        addl    $_GLOBAL_OFFSET_TABLE_, %ebx
        subl    $8, %esp
        leal    misses@tlsldm(%ebx), %eax
        call    ___tls_get_addr@PLT
        movl    misses@dtpoff(%eax), %edx
        addl    hits@dtpoff(%eax), %edx
        addl    $8, %esp
        movl    %edx, %eax
        popl    %ebx
        ret

record:
        pushl   %ebx
        call    __x86.get_pc_thunk.bx
        addl    $_GLOBAL_OFFSET_TABLE_, %ebx
        subl    $8, %esp
        movl    16(%esp), %eax
        testl   %eax, %eax
        je      .L7
        leal    hits@tlsgd(,%ebx,1), %eax
        call    ___tls_get_addr@PLT
        addl    $1, (%eax)
        addl    $8, %esp
        popl    %ebx
        ret
.L7:
        leal    misses@tlsgd(,%ebx,1), %eax
        call    ___tls_get_addr@PLT
        addl    $1, (%eax)
        addl    $8, %esp
        popl    %ebx
        ret

misses:
        .zero   4

hits:
        .zero   4

counter:
        .zero   4

__x86.get_pc_thunk.bx:
        movl    (%esp), %ebx
        ret

Intel syntax gcc-14 -m32 -march=i686 -mtune=generic -O2 -fno-asynchronous-unwind-tables -fPIC -masm=intel -S tls-dynamic.c

next:
        push    ebx
        call    __x86.get_pc_thunk.bx
        add     ebx, OFFSET FLAT:_GLOBAL_OFFSET_TABLE_
        sub     esp, 8
        lea     eax, counter@tlsgd[ebx*1]
        call    ___tls_get_addr@PLT
        mov     ecx, DWORD PTR [eax]
        lea     edx, 1[ecx]
        mov     DWORD PTR [eax], edx
        add     esp, 8
        mov     eax, edx
        pop     ebx
        ret

total:
        push    ebx
        call    __x86.get_pc_thunk.bx
        add     ebx, OFFSET FLAT:_GLOBAL_OFFSET_TABLE_
        sub     esp, 8
        lea     eax, misses@tlsldm[ebx]
        call    ___tls_get_addr@PLT
        mov     edx, DWORD PTR misses@dtpoff[eax]
        add     edx, DWORD PTR hits@dtpoff[eax]
        add     esp, 8
        mov     eax, edx
        pop     ebx
        ret

record:
        push    ebx
        call    __x86.get_pc_thunk.bx
        add     ebx, OFFSET FLAT:_GLOBAL_OFFSET_TABLE_
        sub     esp, 8
        mov     eax, DWORD PTR 16[esp]
        test    eax, eax
        je      .L7
        lea     eax, hits@tlsgd[ebx*1]
        call    ___tls_get_addr@PLT
        add     DWORD PTR [eax], 1
        add     esp, 8
        pop     ebx
        ret
.L7:
        lea     eax, misses@tlsgd[ebx*1]
        call    ___tls_get_addr@PLT
        add     DWORD PTR [eax], 1
        add     esp, 8
        pop     ebx
        ret

misses:
        .zero   4

hits:
        .zero   4

counter:
        .zero   4

__x86.get_pc_thunk.bx:
        mov     ebx, DWORD PTR [esp]
        ret

The padding is for the linker. When it links code into an executable, it knows more than the compiler did and rewrites a general dynamic sequence into an initial exec or local exec one of the same length, and an initial exec one into local exec. The compiler picks a cheaper model than the default when it can, and -ftls-model for a whole file or the tls_model attribute for one variable choose a model explicitly: a library that is never loaded with dlopen may use initial exec.

TLS descriptors

TLS descriptors replace the call of __tls_get_addr with a cheaper one. The GOT holds a descriptor for each variable, two words: a function and its argument, both set by the dynamic linker. The code passes the descriptor’s address in RAX and calls the function, which returns the variable’s offset from the thread pointer in RAX and changes no other register except the flags. For a variable in the static blocks the function just returns a constant offset, and for one of a library loaded with dlopen it does the work of __tls_get_addr. Since the call preserves every register, the caller saves nothing around it.

GCC and Clang emit descriptors with -mtls-dialect=gnu2; on x86-64 both default to the traditional calls, as the listings above show.

The same library with TLS descriptors: @TLSDESC names the descriptor in the GOT, @TLSCALL marks the call for the linker. For two variables of its own, total asks once for the offset of the module’s block, _TLS_MODULE_BASE_, and adds each variable’s @dtpoff.

tls-dynamic.c

/* Code of a shared library, compiled with -fPIC: counter may be
   defined by another module, while hits and misses are this
   library's own. */
_Thread_local int counter;
static _Thread_local int hits, misses;

int next(void)
{
	return ++counter;
}

int total(void)
{
	return hits + misses;
}

void record(int hit)
{
	if (hit)
		hits++;
	else
		misses++;
}

GCC 14.2.0 (Debian 14.2.0-19) x86_64-linux-gnu

AT&T syntax gcc-14 -O2 -fno-asynchronous-unwind-tables -fPIC -mtls-dialect=gnu2 -S tls-dynamic.c

next:
        subq    $8, %rsp
        leaq    counter@TLSDESC(%rip), %rax
        call    *counter@TLSCALL(%rax)
        addq    %fs:0, %rax
        movl    (%rax), %ecx
        leal    1(%rcx), %edx
        movl    %edx, (%rax)
        movl    %edx, %eax
        addq    $8, %rsp
        ret

total:
        subq    $8, %rsp
        leaq    _TLS_MODULE_BASE_@TLSDESC(%rip), %rax
        call    *_TLS_MODULE_BASE_@TLSCALL(%rax)
        movl    %fs:misses@dtpoff(%rax), %edx
        addl    %fs:hits@dtpoff(%rax), %edx
        addq    $8, %rsp
        movl    %edx, %eax
        ret

record:
        subq    $8, %rsp
        testl   %edi, %edi
        je      .L7
        leaq    hits@TLSDESC(%rip), %rax
        call    *hits@TLSCALL(%rax)
        addl    $1, %fs:(%rax)
        addq    $8, %rsp
        ret
.L7:
        leaq    misses@TLSDESC(%rip), %rax
        call    *misses@TLSCALL(%rax)
        addl    $1, %fs:(%rax)
        addq    $8, %rsp
        ret

misses:
        .zero   4

hits:
        .zero   4

counter:
        .zero   4

Intel syntax gcc-14 -O2 -fno-asynchronous-unwind-tables -fPIC -mtls-dialect=gnu2 -masm=intel -S tls-dynamic.c

next:
        sub     rsp, 8
        lea     rax, counter@TLSDESC[rip]
        call    [QWORD PTR [rax+counter@TLSCALL]]
        add     rax, QWORD PTR fs:0
        mov     ecx, DWORD PTR [rax]
        lea     edx, 1[rcx]
        mov     DWORD PTR [rax], edx
        mov     eax, edx
        add     rsp, 8
        ret

total:
        sub     rsp, 8
        lea     rax, _TLS_MODULE_BASE_@TLSDESC[rip]
        call    [QWORD PTR [rax+_TLS_MODULE_BASE_@TLSCALL]]
        mov     edx, DWORD PTR fs:misses@dtpoff[rax]
        add     edx, DWORD PTR fs:hits@dtpoff[rax]
        add     rsp, 8
        mov     eax, edx
        ret

record:
        sub     rsp, 8
        test    edi, edi
        je      .L7
        lea     rax, hits@TLSDESC[rip]
        call    [QWORD PTR [rax+hits@TLSCALL]]
        add     DWORD PTR fs:[rax], 1
        add     rsp, 8
        ret
.L7:
        lea     rax, misses@TLSDESC[rip]
        call    [QWORD PTR [rax+misses@TLSCALL]]
        add     DWORD PTR fs:[rax], 1
        add     rsp, 8
        ret

misses:
        .zero   4

hits:
        .zero   4

counter:
        .zero   4

Clang 23.1.2 x86_64-linux-gnu

AT&T syntax clang --target=x86_64-linux-gnu -O2 -fno-asynchronous-unwind-tables -fPIC -mtls-dialect=gnu2 -S tls-dynamic.c

next:
        pushq   %rax
        leaq    counter@tlsdesc(%rip), %rax
        callq   *counter@tlscall(%rax)
        movl    %fs:(%rax), %ecx
        incl    %ecx
        movl    %ecx, %fs:(%rax)
        movl    %ecx, %eax
        popq    %rcx
        retq

total:
        pushq   %rax
        leaq    _TLS_MODULE_BASE_@tlsdesc(%rip), %rax
        callq   *_TLS_MODULE_BASE_@tlscall(%rax)
        movl    %fs:misses@DTPOFF(%rax), %ecx
        addl    %fs:hits@DTPOFF(%rax), %ecx
        movl    %ecx, %eax
        popq    %rcx
        retq

record:
        pushq   %rax
        leaq    _TLS_MODULE_BASE_@tlsdesc(%rip), %rax
        callq   *_TLS_MODULE_BASE_@tlscall(%rax)
        testl   %edi, %edi
        movq    %fs:0, %rcx
        leaq    misses@DTPOFF(%rax,%rcx), %rdx
        leaq    hits@DTPOFF(%rax,%rcx), %rax
        cmoveq  %rdx, %rax
        incl    (%rax)
        popq    %rax
        retq

counter:
        .long   0

hits:
        .long   0

misses:
        .long   0

Intel syntax clang --target=x86_64-linux-gnu -O2 -fno-asynchronous-unwind-tables -fPIC -mtls-dialect=gnu2 -masm=intel -S tls-dynamic.c

next:
        push    rax
        lea     rax, [rip + counter@tlsdesc]
        call    qword ptr [rax + counter@tlscall]
        mov     ecx, dword ptr fs:[rax]
        inc     ecx
        mov     dword ptr fs:[rax], ecx
        mov     eax, ecx
        pop     rcx
        ret

total:
        push    rax
        lea     rax, [rip + _TLS_MODULE_BASE_@tlsdesc]
        call    qword ptr [rax + _TLS_MODULE_BASE_@tlscall]
        mov     ecx, dword ptr fs:[rax + misses@DTPOFF]
        add     ecx, dword ptr fs:[rax + hits@DTPOFF]
        mov     eax, ecx
        pop     rcx
        ret

record:
        push    rax
        lea     rax, [rip + _TLS_MODULE_BASE_@tlsdesc]
        call    qword ptr [rax + _TLS_MODULE_BASE_@tlscall]
        test    edi, edi
        mov     rcx, qword ptr fs:[0]
        lea     rdx, [rax + rcx + misses@DTPOFF]
        lea     rax, [rax + rcx + hits@DTPOFF]
        cmove   rax, rdx
        inc     dword ptr [rax]
        pop     rax
        ret

counter:
        .long   0

hits:
        .long   0

misses:
        .long   0

Windows: the TEB and the TLS array

Windows has one mechanism instead of four models. GS on x64 and FS on x86 point to the thread environment block, the TEB, which holds a pointer to the thread’s TLS array. Each module with thread-local data, the program or a DLL, has a .tls section and an index that the loader assigns and stores in the module’s _tls_index; the array entry at that index points to the module’s block for this thread, which the loader fills from the .tls section when the thread starts. Microsoft’s PE documentation gives the offset of the array pointer in the 32-bit TEB, 0x2C; LLVM’s code generator names 0x58 for the 64-bit one.

Every access takes three steps: the TLS array from the TEB (%gs:88, 0x58, on x64; %fs:__tls_array, a symbol for 0x2C, on x86), the module’s entry at _tls_index, and the variable at its offset in the .tls section (@SECREL32). Clang for MinGW-w64 emits the same code as for MSVC.

tls-models.c

/* A thread-local variable of the program itself, and one that a
   shared library may define. Each thread has its own copies. */
_Thread_local int counter;
extern _Thread_local int errcode;

int next(void)
{
	return ++counter;
}

int last_error(void)
{
	return errcode;
}

int *counter_addr(void)
{
	return &counter;
}

Clang 23.1.2 x86_64-pc-windows-msvc

AT&T syntax clang --target=x86_64-pc-windows-msvc -O2 -fno-asynchronous-unwind-tables -S tls-models.c

next:
        movl    _tls_index(%rip), %eax
        movq    %gs:88, %rcx
        movq    (%rcx,%rax,8), %rcx
        movl    counter@SECREL32(%rcx), %eax
        incl    %eax
        movl    %eax, counter@SECREL32(%rcx)
        retq

last_error:
        movl    _tls_index(%rip), %eax
        movq    %gs:88, %rcx
        movq    (%rcx,%rax,8), %rax
        movl    errcode@SECREL32(%rax), %eax
        retq

counter_addr:
        movl    _tls_index(%rip), %eax
        movq    %gs:88, %rcx
        movq    (%rcx,%rax,8), %rax
        leaq    counter@SECREL32(%rax), %rax
        retq

counter:
        .long   0

Intel syntax clang --target=x86_64-pc-windows-msvc -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-models.c

next:
        mov     eax, dword ptr [rip + _tls_index]
        mov     rcx, qword ptr gs:[88]
        mov     rcx, qword ptr [rcx + 8*rax]
        mov     eax, dword ptr [rcx + counter@SECREL32]
        inc     eax
        mov     dword ptr [rcx + counter@SECREL32], eax
        ret

last_error:
        mov     eax, dword ptr [rip + _tls_index]
        mov     rcx, qword ptr gs:[88]
        mov     rax, qword ptr [rcx + 8*rax]
        mov     eax, dword ptr [rax + errcode@SECREL32]
        ret

counter_addr:
        mov     eax, dword ptr [rip + _tls_index]
        mov     rcx, qword ptr gs:[88]
        mov     rax, qword ptr [rcx + 8*rax]
        lea     rax, [rax + counter@SECREL32]
        ret

counter:
        .long   0

Clang 23.1.2 i686-pc-windows-msvc

AT&T syntax clang --target=i686-pc-windows-msvc -march=pentium4 -O2 -fno-asynchronous-unwind-tables -S tls-models.c

_next:
        movl    __tls_index, %eax
        movl    %fs:__tls_array, %ecx
        movl    (%ecx,%eax,4), %ecx
        movl    _counter@SECREL32(%ecx), %eax
        incl    %eax
        movl    %eax, _counter@SECREL32(%ecx)
        retl

_last_error:
        movl    __tls_index, %eax
        movl    %fs:__tls_array, %ecx
        movl    (%ecx,%eax,4), %eax
        movl    _errcode@SECREL32(%eax), %eax
        retl

_counter_addr:
        movl    __tls_index, %eax
        movl    %fs:__tls_array, %ecx
        movl    (%ecx,%eax,4), %eax
        leal    _counter@SECREL32(%eax), %eax
        retl

_counter:
        .long   0

Intel syntax clang --target=i686-pc-windows-msvc -march=pentium4 -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-models.c

_next:
        mov     eax, dword ptr [__tls_index]
        mov     ecx, dword ptr fs:[__tls_array]
        mov     ecx, dword ptr [ecx + 4*eax]
        mov     eax, dword ptr [ecx + _counter@SECREL32]
        inc     eax
        mov     dword ptr [ecx + _counter@SECREL32], eax
        ret

_last_error:
        mov     eax, dword ptr [__tls_index]
        mov     ecx, dword ptr fs:[__tls_array]
        mov     eax, dword ptr [ecx + 4*eax]
        mov     eax, dword ptr [eax + _errcode@SECREL32]
        ret

_counter_addr:
        mov     eax, dword ptr [__tls_index]
        mov     ecx, dword ptr fs:[__tls_array]
        mov     eax, dword ptr [ecx + 4*eax]
        lea     eax, [eax + _counter@SECREL32]
        ret

_counter:
        .long   0

Clang 23.1.2 x86_64-w64-windows-gnu

AT&T syntax clang --target=x86_64-w64-windows-gnu -O2 -fno-asynchronous-unwind-tables -S tls-models.c

next:
        movl    _tls_index(%rip), %eax
        movq    %gs:88, %rcx
        movq    (%rcx,%rax,8), %rcx
        movl    counter@SECREL32(%rcx), %eax
        incl    %eax
        movl    %eax, counter@SECREL32(%rcx)
        retq

last_error:
        movl    _tls_index(%rip), %eax
        movq    %gs:88, %rcx
        movq    (%rcx,%rax,8), %rax
        movl    errcode@SECREL32(%rax), %eax
        retq

counter_addr:
        movl    _tls_index(%rip), %eax
        movq    %gs:88, %rcx
        movq    (%rcx,%rax,8), %rax
        leaq    counter@SECREL32(%rax), %rax
        retq

counter:
        .long   0

Intel syntax clang --target=x86_64-w64-windows-gnu -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-models.c

next:
        mov     eax, dword ptr [rip + _tls_index]
        mov     rcx, qword ptr gs:[88]
        mov     rcx, qword ptr [rcx + 8*rax]
        mov     eax, dword ptr [rcx + counter@SECREL32]
        inc     eax
        mov     dword ptr [rcx + counter@SECREL32], eax
        ret

last_error:
        mov     eax, dword ptr [rip + _tls_index]
        mov     rcx, qword ptr gs:[88]
        mov     rax, qword ptr [rcx + 8*rax]
        mov     eax, dword ptr [rax + errcode@SECREL32]
        ret

counter_addr:
        mov     eax, dword ptr [rip + _tls_index]
        mov     rcx, qword ptr gs:[88]
        mov     rax, qword ptr [rcx + 8*rax]
        lea     rax, [rax + counter@SECREL32]
        ret

counter:
        .long   0

Before Windows Vista, a DLL loaded with LoadLibrary could not rely on this mechanism; since then the loader supports it there too. The Windows API offers the same at run time, for any amount of data: TlsAlloc, TlsGetValue, TlsSetValue and TlsFree.

Emulated TLS

The MinGW-w64 GCC of the image, 14.2, does not use the TEB at all, and Clang for OpenBSD does not use FS. Both emulate thread-local storage: each variable gets a control object, here __emutls_v.counter, and every access calls __emutls_get_address from the compiler’s runtime library with the control object’s address, which returns the address of the running thread’s copy.

Every access is a call, and to a name of its own: Clang’s code for MinGW-w64 refers to counter itself, so objects of the two compilers cannot share a thread-local variable there.

tls-models.c

/* A thread-local variable of the program itself, and one that a
   shared library may define. Each thread has its own copies. */
_Thread_local int counter;
extern _Thread_local int errcode;

int next(void)
{
	return ++counter;
}

int last_error(void)
{
	return errcode;
}

int *counter_addr(void)
{
	return &counter;
}

MinGW-w64 GCC 14.2.0 (Debian 14.2.0-19+27+b1) x86_64-w64-mingw32

AT&T syntax x86_64-w64-mingw32-gcc -O2 -fno-asynchronous-unwind-tables -S tls-models.c

next:
        subq    $40, %rsp
        leaq    __emutls_v.counter(%rip), %rcx
        call    __emutls_get_address
        movq    %rax, %rdx
        movl    (%rax), %eax
        addl    $1, %eax
        movl    %eax, (%rdx)
        addq    $40, %rsp
        ret

last_error:
        subq    $40, %rsp
        movq    .refptr.__emutls_v.errcode(%rip), %rcx
        call    __emutls_get_address
        movl    (%rax), %eax
        addq    $40, %rsp
        ret

counter_addr:
        leaq    __emutls_v.counter(%rip), %rcx
        jmp     __emutls_get_address

__emutls_v.counter:
        .quad   4
        .quad   4
        .quad   0
        .quad   0

.refptr.__emutls_v.errcode:
        .quad   __emutls_v.errcode

Intel syntax x86_64-w64-mingw32-gcc -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-models.c

next:
        sub     rsp, 40
        lea     rcx, __emutls_v.counter[rip]
        call    __emutls_get_address
        mov     rdx, rax
        mov     eax, DWORD PTR [rax]
        add     eax, 1
        mov     DWORD PTR [rdx], eax
        add     rsp, 40
        ret

last_error:
        sub     rsp, 40
        mov     rcx, QWORD PTR .refptr.__emutls_v.errcode[rip]
        call    __emutls_get_address
        mov     eax, DWORD PTR [rax]
        add     rsp, 40
        ret

counter_addr:
        lea     rcx, __emutls_v.counter[rip]
        jmp     __emutls_get_address

__emutls_v.counter:
        .quad   4
        .quad   4
        .quad   0
        .quad   0

.refptr.__emutls_v.errcode:
        .quad   __emutls_v.errcode

Clang 23.1.2 x86_64-unknown-openbsd7

AT&T syntax clang --target=x86_64-unknown-openbsd7 -O2 -fno-asynchronous-unwind-tables -S tls-models.c

next:
        endbr64
        pushq   %rbp
        movq    %rsp, %rbp
        leaq    __emutls_v.counter(%rip), %rdi
        callq   __emutls_get_address@PLT
        movl    (%rax), %ecx
        incl    %ecx
        movl    %ecx, (%rax)
        movl    %ecx, %eax
        popq    %rbp
        retq

last_error:
        endbr64
        pushq   %rbp
        movq    %rsp, %rbp
        movq    __emutls_v.errcode@GOTPCREL(%rip), %rdi
        callq   __emutls_get_address@PLT
        movl    (%rax), %eax
        popq    %rbp
        retq

counter_addr:
        endbr64
        pushq   %rbp
        movq    %rsp, %rbp
        leaq    __emutls_v.counter(%rip), %rdi
        callq   __emutls_get_address@PLT
        popq    %rbp
        retq

__emutls_v.counter:
        .quad   4
        .quad   4
        .quad   0
        .quad   0

Intel syntax clang --target=x86_64-unknown-openbsd7 -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-models.c

next:
        endbr64
        push    rbp
        mov     rbp, rsp
        lea     rdi, [rip + __emutls_v.counter]
        call    __emutls_get_address@PLT
        mov     ecx, dword ptr [rax]
        inc     ecx
        mov     dword ptr [rax], ecx
        mov     eax, ecx
        pop     rbp
        ret

last_error:
        endbr64
        push    rbp
        mov     rbp, rsp
        mov     rdi, qword ptr [rip + __emutls_v.errcode@GOTPCREL]
        call    __emutls_get_address@PLT
        mov     eax, dword ptr [rax]
        pop     rbp
        ret

counter_addr:
        endbr64
        push    rbp
        mov     rbp, rsp
        lea     rdi, [rip + __emutls_v.counter]
        call    __emutls_get_address@PLT
        pop     rbp
        ret

__emutls_v.counter:
        .quad   4
        .quad   4
        .quad   0
        .quad   0

macOS: thread-local variable descriptors

macOS gives every thread-local variable a descriptor of three words in the section __DATA,__thread_vars: a function, a key and the variable’s offset. The code loads the descriptor’s address into RDI (@TLVP) and calls the function in its first word, which returns the variable’s address in RAX and, like a TLS descriptor of ELF, preserves every other register.

The descriptor of counter as the compiler writes it: __tlv_bootstrap in the first word, which dyld replaces when it loads the program, and a pointer to the initial value in the third, which the linker turns into an offset.

tls-models.c

/* A thread-local variable of the program itself, and one that a
   shared library may define. Each thread has its own copies. */
_Thread_local int counter;
extern _Thread_local int errcode;

int next(void)
{
	return ++counter;
}

int last_error(void)
{
	return errcode;
}

int *counter_addr(void)
{
	return &counter;
}

Clang 23.1.2 x86_64-apple-macos11

AT&T syntax clang --target=x86_64-apple-macos11 -O2 -fno-asynchronous-unwind-tables -S tls-models.c

_next:
        pushq   %rbp
        movq    %rsp, %rbp
        movq    _counter@TLVP(%rip), %rdi
        callq   *(%rdi)
        movl    (%rax), %ecx
        incl    %ecx
        movl    %ecx, (%rax)
        movl    %ecx, %eax
        popq    %rbp
        retq

_last_error:
        pushq   %rbp
        movq    %rsp, %rbp
        movq    _errcode@TLVP(%rip), %rdi
        callq   *(%rdi)
        movl    (%rax), %eax
        popq    %rbp
        retq

_counter_addr:
        pushq   %rbp
        movq    %rsp, %rbp
        movq    _counter@TLVP(%rip), %rdi
        callq   *(%rdi)
        popq    %rbp
        retq

_counter:
        .quad   __tlv_bootstrap
        .quad   0
        .quad   _counter$tlv$init

Intel syntax clang --target=x86_64-apple-macos11 -O2 -fno-asynchronous-unwind-tables -masm=intel -S tls-models.c

_next:
        push    rbp
        mov     rbp, rsp
        mov     rdi, qword ptr [rip + _counter@TLVP]
        call    qword ptr [rdi]
        mov     ecx, dword ptr [rax]
        inc     ecx
        mov     dword ptr [rax], ecx
        mov     eax, ecx
        pop     rbp
        ret

_last_error:
        push    rbp
        mov     rbp, rsp
        mov     rdi, qword ptr [rip + _errcode@TLVP]
        call    qword ptr [rdi]
        mov     eax, dword ptr [rax]
        pop     rbp
        ret

_counter_addr:
        push    rbp
        mov     rbp, rsp
        mov     rdi, qword ptr [rip + _counter@TLVP]
        call    qword ptr [rdi]
        pop     rbp
        ret

_counter:
        .quad   __tlv_bootstrap
        .quad   0
        .quad   _counter$tlv$init

dyld allocates a module’s thread-local data for a thread only when the thread first uses it, and keeps its address in a pthread key. The kernel makes GS point to the thread’s pthread-specific data, so the fast path of the function reads the slot of that key through GS, adds the offset and returns.

Summary

Thread-local variables in each ABI.
Linux x86-64Linux i386macOS x86-64Windows x64Windows x86
RegisterFSGSGSGSFS
Points toTCBTCBpthread’s thread-specific dataTEBTEB
Variable of the program%fs:x@tpoff%gs:x@ntpoffcall through a descriptorTLS array, _tls_index, offsetTLS array, _tls_index, offset
Variable of a library__tls_get_addr or a TLS descriptor___tls_get_addr or a TLS descriptorcall through a descriptorthe same as in the programthe same as in the program

Sources

  1. System V Application Binary Interface, AMD64 Architecture Processor Supplement, version 1.0: commit e1ce098331da, 2025-03-12
  2. Ulrich Drepper: ELF Handling For Thread-Local Storage, version 0.21: as published on 2026-09-25
  3. Alexandre Oliva: Thread-Local Storage Descriptors for IA32 and AMD64/EM64T, version 0.9.6: as published on 2026-09-25
  4. glibc 2.44: sysdeps/x86_64/nptl/tls.h: commit c3a3a9808ad3, 2026-07-24
  5. glibc 2.44: sysdeps/i386/nptl/tls.h: commit c3a3a9808ad3, 2026-07-24
  6. Linux 7.2: Documentation/arch/x86/x86_64/fsgs.rst: commit 8d3ae59288f1, 2026-08-16
  7. Linux 7.2: arch/x86/include/asm/percpu.h: commit 8d3ae59288f1, 2026-08-16
  8. Linux 7.2: arch/x86/kernel/process.c: commit 8d3ae59288f1, 2026-08-16
  9. GCC 14.2 manual: Options for Code Generation Conventions: as published on 2026-09-25
  10. GCC 14.2 manual: Common Variable Attributes: as published on 2026-09-25
  11. Microsoft Learn: PE Format: commit e103fa4e8810, 2026-09-15
  12. Microsoft Learn: Thread Local Storage (TLS): commit f70d88cd5da7, 2026-09-24
  13. LLVM 23.1.2: llvm/lib/Target/X86/X86ISelLowering.cpp: commit 85ac56026243, 2026-09-20
  14. GCC 14.2 internals manual: Emulating TLS: as published on 2026-09-25
  15. XNU xnu-12377.121.6: osfmk/i386/pcb_native.c: commit ac9718fb1af6, 2026-06-17
  16. dyld-1378: libdyld/ThreadLocalVariables.h: commit fd8d0c4d5232, 2026-06-15