commit e51cf4693982cac804e78807d5e31a9293b67a66
parent d1e302e28a2ae299d6e5beeec54d80ee0ec6087b
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 9 Jun 2026 12:11:16 -0700
windows: resolve JIT printf-family and thread-local gaps
Two things an AOT `kit cc` link resolved but the in-process JIT (`kit run`)
did not on Windows. Both are now fixed and VM-verified on aarch64-windows;
the JIT Toy corpus goes 164/166 -> 166/166.
printf-family: with kit's UCRT profile (__USE_MINGW_ANSI_STDIO=0) mingw's
<stdio.h> resolves the printf/scanf family to out-of-line wrappers that live
only in libucrt.a -- ucrtbase.dll exports just the __stdio_common_v* cores --
so the JIT (which resolves externs via dlsym over loaded DLLs, no static
archives) left `printf` undefined. driver_dlsym_resolver now hands JIT'd code
kit.exe's own statically-linked copies of the family via a KIT_STATIC_STDIO
macro (taking each address pulls its libucrt.a definition into our image).
DLL-exported entry points (puts/fputs/fflush/__stdio_common_v*/__acrt_iob_func)
still resolve through win_dlsym.
thread-local local-exec: the COFF TLS-access idiom (TEB ThreadLocalStoragePointer
-> _tls_index -> block -> +SECREL) cannot run under the JIT (no loaded PE for a
_tls_index, no per-module TEB block). As with ELF Local-Exec, the single-threaded
JIT now relaxes the idiom to address the in-image .tls instance directly: a
unified jit_reloc_is_tls_le classifier routes the terminal SECREL (aa64 SECREL12A
kinds; x64 SECREL to an SK_TLS target) to the per-arch jit_tls_le_relax, which
collapses the 4-/7-instruction sequence to `lea rd,[rip+&var]` / nops+adrp+add;
_tls_index is treated as a JIT-defined no-op (resolve + append preflight) and its
idiom relocs are dropped. 141/142 now return 43/134 under JIT, and a C
_Thread_local program mutates/reads correctly.
test/link/jit_tls_relax_test.c pins the byte rewrite for both arches (the only
automated coverage for the x86-64 idiom, which can't run end-to-end until the
x64 self-host _setjmp blocker is closed); the x64 idiom builder is pinned to a
real-disasm golden. doc/plan/windows.md: mark the gaps done and collect the
remaining open bugs (the AOT "malformed UCN" rt-build defect and the x64
_setjmp cross-link blocker).
Diffstat:
9 files changed, 494 insertions(+), 73 deletions(-)
diff --git a/doc/plan/windows.md b/doc/plan/windows.md
@@ -34,8 +34,9 @@ follow-on (rt path resolution).
- **Toy corpus on the VM** (native `kit.exe`, compare `.expected`):
- AOT (`kit cc` compile + link + execute): **166 / 166 pass**, 0 miscompile,
0 crash, at `-O0`.
- - JIT (`kit run`): **164 / 166** — the two misses are the thread-local cases
- (§1), which return a clean error, not a crash.
+ - JIT (`kit run`): **166 / 166** — the printf-family and thread-local gaps
+ (§1) are fixed; the two thread-local cases (`141`, `142`) now return their
+ values (43, 134).
Fixes the bring-up required (all in tree):
- **Large-frame stack probes in codegen** — *the fix that closed the ~17
@@ -75,35 +76,80 @@ Fixes the bring-up required (all in tree):
`GetModuleFileNameW`) when `argv[0]` isn't a usable path (PATH invocation),
so `<bindir>/support/rt` resolves from any cwd.
-The items below are what is **not** yet done.
-
-## 1. JIT runtime gaps: printf-family and thread-locals
-
-Two things an AOT `kit cc` link resolves but the in-process JIT (`kit run`) does
-not, on Windows specifically. Neither is a codegen bug — the same programs
-compile+link+run correctly under AOT, and run under the host JIT on
-macOS/Linux.
-
-**printf-family.** `kit run` of a printf program fails: `undefined reference to
-'printf'`. mingw's `<stdio.h>` printf wrapper resolves to the static `printf`
-(and `__local_stdio_printf_options`) that live only in `libucrt.a` — not DLL
-exports — and the JIT links no static archives (it resolves externs via dlsym
-over loaded DLLs + in-image symbols). AOT `kit cc` printf works fully (it links
-`libucrt.a`); `puts` and other direct ucrtbase exports work under JIT.
-
-Options: (a) feed the hosted profile's static archives to the JIT link so it
-lazily pulls static members like `printf`/`__local_stdio_printf_options` (and
-routes the short-import members to dlsym) — the general fix; or (b) extend the
-`driver/env/windows.c` dlsym fallback (already used for the
-`__local_stdio_*_options` helpers) to the small set of libucrt-static stdio
-entry points, handing JIT'd code kit.exe's own statically-linked copies.
-
-**Thread-local local-exec.** `kit run` of a `threadlocal`-using program returns
-a clean error (rc 1), not the program's value — local-exec TLS isn't resolved
-by the in-process JIT on Windows. The same cases compile+link+run correctly via
-AOT `kit cc` (PE TLS directory) and under the host JIT, so it's a Windows-JIT
-TLS-resolution gap. Repro: `141_threadlocal_mutate` (expect 43),
-`142_threadlocal_multi` (expect 134) — the only two Toy JIT misses.
+The items below are what is **not** yet done. The concrete open bugs/blockers
+are collected first; the numbered sections are the larger roadmap items.
+
+## Open bugs / blockers
+
+Concrete defects surfaced during bring-up, each blocking a roadmap item below.
+
+- **AOT `malformed UCN` building rt on the VM** (blocks native `kit cc`'s
+ on-demand rt path, and §5 bootstrap). On the VM, `kit.exe cc <prog>` fails
+ while compiling the runtime:
+ `support/rt/lib/int/int.c:203:46: fatal: malformed UCN`. That line is
+ `if (a == ((di_int)((du_int)1 << (N - 1)))) compilerrt_abort();` — there is no
+ `\u`/`\U` anywhere near column 46, so "malformed UCN" is a *misdiagnosis*: the
+ Windows-hosted lexer is mis-tokenizing valid source. It is **not** a stale
+ deploy — it reproduces with a freshly-shipped rt tree, and with the *baseline*
+ `kit.exe` (predating the JIT-gap work), so it is pre-existing. The macOS host
+ `build/kit` compiles the *same* `int.c` for `aarch64-windows` cleanly
+ (`make rt-aarch64-windows` succeeds), so the bug is specific to the
+ **Windows-hosted** kit.exe. Leading hypotheses: (a) a Windows-only file-read /
+ line-ending / encoding path feeding the lexer; or (b) a latent
+ **self-compile codegen bug** in the cross-built kit.exe that only manifests in
+ the lexer's UCN/escape handling — which would also threaten §5 (a stage-2
+ kit.exe that miscompiles its own lexer). Worth bisecting against a known-good
+ source byte stream on the VM (e.g. `kit cc` a minimal file with the exact
+ bytes of line 203) to separate (a) from (b). *Workaround used for verifying
+ the JIT fixes:* host-cross-compile the `.exe` on macOS, then run it on the VM,
+ which never invokes the VM-side rt build.
+
+- **x64 cross-link fails on `_setjmp`** (blocks all of §2, x86_64-windows
+ parity). `scripts/windows_cross.sh x64` cannot link an x64 `kit.exe`:
+ `fatal: link: undefined reference to '_setjmp'`. mingw's x86-64 `<setjmp.h>`
+ routes `setjmp` to the intrinsic `_setjmp` (which takes an implicit
+ frame/SSP-context arg), not the bare `__mingw_setjmp` that `rt/include/setjmp.h`
+ asm-renames `setjmp` to on the aarch64 path. Needs an x64-specific setjmp shim
+ (or an `_setjmp`→`__mingw_setjmp` mapping that supplies the implicit arg)
+ before an x64 `kit.exe` can link. See §2.
+
+## 1. JIT runtime gaps: printf-family and thread-locals — DONE
+
+Two things an AOT `kit cc` link resolved but the in-process JIT (`kit run`) did
+not, on Windows specifically. Both are now fixed and VM-verified on
+aarch64-windows; neither was a codegen bug.
+
+**printf-family (option b).** `kit run` of a printf program failed: `undefined
+reference to 'printf'`. With kit's UCRT profile (`__USE_MINGW_ANSI_STDIO=0`)
+mingw's `<stdio.h>` resolves the printf/scanf family to out-of-line wrappers
+that live only in `libucrt.a` — ucrtbase.dll exports just the
+`__stdio_common_v*` cores (and `__local_stdio_*` helpers). The JIT links no
+static archives (it resolves externs via dlsym over loaded DLLs), so those
+wrappers were unresolvable. Fix: `driver/env/windows.c`'s `driver_dlsym_resolver`
+now hands JIT'd code kit.exe's own statically-linked copies of the family
+(`printf`, `fprintf`, `sprintf`, `snprintf`/`_snprintf`, the `v*`/`scanf`
+variants, …) — taking each function's address pulls its `libucrt.a` definition
+into our image. DLL-exported entry points (`puts`, `fputs`, `fflush`,
+`__stdio_common_v*`, `__acrt_iob_func`) still resolve through dlsym and never
+reach the table. (Option (a) — feeding the JIT link the static archives — was
+the more general alternative; option (b) is smaller and sufficient.)
+
+**Thread-local local-exec.** `kit run` of a `threadlocal`-using program returned
+a clean error (rc 1) — the COFF TLS-access idiom (read TEB
+ThreadLocalStoragePointer, index by `_tls_index`, then `+ SECREL(var)`) can't
+run under the JIT: there is no loaded PE the OS could assign a `_tls_index`, nor
+a per-module TLS block in the thread's TEB array. Fix: as with ELF Local-Exec
+(`RELOC_IS_TLS_LE`), the single-threaded JIT now relaxes the idiom to address
+the in-image `.tls` instance directly. The terminal SECREL reloc(s) drive a
+per-arch idiom rewrite (`x64_jit_tls_le_relax` / `aa64_jit_tls_le_relax` collapse
+the 4-/7-instruction sequence to `lea rd,[rip+&var]` / `adrp+add`), the
+`_tls_index` symbol is treated as a JIT-defined no-op (so resolve doesn't reject
+it) and its idiom relocs are dropped (link_jit.c). `141_threadlocal_mutate`
+(43) and `142_threadlocal_multi` (134) now pass under JIT; a C `_Thread_local`
+program mutates/reads correctly too. Regression guard:
+`test/link/jit_tls_relax_test.c` pins the byte rewrite for both arches (the only
+automated coverage for the x86-64 idiom, since x64-windows can't run end-to-end
+until §2's `_setjmp` gap is closed).
## 2. x86_64-windows parity
@@ -113,7 +159,11 @@ path isn't built/verified end to end:
- Build `rt-x86_64-pc-windows` (the variant exists in `mk/rt.mk` /
`driver/lib/runtime.c`; not built by default here).
- `scripts/windows_cross.sh x64` should produce an x64 `kit.exe`; verify it runs
- via the VM's x64 emulator and re-run the Toy AOT corpus.
+ via the VM's x64 emulator and re-run the Toy AOT corpus. **Blocked** by the
+ `_setjmp` cross-link failure (see *Open bugs / blockers* above) — an x64
+ `kit.exe` can't link until that's fixed. (Independently, the new
+ `test/link/jit_tls_relax_test.c` already pins the x64 JIT-TLS relax bytes, so
+ that path is guarded even before the self-host build is unblocked.)
- Known x64-windows codegen gaps to expect (from the toy VM lanes): `36/37`
`*sret` tail-call crash at **-O1**, and `118_decl_extra_attrs` ADRP-range link
is aarch64-only. Re-confirm against a native x64 `kit.exe`.
@@ -162,7 +212,10 @@ now that the large-frame stack-probe crash is fixed — kit.exe compiles + links
runs the full Toy AOT corpus (166/166) on the VM. Next: confirm kit.exe can
compile the full libkit/driver source set on the VM, then add a
`scripts/windows_bootstrap.sh` to drive the VM-side stages and the
-stage-2==stage-3 byte-identity check.
+stage-2==stage-3 byte-identity check. **Gated** by the `malformed UCN` bug (see
+*Open bugs / blockers*): a stage-1 kit.exe that can't even compile the rt source
+tree on the VM can't compile libkit there — and if that bug is a self-compile
+codegen defect, it threatens stage-2==stage-3 identity directly.
## 6. Debugger fault-guard / SEH on Windows
diff --git a/driver/env/windows.c b/driver/env/windows.c
@@ -1207,28 +1207,56 @@ static void* win_dlsym(const char* name) {
return NULL;
}
-/* mingw's <stdio.h> printf/scanf inline wrappers call these per-module option
- * helpers, whose out-of-line definitions live ONLY in libucrt.a — no DLL
- * exports them. AOT links them in; the JIT resolves externs via dlsym over
- * loaded DLLs (win_dlsym), which therefore can't find them, leaving a
- * printf-using JIT program with an undefined __local_stdio_printf_options.
- * kit.exe statically links libucrt.a, and referencing these here pulls their
- * definitions into our own image, so we can hand JIT'd code our copies. The
- * options storage only ever holds the default (0) flags, so sharing it between
- * the host and the JIT'd program is benign. */
static int win_name_eq(KitSlice s, const char* lit, size_t litlen) {
return s.len == litlen && memcmp(s.s, lit, litlen) == 0;
}
+/* libucrt-static stdio entry points handed to JIT'd code.
+ *
+ * With kit's UCRT profile (__USE_MINGW_ANSI_STDIO=0), mingw's <stdio.h> resolves
+ * the printf/scanf family to out-of-line wrappers that live ONLY in libucrt.a —
+ * ucrtbase.dll exports just the __stdio_common_v* cores (and __local_stdio_*
+ * helpers the wrappers call). An AOT `kit cc` link pulls the wrappers from
+ * libucrt.a; the in-process JIT (`kit run`) resolves externs via dlsym over
+ * loaded DLLs (win_dlsym), which can't see them, so a printf-using JIT program
+ * fails with an undefined `printf`. kit.exe statically links libucrt.a, so
+ * taking each wrapper's address here pulls its definition into our own image and
+ * lets us hand the JIT our copy. Functions that ARE ucrtbase.dll exports (puts,
+ * fputs, fflush, putchar, __stdio_common_v*, __acrt_iob_func, …) resolve through
+ * win_dlsym above and never reach this table. The __local_stdio_*_options
+ * helpers' storage only ever holds the default (0) flags, so sharing one
+ * instance between the host and the JIT'd program is benign. */
void* driver_dlsym_resolver(void* user, KitSlice name_s) {
+ void* p;
(void)user;
if (!name_s.s || name_s.len == 0) return NULL;
- void* p = win_dlsym(name_s.s);
+ p = win_dlsym(name_s.s);
if (p) return p;
- if (win_name_eq(name_s, "__local_stdio_printf_options", 28))
- return (void*)(uintptr_t)&__local_stdio_printf_options;
- if (win_name_eq(name_s, "__local_stdio_scanf_options", 27))
- return (void*)(uintptr_t)&__local_stdio_scanf_options;
+ /* Match by source name; #fn stringizes it and sizeof-1 is its length. */
+#define KIT_STATIC_STDIO(fn) \
+ if (win_name_eq(name_s, #fn, sizeof(#fn) - 1u)) return (void*)(uintptr_t)&fn
+ KIT_STATIC_STDIO(__local_stdio_printf_options);
+ KIT_STATIC_STDIO(__local_stdio_scanf_options);
+ KIT_STATIC_STDIO(printf);
+ KIT_STATIC_STDIO(fprintf);
+ KIT_STATIC_STDIO(sprintf);
+ KIT_STATIC_STDIO(snprintf);
+ KIT_STATIC_STDIO(_snprintf);
+ KIT_STATIC_STDIO(vprintf);
+ KIT_STATIC_STDIO(vfprintf);
+ KIT_STATIC_STDIO(vsprintf);
+ KIT_STATIC_STDIO(vsnprintf);
+ KIT_STATIC_STDIO(_vsnprintf);
+ KIT_STATIC_STDIO(scanf);
+ KIT_STATIC_STDIO(fscanf);
+ KIT_STATIC_STDIO(sscanf);
+ KIT_STATIC_STDIO(_snscanf);
+ KIT_STATIC_STDIO(vscanf);
+ KIT_STATIC_STDIO(vfscanf);
+ KIT_STATIC_STDIO(vsscanf);
+ KIT_STATIC_STDIO(_scprintf);
+ KIT_STATIC_STDIO(_vscprintf);
+#undef KIT_STATIC_STDIO
return NULL;
}
diff --git a/mk/test.mk b/mk/test.mk
@@ -96,6 +96,7 @@ TEST_TARGETS = \
test-link-reloc-uleb128 \
test-link-reloc-desc \
test-link-reloc-apply \
+ test-link-jit-tls-relax \
test-macho \
test-native-direct-target \
test-opt \
@@ -163,6 +164,7 @@ DEFAULT_TEST_TARGETS = \
test-link-reloc-uleb128 \
test-link-reloc-desc \
test-link-reloc-apply \
+ test-link-jit-tls-relax \
test-dbg \
test-disasm-complete \
test-macho \
@@ -394,6 +396,15 @@ RELOC_APPLY_TEST_BIN = build/test/reloc_apply_test
test-link-reloc-apply: $(RELOC_APPLY_TEST_BIN)
$(RELOC_APPLY_TEST_BIN)
+# In-process JIT TLS Local-Exec relaxation for the Windows (COFF) TEB idiom:
+# pins the per-arch byte rewrite (jit_tls_le_relax) for x86-64 and aarch64. The
+# aarch64 path is also covered end-to-end on the Windows VM; this is the only
+# automated guard for the x86-64 idiom. Internal-surface (raw lib objects).
+JIT_TLS_RELAX_TEST_BIN = build/test/jit_tls_relax_test
+
+test-link-jit-tls-relax: $(JIT_TLS_RELAX_TEST_BIN)
+ $(JIT_TLS_RELAX_TEST_BIN)
+
# test-emu-unit: white-box unit tests for the emulator's INTERNAL units (rv64
# decoder, EmuAddrSpace, Linux syscall handler) that have no public API. Reaches
# internal symbols -> links $(LIB_OBJS) (mirrors test-interp), not the archive.
diff --git a/mk/test_unit.mk b/mk/test_unit.mk
@@ -54,7 +54,7 @@ UNIT_TESTS_INTERNAL := \
dwarf_test debug_roundtrip_unit debug_cfi_unit \
aa64_isa_test rv64_decode_test rv32_decode_test aa64_sweep_gen \
reloc_uleb128_unit reloc_desc_test reloc_apply_test emu_rv64_unit_test \
- interp_smoke_test \
+ interp_smoke_test jit_tls_relax_test \
rv64_interp_smoke_test abi_classify_test ir_recorder_test \
native_direct_target_test x64_dbg_test cg_ir_lower_test tiny_inline_test
dwarf_test_SRC := test/dwarf/dwarf_test.c
@@ -67,6 +67,7 @@ aa64_sweep_gen_SRC := test/arch/aa64_sweep_gen.c
reloc_uleb128_unit_SRC := test/link/reloc_uleb128_unit.c
reloc_desc_test_SRC := test/link/reloc_desc_test.c
reloc_apply_test_SRC := test/link/reloc_apply_test.c
+jit_tls_relax_test_SRC := test/link/jit_tls_relax_test.c
emu_rv64_unit_test_SRC := test/emu/rv64_vm_unit_test.c
interp_smoke_test_SRC := test/interp/interp_smoke_test.c
rv64_interp_smoke_test_SRC := test/emu/rv64_interp_smoke_test.c
diff --git a/src/arch/aa64/reloc.c b/src/arch/aa64/reloc.c
@@ -223,19 +223,48 @@ int aa64_reloc_apply_insn(Compiler* c, RelocKind k, u8* P_bytes, u64 S, i64 A,
}
/* In-process JIT TLS Local-Exec relaxation (LinkArchDesc.jit_tls_le_relax).
- * Codegen emits, per access:
+ *
+ * ELF, per access:
* mrs rd, tpidr_el0 (no reloc)
* add rd, rd, #hi12 R_AARCH64_TLSLE_ADD_TPREL_HI12 <- `site`
* add rd, rd, #lo12 R_AARCH64_TLSLE_ADD_TPREL_LO12_NC
- * Single-threaded JIT: address the in-image storage directly, dropping the
- * thread-pointer read:
- * adrp rd, &var ; add rd, rd, :lo12:&var ; nop
- * The HI12 reloc drives the whole rewrite; the LO12 half is then a no-op. */
+ *
+ * Windows/COFF, per access — the 7-instruction TEB idiom (see
+ * aa_tls_addr_of_win):
+ * site-20 ldr rd, [x18, #0x58] TEB.ThreadLocalStoragePointer
+ * site-16 adrp x16, _tls_index }
+ * site-12 add x16, x16, :lo12:_tls_index } &_tls_index (relocs dropped)
+ * site-8 ldr w16, [x16] module TLS index
+ * site-4 ldr rd, [rd, x16, lsl #3] this module's TLS block base
+ * site add rd, rd, :secrel_hi12:sym R_COFF_AARCH64_SECREL_HIGH12A <-
+ * site+4 add rd, rd, :secrel_lo12:sym R_COFF_AARCH64_SECREL_LOW12A
+ *
+ * Single-threaded JIT: in both cases address the in-image storage directly,
+ * dropping the thread-pointer read (and, on Windows, the `_tls_index` / TEB
+ * indirection): adrp rd, &var ; add rd, rd, :lo12:&var ; nop(s). */
void aa64_jit_tls_le_relax(Compiler* c, RelocKind k, u8* site, u64 storage,
u64 site_pc) {
u8* mrs;
u8* add_lo;
u32 rd;
+ /* Windows COFF idiom: the terminal HIGH12A drives the whole rewrite; the
+ * LOW12A half is then a no-op (mirrors the ELF HI12/LO12 split). */
+ if (k == R_COFF_AARCH64_SECREL_LOW12A) return; /* handled with HIGH12A */
+ if (k == R_COFF_AARCH64_SECREL_HIGH12A) {
+ u8* p;
+ rd = rd_u32_le(site) & 0x1fu;
+ /* nop the TEB read, the _tls_index materialize + load, and the block load
+ * (site-20 .. site-8); reuse the block-load slot at site-4 for the ADRP. */
+ for (p = site - 20; p <= site - 8; p += 4) wr_u32_le(p, 0xd503201fu);
+ wr_u32_le(site - 4, 0x90000000u | rd); /* adrp rd, #0 */
+ aa64_reloc_apply_insn(c, R_AARCH64_ADR_PREL_PG_HI21, site - 4, storage, 0,
+ site_pc - 4u);
+ wr_u32_le(site, 0x91000000u | (rd << 5) | rd); /* add rd, rd, #0 */
+ aa64_reloc_apply_insn(c, R_AARCH64_ADD_ABS_LO12_NC, site, storage, 0,
+ site_pc);
+ wr_u32_le(site + 4, 0xd503201fu); /* nop the secrel_lo12 add */
+ return;
+ }
if (k == R_AARCH64_TLSLE_ADD_TPREL_LO12_NC) return; /* handled with HI12 */
if (k != R_AARCH64_TLSLE_ADD_TPREL_HI12)
compiler_panic(c, SRCLOC_NONE, "aa64 jit tls: unexpected reloc kind %u",
diff --git a/src/arch/x64/reloc.c b/src/arch/x64/reloc.c
@@ -59,12 +59,24 @@ int x64_reloc_apply_insn(Compiler* c, RelocKind k, u8* P_bytes, u64 S, i64 A,
}
/* In-process JIT TLS Local-Exec relaxation (LinkArchDesc.jit_tls_le_relax).
- * Codegen emits, per access:
+ *
+ * ELF (R_X64_TPOFF32), per access:
* 64 REX.W 8B modrm sib disp32 mov rd, fs:[0] (fixed 9 bytes)
* REX.W 8D modrm [sib] disp32 lea rd,[rd+tpoff] (7 or 8 bytes; `site`
- * points at this disp32, R_X64_TPOFF32)
- * Single-threaded JIT: nop the whole block and emit `lea rd,[rip+&var]` so rd
- * holds the in-image storage address, dropping the fs read. */
+ * points at this disp32)
+ *
+ * Windows/COFF (R_COFF_SECREL to a TLS symbol), per access — the 4-instruction
+ * TEB idiom (see x64_tls_addr_of_win64), all heading the trailing lea:
+ * 65 REX.W 8B modrm sib disp32 mov rd, gs:[0x58] (9 bytes)
+ * 44 8B 1D disp32 mov r11d, [rip+_tls_index] (7 bytes)
+ * REX 8B modrm sib [disp8] mov rd, [rd+r11*8] (4 or 5 bytes)
+ * REX.W 8D modrm [sib] disp32 lea rd,[rd+sym@SECREL] (7 or 8 bytes;
+ * `site` points at this disp32)
+ *
+ * Single-threaded JIT: in both cases nop the whole block and emit
+ * `lea rd,[rip+&var]` so rd holds the in-image storage address, dropping the
+ * segment read (and, on Windows, the `_tls_index` / TEB indirection — its
+ * `_tls_index` reloc is separately dropped by the JIT reloc pass). */
void x64_jit_tls_le_relax(Compiler* c, RelocKind k, u8* site, u64 storage,
u64 site_pc) {
u8* block_end = site + 4; /* disp32 ends the lea */
@@ -73,16 +85,29 @@ void x64_jit_tls_le_relax(Compiler* c, RelocKind k, u8* site, u64 storage,
u8* p;
u32 rd;
i64 disp;
- if (k != R_X64_TPOFF32)
+ if (k == R_X64_TPOFF32) {
+ /* The 9-byte fs-mov ends where the lea (7 or 8 bytes) begins. */
+ p = block_end - 7 - 9;
+ if (p[0] == 0x64u && p[2] == 0x8Bu)
+ mov = p;
+ else {
+ p = block_end - 8 - 9;
+ if (p[0] == 0x64u && p[2] == 0x8Bu) mov = p;
+ }
+ } else if (k == R_COFF_SECREL) {
+ /* The gs-mov heads the idiom; its distance to block_end is
+ * 9 + 7 + (4|5) + (7|8) = 27..29 bytes. Match the gs-mov's strong
+ * signature: 65 <rex> 8B <modrm> 25 58 00 00 00. */
+ int off;
+ for (off = 27; off <= 29 && !mov; ++off) {
+ p = block_end - off;
+ if (p[0] == 0x65u && p[2] == 0x8Bu && p[4] == 0x25u && p[5] == 0x58u &&
+ p[6] == 0u && p[7] == 0u && p[8] == 0u)
+ mov = p;
+ }
+ } else {
compiler_panic(c, SRCLOC_NONE, "x64 jit tls: unexpected reloc kind %u",
(unsigned)k);
- /* The 9-byte fs-mov ends where the lea (7 or 8 bytes) begins. */
- p = block_end - 7 - 9;
- if (p[0] == 0x64u && p[2] == 0x8Bu)
- mov = p;
- else {
- p = block_end - 8 - 9;
- if (p[0] == 0x64u && p[2] == 0x8Bu) mov = p;
}
if (!mov)
compiler_panic(c, SRCLOC_NONE, "x64 jit tls: unexpected access sequence");
diff --git a/src/link/link_jit.c b/src/link/link_jit.c
@@ -121,6 +121,40 @@ static int perms_for(u32 secflags) {
return p;
}
+/* Windows (COFF) Local-Exec TLS in the single-threaded in-process JIT.
+ *
+ * The codegen emits the PE TLS-access idiom: read TEB.ThreadLocalStoragePointer,
+ * index it by the module's `_tls_index`, then `+ SECREL(var)`. That cannot run
+ * under the JIT — there is no loaded PE the OS could assign a `_tls_index` to,
+ * nor a per-module TLS block in this thread's TEB array. As with ELF Local-Exec
+ * (RELOC_IS_TLS_LE), the JIT relaxes the idiom to address the single in-image
+ * .tls instance directly: the terminal SECREL reloc(s) drive the relaxation
+ * (jit_tls_le_relax), and the idiom's `_tls_index` ADRP/PC32 relocs are dropped
+ * because those instructions get rewritten away. */
+static int jit_tls_is_index_ref(Compiler* c, const LinkSymbol* tgt) {
+ Slice nm;
+ if (obj_format_tls_model(c) != OBJ_TLS_WINDOWS_TEB) return 0;
+ if (!tgt || tgt->name == 0) return 0;
+ nm = pool_slice(c->global, tgt->name);
+ return nm.len == 10u && memcmp(nm.s, "_tls_index", 10u) == 0;
+}
+
+/* True for the terminal SECREL reloc(s) of the Windows TLS idiom: aarch64's
+ * SECREL12A kinds are TLS-only, while x86-64's generic SECREL is a TLS access
+ * exactly when its target is a TLS symbol. */
+static int jit_reloc_is_win_tls_le(Compiler* c, RelocKind k, u8 tgt_kind) {
+ if (obj_format_tls_model(c) != OBJ_TLS_WINDOWS_TEB) return 0;
+ return k == R_COFF_AARCH64_SECREL_HIGH12A ||
+ k == R_COFF_AARCH64_SECREL_LOW12A ||
+ (k == R_COFF_SECREL && tgt_kind == SK_TLS);
+}
+
+/* Unified TLS Local-Exec classifier for the JIT reloc passes: the arch-neutral
+ * RELOC_IS_TLS_LE flag (ELF) plus the COFF Windows idiom above. */
+static int jit_reloc_is_tls_le(Compiler* c, RelocKind k, u8 tgt_kind) {
+ return reloc_kind_is_tls_le(c, k) || jit_reloc_is_win_tls_le(c, k, tgt_kind);
+}
+
/* Find the segment that contains image-relative `vaddr` and return its
* runtime address (the runtime alias, not the write alias). Up to 3
* segments after layout, so a linear scan is fine.
@@ -359,10 +393,14 @@ KitJit* kit_jit_from_image(LinkImage* img) {
const LinkSymbol* tgt = LinkSyms_at(&img->syms, r->target - 1);
u64 S, P;
u8* P_bytes;
- /* ELF Local-Exec TLS -> in-image addressing (single-threaded JIT). The
- * per-arch idiom rewrite lives behind LinkArchDesc.jit_tls_le_relax; this
- * loop stays arch-neutral, classifying via the RELOC_IS_TLS_LE flag. */
- if (reloc_kind_is_tls_le(c, r->kind)) {
+ /* Windows TLS idiom `_tls_index` references are dropped: the access is
+ * relaxed to in-image addressing, so the index load is rewritten away. */
+ if (jit_tls_is_index_ref(c, tgt)) continue;
+ /* Local-Exec TLS -> in-image addressing (single-threaded JIT). The per-arch
+ * idiom rewrite lives behind LinkArchDesc.jit_tls_le_relax; this loop stays
+ * arch-neutral, classifying ELF via RELOC_IS_TLS_LE and the COFF Windows
+ * idiom via its terminal SECREL reloc(s). */
+ if (jit_reloc_is_tls_le(c, r->kind, tgt->kind)) {
const LinkArchDesc* d = link_arch_desc_for(c);
u64 storage = (tgt->kind == SK_ABS)
? tgt->vaddr + (u64)r->addend
@@ -702,10 +740,12 @@ static void jit_apply_one_reloc(KitJit* jit, const LinkRelocApply* r) {
u64 S;
u64 P;
u8* P_bytes;
- /* ELF Local-Exec TLS -> in-image addressing (single-threaded JIT); arch
- * idiom rewrite behind LinkArchDesc.jit_tls_le_relax (see kit_jit_from_image
- * for the mirror of this in the initial reloc pass). */
- if (reloc_kind_is_tls_le(jit->c, r->kind)) {
+ /* Windows TLS idiom `_tls_index` references are dropped (see kit_jit_from_image). */
+ if (jit_tls_is_index_ref(jit->c, tgt)) return;
+ /* Local-Exec TLS -> in-image addressing (single-threaded JIT); arch idiom
+ * rewrite behind LinkArchDesc.jit_tls_le_relax (see kit_jit_from_image for
+ * the mirror of this in the initial reloc pass). */
+ if (jit_reloc_is_tls_le(jit->c, r->kind, tgt->kind)) {
const LinkArchDesc* d = link_arch_desc_for(jit->c);
u64 storage = (tgt->kind == SK_ABS)
? tgt->vaddr + (u64)r->addend
@@ -854,6 +894,10 @@ static void jit_append_obj_inner(KitJit* jit, ObjBuilder* ob) {
size_t nlen = nm_s.len;
if (nm && nlen == 15u && memcmp(nm, "__tlv_bootstrap", 15u) == 0)
ok = 1;
+ /* Windows COFF TLS module-index symbol: the JIT relaxes every TLS
+ * access to in-image addressing, so `_tls_index` is never read. */
+ if (nm && nlen == 10u && memcmp(nm, "_tls_index", 10u) == 0)
+ ok = 1;
if (!ok) {
obj_format_demangle_c(jit->c, &nm, &nlen);
obj_symiter_free(it);
diff --git a/src/link/link_resolve.c b/src/link/link_resolve.c
@@ -481,6 +481,17 @@ void link_resolve_undefs(Linker* l, LinkImage* img) {
s->defined = 1;
continue;
}
+ /* Windows COFF Local-Exec TLS: the in-process JIT relaxes every TLS
+ * access to in-image addressing (see kit_jit_from_image), so the PE
+ * module-index symbol `_tls_index` — normally supplied by the OS loader
+ * via the TLS directory — is never read. Define it as 0 so resolve does
+ * not reject it; its idiom relocs are dropped in the JIT reloc pass. */
+ if (nm && nlen == 10u && memcmp(nm, "_tls_index", 10u) == 0) {
+ s->kind = SK_ABS;
+ s->vaddr = 0;
+ s->defined = 1;
+ continue;
+ }
}
{
Slice nm_s = s->name ? pool_slice(l->c->global, s->name) : SLICE_NULL;
diff --git a/test/link/jit_tls_relax_test.c b/test/link/jit_tls_relax_test.c
@@ -0,0 +1,219 @@
+/* In-process JIT TLS Local-Exec relaxation for the Windows (COFF) TEB idiom.
+ *
+ * The single-threaded JIT rewrites the PE TLS-access idiom (read TEB
+ * ThreadLocalStoragePointer, index by `_tls_index`, then `+ SECREL(var)`) to
+ * address the in-image .tls instance directly, dropping the TEB/`_tls_index`
+ * indirection (see src/link/link_jit.c and the per-arch jit_tls_le_relax). The
+ * aarch64 path is verified end-to-end on the Windows VM; this pins the
+ * byte-level rewrite for BOTH arches, and is the only automated guard for the
+ * x86-64 idiom (x64-windows self-host can't run end-to-end yet).
+ *
+ * Each case hand-encodes the exact idiom the codegen emits
+ * (x64_tls_addr_of_win64 / aa_tls_addr_of_win), drives the relax, and asserts
+ * the result is the in-image address materialization. The x64 builder is pinned
+ * to a golden captured from real `kit cc -c` disassembly so it can't drift away
+ * from the codegen it must match.
+ *
+ * Exit 0 = pass; non-zero = fail. */
+
+#include <stdint.h>
+#include <string.h>
+
+#include <kit/core.h>
+
+#include "core/core.h"
+#include "lib/kit_unit.h"
+#include "obj/obj.h"
+
+/* Reached via LinkArchDesc.jit_tls_le_relax; not exposed in a header. */
+void x64_jit_tls_le_relax(Compiler* c, RelocKind k, u8* site, u64 storage,
+ u64 site_pc);
+void aa64_jit_tls_le_relax(Compiler* c, RelocKind k, u8* site, u64 storage,
+ u64 site_pc);
+
+static KitUnit g_u;
+#define EXPECT(cond, ...) CU_EXPECT(&g_u, cond, __VA_ARGS__)
+
+static KitCompiler* compiler_for(KitArchKind arch) {
+ static KitCompiler* aa64 = NULL;
+ static KitCompiler* x64 = NULL;
+ KitCompiler** slot = arch == KIT_ARCH_ARM_64 ? &aa64 : &x64;
+ if (!*slot) {
+ /* Windows/COFF so the compiler matches the idiom's origin; only the panic
+ * path reads it, so the exact spec barely matters. */
+ KitTargetSpec t = kit_unit_target(arch, KIT_OS_WINDOWS, KIT_OBJ_COFF);
+ if (kit_unit_compiler_new(&g_u, t, slot) != KIT_OK || !*slot) {
+ fprintf(stderr, "compiler_new failed for arch=%d\n", (int)arch);
+ exit(2);
+ }
+ }
+ return *slot;
+}
+
+static u32 rd32(const u8* p) {
+ return (u32)p[0] | ((u32)p[1] << 8) | ((u32)p[2] << 16) | ((u32)p[3] << 24);
+}
+
+/* ---- x86-64 -------------------------------------------------------------- */
+
+/* Emit the 4-instruction Win64 TLS idiom for destination register `rd` into
+ * `b`, mirroring x64_tls_addr_of_win64 byte-for-byte. Returns total length;
+ * *site_off receives the offset of the trailing lea's SECREL disp32. */
+static u32 x64_emit_idiom(u8* b, u32 rd, u32* site_off) {
+ u32 n = 0;
+ /* (1) mov rd, gs:[0x58] (9 bytes) */
+ b[n++] = 0x65;
+ b[n++] = (u8)(0x48u | ((rd & 8u) ? 0x04u : 0u));
+ b[n++] = 0x8B;
+ b[n++] = (u8)(((rd & 7u) << 3) | 4u);
+ b[n++] = 0x25;
+ b[n++] = 0x58;
+ b[n++] = 0;
+ b[n++] = 0;
+ b[n++] = 0;
+ /* (2) mov r11d, [rip+_tls_index] (7 bytes) */
+ b[n++] = 0x44;
+ b[n++] = 0x8B;
+ b[n++] = 0x1D;
+ b[n++] = 0;
+ b[n++] = 0;
+ b[n++] = 0;
+ b[n++] = 0;
+ /* (3) mov rd, [rd+r11*8] (4 or 5 bytes) */
+ b[n++] = (u8)(0x4Au | ((rd & 8u) ? 0x05u : 0u));
+ b[n++] = 0x8B;
+ if ((rd & 7u) == 5u) {
+ b[n++] = (u8)((1u << 6) | ((rd & 7u) << 3) | 4u);
+ b[n++] = (u8)(0xD8u | (rd & 7u));
+ b[n++] = 0;
+ } else {
+ b[n++] = (u8)(((rd & 7u) << 3) | 4u);
+ b[n++] = (u8)(0xD8u | (rd & 7u));
+ }
+ /* (4) lea rd, [rd + sym@SECREL] (7 or 8 bytes) */
+ b[n++] = (u8)(0x48u | ((rd & 8u) ? 0x05u : 0u));
+ b[n++] = 0x8D;
+ if ((rd & 7u) == 4u) {
+ b[n++] = (u8)((2u << 6) | ((rd & 7u) << 3) | 4u);
+ b[n++] = (u8)((4u << 3) | (rd & 7u));
+ } else {
+ b[n++] = (u8)((2u << 6) | ((rd & 7u) << 3) | (rd & 7u));
+ }
+ *site_off = n;
+ b[n++] = 0;
+ b[n++] = 0;
+ b[n++] = 0;
+ b[n++] = 0;
+ return n;
+}
+
+static void x64_check(u32 rd) {
+ u8 buf[40];
+ u32 site_off;
+ u32 total = x64_emit_idiom(buf, rd, &site_off);
+ /* Distinct write/runtime "addresses": disp must use site_pc, not &buf. */
+ const u64 site_pc = 0x140005000ull + site_off;
+ const u64 storage = 0x140009123ull;
+ u32 lea = total - 7u; /* the rewritten 7-byte rip-lea sits at the block end */
+ i64 want_disp = (i64)storage - (i64)(site_pc + 4u);
+ u32 i;
+ x64_jit_tls_le_relax(compiler_for(KIT_ARCH_X86_64), R_COFF_SECREL,
+ &buf[site_off], storage, site_pc);
+ for (i = 0; i < lea; ++i)
+ EXPECT(buf[i] == 0x90u, "x64 rd=%u: byte %u not NOP (0x%02x)", rd, i,
+ buf[i]);
+ EXPECT(buf[lea] == (u8)(0x48u | ((rd >= 8u) ? 0x04u : 0u)),
+ "x64 rd=%u: lea REX 0x%02x", rd, buf[lea]);
+ EXPECT(buf[lea + 1] == 0x8Du, "x64 rd=%u: lea opcode 0x%02x", rd,
+ buf[lea + 1]);
+ EXPECT(buf[lea + 2] == (u8)(((rd & 7u) << 3) | 5u),
+ "x64 rd=%u: lea modrm 0x%02x", rd, buf[lea + 2]);
+ EXPECT((i32)rd32(&buf[lea + 3]) == (i32)want_disp,
+ "x64 rd=%u: disp 0x%08x want 0x%08x", rd, rd32(&buf[lea + 3]),
+ (u32)want_disp);
+}
+
+/* Pin x64_emit_idiom to real codegen: the rd=r8 idiom captured from
+ * `kit cc -target x86_64-windows -c` disassembly. A drift in the builder (and
+ * thus a relax tested against a fictional idiom) turns this red. */
+static void x64_golden(void) {
+ static const u8 want[] = {0x65, 0x4c, 0x8b, 0x04, 0x25, 0x58, 0x00, 0x00,
+ 0x00, 0x44, 0x8b, 0x1d, 0x00, 0x00, 0x00, 0x00,
+ 0x4f, 0x8b, 0x04, 0xd8, 0x4d, 0x8d, 0x80, 0x00,
+ 0x00, 0x00, 0x00};
+ u8 buf[40];
+ u32 site_off;
+ u32 total = x64_emit_idiom(buf, 8u /* r8 */, &site_off);
+ EXPECT(total == sizeof want, "x64 golden length %u != %zu", total,
+ sizeof want);
+ EXPECT(memcmp(buf, want, sizeof want) == 0, "x64 golden idiom mismatch");
+}
+
+/* ---- aarch64 ------------------------------------------------------------- */
+
+#define AA_NOP 0xd503201fu
+
+static u32 aa_add_imm(u32 rd, u32 rn, u32 imm12, u32 sh) {
+ return 0x91000000u | (sh << 22) | ((imm12 & 0xfffu) << 10) | ((rn & 0x1fu)
+ << 5) | (rd & 0x1fu);
+}
+
+/* Emit the 7-instruction Win64/aarch64 TLS idiom for `rd` (aa_tls_addr_of_win).
+ * Only instruction (6) — the HIGH12A add the relax keys on — needs a faithful
+ * encoding; the rest are placeholders the relax overwrites unconditionally. */
+static u32 aa_emit_idiom(u32* w, u32 rd) {
+ w[0] = 0xf9400240u | (18u << 5) | rd; /* ldr rd,[x18,#0x58] (placeholder) */
+ w[1] = 0x90000010u; /* adrp x16,_tls_index */
+ w[2] = aa_add_imm(16u, 16u, 0u, 0u); /* add x16,x16,:lo12: */
+ w[3] = 0xb9400210u; /* ldr w16,[x16] */
+ w[4] = 0xf8607a00u | rd; /* ldr rd,[rd,x16,lsl#3] */
+ w[5] = aa_add_imm(rd, rd, 0u, 1u); /* add rd,rd,:secrel_hi12: (HIGH12A) */
+ w[6] = aa_add_imm(rd, rd, 0u, 0u); /* add rd,rd,:secrel_lo12: (LOW12A) */
+ return 5u; /* word index of the HIGH12A site the relax keys on */
+}
+
+/* Decode ADRP+ADD (the relax's output) back to the absolute address it
+ * materializes, so we confirm it equals `storage`. */
+static u64 aa_decode_adrp_add(u32 adrp, u32 add, u64 adrp_pc) {
+ u32 immlo = (adrp >> 29) & 0x3u;
+ u32 immhi = (adrp >> 5) & 0x7ffffu;
+ i64 imm = (i64)(((u64)immhi << 2) | immlo);
+ if (imm & (1ll << 20)) imm -= (1ll << 21); /* sign-extend 21 bits */
+ u64 page = (adrp_pc & ~0xfffull) + ((u64)imm << 12);
+ return page + ((add >> 10) & 0xfffu);
+}
+
+static void aa_check(u32 rd) {
+ u32 w[7];
+ u32 site_i = aa_emit_idiom(w, rd);
+ const u64 site_pc = 0x140005000ull + site_i * 4u;
+ const u64 storage = 0x140009123ull;
+ u8* site = (u8*)&w[site_i];
+ u32 i;
+ aa64_jit_tls_le_relax(compiler_for(KIT_ARCH_ARM_64),
+ R_COFF_AARCH64_SECREL_HIGH12A, site, storage, site_pc);
+ for (i = 0; i < 4u; ++i) /* instructions (1)-(4) -> NOP */
+ EXPECT(w[i] == AA_NOP, "aa64 rd=%u: insn %u not NOP (0x%08x)", rd, i, w[i]);
+ EXPECT((w[4] & 0x9f000000u) == 0x90000000u && (w[4] & 0x1fu) == rd,
+ "aa64 rd=%u: insn(5) not ADRP rd (0x%08x)", rd, w[4]);
+ EXPECT((w[5] & 0xff800000u) == 0x91000000u && (w[5] & 0x1fu) == rd &&
+ ((w[5] >> 5) & 0x1fu) == rd,
+ "aa64 rd=%u: insn(6) not ADD rd,rd,#imm (0x%08x)", rd, w[5]);
+ EXPECT(w[6] == AA_NOP, "aa64 rd=%u: insn(7) not NOP (0x%08x)", rd, w[6]);
+ EXPECT(aa_decode_adrp_add(w[4], w[5], site_pc - 4u) == storage,
+ "aa64 rd=%u: ADRP+ADD != storage", rd);
+}
+
+int main(void) {
+ /* Cover the x64 length-variant branches: rd&7==4 (rsp/r12 -> lea sib),
+ * rd&7==5 (rbp/r13 -> mov disp8), and rd>=8 (REX.R/B). */
+ static const u32 x64_rds[] = {0, 1, 4, 5, 8, 12, 13};
+ static const u32 aa_rds[] = {0, 1, 9, 20};
+ size_t i;
+ kit_unit_init(&g_u);
+ x64_golden();
+ for (i = 0; i < sizeof x64_rds / sizeof x64_rds[0]; ++i) x64_check(x64_rds[i]);
+ for (i = 0; i < sizeof aa_rds / sizeof aa_rds[0]; ++i) aa_check(aa_rds[i]);
+ kit_unit_summary(&g_u, "jit_tls_relax_test");
+ return kit_unit_status(&g_u);
+}