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 uphangs forever (it is compiling thevuptool; the compiler spins inflat.node_payload_lockbecauseg_node_payload_lockstarts as0xffffffff);- compiling a hello world panics with
V panic: fixed array index out of range (index: -1, len: 64)inworkers.register_open_pool(v3_open_pools_lenstarts as-1); makeon master ends with that same panic in thecmd/tools/detect_tcc.vstep.
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:
vmmapatprocess launch -s:__DATA 1014a0000-1014a4000 SM=COW(file) +1014a4000-1014a6000 SM=NUL(zero fill). Atmain:__DATA 1014a0000-1014a6000 SM=COW+mapped file 1014a6000-1014a8000, i.e. 2 pages remapped from the file, exactly thepage_count = 2thatotool -chained_fixupsshows for__DATA. A hardware watchpoint on the corrupted global never fires: the page is remapped, not written.- Building that same
cmd/vwith 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.exeswapped intothirdparty/tcc:makecompletes (includingdetect_tcc) with a TCC-linked./vthat has the exact failing layout above butpage_count = 1. Building thevuptool (the original hang) takes about 1s, andv test vlib/v/pref/ vlib/os/ vlib/v/driver/c_compiler_flags_test.vpasses. - Edge cases built with the patched TCC: a
-shareddylib loaded withdlopen(zero__bss, correct rebased pointer), a__DATAwith only__bss(no starts entry,seg_offset 0),__DATAending exactly on a page boundary;dyld_info -fixupsparses all of them andcodesign -vpasses.
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 ccV 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
2Alternative 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/vbuilds a TCC-linked compiler, with both-gc noneand the default Boehm GC;v_tcc versionworks.- That compiler builds and runs a hello world and an
import osprogram, both with-cc tccand 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
-gpanic 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.cpatch above is still worth sending upstream for other TCC users. This change only stops the bundled TCC from depending on it.
Possible path:
- Change line 206 to
--config-new_macho=no. - Dispatch
update_tccbin.ymlformacos-arm64(or wait for the monthly run). - Once the new bundle is out (
v uprunsmake latest_tccfirst), remove the Darwin >= 27 guard from #29745.
thirdparty-macos-amd64_tcc.sh sets the same flag. I could only test arm64.
Plain C reproducer (no V involved)
Running a TCC-linked C program is enough to reproduce this. Two conditions have to hold:
__bsshas to spill past the first 16K page of__DATA.__DATAlies 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.
CCdefaults to the bundled TCC:Results on macOS 27.0.1 (Darwin 27.0.0), arm64:
bss_big(~738K file)bss_small(~51K file)tcc.exe(tccbin1d0ad0e, tinycc85ba3ae8)__bssbytesmob43c7708b, unpatchedmob43c7708b+ the patch aboveIn both TCC builds
__DATAhasfilesize 0x70andvmsize 0x8070, andotool -chained_fixupsreportspage_count 3; with the patch it reportspage_count 1.bss_big:__DATAis at file offset0x4c000, so the 3 listed pages (0x4c000-0x58000) are inside the 738K file. They get mapped from the file, and__bssreceives__LINKEDITbytes.bss_small:__DATAis at0x8000, so the listed pages (0x8000-0x14000) run past the end of the 51K file, and__bssstays 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.