commit 0020f1ce0b5092ec7547fea6464babcb19b42836
parent 7336010e1c2df57fdc23b794f497d361843c796a
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Wed, 10 Jun 2026 17:06:21 -0700
fix(obj): make COFF/Mach-O objects linkable by system linkers
kit-emitted objects were rejected by external linkers (LLVM lld's
ELF/COFF/Mach-O personalities and Apple ld); only kit's own `ld` consumed
them. Two bugs:
- COFF: NOBITS/.bss sections wrote their size into VirtualSize with
SizeOfRawData=0. COFF object files carry the size in SizeOfRawData
(VirtualSize is image-only); lld treated SizeOfRawData==0 as empty,
discarded .bss, and every relocation to a global defined there dangled
("relocation against symbol in discarded section"). Now: size in
SizeOfRawData, VirtualSize 0, no raw data (matches clang/MSVC). The COFF
reader already falls back to SizeOfRawData, so round-trip is stable.
- Mach-O: the DWARF FDE pc-begin was emitted as R_PC32, which on arm64
Mach-O has no representation and became a pcrel UNSIGNED reloc ld64 /
ld64.lld reject ("UNSIGNED relocation must not be PC-relative"; Apple ld:
"empty CIE"). clang uses __compact_unwind instead. kit now stops emitting
__eh_frame on Mach-O (the emits_eh_frame target gate excludes
KIT_OBJ_MACHO); macOS backtraces walk the frame-pointer chain, so no
capability is lost. ELF/COFF keep .eh_frame.
ELF (aa64/x64/rv64) already linked with ld.lld. Regression guard:
test/extlink/run.sh / `make test-extlink` links the corpus with ld.lld,
lld-link, ld64.lld, and Apple ld (link + native run); lanes self-skip when
a linker/SDK is absent. Dropping the Mach-O __eh_frame makes --strip-all
correctly drop _main (the FDE reloc used to keep it alive), matching the
strip tests' documented intent — strip/objcopy goldens updated.
Known follow-up: x86_64-macos still mis-emits __text addend relocs (the
arm64-only ARM64_RELOC_ADDEND pair path runs for x64); see doc/OBJ.md.
Diffstat:
12 files changed, 183 insertions(+), 15 deletions(-)
diff --git a/doc/OBJ.md b/doc/OBJ.md
@@ -312,6 +312,20 @@ linker's `-l` path: `read_macho_dso` (MH_DYLIB exports) and `tbd_read.c` (Apple
model, the `__DWARF` segment section-name spellings — are concentrated in
`obj_secnames.c`/`obj_tls.c` and the writer, not the backends.
+Mach-O objects deliberately carry **no `__eh_frame`** (the `emits_eh_frame`
+target gate in `src/api/core.c` excludes `KIT_OBJ_MACHO`). arm64 Mach-O has no
+pcrel-32 data relocation for a DWARF FDE pc-begin, and ld64's legacy
+`__eh_frame` path is effectively unused upstream — clang emits
+`__compact_unwind` instead — so kit's raw `__eh_frame` made objects unlinkable
+by `ld64`/`ld64.lld` ("UNSIGNED relocation must not be PC-relative", "empty
+CIE"). kit's macOS backtraces walk the frame-pointer chain, so dropping it
+costs no capability. `test/extlink/run.sh` (the `test-extlink` target) is the
+regression guard: it links kit objects with the system linkers across all three
+formats. Known follow-up: `x86_64-macos` still mis-emits the `__text`
+data-access addend reloc (the arm64-only `ARM64_RELOC_ADDEND` pair path in
+`macho/emit.c` runs for x86_64, where the addend must instead fold inline into a
+single `X86_64_RELOC_SIGNED`); the arm64 path is correct.
+
### COFF / PE (Windows, `src/obj/coff/`)
64-bit only (`x86_64-windows`, `aarch64-windows`); the hosted profile is
diff --git a/include/kit/core.h b/include/kit/core.h
@@ -206,7 +206,8 @@ typedef struct KitTargetSpec {
uint8_t long_size; /* sizeof(long): 8 for LP64, else 4 (LLP64/ILP32) */
uint8_t wchar_size; /* sizeof(wchar_t): 2 on Windows, else 4 */
uint8_t long_double_format; /* KitLongDoubleFormat */
- uint8_t emits_eh_frame; /* 1 when os != KIT_OS_FREESTANDING, else 0 */
+ uint8_t emits_eh_frame; /* 1 for hosted ELF/COFF; 0 for freestanding
+ and Mach-O (ld64 rejects raw __eh_frame) */
uint8_t os_version_major; /* OS major version (FreeBSD: 14/15/…); 0 =
unspecified */
} KitTargetSpec;
diff --git a/mk/test.mk b/mk/test.mk
@@ -80,6 +80,7 @@ TEST_TARGETS = \
test-driver-strip \
test-dwarf \
test-elf \
+ test-extlink \
test-emu \
test-emu-unit \
test-interp \
@@ -172,6 +173,7 @@ DEFAULT_TEST_TARGETS = \
test-dbg \
test-disasm-complete \
test-macho \
+ test-extlink \
test-interp-toy \
test-wasm \
test-libc \
@@ -299,6 +301,13 @@ test-driver-objdump: bin
test-driver-pkg: bin
@KIT=$(abspath $(BIN)) sh test/pkg/run.sh
+# test-extlink: prove kit-emitted objects link with the *system* linkers
+# (LLVM lld ELF/COFF/Mach-O personalities + Apple ld), not just kit's own
+# `ld`. Lanes self-skip when a linker / SDK is absent, so it is safe in the
+# default set on hosts without lld installed.
+test-extlink: bin
+ @KIT=$(abspath $(BIN)) sh test/extlink/run.sh
+
# test-dist: one-command verification of the dist subsystem (content-addressed
# store + signed packaging). Runs the CAS and pkg driver corpora, which cover
# round-trip, integrity/tamper detection, trust store, and the read-side
diff --git a/src/api/core.c b/src/api/core.c
@@ -72,8 +72,17 @@ KitStatus kit_target_new(const KitContext* ctx, const KitTargetOptions* opts,
} else {
t->spec.long_double_format = (uint8_t)KIT_LDBL_DOUBLE;
}
- /* #17 eh_frame: hosted targets emit .eh_frame; freestanding does not. */
- t->spec.emits_eh_frame = (t->spec.os != KIT_OS_FREESTANDING) ? 1u : 0u;
+ /* #17 eh_frame: hosted ELF/COFF targets emit .eh_frame for the host
+ * unwinder; freestanding does not (no unwinder, and it would orphan an
+ * ALLOC section). Mach-O is also excluded: ld64 / ld64.lld reject kit's
+ * raw __eh_frame — arm64 Mach-O has no pcrel-32 data relocation for the
+ * FDE pc-begin, and ld64's legacy __eh_frame path is effectively unused
+ * (clang emits __compact_unwind instead). kit's macOS backtrace walks
+ * the frame-pointer chain, so dropping __eh_frame costs no capability
+ * while making kit objects linkable by the system linker. */
+ t->spec.emits_eh_frame =
+ (t->spec.os != KIT_OS_FREESTANDING && t->spec.obj != KIT_OBJ_MACHO) ? 1u
+ : 0u;
nwords = (arch->ntarget_features + 63u) / 64u;
if (nwords) {
diff --git a/src/obj/coff/emit.c b/src/obj/coff/emit.c
@@ -66,8 +66,8 @@ static int coff_rel32_absorbs_minus4(KitArchKind arch, RelocKind kind,
typedef struct CSec {
/* IMAGE_SECTION_HEADER fields (little-endian-encoded at write time). */
char name8[8]; /* Name field bytes; "/N" form if long name */
- u32 virtual_size; /* nonzero for NOBITS (bss size) */
- u32 size_of_raw_data; /* zero for NOBITS */
+ u32 virtual_size; /* 0 for .obj (VirtualSize is image-only) */
+ u32 size_of_raw_data; /* section size, incl. NOBITS (bss) size */
u32 pointer_to_raw_data;
u32 pointer_to_relocations;
u16 number_of_relocations;
@@ -317,8 +317,16 @@ void emit_coff(Compiler* c, ObjBuilder* ob, Writer* w) {
if (s->sem == SSEM_NOBITS || s->kind == SEC_BSS) {
cs->is_nobits = 1;
- cs->virtual_size = s->bss_size;
- cs->size_of_raw_data = 0;
+ /* COFF object files carry the section size in SizeOfRawData (with
+ * PointerToRawData=0 / no file bytes for uninitialized data) and
+ * keep VirtualSize=0 — VirtualSize is meaningful only for linked
+ * images. lld / link.exe treat a section with SizeOfRawData==0 as
+ * empty and discard it, orphaning every symbol defined there and
+ * breaking relocations that target them ("relocation against
+ * symbol in discarded section"). Match clang/MSVC: size in
+ * SizeOfRawData, VirtualSize 0, no raw data. */
+ cs->virtual_size = 0;
+ cs->size_of_raw_data = s->bss_size;
cs->obj_bytes = NULL;
} else {
cs->is_nobits = 0;
diff --git a/test/extlink/run.sh b/test/extlink/run.sh
@@ -0,0 +1,135 @@
+#!/bin/sh
+# test/extlink/run.sh — external-linker linkability harness (Type-K mode-P).
+#
+# Proves that objects emitted by `kit cc` are consumable by the *system*
+# linkers, not just kit's own `ld`: LLVM lld in its three personalities
+# (ld.lld / lld-link / ld64.lld) plus Apple's ld on macOS. One lane per
+# (object-format, linker); a lane is SKIPPED (never failed) when its linker
+# or a required SDK is missing, so the suite is portable across CI hosts.
+#
+# Regression guard for two fixes:
+# - COFF: .bss size must live in SizeOfRawData (not VirtualSize), else lld
+# discards the section -> "relocation against symbol in discarded section".
+# - Mach-O: kit no longer emits a raw __eh_frame whose FDE pc-begin reloc
+# ld64 / ld64.lld reject ("UNSIGNED relocation must not be PC-relative").
+#
+# The corpus is two TUs that reference each other across a call and a shared
+# global (so the link exercises both a branch reloc and a data reloc).
+
+set -u
+
+script_dir=$(cd "$(dirname "$0")" && pwd)
+repo_root=$(cd "$script_dir/../.." && pwd)
+KIT_KIT_DIR="$repo_root/test/lib"
+. "$repo_root/test/lib/kit_sh_kit.sh"
+
+KIT="${KIT:-$repo_root/build/kit}"
+export KIT
+kit_require_kit extlink
+kit_workdir extlink
+work="$KIT_WORK"
+kit_report_init
+
+cat > "$work/a.c" <<'EOF'
+extern int kx_b(int);
+int kx_g = 7;
+int kx_a(void) { return kx_b(kx_g) + kx_g; }
+EOF
+cat > "$work/b.c" <<'EOF'
+extern int kx_g;
+int kx_b(int x) { return x + kx_g; }
+EOF
+
+# compile TARGET EXT : build a.<EXT> and b.<EXT> for TARGET (empty = native).
+# Returns nonzero (with the diagnostic in cc.err) if kit cc fails.
+compile() {
+ _t=$1
+ _ext=$2
+ "$KIT" cc ${_t:+-target "$_t"} -O0 -c "$work/a.c" -o "$work/a.$_ext" \
+ 2>"$work/cc.err" &&
+ "$KIT" cc ${_t:+-target "$_t"} -O0 -c "$work/b.c" -o "$work/b.$_ext" \
+ 2>>"$work/cc.err"
+}
+
+# ---- ELF: ld.lld --------------------------------------------------------
+if command -v ld.lld >/dev/null 2>&1; then
+ for t in aarch64-linux x86_64-linux riscv64-linux; do
+ if compile "$t" o; then
+ run_ok "elf-ld.lld-$t" ld.lld -static -no-pie -e kx_a \
+ -o "$work/$t.elf" "$work/a.o" "$work/b.o"
+ is_executable "elf-ld.lld-$t-exe" "$work/$t.elf"
+ else
+ not_ok "elf-ld.lld-$t" "$work/cc.err"
+ fi
+ done
+else
+ skip_test "elf-ld.lld" "ld.lld not on PATH"
+fi
+
+# ---- COFF: lld-link -----------------------------------------------------
+if command -v lld-link >/dev/null 2>&1; then
+ for t in x86_64-windows aarch64-windows; do
+ if compile "$t" obj; then
+ run_ok "coff-lld-link-$t" lld-link /dll /noentry \
+ /export:kx_a /export:kx_b \
+ "/out:$work/$t.dll" "$work/a.obj" "$work/b.obj"
+ else
+ not_ok "coff-lld-link-$t" "$work/cc.err"
+ fi
+ done
+else
+ skip_test "coff-lld-link" "lld-link not on PATH"
+fi
+
+# ---- Mach-O: Apple ld + ld64.lld (macOS, Apple Silicon) -----------------
+# Needs an SDK (libSystem + crt), so gate on xcrun. x86_64-macOS has a
+# separate, still-open __text addend-reloc bug, so restrict the native lane
+# to arm64 hosts (see doc note); link+run a real main() through the clang
+# driver, exercising the default Apple ld and lld end to end.
+machos_skip=""
+if ! command -v xcrun >/dev/null 2>&1; then
+ machos_skip="no macOS SDK (xcrun)"
+elif [ "$(uname -m 2>/dev/null)" != "arm64" ]; then
+ machos_skip="x86_64-macOS __text reloc fix pending"
+elif ! command -v clang >/dev/null 2>&1; then
+ machos_skip="clang not on PATH"
+fi
+if [ -n "$machos_skip" ]; then
+ skip_test "macho" "$machos_skip"
+else
+ SDK=$(xcrun --show-sdk-path 2>/dev/null || echo)
+ cat > "$work/m.c" <<'EOF'
+extern int kx_helper(int);
+int main(void) { return kx_helper(21); } /* expect exit 42 */
+EOF
+ cat > "$work/mh.c" <<'EOF'
+int kx_g2 = 0;
+int kx_helper(int x) { kx_g2 = x; return kx_g2 * 2; }
+EOF
+ if "$KIT" cc -O0 -c "$work/m.c" -o "$work/m.o" 2>"$work/cc.err" &&
+ "$KIT" cc -O0 -c "$work/mh.c" -o "$work/mh.o" 2>>"$work/cc.err"; then
+ # Apple ld (clang's default linker).
+ if clang -isysroot "$SDK" -o "$work/m_apple" "$work/m.o" "$work/mh.o" \
+ 2>"$work/apple.err"; then
+ "$work/m_apple" 2>/dev/null
+ if [ $? -eq 42 ]; then ok "macho-apple-ld"
+ else echo "wrong exit code" >"$work/apple.err"; not_ok "macho-apple-ld" "$work/apple.err"; fi
+ else
+ not_ok "macho-apple-ld" "$work/apple.err"
+ fi
+ # ld64.lld.
+ if clang -fuse-ld=lld -isysroot "$SDK" -o "$work/m_lld" \
+ "$work/m.o" "$work/mh.o" 2>"$work/lld.err"; then
+ "$work/m_lld" 2>/dev/null
+ if [ $? -eq 42 ]; then ok "macho-ld64.lld"
+ else echo "wrong exit code" >"$work/lld.err"; not_ok "macho-ld64.lld" "$work/lld.err"; fi
+ else
+ not_ok "macho-ld64.lld" "$work/lld.err"
+ fi
+ else
+ not_ok "macho-compile" "$work/cc.err"
+ fi
+fi
+
+kit_summary extlink
+kit_exit
diff --git a/test/objcopy/cases/01-rename-section.expected b/test/objcopy/cases/01-rename-section.expected
@@ -1,2 +1 @@
__TEXT,__mytext
-__TEXT,__eh_frame
diff --git a/test/objcopy/cases/04-add-section.expected b/test/objcopy/cases/04-add-section.expected
@@ -1,3 +1,2 @@
__DATA,__custom
-__TEXT,__eh_frame
__TEXT,__text
diff --git a/test/strip/cases/01-strip-debug.expected b/test/strip/cases/01-strip-debug.expected
@@ -1,5 +1,4 @@
== sections ==
-__TEXT,__eh_frame
__TEXT,__text
== symbols ==
_helper
diff --git a/test/strip/cases/02-strip-all-keeps-reloc-targets.expected b/test/strip/cases/02-strip-all-keeps-reloc-targets.expected
@@ -1,6 +1,4 @@
== symbols ==
_helper
-_main
== sections ==
-__TEXT,__eh_frame
__TEXT,__text
diff --git a/test/strip/cases/03-keep-symbol.expected b/test/strip/cases/03-keep-symbol.expected
@@ -1,4 +1,3 @@
== symbols ==
_helper
-_main
_unused
diff --git a/test/strip/cases/04-archive-strip-debug.expected b/test/strip/cases/04-archive-strip-debug.expected
@@ -2,8 +2,6 @@
a.o
b.o
== a.o sections ==
-__TEXT,__eh_frame
__TEXT,__text
== b.o sections ==
-__TEXT,__eh_frame
__TEXT,__text