Skip to content

macOS 27 arm64: bundled TCC links binaries with garbage in __bss (v up hangs, make panics) #29744

Open

Description

@quaesitor-scientiam

Describe the bug

On macOS 27 (arm64), executables linked by the bundled TCC can start with garbage in __bss
instead of zeros. When the V compiler itself is TCC-linked (the default make / v self result
on Apple Silicon), its zero-initialized __globals are corrupted, so:

  • v up hangs forever (it is compiling the vup tool; the compiler spins in
    flat.node_payload_lock because g_node_payload_lock starts as 0xffffffff);
  • compiling a hello world panics with
    V panic: fixed array index out of range (index: -1, len: 64) in
    workers.register_open_pool (v3_open_pools_len starts as -1);
  • make on master ends with that same panic in the cmd/tools/detect_tcc.v step.

Whether a given binary is hit depends only on its layout (see below), so it comes and goes
with unrelated source or flag changes. #28030 (empty main segfaults with -cc tcc on macOS
27.0, goes away with -g / -gc boehm, closed as not reproducible on macOS 26) very likely has
the same root cause; I could not confirm that exact case, because today's V no longer produces a
triggering layout for an empty main.

Reproduction Steps

On macOS 27, Apple Silicon, at 1b4ecb9 with the current bundled TCC:

make                      # ends with: V panic: fixed array index out of range (index: -1, len: 64)
./v run hello.v           # same panic
./v -o t t.v              # t.v: `import os` + main -> hangs forever
./v up                    # hangs (building the vup tool)

A deterministic reproducer of the bad layout, independent of how the current ./v was built:
v -cc tcc -nocache -gc none -o v_tcc cmd/v produces a __DATA segment with
filesize 0x3ff0, vmsize 0x5438, and v_tcc then panics/hangs as above.

Expected Behavior

__bss is zero-filled; TCC-linked programs (and a TCC-linked V) work as on macOS 26.

Current Behavior

__bss pages after the end of __DATA's file contents hold the bytes that follow __DATA in the
file: the start of __LINKEDIT (the first words read back as a dyld_chained_fixups_header:
starts_offset 0x20, imports_offset 0xad8, symbols_offset 0xd68, imports_count 0xa4).

Possible Solution

Root cause is in TinyCC's Mach-O writer (tccmacho.c), not in V. With
CONFIG_NEW_MACHO, calc_fixup_size() and bind_rebase_import() compute each segment's
dyld_chained_starts_in_segment.page_count from vmsize, so for __DATA it also lists the
zero-fill __bss pages past the end of the segment's file contents. ld64 lists only the
file-backed pages. On macOS 27, dyld's page-in linking maps every listed page from the file.
So whenever __DATA's file contents end at or before a page boundary and __bss continues into
the next page, that page is mapped from the file (the following __LINKEDIT bytes) instead of
being zero-filled.

Evidence:

  • vmmap at process launch -s: __DATA 1014a0000-1014a4000 SM=COW (file) +
    1014a4000-1014a6000 SM=NUL (zero fill). At main: __DATA 1014a0000-1014a6000 SM=COW +
    mapped file 1014a6000-1014a8000, i.e. 2 pages remapped from the file, exactly the
    page_count = 2 that otool -chained_fixups shows for __DATA. A hardware watchpoint on the
    corrupted global never fires: the page is remapped, not written.
  • Building that same cmd/v with TCC variants (same layout every time):
TCC used __bss section offset __DATA page_count hello world import os
bundled tcc.exe (tccbin 274abd2, tinycc 85ba3ae8) non-zero 2 panic hang
tinycc 85ba3ae8 built locally, unpatched non-zero 2 panic hang
only __bss offset set to 0 (like ld64) 0 2 panic hang
only page_count from filesize (patch below) non-zero 1 ok ok

So the non-zero file offset TCC gives __bss (ld64 writes 0) is harmless; only page_count
matters.

Patch (against tinycc mob 43c7708b, also applies to 85ba3ae8). It is not upstream yet:

diff --git a/tccmacho.c b/tccmacho.c
index cee09fd7..ecd467da 100644
--- a/tccmacho.c
+++ b/tccmacho.c
@@ -1272,6 +1272,15 @@ const struct {
 #define	N_SEGMENT	(sizeof(all_segment)/sizeof(all_segment[0]))
 
 #ifdef CONFIG_NEW_MACHO
+/* Only the pages of a segment that are backed by the file can hold fixups.
+   dyld's page-in linking maps every page listed in a segment's chained starts
+   from the file, so listing the zero-fill tail (__bss) as well would fill it
+   with the bytes that follow the segment in the file, instead of zeros. */
+static int fixup_page_count(struct segment_command_64 *seg)
+{
+    return (seg->filesize + SEG_PAGE_SIZE - 1) / SEG_PAGE_SIZE;
+}
+
 static void calc_fixup_size(TCCState *s1, struct macho *mo)
 {
     int i, size;
@@ -1279,7 +1288,9 @@ static void calc_fixup_size(TCCState *s1, struct macho *mo)
     size = (sizeof(struct dyld_chained_fixups_header) + 7) & -8;
     size += (sizeof(struct dyld_chained_starts_in_image) + (mo->nseg - 1) * sizeof(uint32_t) + 7) & -8;
     for (i = (s1->output_type == TCC_OUTPUT_EXE); i < mo->nseg - 1; i++) {
-	int page_count = (get_segment(mo, i)->vmsize + SEG_PAGE_SIZE - 1) / SEG_PAGE_SIZE;
+	int page_count = fixup_page_count(get_segment(mo, i));
+	if (page_count == 0)
+	    continue;
 	size += (sizeof(struct dyld_chained_starts_in_segment) + (page_count - 1) * sizeof(uint16_t) + 7) & -8;
     }
     size += mo->n_bind * sizeof (struct dyld_chained_import) + 1;
@@ -2059,10 +2070,13 @@ ST_FUNC void bind_rebase_import(TCCState *s1, struct macho *mo)
     data += (size + 7) & -8;
     image->seg_count = mo->nseg;
     for (i = (s1->output_type == TCC_OUTPUT_EXE); i < mo->nseg - 1; i++) {
+	seg = get_segment(mo, i);
+	page_count = fixup_page_count(seg);
+	/* No file-backed pages, so no fixups: an offset of 0 says so. */
+	if (page_count == 0)
+	    continue;
         image->seg_info_offset[i] = (data - mo->chained_fixups->data) -
 				    header->starts_offset;
-	seg = get_segment(mo, i);
-	page_count = (seg->vmsize + SEG_PAGE_SIZE - 1) / SEG_PAGE_SIZE;
 	size = sizeof(struct dyld_chained_starts_in_segment) +
 		      (page_count - 1) * sizeof(uint16_t);
         segment = (struct dyld_chained_starts_in_segment *) data;

Verified locally on macOS 27.0.1 / arm64:

  • tinycc's own make test: "ALL TESTS PASSED", same as unpatched.
  • Unpatched V with the patched tcc.exe swapped into thirdparty/tcc: make completes
    (including detect_tcc) with a TCC-linked ./v that has the exact failing layout above but
    page_count = 1. Building the vup tool (the original hang) takes about 1s, and
    v test vlib/v/pref/ vlib/os/ vlib/v/driver/c_compiler_flags_test.v passes.
  • Edge cases built with the patched TCC: a -shared dylib loaded with dlopen (zero __bss,
    correct rebased pointer), a __DATA with only __bss (no starts entry, seg_offset 0),
    __DATA ending exactly on a page boundary; dyld_info -fixups parses all of them and
    codesign -v passes.

Suggested path: get the patch into tinycc mob, then dispatch update_tccbin.yml for
macos-arm64 with the new tcc_commit (the monthly run would pick it up otherwise).
macos-amd64 uses the same writer code.

If an interim guard is wanted until the bundle is rebuilt, I have a V-side change ready that
stops V from selecting TCC by itself on Darwin >= 27 (driver default, the v self default on
Apple Silicon, and the GNUmakefile bootstrap), while still honoring an explicit -cc tcc.

Additional Information/Context

Workaround for affected users (verified on unpatched master): build and update with the
system compiler.

export VFLAGS='-cc cc'
make          # produces a clang-linked ./v
v up          # `v self` then also uses -cc cc

V version

V 0.5.2 1b4ecb9

Environment details (OS name and version, etc.)

macOS 27.0.1 (26A434), Darwin 27.0.0, arm64. Bundled TCC:
tcc version 0.9.28rc 2026-07-24 HEAD@85ba3ae8 (AArch64 Darwin), tccbin
thirdparty-macos-arm64 at 1d0ad0e.

Note

You can use the 👍 reaction to increase the issue's priority for developers.

Please note that only the 👍 reaction to the issue itself counts as a vote.
Other reactions and those to comments will not be taken into account.

Activity

  1. quaesitor-scientiam commented on Oct 8, 2026

    @quaesitor-scientiam
    ContributorAuthor

    Plain C reproducer (no V involved)

    Running a TCC-linked C program is enough to reproduce this. Two conditions have to hold:

    1. __bss has to spill past the first 16K page of __DATA.
    2. The file has to be large enough that every page TCC lists for __DATA lies inside it. A big
      symbol table, and therefore a big __LINKEDIT, does that.

    Small binaries don't trigger it, which is why a minimal C test looks fine. Run this from the V repo
    root on macOS 27 / arm64. CC defaults to the bundled TCC:

    #!/bin/sh
    # Run from the V repo root: sh bss_repro.sh   (CC defaults to the bundled TCC)
    CC=${CC:-thirdparty/tcc/tcc.exe}
    gen() { # gen <number of padding functions>
    	printf '#include <stdio.h>\n'
    	printf 'char data_bytes[100] = {1};\n'
    	printf 'char *data_ptr = data_bytes; /* a pointer in __DATA, so __DATA gets chained-fixup starts */\n'
    	printf 'static char big_bss[32 * 1024]; /* zero-fill that spills past the first 16K page */\n'
    	i=0
    	while [ $i -lt $1 ]; do
    		i=$((i + 1))
    		printf 'int padding_function_with_a_long_name_%d(void) { return %d; }\n' $i $i
    	done
    	cat <<'EOF'
    int main(void) {
    	long bad = 0;
    	for (long i = 0; i < (long)sizeof(big_bss); i++)
    		bad += big_bss[i] != 0;
    	printf("non-zero __bss bytes: %ld\n", bad);
    	return bad != 0;
    }
    EOF
    }
    dir=$(mktemp -d)
    gen 6000 > "$dir/bss_big.c"   # large symbol table -> large __LINKEDIT
    gen 0 > "$dir/bss_small.c"    # same program, tiny file
    for f in bss_big bss_small; do
    	$CC -o "$dir/$f" "$dir/$f.c" || exit 1
    	printf '%-9s (%7d bytes): ' $f $(stat -f %z "$dir/$f")
    	"$dir/$f"
    done

    Results on macOS 27.0.1 (Darwin 27.0.0), arm64:

    C compiler bss_big (~738K file) bss_small (~51K file)
    bundled tcc.exe (tccbin 1d0ad0e, tinycc 85ba3ae8) 13561 non-zero __bss bytes 0
    tinycc mob 43c7708b, unpatched 13561 0
    tinycc mob 43c7708b + the patch above 0 0
    clang 0 0

    In both TCC builds __DATA has filesize 0x70 and vmsize 0x8070, and otool -chained_fixups
    reports page_count 3; with the patch it reports page_count 1.

    • bss_big: __DATA is at file offset 0x4c000, so the 3 listed pages (0x4c000-0x58000) are
      inside the 738K file. They get mapped from the file, and __bss receives __LINKEDIT bytes.
    • bss_small: __DATA is at 0x8000, so the listed pages (0x8000-0x14000) run past the end
      of the 51K file, and __bss stays zero.

    So, refining the description above: dyld does not map every listed page in every binary. It looks
    like page-in linking only happens when all listed pages are inside the file, and that otherwise
    dyld falls back to applying the fixups itself. I inferred that from these two layouts; I haven't
    checked it against dyld's source. Either way, the patch fixes both cases, and this gives tinycc a
    test case that doesn't need V.

  2. quaesitor-scientiam commented on Oct 8, 2026

    @quaesitor-scientiam
    ContributorAuthor

    Alternative fix that needs no tinycc change: build tccbin with --config-new_macho=no

    TinyCC can still write executables in the older Mach-O format (LC_DYLD_INFO_ONLY
    rebase/bind opcodes) instead of chained fixups. With that format dyld applies the fixups itself
    and never maps any __DATA pages from the file, so the bad page_count is never written at all.
    The tccbin build script asks for the new format explicitly, at
    thirdparty/build_scripts/thirdparty-macos-arm64_tcc.sh line 206:

    	    --config-new_macho=yes \

    Changing that to --config-new_macho=no fixes this with unmodified tinycc mob.
    update_tccbin.yml runs that script from the vlang/v checkout, so the rebuilt bundle would
    still come from a published repo.or.cz hash.

    Tested on macOS 27.0.1 (Darwin 27.0.0), arm64, with tinycc mob 43c7708b configured with
    --config-new_macho=no --config-codesign:

    C compiler output format bss_big (C reproducer above) bss_small
    tinycc 43c7708b, new_macho=yes (current setting) chained fixups 13561 non-zero __bss bytes 0
    tinycc 43c7708b, new_macho=no LC_DYLD_INFO_ONLY 0 0

    With that tcc.exe (and its libtcc1.a, bt-*.o, bcheck.o, runmain.o) swapped into
    thirdparty/tcc of current master:

    • v -cc tcc -nocache -o v_tcc cmd/v builds a TCC-linked compiler, with both -gc none and the
      default Boehm GC; v_tcc version works.
    • That compiler builds and runs a hello world and an import os program, both with -cc tcc and
      with its default C compiler.
    • It rebuilds V again with -cc tcc, and that second compiler works too.
    • v_tcc -cc tcc -silent test vlib/strings/: 5 passed, 4 skipped.
    • A -g panic prints the same output as with the current bundle; neither one prints a backtrace.

    Trade-offs:

    • It is Apple's older format, and it is less exercised on arm64 in tinycc than the default.
    • Launch skips page-in linking, which makes no measurable difference at V's binary sizes.
    • The bug stays in tinycc's default output, so the tccmacho.c patch above is still worth sending
      upstream for other TCC users. This change only stops the bundled TCC from depending on it.

    Possible path:

    1. Change line 206 to --config-new_macho=no.
    2. Dispatch update_tccbin.yml for macos-arm64 (or wait for the monthly run).
    3. Once the new bundle is out (v up runs make latest_tcc first), remove the Darwin >= 27 guard
      from driver, tools: do not pick TCC implicitly on macOS 27 #29745.

    thirdparty-macos-amd64_tcc.sh sets the same flag. I could only test arm64.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugThis tag is applied to issues which reports bugs.CriticalThis label is used for critical issues

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions