commit 30873143c411b1f4c5fd73db6ecb79b0b0fb4c32
parent 13677b4af13ca7c0336624e9f49d44c792de6201
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Wed, 10 Jun 2026 18:39:54 -0700
feat(x64-macos): thread-locals via Mach-O TLV descriptor model
x86_64-macos _Thread_local accesses SIGSEGV'd: the x64 backend emitted the
ELF Local-Exec idiom (mov %fs:0 + R_X64_TPOFF32, mis-mapped onto
X86_64_RELOC_TLV) instead of the macOS TLV descriptor model, so the
linker's TLV collection (keyed on RELOC_IS_TLVP) found nothing and the
load dereferenced garbage. arm64-macos already used the descriptor model.
- New R_X64_TLV reloc kind (RIP-relative disp32, RELOC_IS_TLVP), the
x86_64 peer of arm64 TLVP_LOAD_PAGE21/PAGEOFF12; maps to/from
X86_64_RELOC_TLV. Applied via the neutral RIP-relative path (-4 bias in
the addend, like the other x64 RIP kinds). The existing arch-neutral
__thread_ptrs slot machinery + obj_define_tls descriptor now serve both
arches unchanged.
- x64_tls_addr_of: descriptor branch emits
movq sym@TLVP(%rip), %rdi ; callq *(%rdi) ; [add %rax, addend] ; mov rd, %rax
modeling the thunk's {rax, rdi} clobbers in x64_machine_op_clobbers.
- Leaf analysis: a descriptor-model IR_TLS_ADDR_OF is a hidden call (the
resolver thunk). Mark has_call so the x64 SysV red-zone leaf tier is not
selected -- otherwise callee-saved spills land in the red zone and the
thunk's call instruction corrupts them (wrong results at -O1; -O0 was fine).
Full x86_64-macos toy corpus now 330 pass / 0 fail / 1 skip (was 324/330);
kit TLV objects link under system ld64 too. Removes both x86_64-macos
entries from doc/plan/TODO.md.
Diffstat:
9 files changed, 102 insertions(+), 39 deletions(-)
diff --git a/doc/plan/TODO.md b/doc/plan/TODO.md
@@ -85,38 +85,6 @@ Add new deferred fixes below as they are discovered.
the existing scratch between mem-staging and operand-staging — a register-policy
decision, hence "reviewed change."
-- **x86_64-macos chained-fixups rebase is off by one page (SIGBUS at dyld load).**
- A global pointer initialized to another global's address (`var gp: *i64 = &g`,
- or C `long g; long *gp = &g;`) crashes at process start *inside dyld* —
- `applyFixupsGeneric` does `movq` to e.g. `0x100008000`, exactly **one 16 KB
- page past** `__data` (`0x100004000`), faulting on unmapped memory. The rebased
- pointer *value* kit writes is correct (gp holds g's address, same bytes as the
- working arm64-macos build); the bug is the **`dyld_chained_starts_in_segment`
- page table** kit emits for x86_64 — a wrong `page_start` / `segment_offset`
- (or page indexing) sends dyld's chain walk into the next page. arm64-macos is
- fine. Reproduces both freestanding and hosted (`-lc`). Location:
- `src/obj/macho/link.c` ~1544–1700 (the chained-fixups emit; `MZ_PAGE=0x4000`).
- Repro / guard: toy case `15_cg_api_types_bytes_globals` (SIGBUS, want exit 92)
- under `make test-cross TARGET=macos-x64 DEPTH=full` (test/toy/vm.sh macos).
-
-- **x86_64-macos thread-locals are not linked (TLV pass is arm64-only).** An
- extern/`_Thread_local` access on x86_64-macos SIGSEGVs at runtime: the Mach-O
- linker's TLV collection + apply passes (`src/obj/macho/link.c` ~251–264,
- 465–468, plus the `__thread_ptrs` slot pass ~1638) key off the **arm64** TLV
- relocs (`ARM64_RELOC_TLVP_LOAD_PAGE21/PAGEOFF12`); x86_64's single
- `X86_64_RELOC_TLV` is never collected, so no `__thread_ptrs` slot / descriptor
- is set up and the thread-local load dereferences garbage. (Freestanding x86_64
- thread-locals additionally can't even link — `undefined reference to
- '_tlv_bootstrap'`, a libSystem symbol — which is why these cases carry a
- `.link.hosted` sidecar and must link `-lc`.) arm64-macos is fine. Repro / guard:
- toy cases `141_threadlocal_mutate` (want 43) + `142_threadlocal_multi`
- (want 134), both SIGSEGV, in the `macos-x64 DEPTH=full` lane above.
-
- (Context: x86_64-macos compile+link+execute via Rosetta otherwise works as of
- `feat(macho): x86_64-macos compile+link+execute via Rosetta`; the full toy
- corpus is 324/330 green and these two gaps are the only failures. The lane is
- committed RED to track them.)
-
## Deferred dedups & abstraction cleanups ("use the shared seam")
- **A.2 — optimizer-path inline-asm dedup.** The three `aa_/x64_/rv_asm_block_native`
diff --git a/src/arch/x64/native.c b/src/arch/x64/native.c
@@ -2949,6 +2949,63 @@ static void x64_tls_addr_of_win64(NativeTarget* t, NativeLoc dst, ObjSymId sym,
}
}
+/* x86-64 Mach-O TLV (descriptor model): load the __thread_ptrs slot holding
+ * the TLV descriptor address into %rdi, then call the descriptor's resolver
+ * thunk (descriptor[0]); it returns the variable's storage address in %rax.
+ * Peer of arm64's aa_tls_addr_of descriptor path. The slot is addressed by an
+ * R_X64_TLV reloc (RIP-relative disp32) that the Mach-O linker routes through
+ * __thread_ptrs. The Apple TLV thunk preserves every register except %rax, so
+ * the only clobbers are %rdi (we load it) and %rax (the result) — modeled in
+ * x64_machine_op_clobbers so no value lives across the access in them. Any
+ * addend is a byte offset into the variable, applied to %rax after the call. */
+static void x64_tls_addr_of_macho(NativeTarget* t, NativeLoc dst, ObjSymId sym,
+ i64 addend) {
+ MCEmitter* mc = t->mc;
+ u32 sec = mc->section_id;
+ u32 rd = loc_reg(dst);
+ u32 disp_pos;
+ /* movq sym@TLVP(%rip), %rdi (48 8B 3D disp32). */
+ emit_rex(mc, 1, X64_RDI, 0, 0);
+ {
+ u8 op = X64_OPC_MOV_R_RM;
+ mc->emit_bytes(mc, &op, 1);
+ }
+ {
+ u8 mr = modrm(0u, X64_RDI & 7u, 5u); /* [rip + disp32] */
+ mc->emit_bytes(mc, &mr, 1);
+ }
+ disp_pos = mc->pos(mc);
+ emit_u32le(mc, 0);
+ mc->emit_reloc_at(mc, sec, disp_pos, R_X64_TLV, sym, -4, 1, 0);
+ /* callq *(%rdi) (FF /2, mem [rdi]) -> %rax = &storage. */
+ {
+ u8 call[2] = {0xFFu, modrm(0u, 2u, X64_RDI & 7u)};
+ mc->emit_bytes(mc, call, 2);
+ }
+ /* %rax += addend (byte offset into the variable). */
+ if (addend) {
+ i32 a = (i32)addend;
+ if (a == addend) {
+ emit_rex(mc, 1, 0, 0, X64_RAX);
+ if (imm_fits_i8(a)) {
+ u8 buf[3] = {X64_OPC_ALU_IMM8, modrm(3u, X64_ALU_SUB_ADD, X64_RAX & 7u),
+ (u8)a};
+ mc->emit_bytes(mc, buf, 3);
+ } else {
+ u8 buf[2] = {X64_OPC_ALU_IMM32,
+ modrm(3u, X64_ALU_SUB_ADD, X64_RAX & 7u)};
+ mc->emit_bytes(mc, buf, 2);
+ emit_u32le(mc, (u32)a);
+ }
+ } else {
+ /* Wide addend: materialize in r11 then add. */
+ x64_emit_load_imm(mc, 1, X64_R11, addend);
+ emit_alu_rr(mc, 1, X64_OPC_ALU_ADD, X64_RAX, X64_R11);
+ }
+ }
+ if (rd != X64_RAX) emit_mov_rr(mc, 1, rd, X64_RAX);
+}
+
/* x86-64 TLS Local-Exec: mov rd, fs:0; lea rd, [rd + sym@tpoff]. */
static void x64_tls_addr_of(NativeTarget* t, NativeLoc dst, ObjSymId sym,
i64 addend) {
@@ -2956,6 +3013,10 @@ static void x64_tls_addr_of(NativeTarget* t, NativeLoc dst, ObjSymId sym,
u32 sec = mc->section_id;
u32 rd = loc_reg(dst);
u32 disp_pos;
+ if (obj_format_tls_via_descriptor(t->c)) {
+ x64_tls_addr_of_macho(t, dst, sym, addend);
+ return;
+ }
if (obj_format_tls_model(t->c) == OBJ_TLS_WINDOWS_TEB) {
x64_tls_addr_of_win64(t, dst, sym, addend);
return;
@@ -4113,9 +4174,18 @@ static void x64_set_loc(NativeTarget* t, SrcLoc loc) {
* nothing). */
static int x64_machine_op_clobbers(NativeTarget* t, const NativeMachineOp* op,
u32 mask[NATIVE_CALL_PLAN_CLASSES]) {
- (void)t;
mask[0] = mask[1] = mask[2] = 0;
switch ((NativeMachineOpKind)op->kind) {
+ case NATIVE_MOP_TLS_ADDR:
+ /* ELF Local-Exec / Windows TEB materialize the address using only the
+ * destination register (no fixed clobbers). The Mach-O TLV descriptor
+ * sequence loads the descriptor into %rdi and calls its resolver thunk,
+ * which returns the storage in %rax and preserves every other register;
+ * model the %rdi/%rax clobbers so no value lives across the access in
+ * them. */
+ if (!obj_format_tls_via_descriptor(t->c)) return 0;
+ mask[NATIVE_REG_INT] = (1u << X64_RAX) | (1u << X64_RDI);
+ return 1;
case NATIVE_MOP_BINOP:
switch ((BinOp)op->binop) {
case BO_SDIV:
diff --git a/src/arch/x64/reloc.c b/src/arch/x64/reloc.c
@@ -28,6 +28,7 @@ static const RelocDescRow x64_rows[] = {
{R_X64_GOTPC32, {4, 0}},
{R_X64_GOTTPOFF, {4, RELOC_IS_TLS_GOT}},
{R_X64_TPOFF32, {4, RELOC_IS_TLS_LE}},
+ {R_X64_TLV, {4, RELOC_IS_TLVP}},
{R_X64_GLOB_DAT, {8, 0}},
{R_X64_JUMP_SLOT, {8, 0}},
{R_X64_RELATIVE, {8, 0}},
diff --git a/src/obj/macho/reloc_x86_64.c b/src/obj/macho/reloc_x86_64.c
@@ -22,7 +22,7 @@ u32 macho_x86_64_reloc_to(u32 kind /* RelocKind */) {
return X86_64_RELOC_GOT_LOAD;
case R_X64_GOTPCREL:
return X86_64_RELOC_GOT;
- case R_X64_TPOFF32:
+ case R_X64_TLV:
return X86_64_RELOC_TLV;
default:
return (u32)-1;
@@ -41,7 +41,7 @@ u32 macho_x86_64_reloc_pcrel(u32 kind /* RelocKind */) {
case R_X64_GOTPCREL:
case R_X64_GOTPCRELX:
case R_X64_REX_GOTPCRELX:
- case R_X64_TPOFF32:
+ case R_X64_TLV:
return 1;
default:
return 0;
@@ -77,7 +77,7 @@ u32 macho_x86_64_reloc_from(u32 macho_type) {
case X86_64_RELOC_GOT:
return R_X64_GOTPCREL;
case X86_64_RELOC_TLV:
- return R_X64_TPOFF32;
+ return R_X64_TLV;
default:
return (u32)-1;
}
diff --git a/src/obj/obj.c b/src/obj/obj.c
@@ -1308,6 +1308,7 @@ const char* reloc_kind_name(RelocKind k) {
_CASE(R_X64_JUMP_SLOT);
_CASE(R_X64_RELATIVE);
_CASE(R_X64_COPY);
+ _CASE(R_X64_TLV);
_CASE(R_RV_HI20);
_CASE(R_RV_LO12_I);
_CASE(R_RV_LO12_S);
diff --git a/src/obj/obj.h b/src/obj/obj.h
@@ -209,6 +209,12 @@ typedef enum RelocKind {
R_X64_JUMP_SLOT,
R_X64_RELATIVE,
R_X64_COPY,
+ /* Mach-O x86_64 TLV access: RIP-relative disp32 in `movq sym@TLVP(%rip),
+ * %rdi` addressing the __thread_ptrs slot that holds the TLV descriptor
+ * address (the descriptor-model peer of arm64's TLVP_LOAD_PAGE21/PAGEOFF12;
+ * maps to X86_64_RELOC_TLV). Distinct from the ELF R_X64_TPOFF32 Local-Exec
+ * kind. */
+ R_X64_TLV,
R_RV_HI20,
R_RV_LO12_I,
R_RV_LO12_S,
diff --git a/src/obj/reloc_apply.c b/src/obj/reloc_apply.c
@@ -127,11 +127,16 @@ int reloc_apply_neutral(Compiler* c, RelocKind k, u8* P_bytes, u64 S, i64 A,
case R_X64_GOTPCRELX:
case R_X64_REX_GOTPCRELX:
case R_X64_GOTPC32:
- case R_X64_GOTTPOFF: {
+ case R_X64_GOTTPOFF:
+ case R_X64_TLV: {
/* GOTTPOFF (TLS Initial-Exec) is a RIP-relative load of a GOT slot
* that the linker fills with the symbol's TP-relative offset; the
* fixup is identical to GOTPCREL once the target has been redirected
* to that slot (see link_layout_got). */
+ /* TLV (Mach-O x86_64 descriptor model): RIP-relative disp32 in
+ * `movq sym@TLVP(%rip), %rdi`, redirected by the linker to address the
+ * __thread_ptrs slot (S = slot vaddr). The -4 RIP bias rides in the
+ * addend, same as the other RIP-relative kinds here. */
/* AArch64 ELF: PREL32 maps to either of these; both encode a
* 32-bit signed PC-relative displacement. The kit-canonical
* distinction (section-relative vs PC-relative) collapses on
diff --git a/src/opt/pass_native_emit.c b/src/opt/pass_native_emit.c
@@ -1435,6 +1435,14 @@ static void plan_frame(NativeEmitCtx* e, const CGFuncDesc* fd) {
if (aux && (aux->kind == INTRIN_FRAME_ADDRESS ||
aux->kind == INTRIN_RETURN_ADDRESS))
reads_frame = 1;
+ } else if ((IROp)in->op == IR_TLS_ADDR_OF) {
+ /* The Mach-O TLV descriptor access calls the resolver thunk — a hidden
+ * call the leaf analysis must see, or the frame-eliding leaf tiers (the
+ * x64 SysV red zone, which keeps spills below sp; any return-address
+ * elision) miscompile: the thunk's `call`/`blr` clobbers the return
+ * address and the bytes below sp. ELF Local-Exec and Windows TEB
+ * accesses make no call, so only the descriptor model disqualifies. */
+ if (obj_format_tls_via_descriptor(t->c)) has_call = 1;
}
}
}
diff --git a/test/link/reloc_desc_test.c b/test/link/reloc_desc_test.c
@@ -52,6 +52,9 @@ static u8 oracle_width(RelocKind k) {
case R_X64_REX_GOTPCRELX:
case R_X64_GOTPC32:
case R_X64_GOTTPOFF:
+ /* R_X64_TLV: post-freeze addition — the Mach-O x86_64 TLV descriptor
+ * access (RIP-relative disp32), peer of arm64's TLVP_LOAD_PAGE21/12. */
+ case R_X64_TLV:
return 4;
case R_ABS64:
case R_REL64:
@@ -186,10 +189,11 @@ static int oracle_rv_branch(RelocKind k) {
return k == R_RV_CALL || k == R_PLT32;
}
-/* is_tlvp_reloc (Mach-O aa64 only). */
+/* is_tlvp_reloc (Mach-O). */
static int oracle_aa64_tlvp(RelocKind k) {
return k == R_AARCH64_TLVP_LOAD_PAGE21 || k == R_AARCH64_TLVP_LOAD_PAGEOFF12;
}
+static int oracle_x64_tlvp(RelocKind k) { return k == R_X64_TLV; }
/* is_direct_page_reloc (Mach-O aa64 only). */
static int oracle_aa64_direct_page(RelocKind k) {
@@ -227,7 +231,7 @@ static const ArchOracle kArchOracles[] = {
{"aarch64", KIT_ARCH_ARM_64, oracle_aa64_got_use, oracle_aa64_tls_got,
oracle_aa64_branch, oracle_aa64_tlvp, oracle_aa64_direct_page},
{"x86_64", KIT_ARCH_X86_64, oracle_x64_got_use, oracle_x64_tls_got,
- oracle_x64_branch, zero_oracle, zero_oracle},
+ oracle_x64_branch, oracle_x64_tlvp, zero_oracle},
{"rv64", KIT_ARCH_RV64, oracle_rv_got_use, oracle_rv_tls_got,
oracle_rv_branch, zero_oracle, zero_oracle},
{"rv32", KIT_ARCH_RV32, oracle_rv_got_use, oracle_rv_tls_got,