commit 6405d2b7504c93c0c3b41a7ac8180c45691fc0f8
parent fc7bdd38228d702866ea143dd58bf85d06fa48c0
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 23 May 2026 17:37:07 -0700
test: add format-bounce stress harness; fix assembler local UNDEFs
Add `make test-bounce`: take one compiled program and bounce its
relocatable object through chains of format conversions and toolchain
transforms (objcopy -O elf/mach-o/coff round-trips, ld -r partial-link,
strip, ar archive), relink an ELF executable, run it, and assert the
exit code matches a host-cc reference. The cross-TU call to bounce_main
must survive each re-encoding or the exit code diverges, so the chains
exercise obj read/write, reloc translation, partial-link, and archive
symbol resolution rather than the arches.
Chains are gated to formats each arch actually targets: ELF everywhere,
COFF on x64+aarch64, Mach-O on aarch64 only. Defaults to the host-native
arch; CFREE_BOUNCE_ARCHES sweeps the rest via podman. Green on aarch64
(70), x64 (50), and rv64 (40).
Two fixes the harness depends on / surfaced:
- lang asm: a symbol referenced but never defined or declared .local was
emitted as a local *UND*, which the linker (correctly) will not pull
from an archive. Promote undefined locals to global SK_UNDEF at
asm_parse finalize, matching GNU as. Without this an asm/crt stub
cannot link against an archived definition.
- arch/x64: add the `syscall` mnemonic (0F 05) to the ISA table and the
assembler's mnemonic normalizer (it ends in 'l', so it needs an early
return before the generic size-suffix strip). It was entirely missing.
Diffstat:
12 files changed, 507 insertions(+), 1 deletion(-)
diff --git a/src/arch/x64/asm.c b/src/arch/x64/asm.c
@@ -445,6 +445,11 @@ static int parse_mnemonic(const char* s, size_t n, X64MnInfo* out) {
if (n == 3 && memcmp(s, "ret", 3) == 0) {
out->base_len = 3; return 1;
}
+ /* "syscall" ends in 'l' — return early so the generic size-suffix
+ * stripper below does not mistake it for a movl-style width letter. */
+ if (n == 7 && memcmp(s, "syscall", 7) == 0) {
+ out->base_len = 7; return 1;
+ }
/* Indirect-branch spellings carry an explicit 'q' suffix that must be
* preserved — the BR_RM rows in the table are keyed on "jmpq"/"callq". */
diff --git a/src/arch/x64/isa.c b/src/arch/x64/isa.c
@@ -47,6 +47,9 @@ const X64InsnDesc x64_insn_table[] = {
/* ---- two-byte UD2 ---- */
ROW("ud2", X64_PFX_NONE, 2, 0x0F, 0x0B, 0, 0xFF, NO_MODRM, X64_W_REQ_ANY,
X64_FMT_NULLARY, 0),
+ /* ---- SYSCALL (0F 05): fast system call ---- */
+ ROW("syscall", X64_PFX_NONE, 2, 0x0F, 0x05, 0, 0xFF, NO_MODRM,
+ X64_W_REQ_ANY, X64_FMT_NULLARY, 0),
ROW("mfence", X64_PFX_NONE, 3, 0x0F, 0xAE, 0xF0, 0xFF, NO_MODRM,
X64_W_REQ_ANY, X64_FMT_NULLARY, 0),
diff --git a/src/asm/asm.c b/src/asm/asm.c
@@ -176,6 +176,26 @@ static ObjSym* sym_mut(AsmDriver* d, ObjSymId id) {
return (ObjSym*)obj_symbol_get(d->ob, id);
}
+/* GNU `as` makes any symbol that is referenced but neither defined nor
+ * declared `.local` an undefined *global* (a local UNDEF is meaningless
+ * in ELF and won't pull a member out of an archive at link time).
+ * intern_sym mints every new symbol SB_LOCAL/SK_NOTYPE, so after the
+ * parse we promote the ones that stayed undefined to global SK_UNDEF.
+ * Defined locals (labels), `.local` decls, and absolute/common symbols
+ * are left untouched. */
+static void promote_undef_externs(AsmDriver* d) {
+ ObjSymIter* it = obj_symiter_new(d->ob);
+ ObjSymEntry e;
+ while (obj_symiter_next(it, &e)) {
+ if (e.sym->section_id != OBJ_SEC_NONE) continue; /* defined here */
+ if (e.sym->bind != SB_LOCAL) continue;
+ if (e.sym->kind == SK_ABS || e.sym->kind == SK_COMMON) continue;
+ obj_symbol_set_bind(d->ob, e.id, SB_GLOBAL);
+ sym_mut(d, e.id)->kind = (u16)SK_UNDEF;
+ }
+ obj_symiter_free(it);
+}
+
/* ---- expression evaluator (constants + sym ± const) ---- */
typedef struct AsmExpr {
@@ -1036,6 +1056,8 @@ void asm_parse(Compiler* c, AsmLexer* l, MCEmitter* mc) {
d_skip_to_eol(&d);
}
+ promote_undef_externs(&d);
+
if (d.arch_asm && d.arch_asm->destroy) d.arch_asm->destroy(d.arch_asm);
SymSecMap_fini(&d.sec_map);
SymSymMap_fini(&d.sym_map);
diff --git a/test/bounce/CORPUS.md b/test/bounce/CORPUS.md
@@ -0,0 +1,58 @@
+# test/bounce — format-bounce stress corpus
+
+Stresses cfree's object **read/write/reloc**, **archive**, and
+**partial-link** machinery by taking one compiled program and *bouncing*
+its relocatable object through chains of format conversions and toolchain
+transforms, relinking, and checking the result still computes the right
+answer. Cross-arch *run* coverage lives elsewhere (test/link, test/smoke);
+this corpus exercises the format paths, not the arches.
+
+Run with `make test-bounce` (opt-in; needs podman/qemu + a host `cc`).
+
+## Cases
+
+`cases/NN_name.c` each define `int bounce_main(void)` returning a value in
+`[0,127]`. They are portable LP64 integer C, so the **host-cc result is the
+oracle** every target and every bounce chain must reproduce. `crt/crt.c` is
+the freestanding `_start`: it calls `bounce_main` with a normal C call (so
+the compiler emits a real cross-TU relocation — the edge under test) and
+exits via `exit_group` with the return value.
+
+## Chains
+
+For each (arch, case, -O level) the program is compiled to one `.o`, then
+each chain rebuilds an executable from it and runs it:
+
+| Chain | Transform | Stresses |
+|-------|-----------|----------|
+| `direct` | link straight through | baseline |
+| `macho_rt` | ELF → Mach-O → ELF (objcopy) | `macho_emit`/`macho_read` + reloc xlate |
+| `coff_rt` | ELF → COFF → ELF (objcopy) | `coff_emit`/`coff_read` + reloc xlate |
+| `tri_rt` | ELF → Mach-O → COFF → ELF | chained round-trip |
+| `ldr` | `ld -r` partial-link merge → link | relocatable output |
+| `strip_dbg` | `objcopy --strip-debug` → link | strip + reduced symtab |
+| `ar` | `ar rcs` → on-demand link | GNU archive index r/w + symbol resolution |
+
+A chain only runs where every format it routes through is a real cfree
+target for that arch:
+
+| Format | Arches |
+|--------|--------|
+| ELF | all (Linux / freestanding) |
+| COFF | x64, aarch64 (Windows) |
+| Mach-O | aarch64 only (Apple Silicon; cfree does not target x64 Mach-O) |
+
+So `macho_rt`/`tri_rt` run on aarch64 only, `coff_rt` on aarch64+x64, and
+the ELF-only chains everywhere.
+
+## Arches
+
+Defaults to the host-native Linux arch (no emulation). Sweep others with
+`CFREE_BOUNCE_ARCHES="aarch64 x64 rv64"`. Optimization levels default to
+`0 1` (override with `CFREE_BOUNCE_OPTS`).
+
+## Conventions
+
+Skip-vs-fail mirrors the smoke harness: a skip counts as failure unless
+`CFREE_TEST_ALLOW_SKIP=1`. Add a case by dropping a `cases/NN_name.c` that
+defines `bounce_main`; no other wiring is needed.
diff --git a/test/bounce/bounce.sh b/test/bounce/bounce.sh
@@ -0,0 +1,263 @@
+#!/usr/bin/env bash
+# test/bounce/bounce.sh — format-bounce stress test.
+#
+# Goal: stress cfree's object read/write/reloc, archive, and partial-link
+# paths by taking one compiled program and *bouncing* its object through
+# chains of format conversions and toolchain transforms, then verifying
+# the relinked executable still produces the right answer. We already have
+# cross-arch run coverage elsewhere; this test exists to exercise the
+# format machinery, not the arches.
+#
+# For each (arch, case, -O level) the program is compiled to a relocatable
+# object once, then each bounce chain rebuilds an executable from it:
+#
+# direct baseline: link prog.o + crt.o straight through
+# macho_rt ELF -> Mach-O -> ELF (objcopy) then link
+# coff_rt ELF -> COFF -> ELF (objcopy) then link
+# tri_rt ELF -> Mach-O -> COFF -> ELF (chained) then link
+# ldr `ld -r` partial-link merge of prog.o+crt.o, then final link
+# strip_dbg objcopy --strip-debug on prog.o, then link
+# ar `ar rcs` prog.o into an archive, on-demand link with crt.o
+#
+# Every chain's executable is run (batched through one podman exec) and its
+# exit code compared to a host-cc reference for that case. Skip-vs-fail
+# follows the smoke harness: a skip is a failure unless CFREE_TEST_ALLOW_SKIP=1.
+#
+# Arch selection: defaults to the host-native Linux arch (fast, no
+# emulation). Override with CFREE_BOUNCE_ARCHES="aarch64 x64 rv64" to sweep
+# others through podman/qemu.
+set -u
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+BIN="${CFREE_BIN:-$ROOT/build/cfree}"
+HOSTCC="${CC:-cc}"
+BUILD="$ROOT/build/test/bounce"
+CRT="$ROOT/test/bounce/crt/crt.c"
+ALLOW_SKIP="${CFREE_TEST_ALLOW_SKIP:-0}"
+OPT_LEVELS="${CFREE_BOUNCE_OPTS:-0 1}"
+
+rm -rf "$BUILD"
+mkdir -p "$BUILD"
+
+color_red() { printf '\033[31m%s\033[0m' "$1"; }
+color_grn() { printf '\033[32m%s\033[0m' "$1"; }
+color_yel() { printf '\033[33m%s\033[0m' "$1"; }
+
+PASS=0; FAIL=0; SKIP=0
+note_pass() { PASS=$((PASS+1)); printf ' %s %s\n' "$(color_grn PASS)" "$1"; }
+note_fail() { FAIL=$((FAIL+1)); printf ' %s %s\n' "$(color_red FAIL)" "$1"; }
+note_skip() { SKIP=$((SKIP+1)); printf ' %s %s — %s\n' "$(color_yel SKIP)" "$1" "$2"; }
+
+# ---- arch selection --------------------------------------------------------
+
+case "$(uname -m 2>/dev/null)" in
+ arm64|aarch64) NATIVE=aarch64 ;;
+ x86_64) NATIVE=x64 ;;
+ *) NATIVE="" ;;
+esac
+ARCHES="${CFREE_BOUNCE_ARCHES:-$NATIVE}"
+
+arch_triple() {
+ case "$1" in
+ aarch64) echo aarch64-linux ;;
+ x64) echo x86_64-linux ;;
+ rv64) echo riscv64-linux ;;
+ *) echo "" ;;
+ esac
+}
+arch_define() {
+ case "$1" in
+ aarch64) echo BOUNCE_AARCH64 ;;
+ x64) echo BOUNCE_X64 ;;
+ rv64) echo BOUNCE_RV64 ;;
+ *) echo "" ;;
+ esac
+}
+
+# ---- exec_target wiring ----------------------------------------------------
+
+have_podman=0; command -v podman >/dev/null 2>&1 && have_podman=1
+have_qemu=0; QEMU_BIN=""
+case "$(uname -m 2>/dev/null)" in arm64|aarch64) is_aarch64=1 ;; *) is_aarch64=0 ;; esac
+export have_podman have_qemu QEMU_BIN is_aarch64
+EXEC_TARGET_MOUNT_ROOT="$BUILD"
+export EXEC_TARGET_MOUNT_ROOT
+# shellcheck source=../lib/exec_target.sh
+source "$ROOT/test/lib/exec_target.sh"
+
+# ---- prerequisites ---------------------------------------------------------
+
+if [ ! -x "$BIN" ]; then
+ echo "missing $BIN — run \`make bin\` first" >&2
+ exit 2
+fi
+if [ -z "$ARCHES" ]; then
+ note_skip "arch" "unsupported host arch $(uname -m) — set CFREE_BOUNCE_ARCHES"
+ printf '\nResults: %s pass, %s fail, %s skip\n' "$PASS" "$FAIL" "$SKIP"
+ [ "$ALLOW_SKIP" = "1" ] && exit 0 || exit 1
+fi
+if ! "$HOSTCC" -x c -c /dev/null -o /dev/null 2>/dev/null; then
+ note_skip "hostcc" "host '$HOSTCC' cannot compile — set CC"
+ printf '\nResults: %s pass, %s fail, %s skip\n' "$PASS" "$FAIL" "$SKIP"
+ [ "$ALLOW_SKIP" = "1" ] && exit 0 || exit 1
+fi
+
+CASES="$(ls "$ROOT"/test/bounce/cases/*.c 2>/dev/null | sort)"
+if [ -z "$CASES" ]; then
+ echo "no cases under test/bounce/cases" >&2
+ exit 2
+fi
+
+# ---- per-case host reference ----------------------------------------------
+# Reference exit code for a case = running it on the host. The cases are
+# portable LP64 integer C, so the host result is the oracle every target
+# and every bounce chain must reproduce.
+compute_ref() {
+ local case="$1" name="$2" ref_exe="$BUILD/$name.ref" drv="$BUILD/$name.refmain.c"
+ printf 'int bounce_main(void);\nint main(void){return bounce_main();}\n' >"$drv"
+ if ! "$HOSTCC" -O1 "$case" "$drv" -o "$ref_exe" 2>"$BUILD/$name.ref.err"; then
+ return 1
+ fi
+ "$ref_exe"; echo $?
+}
+
+# ---- bounce chains ---------------------------------------------------------
+# Each chain takes prog.o + crt.o and produces an executable at $out, or
+# returns nonzero (with a diagnostic on stdout) if any transform/link fails.
+# $1=prog.o $2=crt.o $3=out $4=workdir
+chain_direct() { "$BIN" ld -o "$3" -e _start -static "$1" "$2" 2>&1; }
+chain_macho_rt() {
+ "$BIN" objcopy -O mach-o "$1" "$4/m.o" 2>&1 || return 1
+ "$BIN" objcopy -O elf "$4/m.o" "$4/m2.o" 2>&1 || return 1
+ "$BIN" ld -o "$3" -e _start -static "$4/m2.o" "$2" 2>&1
+}
+chain_coff_rt() {
+ "$BIN" objcopy -O coff "$1" "$4/c.o" 2>&1 || return 1
+ "$BIN" objcopy -O elf "$4/c.o" "$4/c2.o" 2>&1 || return 1
+ "$BIN" ld -o "$3" -e _start -static "$4/c2.o" "$2" 2>&1
+}
+chain_tri_rt() {
+ "$BIN" objcopy -O mach-o "$1" "$4/t1.o" 2>&1 || return 1
+ "$BIN" objcopy -O coff "$4/t1.o" "$4/t2.o" 2>&1 || return 1
+ "$BIN" objcopy -O elf "$4/t2.o" "$4/t3.o" 2>&1 || return 1
+ "$BIN" ld -o "$3" -e _start -static "$4/t3.o" "$2" 2>&1
+}
+chain_ldr() {
+ "$BIN" ld -r -o "$4/merged.o" "$1" "$2" 2>&1 || return 1
+ "$BIN" ld -o "$3" -e _start -static "$4/merged.o" 2>&1
+}
+chain_strip_dbg() {
+ "$BIN" objcopy --strip-debug "$1" "$4/s.o" 2>&1 || return 1
+ "$BIN" ld -o "$3" -e _start -static "$4/s.o" "$2" 2>&1
+}
+chain_ar() {
+ rm -f "$4/lib.a"
+ "$BIN" ar rcs "$4/lib.a" "$1" 2>&1 || return 1
+ "$BIN" ld -o "$3" -e _start -static "$2" "$4/lib.a" 2>&1
+}
+CHAINS="direct macho_rt coff_rt tri_rt ldr strip_dbg ar"
+
+# A chain is only meaningful when every object format it routes through is
+# a real cfree target for that arch. Support matrix:
+# ELF all arches (Linux/freestanding)
+# COFF x64 + aarch64 (Windows)
+# Mach-O aarch64 only (Apple Silicon; cfree does not target x64 Mach-O)
+# Returns 0 when $chain is applicable for $arch.
+chain_applies() {
+ local arch="$1" chain="$2"
+ case "$chain" in
+ macho_rt | tri_rt) # route through Mach-O
+ [ "$arch" = aarch64 ] ;;
+ coff_rt) # route through COFF
+ case "$arch" in aarch64 | x64) return 0 ;; *) return 1 ;; esac ;;
+ *) return 0 ;; # ELF-only: direct, ldr, strip_dbg, ar
+ esac
+}
+
+# ---- queue bookkeeping (parallel indexed arrays; bash 3.2 safe) -----------
+Q_LABEL=(); Q_RCFILE=(); Q_EXPECT=()
+
+# ---- main sweep ------------------------------------------------------------
+
+for arch in $ARCHES; do
+ triple="$(arch_triple "$arch")"
+ define="$(arch_define "$arch")"
+ if [ -z "$triple" ]; then
+ note_skip "$arch" "unknown arch tag"
+ continue
+ fi
+ if ! exec_target_supported "$arch"; then
+ note_skip "$arch" "no runner (need podman or qemu for $arch-linux)"
+ continue
+ fi
+
+ for opt in $OPT_LEVELS; do
+ # crt depends only on (arch, opt); build it once.
+ crt_o="$BUILD/crt.$arch.O$opt.o"
+ if ! "$BIN" cc -target "$triple" -D"$define" -O"$opt" -c "$CRT" \
+ -o "$crt_o" 2>"$BUILD/crt.$arch.O$opt.err"; then
+ note_fail "$arch/O$opt crt.c (see $BUILD/crt.$arch.O$opt.err)"
+ continue
+ fi
+
+ for case in $CASES; do
+ name="$(basename "$case" .c)"
+ ref="$(compute_ref "$case" "$name")"
+ if [ -z "$ref" ]; then
+ note_fail "$name reference build (host cc; see $BUILD/$name.ref.err)"
+ continue
+ fi
+
+ prog_o="$BUILD/$name.$arch.O$opt.o"
+ if ! "$BIN" cc -target "$triple" -O"$opt" -c "$case" -o "$prog_o" \
+ 2>"$BUILD/$name.$arch.O$opt.err"; then
+ note_fail "$name $arch/O$opt compile (see $BUILD/$name.$arch.O$opt.err)"
+ continue
+ fi
+
+ for chain in $CHAINS; do
+ chain_applies "$arch" "$chain" || continue
+ tag="$name.$arch.O$opt.$chain"
+ wd="$BUILD/$tag.d"; mkdir -p "$wd"
+ exe="$BUILD/$tag.exe"
+ if ! "chain_$chain" "$prog_o" "$crt_o" "$exe" "$wd" \
+ >"$BUILD/$tag.link.log" 2>&1; then
+ note_fail "$tag (transform/link; see $BUILD/$tag.link.log)"
+ continue
+ fi
+ # Re-read the produced executable's headers to catch malformed
+ # output the linker accepted but a reader would choke on.
+ rcfile="$BUILD/$tag.rc"
+ Q_LABEL+=("$tag"); Q_RCFILE+=("$rcfile"); Q_EXPECT+=("$ref")
+ exec_target_queue "$arch" "$tag" "$exe" \
+ "$BUILD/$tag.out" "$BUILD/$tag.err" "$rcfile"
+ done
+ done
+ done
+done
+
+# ---- run everything in batched container execs, then score -----------------
+exec_target_flush
+
+i=0; n="${#Q_LABEL[@]}"
+while [ "$i" -lt "$n" ]; do
+ label="${Q_LABEL[$i]}"; rcfile="${Q_RCFILE[$i]}"; expect="${Q_EXPECT[$i]}"
+ i=$((i+1))
+ if [ ! -f "$rcfile" ]; then
+ note_fail "$label (no rc recorded)"
+ continue
+ fi
+ got="$(cat "$rcfile")"
+ if [ "$got" = "$expect" ]; then
+ note_pass "$label (rc=$got)"
+ else
+ note_fail "$label (expected $expect got $got)"
+ fi
+done
+
+printf '\nResults: %s pass, %s fail, %s skip\n' "$PASS" "$FAIL" "$SKIP"
+[ "$FAIL" -eq 0 ] || exit 1
+if [ "$PASS" -eq 0 ]; then
+ [ "$ALLOW_SKIP" = "1" ] && exit 0 || exit 1
+fi
+exit 0
diff --git a/test/bounce/cases/01_arith.c b/test/bounce/cases/01_arith.c
@@ -0,0 +1,11 @@
+/* Integer arithmetic: add/sub/mul/div/mod/shift across a few widths.
+ * bounce_main returns a value in [0,127]. */
+int bounce_main(void) {
+ unsigned u = 0xDEADBEEFu;
+ int a = 1234567, b = 89;
+ long c = (long)a * b - (a / b) + (a % b);
+ c ^= (long)(u >> 7);
+ c += (c << 3) - (c >> 2);
+ int r = (int)(c & 0x7f);
+ return r;
+}
diff --git a/test/bounce/cases/02_control.c b/test/bounce/cases/02_control.c
@@ -0,0 +1,22 @@
+/* Control flow: nested loops, break/continue, conditional accumulation.
+ * Computes a Collatz-step total. Returns a value in [0,127]. */
+static int collatz_steps(unsigned n) {
+ int s = 0;
+ while (n != 1u) {
+ if (n & 1u)
+ n = 3u * n + 1u;
+ else
+ n = n / 2u;
+ if (++s > 1000) break;
+ }
+ return s;
+}
+
+int bounce_main(void) {
+ long total = 0;
+ for (unsigned i = 1; i <= 50u; ++i) {
+ if (i % 7u == 0u) continue;
+ total += collatz_steps(i);
+ }
+ return (int)(total & 0x7f);
+}
diff --git a/test/bounce/cases/03_struct.c b/test/bounce/cases/03_struct.c
@@ -0,0 +1,30 @@
+/* Aggregates: struct-by-value args/returns, arrays, pointer walks.
+ * Returns a value in [0,127]. */
+struct vec3 {
+ long x, y, z;
+};
+
+static struct vec3 add3(struct vec3 a, struct vec3 b) {
+ struct vec3 r = {a.x + b.x, a.y + b.y, a.z + b.z};
+ return r;
+}
+
+static long dot(struct vec3 a, struct vec3 b) {
+ return a.x * b.x + a.y * b.y + a.z * b.z;
+}
+
+int bounce_main(void) {
+ struct vec3 acc = {0, 0, 0};
+ struct vec3 step = {1, 2, 3};
+ for (int i = 0; i < 16; ++i) {
+ acc = add3(acc, step);
+ step.x += i;
+ step.z -= 1;
+ }
+ long arr[8];
+ for (int i = 0; i < 8; ++i) arr[i] = acc.x + i * acc.y - acc.z;
+ long s = 0;
+ for (long* p = arr; p < arr + 8; ++p) s += *p;
+ s += dot(acc, step);
+ return (int)(s & 0x7f);
+}
diff --git a/test/bounce/cases/04_recursion.c b/test/bounce/cases/04_recursion.c
@@ -0,0 +1,19 @@
+/* Recursion and the call/spill machinery: fib + a small Ackermann.
+ * Returns a value in [0,127]. */
+static long fib(int n) {
+ if (n < 2) return n;
+ return fib(n - 1) + fib(n - 2);
+}
+
+static int ack(int m, int n) {
+ if (m == 0) return n + 1;
+ if (n == 0) return ack(m - 1, 1);
+ return ack(m - 1, ack(m, n - 1));
+}
+
+int bounce_main(void) {
+ long s = fib(20); /* 6765 */
+ s += ack(2, 3); /* 9 */
+ s += ack(3, 3); /* 61 */
+ return (int)(s & 0x7f);
+}
diff --git a/test/bounce/cases/05_wide.c b/test/bounce/cases/05_wide.c
@@ -0,0 +1,12 @@
+/* Wide arithmetic: 64-bit multiply/divide/shift mixes that lower to
+ * compiler-rt helpers on some targets. Returns a value in [0,127]. */
+int bounce_main(void) {
+ unsigned long long a = 0x0123456789ABCDEFull;
+ unsigned long long b = 0xFEDCBA9876543210ull;
+ unsigned long long m = a * b; /* low 64 of the product */
+ unsigned long long d = b / (a | 1ull); /* 64-bit divide */
+ long long s = (long long)(a >> 17) - (long long)(b >> 29);
+ s += (long long)(m ^ d);
+ s ^= (long long)(a % 1000003ull);
+ return (int)(s & 0x7f);
+}
diff --git a/test/bounce/crt/crt.c b/test/bounce/crt/crt.c
@@ -0,0 +1,48 @@
+/* Freestanding entry point for the bounce harness.
+ *
+ * The harness compiles this with a -DBOUNCE_<ARCH> define selecting the
+ * guest architecture (cfree does not predefine __x86_64__ etc.). _start
+ * calls bounce_main with a normal C call so the compiler emits the proper
+ * cross-translation-unit relocation — the very edge the format-bounce
+ * exercises — then exits via exit_group with its return value.
+ *
+ * The syscall is set up with explicit `mov` instructions taking the exit
+ * code through a generic "r" operand rather than GCC local register
+ * variables (which cfree does not honor) or per-arch call/syscall pseudos
+ * (which the standalone assembler does not model). */
+
+int bounce_main(void);
+
+__attribute__((noreturn)) static void sys_exit(int code) {
+#if defined(BOUNCE_X64)
+ __asm__ volatile(
+ "movl %0, %%edi\n\t"
+ "movl $231, %%eax\n\t" /* exit_group */
+ "syscall\n\t"
+ :
+ : "r"(code)
+ : "eax", "edi", "memory");
+#elif defined(BOUNCE_AARCH64)
+ __asm__ volatile(
+ "mov x0, %0\n\t"
+ "mov x8, #94\n\t"
+ "svc #0\n\t"
+ :
+ : "r"((long)code)
+ : "x0", "x8", "memory");
+#elif defined(BOUNCE_RV64)
+ __asm__ volatile(
+ "mv a0, %0\n\t"
+ "li a7, 94\n\t"
+ "ecall\n\t"
+ :
+ : "r"((long)code)
+ : "a0", "a7", "memory");
+#else
+#error "define BOUNCE_X64 / BOUNCE_AARCH64 / BOUNCE_RV64"
+#endif
+ for (;;) {
+ }
+}
+
+void _start(void) { sys_exit(bounce_main()); }
diff --git a/test/test.mk b/test/test.mk
@@ -26,7 +26,7 @@
# asm_parse / cfree_disasm_iter_* are still stubs; the harness builds
# and runs end-to-end so the wiring stays exercised. See doc/ASM.md.
-.PHONY: test test-driver test-pp test-pp-err test-elf test-coff test-coff-mingw-import test-coff-windows-ucrt test-ar test-ar-driver test-strip-driver test-objcopy-driver test-objdump-driver test-link test-cg-api test-toy test-opt test-dwarf test-debug test-parse test-parse-err test-asm test-wasm-front test-isa test-aa64-inline test-rv64-inline test-rv64-jit test-emu test-x64-inline test-x64-dbg test-rt-headers test-rt-runtime test-musl test-musl-rv64 test-glibc test-glibc-rv64 test-lib-deps test-smoke-x64 test-smoke-rv64 test-cbackend rv64-doctor
+.PHONY: test test-driver test-pp test-pp-err test-elf test-coff test-coff-mingw-import test-coff-windows-ucrt test-ar test-ar-driver test-strip-driver test-objcopy-driver test-objdump-driver test-link test-cg-api test-toy test-opt test-dwarf test-debug test-parse test-parse-err test-asm test-wasm-front test-isa test-aa64-inline test-rv64-inline test-rv64-jit test-emu test-x64-inline test-x64-dbg test-rt-headers test-rt-runtime test-musl test-musl-rv64 test-glibc test-glibc-rv64 test-lib-deps test-smoke-x64 test-smoke-rv64 test-bounce test-cbackend rv64-doctor
test: test-driver test-pp test-pp-err test-elf test-coff test-ar test-ar-driver test-strip-driver test-objcopy-driver test-objdump-driver test-link test-toy test-dwarf test-debug test-parse test-parse-err test-asm test-isa test-aa64-inline test-rv64-inline test-rv64-jit test-emu test-x64-inline test-x64-dbg test-rt-headers test-lib-deps
# `test-cbackend` is intentionally not in the default `test` target: the
@@ -404,6 +404,19 @@ test-smoke-x64:
test-smoke-rv64:
bash test/smoke/rv64.sh
+# test-bounce: format-bounce stress test. Compiles small programs with
+# cfree, then bounces each object through chains of format conversions
+# (ELF<->Mach-O<->COFF), partial links (ld -r), strip, and archive
+# round-trips, relinks, and runs the result, asserting the exit code
+# matches a host-cc reference. Stresses obj read/write/reloc, ar, and
+# partial-link paths rather than the arches. Defaults to the host-native
+# Linux arch (no emulation); sweep others with
+# CFREE_BOUNCE_ARCHES="aarch64 x64 rv64". Excluded from the default `test`
+# target because it needs podman/qemu + a host cc; opt-in via
+# `make test-bounce`.
+test-bounce: $(BIN)
+ bash test/bounce/bounce.sh
+
# test-musl / test-glibc: end-to-end static + dynamic libc link/run on
# aarch64. Each variant pulls its own pinned sysroot (podman, ~30s on
# first run) and shares the same case files under test/libc/cases/: