cstage+selfhost+test: System V AMD64 sret discipline for >24B struct return (#23)

Class B shared miscompile pre-fix: cstage skipped the CALL emit at the
receive site (frame collapsed, exit 11); wwstage emitted CALL but
truncated 32B return to AX only (slice payload garbage, segfault on
g.b[0]). Both stages now lower plain TY_STRUCT > 24B through the SysV
sret discipline: caller pre-allocates dest, passes &dest in RDI as a
hidden first-arg (user args shift to SI/DX/CX/R8/R9/+stack), callee
saves RDI to @sretarg at the prologue and writes through it, returns
RDI in RAX. Surfaced by lib/encoding/utf8 pre-flight when the
Hoehrmann decoder (32B) hit 698_cgreturn_struct.c's OUT-OF-SCOPE
marker.

Scope: plain TY_STRUCT > 24B only — tagged unions, tuples, str, slice
keep their existing register-return ABIs. `return f()` forwarding
from a sret callee is fail-loud-not-wired (compile-time error in
both stages, follow-up filed); the workaround `let r = f(); return
r;` is wired and byte-identical. Discard-context calls (`f();` of an
sret-returning function) share a per-fn single-slot @sretscr;
consecutive discards reuse the same slot.

698_cgreturn_struct.c's OUT-OF-SCOPE marker retired in the same
commit; three positive rows (32B quad, 32B decoder, 40B five) now
assert the sret discipline across both stages via byte-id diff.

Tests:
  - 721_sret_struct_return pins three asm-presence sentinels per
    row: (a) LEAQ -K(BP), DI immediately before CALL at the receive
    site, (b) MOVQ -K(BP), AX before RET in the callee (sret return-
    the-pointer), (c) negative-assert no MOVQ AX, -K(BP) capture for
    return type >8B. Three rows × both stages × cmp -s byte-id.
  - 925_sret_struct_return_run runtime-pins 7 rows × 2 stages
    including the collision row (25B+ struct BOTH returned AND passed
    by-value as arg — catches arg-shift, sister site to #11), nested
    struct payload, slice payload, reassign-receive, N_IDENT return
    rhs.

89/89 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
This commit is contained in:
2026-05-17 23:17:03 +09:00
parent 6ab865d933
commit 7e0c280691
12 changed files with 1723 additions and 42 deletions

View File

@@ -39,6 +39,20 @@ fn tagscrbump(c: *cgen, need: i32) i32 = {
return delta;
};
// sretscrbump — sister of tagscrbump for the sret discard slot
// (#23). Tracks max sret return type used as a CALL discard / nested
// receiver. Returns frame-byte delta vs the previous high water mark
// (rounded up to 8B).
fn sretscrbump(c: *cgen, need: i32) i32 = {
let n: i32 = need;
if (n < 8) { n = 8; };
if ((n & 7) != 0) { n = (n + 7) & ~7; };
if (n <= c.sretscrsz) { return 0; };
let delta: i32 = n - c.sretscrsz;
c.sretscrsz = n;
return delta;
};
//
// Recursively walks the body to count every local `let`. Each gets a
// slot sized by slotsize(typ); 8-byte default. Match-bindings + for-
@@ -375,6 +389,17 @@ fn scanlocals(c: *cgen, n: *node) i32 = {
};
};
};
// sret CALL (#23): callee returns plain TY_STRUCT > 24B. The
// receive site (cglet / cgassign ident) overrides at emit time
// with the dest local's own slot; discards / nested calls fall
// back to @sretscr. Single-slot per fn sized to the max sret
// return — sretscrbump tracks the high-water mark so a later
// larger call grows the frame without re-counting the prior
// reservation. Mirrors @tagscr's cumulative tagscrbump.
if (n.kind == nkind.N_CALL) {
let scs: i32 = callsretsize(c, n);
if (scs > 0) { total += sretscrbump(c, scs); };
};
// Call-site struct-payload widening uses @tagscr — when the
// arg is a struct literal/ident and the callee's param is
// tagged, pushargsrev materialises in scratch and pushes.
@@ -489,7 +514,11 @@ fn scanlocals(c: *cgen, n: *node) i32 = {
fn cgfnparams(c: *cgen, params: *node) void = {
let p: *node = params;
// sret (#23): RDI is consumed by the hidden dest pointer
// (already spilled to @sretarg by cgfn); the first user param
// lands in SI.
let idx: i32 = 0;
if (c.sretargoff != 0) { idx = 1; };
let fidx: i32 = 0;
// Cursor for args that overflow the SysV reg windows. Each
// stack-passed arg lives at 16+8*k(BP) — no spill, the local
@@ -799,6 +828,14 @@ fn cgfn(c: *cgen, fn_: *node) void = {
c.curmod = fn_.module;
c.fnret = fn_.lhs;
// sret callee (#23): return type is plain TY_STRUCT > 24B.
// Reserve 8B for @sretarg (holds the saved hidden RDI dest
// pointer); cgfnparams skips DI for user args, cgreturn writes
// through *(@sretarg) and returns @sretarg in RAX. Decision
// made here so the frame pre-scan and cgfnparams see the same
// view of the int-arg cursor.
let sret_callee: bool = sretretsize(c, c.fnret) > 0;
// Emit the TEXT label via emitfnname so the def site picks up the
// same skip rule (FFI / `main` / empty-module) and the same module
// hint (this fn's own module) that the call sites use. Drops the
@@ -821,6 +858,12 @@ fn cgfn(c: *cgen, fn_: *node) void = {
let frame: i32 = 0;
let argi: i32 = 0;
let fargi: i32 = 0;
// Reserve @sretarg (8B) BEFORE the param-induced frame, and
// start argi at 1 so the param walker sees RDI as consumed.
if (sret_callee) {
frame += 8;
argi = 1;
};
for (scanp != nil) {
if (scanp.kind == nkind.N_PARAM) {
let isvar: bool = scanp.op == tkind.TK_ELLIPSIS;
@@ -913,6 +956,13 @@ fn cgfn(c: *cgen, fn_: *node) void = {
emitint(frame: i64);
emitline(", SP\n");
if (sret_callee) {
let saoff: i32 = localadd(c, "@sretarg", 8, nil);
emitline("\tMOVQ\tDI, ");
emitoff(saoff: i64);
emitline("(BP)\n");
};
cgfnparams(c, fn_.list);
c.lastwasreturn = 0;
// Iterate the fn body's statements directly rather than dispatching