Back to course

Thread-Local Storage

15 minadvancedModule 7: Resources & Runtime TablesContinues from pe-resources
15 min · Module 7: Resources & Runtime Tables · Structures

Why This Matters

A global variable has one copy for the whole process. The moment two threads write it, your program is guessing. Thread-local storage gives every thread its own copy of a variable, declared once and used like a normal static: __declspec (thread) int tlsFlag = 1; — the exact example from the PE/COFF specification.

The format supports this with a dedicated structure. The loader reads it, hands each thread a private block of memory, seeds it from a template, and (optionally) runs your callback functions at thread start and exit. Compilers, runtimes, and even the operating system lean on it: exception state, locale, random seeds, and C run-time per-thread data all live here.

Data directory index 9 (TLS Table) points at the TLS directory. An image with no static TLS simply leaves that slot empty — our cli-64.exe does exactly that — while conpty.dll carries a full one.

A Simple Model

Think of TLS as a photocopy machine with a master sheet:

  1. The template — a master block of initial bytes in the image. Every new thread gets a fresh copy of it.
  2. The index — a slot number the loader writes into your image so the code can find which entry of the thread array belongs to this module.
  3. The callbacks — an optional, null-terminated array of functions the loader can call at thread and process attach or detach.

The model's missing piece: the TLS directory does not use relative addresses. Its pointers are full virtual addresses, which ties the structure to the load address and forces the file to carry base relocations for it.

The Actual Detail

The specification chapter "The .tls Section" gives the six fields (PE32+ sizes):

OffsetSizeFieldSpec rule
08Raw Data Start VAStart of the template — a VA, not an RVA; there should be a base relocation for it in .reloc
88Raw Data End VALast byte of the template, also a VA
168Address of IndexLocation in an ordinary data section where the loader assigns the TLS index
248Address of CallbacksPointer to a null-terminated array of callback functions; an empty list is valid
324Size of Zero FillExtra bytes beyond the template, zero-filled for each thread
364CharacteristicsBits [23:20] hold IMAGE_SCN_ALIGN_* alignment; the other 28 bits are reserved

The spec also spells out how code reaches its per-thread data: the linker places the Address of Index so the runtime can read the index the loader writes there; the loader publishes each thread TLS array through the thread environment block (the spec notes the FS register and the 0x2C offset as Intel x86-specific details); code then indexes that array to find this module's data area for the current thread, and every variable is a fixed offset into that area.

Callback functions take the same three parameters as a DLL entry-point function — DLL handle, reason, reserved — and the reason values are DLL_PROCESS_DETACH (0), DLL_PROCESS_ATTACH (1), DLL_THREAD_ATTACH (2), and DLL_THREAD_DETACH (3). Multiple callbacks run in array order; a null pointer ends the list, and the spec states it is perfectly valid to have an empty list whose only member is that null pointer.

Common mistake. The data directory entry for TLS is an RVA of the TLS directory itself, but every address inside the directory is an absolute VA. Parse the outer pointer as an RVA, then treat the six inner pointers as VAs — otherwise your template address is off by ImageBase.

A Real Example

conpty.dll declares TLS directory index 9 at RVA 0x14180, size 0x28, which lands at file offset 0x12D80 in .rdata. Decoding all six fields:

00012d80: 404b 0180 0100 0000 484b 0180 0100 0000  @K......HK......
00012d90: 888a 0180 0100 0000 7814 0180 0100 0000  ........x.......
00012da0: 0000 0000 0000 3000                      ......0.

Walk-through:

  1. Start VA = 0x180014B40, End VA = 0x180014B48 — an 8-byte template. Those bytes read 00 00 00 00 00 00 00 80 (file 0x13740), the initial value this module gives each thread block.
  2. Address of Index = 0x180018A88, inside .data. That section has VirtualSize 0xC30 but SizeOfRawData only 0x400, and the index sits at offset 0xA88 — beyond the raw bytes. Per the spec, the remainder is zero-filled in memory, so the slot exists at runtime but not on disk, waiting for the loader to write the assigned index.
  3. Address of Callbacks = 0x180011478 → file 0x10078 holds eight zero bytes: the null terminator and nothing else. This DLL registers no TLS callbacks, which the spec explicitly allows.
  4. Size of Zero Fill = 0. Characteristics = 0x00300000: bits [23:20] equal 3, the IMAGE_SCN_ALIGN_4BYTES value.
00010078: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00013740: 0000 0000 0000 0080                      ........

The spec also carries a practical warning: before Windows Vista, static TLS was unreliable in DLLs loaded with LoadLibrary; later loaders can allocate TLS slots for such DLLs at load time. If you ship static TLS in a DLL, that history is part of your compatibility story.

Try It

On Windows, dumpbin shows the whole structure and the callback addresses (Microsoft, "dumpbin /TLS"):

dumpbin /TLS conpty.dll

On Linux or macOS, dump the directory bytes and decode six little-endian fields (the last two are 32-bit):

$ xxd -s 0x12d80 -l 40 conpty.dll
00012d80: 404b 0180 0100 0000 484b 0180 0100 0000  @K......HK......
00012d90: 888a 0180 0100 0000 7814 0180 0100 0000  ........x.......
00012da0: 0000 0000 0000 3000                      ......0.

What to look for: the top byte of each VA is 0x01 — the images live at 0x180000000 and 0x140000000 style bases, so the high byte of a 64-bit little-endian pointer shows up at the far right of its group. And if the last two bytes decode to 0x00300000, you have just read the alignment field.

Check Your Understanding

Without looking back: whose job is the value stored at Address of Index, and where does it live?

Apply It

A TLS directory has Characteristics = 0x00300000. What do bits [23:20] say, according to the spec?

The pattern: mask the four bits, shift them down 20, and match the enum — the same bit-reading habit every PE flag needs.

Connect

TLS describes per-thread data and startup hooks. The next table describes something much less friendly: how the processor is supposed to rebuild a stack that has already crashed.

Next: Exception Tables (.pdata) — RUNTIME_FUNCTION entries that make x64 unwinding work.