#29744

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

Openquaesitor-scientiamopened 24d ago

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_counthello worldimport os
bundled tcc.exe (tccbin 274abd2, tinycc 85ba3ae8)non-zero2panichang
tinycc 85ba3ae8 built locally, unpatchednon-zero2panichang
only __bss offset set to 0 (like ld64)02panichang
only page_count from filesize (patch below)non-zero1okok

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

2
quaesitor-scientiam22d ago

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 compilerbss_big (~738K file)bss_small (~51K file)
bundled tcc.exe (tccbin 1d0ad0e, tinycc 85ba3ae8)13561 non-zero __bss bytes0
tinycc mob 43c7708b, unpatched135610
tinycc mob 43c7708b + the patch above00
clang00

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.

quaesitor-scientiam22d ago

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 compileroutput formatbss_big (C reproducer above)bss_small
tinycc 43c7708b, new_macho=yes (current setting)chained fixups13561 non-zero __bss bytes0
tinycc 43c7708b, new_macho=noLC_DYLD_INFO_ONLY00

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 #29745.

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