The ARM64 Mach-O backend has never implemented thread-local storage:
_Thread_local/__thread globals compiled fine (parsing already sets
VT_TLS) but arm64_sym()'s TLS branch emitted the ELF/Linux sequence
(mrs xr, tpidr_el0 + R_AARCH64_TLSLE_ADD_TPREL_* relocations), which
Mach-O's linker never implements. The relocations silently resolve to
zero, so every access reads/writes whatever TPIDR_EL0 happens to hold
on Darwin - not a valid per-thread storage base there at all. This
crashes or corrupts memory depending on what garbage offset results.

macOS uses a completely different mechanism: each Thread_local global
gets a 3-word TLV descriptor {thunk, reserved, offset}; access means
calling thunk(&descriptor), which dyld patches at image load time to
return this thread's copy. This patch implements that:

* tccgen.c: when a Mach-O ARM64 TLS global is defined (single
translation unit only - see comment), synthesize its TLV descriptor
in a new __DATA,__thread_vars-mapped section and redirect the
symbol's address to the descriptor instead of the backing storage.
The descriptor's third field is a byte offset into the image's
thread-local template region, not a relocated pointer - reusing
R_DATA_PTR here silently produces an absolute address, which dyld
rejects at load time ("malformed thread-local, offset ... larger
than total size").

* arm64-gen.c: teach arm64_sym()'s TLS branch to, on Mach-O, load the
descriptor's address and call through its first field instead of
using the ELF TPIDR sequence. That call is a real AAPCS64 call and
can clobber x0-x17, which may collide with values TCC has live there
that save_regs() does not protect against inline codegen physically
overwriting a register - store()'s "value to store" register in
particular, reachable directly by something as simple as
`g_tls = some_call();`. So the whole x0-x17 range is saved/restored
around the call. FP/SIMD caller-saved registers (v0-v7, v16-v31) are
a known gap, documented in the comment.

* tccmacho.c: three separate, previously entirely absent pieces of
Mach-O object-file plumbing this needs, found by comparing against
clang's own -S output for the same source: correct section types for
__thread_vars/__thread_bss/__thread_data (S_THREAD_LOCAL_VARIABLES/
ZEROFILL/REGULAR); the MH_HAS_TLV_DESCRIPTORS mach_header flag,
without which dyld silently skips descriptor patching entirely even
though everything else is otherwise correct; and a hardcoded minos
of 10.6 on the LC_BUILD_VERSION load command, which predates both
Apple Silicon and TLV support and which dyld also gates descriptor
patching on (bumped to 11.0 for TCC_TARGET_ARM64 specifically, since
that's the oldest macOS that can run on Apple Silicon anyway).

Scope: only variables *defined* in the translation unit being compiled
are redirected. extern _Thread_local declared but defined in another
object file or a dylib still emits the (broken) ELF-style codegen -
implementing that would need chasing the same descriptor through a
separate compilation unit's symbol table, which is a bigger change I
have not attempted or verified.

Testing:
- make test: full suite passes (73_arm64, 124_atomic_counter,
125_atomic_misc, 136_atomic_gcc_style, 138/139/140/152_arm64_*,
btest, memtest, dlltest, cross-test), no regressions observed.
- Original repro (single _Thread_local int, read-modify-write) now
compiles, links, and runs correctly end to end.
- 4-thread pthread stress test confirms genuine per-thread isolation:
independent storage addresses, correct independent counters under
concurrent increment.
- Targeted test for the store()-register-collision risk described
above (TLS store immediately from a function-call result, unrelated
live locals interleaved, address-of and pointer-chasing through TLS
storage, multiple TLS globals sharing one __thread_bss region) - all
pass.
- Rebuilt V's (vlang/v) actual self-hosted compiler (cmd/v, ~500K
lines of generated C) with this tcc as -cc tcc: the _Thread_local
failure that motivated this (vlang/v#28023, vlang/tccbin#86) is
gone. Surfaced one separate, pre-existing gap while doing so -
__sync_add_and_fetch/__sync_bool_compare_and_swap/
__sync_lock_test_and_set (GCC atomic builtins) are unresolved at
link time - previously masked because compilation never reached
that code path before hitting the _Thread_local error first.
Confirmed unrelated to this patch (reproduces identically on
unpatched tcc once _Thread_local is avoided via -gc boehm instead of
-gc none). Not addressed here.
---
arm64-gen.c | 47 ++++++++++++++++++++++++++++++++++++++++++++-
tccgen.c | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
tccmacho.c | 33 +++++++++++++++++++++++++++++++-
3 files changed, 132 insertions(+), 3 deletions(-)

diff --git a/arm64-gen.c b/arm64-gen.c
index 85aa7c0e..f65c2b3f 100644
--- a/arm64-gen.c
+++ b/arm64-gen.c
@@ -466,7 +466,52 @@ static void arm64_strv(int sz_, int dst, int bas, uint64_t 
off)
static void arm64_sym(int r, Sym *sym, addr_t addend)
{
if (sym->type.t & VT_TLS) {
-#if TCC_TARGET_PE
+#ifdef TCC_TARGET_MACHO
+ /* Mach-O has no ELF-style TPIDR-relative TLS. Instead, a
+ `_Thread_local`/`__thread` symbol's address (see tccgen.c,
+ tlv_redirect_sym) is redirected to a 3-pointer TLV descriptor
+ {thunk, reserved, init_ptr} living in the __DATA,__thread_vars
+ section - `sym` itself still carries VT_TLS, but by this point
+ its (section, offset) binding already points at that descriptor,
+ not at the backing storage. Getting the real per-thread address
+ means loading the descriptor's address and calling through its
+ first field, `thunk(&descriptor) -> address`. That's an ordinary
+ GOT-relative load, emitted directly here rather than via a
+ recursive arm64_sym() call, since `sym` still has VT_TLS set and
+ would just re-enter this branch.
+
+ That call is a real AAPCS64 call and may clobber any of x0-x17,
+ which may collide with values TCC currently has live there (e.g.
+ store()'s "value to store" register, or another operand
+ mid-expression) that TCC's own save_regs() does not protect
+ against physical clobbering by inline codegen like this. So
+ explicitly save/restore x0-x17 around the call, and stash the
+ result across the restore so it lands correctly in `r` even if
+ `r` itself is one of the protected registers.
+
+ NOTE (scope): caller-saved SIMD/FP registers (v0-v7, v16-v31)
+ are *not* preserved here. A `_Thread_local` access interleaved
+ with a live FP/SIMD temporary in those registers is unsafe with
+ this patch. */
+ {
+ int i;
+ o(ARM64_SUB_IMM | ARM64_SF(1) | ARM64_RN(31) | ARM64_RD(31) | 
ARM64_IMM12(160)); // sub sp, sp, #160
+ for (i = 0; i < 18; i += 2)
+ o(ARM64_STP_X | ARM64_RT(i) | ARM64_RT2(i + 1) | ARM64_RN(31) | 
ARM64_IMM7(i)); // stp xi, xi+1, [sp, #i*8]
+ greloca(cur_text_section, sym, ind, R_AARCH64_ADR_GOT_PAGE, 0);
+ o(ARM64_ADRP | 0); // adrp x0, &descriptor@page
+ greloca(cur_text_section, sym, ind, R_AARCH64_LD64_GOT_LO12_NC, 0);
+ o(ARM64_LDR_X | ARM64_RN(0) | 0); // ldr x0, [x0, &descriptor@pageoff]
+ o(ARM64_LDR_X | ARM64_RN(0) | 8); // ldr x8, [x0] ; x8 = thunk
+ o(ARM64_BLR | ARM64_RN(8)); // blr x8 ; x0 = real address
+ o(ARM64_STR_X | ARM64_RN(31) | ARM64_IMM12(144 / 8) | 0); // str x0, [sp, 
#144]
+ for (i = 0; i < 18; i += 2)
+ o(ARM64_LDP_X | ARM64_RT(i) | ARM64_RT2(i + 1) | ARM64_RN(31) | 
ARM64_IMM7(i)); // ldp xi, xi+1, [sp, #i*8]
+ o(ARM64_LDR_X | ARM64_RN(31) | ARM64_IMM12(144 / 8) | r); // ldr xr, [sp, 
#144]
+ o(ARM64_ADD_IMM | ARM64_SF(1) | ARM64_RN(31) | ARM64_RD(31) | 
ARM64_IMM12(160)); // add sp, sp, #160
+ }
+ goto add_addend;
+#elif TCC_TARGET_PE
Sym *s2 = external_global_sym(TOK___tls_index, &int_type);
int r2 = get_reg(RC_INT);
arm64_sym(30, s2, 0);
diff --git a/tccgen.c b/tccgen.c
index 227ff618..5e9978db 100644
--- a/tccgen.c
+++ b/tccgen.c
@@ -8231,14 +8231,62 @@ static void decl_initializer(init_params *p, CType 
*type, unsigned long c, int f
}
}

+#if defined(TCC_TARGET_MACHO) && defined(TCC_TARGET_ARM64)
+/* Redirect a Mach-O `_Thread_local`/`__thread` global's address from its
+ backing storage (already allocated at (backing_sec, backing_addr) by the
+ caller, in a `.tbss`/`.tdata` section) to a freshly synthesized 3-pointer
+ TLV descriptor { thunk, reserved, init_ptr }, matching the layout Apple's
+ own toolchain emits for `@TLVPPAGE`/`@TLVPPAGEOFF`-addressed variables.
+ arm64_macho_tls_sym() (see arm64-gen.c) then accesses the variable by
+ loading this descriptor's address and calling through its first field.
+
+ Scope: this only wires up variables *defined* in this translation unit
+ (V always compiles to a single .c file per `v self`/`tcc -o exe file.c`
+ build). `extern _Thread_local` symbols defined in another object file or
+ dylib are not redirected and keep using the (broken, on this target)
+ ELF-style TPIDR codegen - out of scope for this patch. */
+static void tlv_redirect_sym(Sym *sym, Section *backing_sec, addr_t 
backing_addr,
+ unsigned long size)
+{
+ Section *tv_sec;
+ Sym *bootstrap_sym;
+ addr_t desc_addr;
+
+ tv_sec = find_section(tcc_state, ".thread_vars");
+ tv_sec->sh_flags = SHF_ALLOC | SHF_WRITE;
+ tv_sec->sh_type = SHT_PROGBITS;
+
+ /* external_global_sym()/put_extern_sym() go through the same C-symbol
+ path as normal parsed identifiers, which prepends the Mach-O leading
+ underscore. The real libSystem-exported symbol is `__tlv_bootstrap`
+ (two underscores), so ask for one underscore here to land on two. */
+ bootstrap_sym = external_helper_sym(tok_alloc("_tlv_bootstrap", 14)->tok);
+
+ desc_addr = section_add(tv_sec, 3 * PTR_SIZE, PTR_SIZE);
+ greloca(tv_sec, bootstrap_sym, desc_addr, R_DATA_PTR, 0);
+ /* The third field is *not* a relocated pointer to the backing storage:
+ dyld's TLV loader reads it as a byte offset into this image's
+ per-thread template region (the concatenation of its __thread_bss/
+ __thread_data sections), and rejects anything that looks like an
+ absolute (ASLR-slid) address as "malformed thread-local". Since
+ `backing_addr` is already section-relative (see section_add() above,
+ the caller's `.tbss`/`.tdata` allocation), write it as a plain
+ constant, unrelocated. */
+ write64le(tv_sec->data + desc_addr + 2 * PTR_SIZE, backing_addr);
+
+ put_extern_sym(sym, tv_sec, desc_addr, size);
+}
+#endif
+
/* parse an initializer for type 't' if 'has_init' is non zero, and
+ 'v' is a variable, 'type' is the associated type. If 'has_init' is
allocate space in local or global data space ('r' is either
VT_LOCAL or VT_CONST). If 'v' is non zero, then an associated
variable 'v' of scope 'scope' is declared before initializers
are parsed. If 'v' is zero, then a reference to the new object
is put in the value stack. If 'has_init' is 2, a special parsing
is done to handle string constants. */
-static void decl_initializer_alloc(CType *type, AttributeDef *ad, int r,
+static void decl_initializer_alloc(CType *type, AttributeDef *ad, int r,
int has_init, int v, int scope)
{
int size, align, addr;
@@ -8431,6 +8479,11 @@ static void decl_initializer_alloc(CType *type, 
AttributeDef *ad, int r,
patch_storage(sym, ad, NULL);
}
/* update symbol definition */
+#if defined(TCC_TARGET_MACHO) && defined(TCC_TARGET_ARM64)
+ if ((type->t & VT_TLS) && !has_init)
+ tlv_redirect_sym(sym, sec, addr, size);
+ else
+#endif
put_extern_sym(sym, sec, addr, size);
} else {
/* push global reference */
diff --git a/tccmacho.c b/tccmacho.c
index cee09fd7..49087f0e 100644
--- a/tccmacho.c
+++ b/tccmacho.c
@@ -45,6 +45,7 @@
#define MH_DYLDLINK (0x4)
#define MH_DYLIB (0x6)
#define MH_PIE (0x200000)
+#define MH_HAS_TLV_DESCRIPTORS (0x800000)

#define CPU_SUBTYPE_LIB64 (0x80000000)
#define CPU_SUBTYPE_X86_ALL (3)
@@ -230,6 +231,9 @@ struct dyld_chained_ptr_64_bind
#define S_SYMBOL_STUBS 0x8
#define S_MOD_INIT_FUNC_POINTERS 0x9
#define S_MOD_TERM_FUNC_POINTERS 0xa
+#define S_THREAD_LOCAL_REGULAR 0x11
+#define S_THREAD_LOCAL_ZEROFILL 0x12
+#define S_THREAD_LOCAL_VARIABLES 0x13

#define S_ATTR_PURE_INSTRUCTIONS 0x80000000
#define S_ATTR_SOME_INSTRUCTIONS 0x00000400
@@ -400,6 +404,9 @@ enum skind {
sk_fini,
sk_rw_data,
sk_bss,
+ sk_thread_vars,
+ sk_thread_data,
+ sk_thread_bss,
sk_linkedit,
sk_last
};
@@ -1247,6 +1254,9 @@ const struct {
/*[sk_fini] =*/ { 4, S_MOD_TERM_FUNC_POINTERS, "__mod_term_func" },
/*[sk_rw_data] =*/ { 4, S_REGULAR, "__data" },
/*[sk_bss] =*/ { 4, S_ZEROFILL, "__bss" },
+ /*[sk_thread_vars] =*/ { 4, S_THREAD_LOCAL_VARIABLES, "__thread_vars" },
+ /*[sk_thread_data] =*/ { 4, S_THREAD_LOCAL_REGULAR, "__thread_data" },
+ /*[sk_thread_bss] =*/ { 4, S_THREAD_LOCAL_ZEROFILL, "__thread_bss" },
/*[sk_linkedit] =*/ { 5, S_REGULAR, NULL },
};

@@ -1649,7 +1659,7 @@ static void collect_sections(TCCState *s1, struct macho 
*mo, const char *filenam
default: sk = sk_unknown; break;
case SHT_INIT_ARRAY: sk = sk_init; break;
case SHT_FINI_ARRAY: sk = sk_fini; break;
- case SHT_NOBITS: sk = sk_bss; break;
+ case SHT_NOBITS: sk = (flags & SHF_TLS) ? sk_thread_bss : sk_bss; break;
case SHT_SYMTAB: sk = sk_discard; break;
case SHT_STRTAB:
if (s == stabstr_section)
@@ -1686,6 +1696,10 @@ static void collect_sections(TCCState *s1, struct macho 
*mo, const char *filenam
sk = sk_debug_str;
else if (s == dwarf_line_str_section)
sk = sk_debug_line_str;
+ else if (!strcmp(s->name, ".thread_vars"))
+ sk = sk_thread_vars;
+ else if (flags & SHF_TLS)
+ sk = sk_thread_data;
else if (flags & SHF_EXECINSTR)
sk = sk_text;
else if (flags & SHF_WRITE)
@@ -1756,8 +1770,18 @@ static void collect_sections(TCCState *s1, struct macho 
*mo, const char *filenam

dyldbv = add_lc(mo, LC_BUILD_VERSION, sizeof(*dyldbv));
dyldbv->platform = PLATFORM_MACOS;
+#ifdef TCC_TARGET_ARM64
+ /* Apple silicon only ever runs macOS 11.0+, and dyld gates TLV
+ descriptor patching (among other things) on the image's declared
+ minos - claiming 10.6 (pre-TLV, pre-Apple-silicon) leaves any
+ `_Thread_local`/`__thread` global's TLV thunk unpatched, so calling
+ it lands on the `__tlv_bootstrap` trap and aborts. */
+ dyldbv->minos = (11 << 16);
+ dyldbv->sdk = (11 << 16);
+#else
dyldbv->minos = (10 << 16) + (6 << 8);
dyldbv->sdk = (10 << 16) + (6 << 8);
+#endif
dyldbv->ntools = 0;

dyldsv = add_lc(mo, LC_SOURCE_VERSION, sizeof(*dyldsv));
@@ -1978,6 +2002,13 @@ static void macho_write(TCCState *s1, struct macho *mo, 
FILE *fp)
mo->mh.mh.filetype = MH_DYLIB;
mo->mh.mh.flags = MH_DYLDLINK;
}
+ if (mo->sk_to_sect[sk_thread_vars].s)
+ /* Tell dyld this image has __DATA,__thread_vars TLV descriptors
+ that need their thunk pointer patched at load time - without
+ this flag dyld skips that step entirely and the descriptor is
+ left pointing at the `__tlv_bootstrap` trap (abort on first
+ access to any `_Thread_local`/`__thread` variable). */
+ mo->mh.mh.flags |= MH_HAS_TLV_DESCRIPTORS;
mo->mh.mh.ncmds = mo->nlc;
mo->mh.mh.sizeofcmds = 0;
for (i = 0; i < mo->nlc; i++)
--
2.52.0

Richard Wheeler
_______________________________________________
Tinycc-devel mailing list
[email protected]
https://lists.nongnu.org/mailman/listinfo/tinycc-devel

Reply via email to