commit 03954357a132585b90688f2e6551880974081941
parent bb82ba8985a793406af848a2ba1b3171a509a525
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sun, 19 Jul 2026 10:33:58 -0700
x64 backend bug fixes
Diffstat:
4 files changed, 170 insertions(+), 24 deletions(-)
diff --git a/doc/BACKEND_REFACTOR_CROSS_TEST.md b/doc/BACKEND_REFACTOR_CROSS_TEST.md
@@ -0,0 +1,38 @@
+# Backend refactor cross-architecture findings
+
+Date: 2026-07-19
+Revision tested: `8d7927db` (`refactor(opt): make scratch and clobber ownership explicit`)
+
+## Run
+
+```sh
+make test-cross TARGET=all DEPTH=full KIT_VM=1 RUN=1
+```
+
+The run covered all 19 configured target selectors and completed in about 3h32m:
+
+- 233 lane checks passed, 26 failed, 0 skipped.
+- All compile/link smoke cases passed.
+- All basic smoke executions passed.
+- Full corpora ran where wired; Android and 64-bit freestanding targets have smoke-only coverage.
+
+## Findings
+
+| Scope | Finding |
+| --- | --- |
+| Hosted x64, O0/O1 | Aggregate/sret returns are corrupt across Linux, FreeBSD, Windows, and macOS. `x64_copy_bytes()` resolves a stack source through R11 while R11 holds the sret destination, redirecting stores into the stack frame. |
+| x64 and RV64, O1 | Overflow-result tests miscompile. |
+| Arm32, O1 | Two aggregate-return cases abort emission with `no instruction-local native temporary`. |
+| Windows x64 | Packed indirect musttail is rejected at O0 and returns 173 at O1. |
+| Linux RV64 | Initialized C TLS returns zero under both glibc and musl at O0/O1. |
+| FreeBSD RV64 dynamic | 334 cases per FreeBSD matrix fail before execution because `libc.so.7` uses System V ELF OSABI and carries FreeBSD identity in `NT_FREEBSD_ABI_TAG`; Kit classifies it as Linux. The other 10 failures are the x64 regressions above. |
+
+All actual AArch64 cases passed. RV32 toy/parser coverage, all glibc/musl libc suites, and non-x64 front/back amalgam executions also passed.
+
+Linux x64 QEMU-user processes reported SIGSEGV and then hung; the affected test containers were stopped, so the aggregate log records status 137. Native FreeBSD and macOS runs reproduced the segmentation fault.
+
+## Artifact
+
+Full log: `build/test-logs/test-cross-all-full.log`
+
+The tested binaries were built from clean revision `8d7927db`. Workspace edits made after that build were not included in this run.
diff --git a/src/arch/x64/native.c b/src/arch/x64/native.c
@@ -2015,6 +2015,9 @@ static u32 x64_build_prologue(X64NativeTarget* a, u8* buf, u32 cap,
return wi;
}
+static u32 x64_signature_stack_bytes(NativeTarget* t, KitCgTypeId fn_type,
+ int* variadic, u32* nparams);
+
static void x64_func_begin_common(NativeTarget* t, const CGFuncDesc* fd) {
X64NativeTarget* a = x64_of(t);
MCEmitter* mc = t->mc;
@@ -2025,7 +2028,15 @@ static void x64_func_begin_common(NativeTarget* t, const CGFuncDesc* fd) {
/* Shared frame bookkeeping: clears the slot table, cum_off, max_outgoing,
* callee-save set, and known_frame/has_alloca/frame_final. */
native_frame_reset(&a->frame);
- a->incoming_stack_size = 0;
+ /* Tail-call legality is queried while the single-pass frontend is emitting
+ * the body, so derive the complete incoming stack window from the signature
+ * up front rather than waiting for bind_param to walk every declaration.
+ * x64_signature_stack_bytes includes Win64 shadow space; keep only the
+ * reusable argument suffix here because x64_no_tail adds the common prefix
+ * on both sides of its comparison. */
+ a->incoming_stack_size =
+ x64_signature_stack_bytes(t, fd->fn_type, NULL, NULL) -
+ a->abi->shadow_space;
a->next_param_int = 0;
a->next_param_fp = 0;
a->next_param_stack = 0;
@@ -2396,8 +2407,10 @@ static int x64_direct_to_stack(const X64ABIRegs* abi, const ABIArgInfo* ai,
next_fp + need_fp > abi->n_fp_args;
}
-/* Outgoing stack bytes a call uses (16-aligned), per the ABI. */
-static u32 x64_call_stack_size(NativeTarget* t, const NativeCallDesc* desc) {
+/* Raw outgoing argument bytes a call uses, including Win64 shadow space but
+ * excluding the final call-site alignment padding. */
+static u32 x64_call_stack_raw_size(NativeTarget* t,
+ const NativeCallDesc* desc) {
const ABIFuncInfo* abi = abi_cg_func_info(t->c->abi, desc->fn_type);
const X64ABIRegs* aregs = x64_abi_for_os(t->c->target.os);
u32 next_int = (abi && abi->has_sret) ? 1u : 0u;
@@ -2439,11 +2452,42 @@ static u32 x64_call_stack_size(NativeTarget* t, const NativeCallDesc* desc) {
x64_sync_slot(aregs, &next_int, &next_fp);
}
}
- return align_up_u32(stack, 16u);
+ return stack;
+}
+
+/* Outgoing stack bytes a call uses (16-aligned), per the ABI. */
+static u32 x64_call_stack_size(NativeTarget* t, const NativeCallDesc* desc) {
+ return align_up_u32(x64_call_stack_raw_size(t, desc), 16u);
+}
+
+/* A normal indirect/byval argument can point into the caller's local frame,
+ * because that frame remains live through a call. A sibling call tears the
+ * frame down first, so every such payload needs stable storage in the reusable
+ * incoming argument window. Pack those payloads after the raw outgoing ABI
+ * slots (using their natural alignment); final call alignment may provide all
+ * the space, as in a packed three-byte Win64 argument after one stack slot. */
+static u32 x64_tail_call_stack_size(NativeTarget* t,
+ const NativeCallDesc* desc) {
+ const ABIFuncInfo* abi = abi_cg_func_info(t->c->abi, desc->fn_type);
+ u32 payload = x64_call_stack_raw_size(t, desc);
+ u32 i;
+ for (i = 0; i < desc->nargs; ++i) {
+ ABIArgInfo tmp;
+ const ABIArgInfo* ai = x64_param_abi(t, abi, desc, i, &tmp);
+ u32 align, size;
+ if (ai->kind != ABI_ARG_INDIRECT) continue;
+ align = ai->indirect_align;
+ if (!align) align = native_type_align(t, desc->args[i].type);
+ size = native_type_size(t, desc->args[i].type);
+ payload = align_up_u32(payload, align ? align : 1u);
+ payload += size;
+ }
+ return align_up_u32(payload, 16u);
}
static u32 x64_call_stack_bytes(NativeTarget* t, const NativeCallDesc* desc) {
- return x64_call_stack_size(t, desc);
+ return (desc->flags & CG_CALL_TAIL) ? x64_tail_call_stack_size(t, desc)
+ : x64_call_stack_size(t, desc);
}
static u32 x64_signature_stack_bytes(NativeTarget* t, KitCgTypeId fn_type,
@@ -2669,7 +2713,12 @@ static void x64_bind_native_param(NativeTarget* t, const CGParamDesc* p,
if (a->next_param_int < a->abi->n_int_args) {
ptr_reg = a->abi->int_args[a->next_param_int++];
} else {
- ptr_reg = X64_R11;
+ /* Keep a stack-homed byval pointer out of R10/R11 while copying from
+ * it. x64_copy_bytes owns that private bank for transfer/address
+ * resolution; in particular, resolving the frame destination through
+ * R11 must not overwrite the source pointer between packed chunks. RAX
+ * is reserved from allocation and has no live entry value here. */
+ ptr_reg = X64_RAX;
emit_mov_load(t->mc, 8, 0, ptr_reg, X64_RBP,
incoming_bias + (i32)a->next_param_stack);
a->next_param_stack += 8u;
@@ -2769,7 +2818,6 @@ static void x64_bind_native_param(NativeTarget* t, const CGParamDesc* p,
tloc, 0, part->size);
}
}
- a->incoming_stack_size = align_up_u32(a->next_param_stack, 16u);
}
/* Flush the deferred register-destination param binds as a parallel copy (the
@@ -2871,7 +2919,7 @@ static void x64_plan_call(NativeTarget* t, const NativeCallDesc* desc,
plan->flags = desc->flags;
plan->has_sret = abi && abi->has_sret;
plan->is_variadic = abi && abi->variadic;
- plan->stack_arg_size = x64_call_stack_size(t, desc);
+ plan->stack_arg_size = x64_call_stack_bytes(t, desc);
if (plan->stack_arg_size > a->frame.max_outgoing)
a->frame.max_outgoing = plan->stack_arg_size;
for (c = 0; c < NATIVE_CALL_PLAN_CLASSES; ++c) {
@@ -2895,6 +2943,7 @@ static void x64_plan_call(NativeTarget* t, const NativeCallDesc* desc,
u32 next_int = (abi && abi->has_sret) ? 1u : 0u;
u32 next_fp = 0, stack = aregs->shadow_space, nmoves = 0, i;
int tail = (desc->flags & CG_CALL_TAIL) != 0;
+ u32 tail_payload = tail ? x64_call_stack_raw_size(t, desc) : 0u;
u16 p;
X64ArgMove moves[X64_MAX_REG_ARG_MOVES];
x64_sync_slot(aregs, &next_int, &next_fp);
@@ -2904,17 +2953,36 @@ static void x64_plan_call(NativeTarget* t, const NativeCallDesc* desc,
int variadic_arg = abi && i >= abi->nparams;
if (ai->kind == ABI_ARG_IGNORE) continue;
if (ai->kind == ABI_ARG_INDIRECT) {
+ NativeLoc src = desc->args[i];
+ if (tail) {
+ AggregateAccess access;
+ NativeAddr from, to;
+ u32 align = ai->indirect_align;
+ u32 size = native_type_size(t, src.type);
+ if (!align) align = native_type_align(t, src.type);
+ tail_payload = align_up_u32(tail_payload, align ? align : 1u);
+ src = x64_incoming_stack_loc(
+ src.type, NATIVE_REG_INT, (i32)(16u + tail_payload));
+ from = x64_storage_addr(a, desc->args[i], 0);
+ to = x64_storage_addr(a, src, 0);
+ memset(&access, 0, sizeof access);
+ access.type = src.type;
+ access.size = size;
+ access.align = align ? align : 1u;
+ x64_copy_bytes(t, to, from, access);
+ tail_payload += size;
+ }
if (next_int < aregs->n_int_args) {
X64ArgMove* m = &moves[nmoves++];
memset(m, 0, sizeof *m);
m->dst =
native_loc_reg(i64t, NATIVE_REG_INT, aregs->int_args[next_int++]);
- m->src = desc->args[i];
+ m->src = src;
m->size = 8;
m->is_addr = 1;
} else {
NativeLoc ptr = native_loc_reg(i64t, NATIVE_REG_INT, X64_RAX);
- x64_addr_of_loc(t, ptr, desc->args[i]);
+ x64_addr_of_loc(t, ptr, src);
x64_store_outgoing_part(t, tail, stack, ptr, 8);
stack += 8u;
}
@@ -3092,17 +3160,21 @@ static void x64_plan_ret(NativeTarget* t, const CGFuncDesc* fd,
u32 nr = 0;
if (value) rets = arena_zarray(t->c->tu, NativeCallPlanRet, 4);
if (value && abi && abi->ret.kind == ABI_ARG_INDIRECT) {
- /* sret: reload destination pointer (spilled at entry) into r11, memcpy the
- * source aggregate into [r11], and convention-return the pointer in rax. */
+ /* sret: reload the destination pointer (spilled at entry) into rax and
+ * copy the source aggregate into [rax]. Keep it out of R10/R11: those are
+ * x64_copy_bytes' private transfer/address bank, and resolving a stack
+ * source through R11 would otherwise overwrite the destination. RAX is
+ * also the ABI's sret return register, so it can stay live through the
+ * copy and needs no reload afterwards. */
KitCgTypeId i64t = builtin_id(KIT_CG_BUILTIN_I64);
- NativeLoc dstp = native_loc_reg(i64t, NATIVE_REG_INT, X64_R11);
+ NativeLoc dstp = native_loc_reg(i64t, NATIVE_REG_INT, X64_RAX);
NativeLoc saved = native_loc_stack(i64t, a->sret_ptr_slot, 0);
NativeAddr dst_addr, src_addr;
AggregateAccess access;
x64_load_part(t, dstp, saved, 0, 8);
memset(&dst_addr, 0, sizeof dst_addr);
dst_addr.base_kind = NATIVE_ADDR_BASE_REG;
- dst_addr.base.reg = X64_R11;
+ dst_addr.base.reg = X64_RAX;
dst_addr.base_type = value->type;
src_addr = x64_storage_addr(a, *value, 0);
src_addr.base_type = value->type;
@@ -3111,9 +3183,6 @@ static void x64_plan_ret(NativeTarget* t, const CGFuncDesc* fd,
access.size = (u32)cg_type_size(t->c, value->type);
access.align = native_type_align(t, value->type);
x64_copy_bytes(t, dst_addr, src_addr, access);
- /* rax = sret pointer. Reload it (copy_bytes clobbered r11/rax). */
- x64_load_part(t, native_loc_reg(i64t, NATIVE_REG_INT, X64_RAX), saved, 0,
- 8);
*out_rets = NULL;
*out_nrets = 0;
return;
@@ -3900,6 +3969,21 @@ static void emit_stack_guard(MCEmitter* mc, u32 rd) {
emit_u32le(mc, 0x28u);
}
+/* Optimizer folding may put an immediate directly in a native intrinsic even
+ * when the instruction form itself requires a register. Materialize those
+ * arithmetic operands in the backend-private bank; ordinary MIR/NDT operand
+ * locations never occupy R10/R11. Callers pass a distinct scratch for each
+ * simultaneously-live argument. */
+static u32 x64_intrinsic_arg_reg(X64NativeTarget* a, NativeLoc arg,
+ u32 scratch, int w) {
+ if (arg.kind == NATIVE_LOC_REG) return loc_reg(arg);
+ if (arg.kind == NATIVE_LOC_IMM) {
+ x64_emit_load_imm(a->base.mc, w, scratch, arg.v.imm);
+ return scratch;
+ }
+ x64_panic(a, "arithmetic intrinsic operand is not register/immediate");
+}
+
static void x64_intrinsic(NativeTarget* t, IntrinKind kind,
const NativeLoc* dsts, u32 ndst,
const NativeLoc* args, u32 narg) {
@@ -4027,7 +4111,8 @@ static void x64_intrinsic(NativeTarget* t, IntrinKind kind,
case INTRIN_USUB_OVERFLOW: {
int w = x64_is_64(t, dsts[0].type) ? 1 : 0;
u32 rd = loc_reg(dsts[0]), rovf = loc_reg(dsts[1]);
- u32 ra = loc_reg(args[0]), rb = loc_reg(args[1]);
+ u32 ra = x64_intrinsic_arg_reg(a, args[0], X64_TMP_INT, w);
+ u32 rb = x64_intrinsic_arg_reg(a, args[1], X64_TMP_INT2, w);
u8 op = (kind == INTRIN_SADD_OVERFLOW || kind == INTRIN_UADD_OVERFLOW)
? X64_OPC_ALU_ADD
: X64_OPC_ALU_SUB;
@@ -4043,7 +4128,8 @@ static void x64_intrinsic(NativeTarget* t, IntrinKind kind,
case INTRIN_SMUL_OVERFLOW: {
int w = x64_is_64(t, dsts[0].type) ? 1 : 0;
u32 rd = loc_reg(dsts[0]), rovf = loc_reg(dsts[1]);
- u32 ra = loc_reg(args[0]), rb = loc_reg(args[1]);
+ u32 ra = x64_intrinsic_arg_reg(a, args[0], X64_TMP_INT, w);
+ u32 rb = x64_intrinsic_arg_reg(a, args[1], X64_TMP_INT2, w);
if (rd != ra) emit_mov_rr(mc, w, rd, ra);
emit_imul_rr(mc, w, rd, rb);
emit_setcc(mc, X64_CC_O, rovf);
@@ -4053,7 +4139,8 @@ static void x64_intrinsic(NativeTarget* t, IntrinKind kind,
case INTRIN_UMUL_OVERFLOW: {
int w = x64_is_64(t, dsts[0].type) ? 1 : 0;
u32 rd = loc_reg(dsts[0]), rovf = loc_reg(dsts[1]);
- u32 ra = loc_reg(args[0]), rb = loc_reg(args[1]);
+ u32 ra = x64_intrinsic_arg_reg(a, args[0], X64_TMP_INT, w);
+ u32 rb = x64_intrinsic_arg_reg(a, args[1], X64_TMP_INT2, w);
if (rb == X64_RAX || rb == X64_RDX) {
emit_mov_rr(mc, w, X64_R11, rb);
rb = X64_R11;
@@ -4069,8 +4156,8 @@ static void x64_intrinsic(NativeTarget* t, IntrinKind kind,
case INTRIN_UMUL_HIGH: {
int w = x64_is_64(t, dsts[0].type) ? 1 : 0;
u32 rd = loc_reg(dsts[0]);
- u32 ra = loc_reg(args[0]);
- u32 rb = loc_reg(args[1]);
+ u32 ra = x64_intrinsic_arg_reg(a, args[0], X64_TMP_INT, w);
+ u32 rb = x64_intrinsic_arg_reg(a, args[1], X64_TMP_INT2, w);
if (rb == X64_RAX || rb == X64_RDX) {
emit_mov_rr(mc, w, X64_R11, rb);
rb = X64_R11;
@@ -4726,8 +4813,8 @@ static const char* x64_no_tail(NativeDirectTarget* d, const CGCallDesc* call) {
if (a->frame.ncallee_saves)
return "x64 tail call: callee-saved registers in use";
native_direct_project_tail_call_desc(d, call, &nd);
- stack = x64_call_stack_size(d->native, &nd);
- /* x64_call_stack_size includes the shadow-space prefix; the caller's incoming
+ stack = x64_tail_call_stack_size(d->native, &nd);
+ /* The tail footprint includes the shadow-space prefix; the caller's incoming
* window has the same prefix, so compare against incoming_stack_size + it. */
if (stack > a->incoming_stack_size + a->abi->shadow_space)
return "x64 tail call: stack argument area too small";
diff --git a/src/cg/native_direct_target.c b/src/cg/native_direct_target.c
@@ -661,6 +661,7 @@ static void nd_store_operand_from_reg(NativeDirectTarget* d, Operand dst,
static int nd_local_cacheable(NativeDirectTarget* d,
const NativeDirectLocal* l) {
return !l->address_taken && !l->memory_required && l->size != 0 &&
+ !cg_type_is_aggregate(d->base.c, l->type) &&
l->size <= (u32)d->base.c->target.ptr_size;
}
diff --git a/src/opt/opt.c b/src/opt/opt.c
@@ -998,6 +998,26 @@ static const char* opt_on_tail_call_unrealizable_reason(
if (caller_va) return "variadic caller cannot host a sibling call";
if (callee_va && call->nargs > callee_nparams)
return "variadic tail call arguments are not realizable as a sibling call";
+ /* A backend may need tail-only stable storage in the reusable incoming
+ * window in addition to the ABI argument slots themselves (x64 indirect /
+ * byval payloads are one example). Once variadic-extra arguments have been
+ * excluded above, synthesize the pure NativeCallDesc shape from the fixed
+ * signature so call_stack_bytes can include that target-specific tail cost.
+ */
+ if (o->native->call_stack_bytes) {
+ NativeCallDesc d;
+ NativeLoc* args = NULL;
+ memset(&d, 0, sizeof d);
+ if (call->nargs)
+ args = arena_zarray(o->c->tu, NativeLoc, call->nargs);
+ for (u32 i = 0; i < call->nargs; ++i)
+ args[i].type = cg_type_func_param_id(o->c, call->fn_type, i);
+ d.fn_type = call->fn_type;
+ d.args = args;
+ d.nargs = call->nargs;
+ d.flags = CG_CALL_TAIL;
+ outgoing = o->native->call_stack_bytes(o->native, &d);
+ }
if (outgoing > incoming)
return "tail call stack arguments exceed the caller's parameter area";
return NULL;