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,26 @@ static int *cg_frame;
* semantics for synthetic scratches). 0 means "not yet allocated";
* negative offsets returned by local_alloc are the live value. */
static int cg_retscr;
/* System V AMD64 sret discipline (task #23). Plain TY_STRUCT returns
* with size > 24B are passed via a hidden first-arg pointer (RDI) to
* a caller-prealloc dest; the callee writes through that pointer and
* returns it in RAX. Tagged returns (slot ≤ 32B in AX/DX/CX/R8) and
* tuples (16/24B in AX/DX/CX) keep their existing register-return ABI.
*
* cg_sret_arg_off — callee-side @sretarg slot (8B, holds saved RDI).
* Set in cgfn prologue when ret > 24B plain struct.
* cg_sret_dest_off — caller-side dest offset, propagated from a receive
* site (N_LET / N_ASSIGN ident) to the nested N_CALL
* so the call emits `LEAQ off(BP), RDI` instead of
* allocating a scratch. 0 means no receiver wired.
* cg_sretscr_off — per-fn @sretscr discard slot for sret CALLs whose
* result is dropped (no named receiver). Single-slot
* SSoT mirroring cg_retscr. Sized to the largest
* discarded sret return type in the fn. */
static int cg_sret_arg_off;
static int cg_sret_dest_off;
static int cg_sretscr_off;
static int cg_sretscr_sz;
/* Per-fn defer stack: pushed in registration order, popped (emitted)
* in reverse at each return. */
@@ -71,6 +91,20 @@ cg_isfloat(Type *t)
|| t->kind == TY_UNTYPED_FLOAT;
}
/* cg_sret_retsize — if `rt` is a plain TY_STRUCT > 24B, return its
* natural size (the sret threshold); else 0. Tagged unions, tuples,
* str, and slices route through their existing register-return ABIs
* regardless of size. Task #23. */
static int
cg_sret_retsize(Type *rt)
{
if (rt == NULL) return 0;
if (rt->kind == TY_NAMED) rt = rt->under;
if (rt == NULL || rt->kind != TY_STRUCT) return 0;
if ((int)rt->size <= 24) return 0;
return (int)rt->size;
}
static int
node_isfloat(Node *n)
{
@@ -3629,6 +3663,21 @@ cgexpr(Cg *c, Node *n, Local *locals)
}
break;
}
/* sret receive (#23): `s = f();` where s is a struct
* local >24B. s's slot IS the caller-prealloc dest;
* the callee writes through hidden RDI. Mirrors the
* cglet branch above. */
if (lu && lu->kind == TY_STRUCT && (int)lu->size > 24
&& n->rhs && n->rhs->kind == N_CALL
&& n->op == TK_ASSIGN) {
int off = localfind(locals, n->lhs->str);
if (off != 0) {
cg_sret_dest_off = off;
cgexpr(c, n->rhs, locals);
cg_sret_dest_off = 0;
break;
}
}
/* Struct local reassignment: `s = expr;` where s is
* a TY_STRUCT local of size <=24B. Two rhs shapes,
* mirroring cglet's N_STRUCTLIT and the call-result
@@ -4389,11 +4438,48 @@ cgexpr(Cg *c, Node *n, Local *locals)
ins1(c, A_PUSHQ, areg(D_AX));
}
}
/* sret discipline (#23): callee returns plain TY_STRUCT
* > 24B. Reserve RDI for the hidden dest-pointer arg by
* starting the int-arg cursor at 1 and emit the LEAQ AFTER
* the pop loop (so the pops don't clobber RDI). The dest
* slot is either the receiver's own slot (cg_sret_dest_off,
* propagated from N_LET / N_ASSIGN ident receive) or a
* per-fn @sretscr discard slot. Sized at the receive site
* or here for discards.
*
* Stack alignment is unaffected because pushargsrev/pops
* left RDI free — we never popped a user arg into it. */
int sret_call_sz = 0;
int sret_call_off = 0;
{
Type *ret = (cu && cu->kind == TY_FN)
? cu->ret : NULL;
sret_call_sz = cg_sret_retsize(ret);
}
if (sret_call_sz > 0) {
/* Always pre-allocate @sretscr at the first sret CALL
* regardless of whether cg_sret_dest_off is set — keeps
* cstage's frame in lockstep with wwstage's
* scanlocals-based reservation. Single-slot SSoT (cg_
* sretscr_off) mirrors @retscr / @tagscr conventions. */
if (cg_sretscr_off == 0) {
cg_sretscr_off = local_alloc(c,
&locals, "@sretscr",
sret_call_sz, cg_frame);
cg_sretscr_sz = sret_call_sz;
}
if (cg_sret_dest_off != 0) {
sret_call_off = cg_sret_dest_off;
cg_sret_dest_off = 0;
} else {
sret_call_off = cg_sretscr_off;
}
}
/* pop forward into the right register class. Args that
* don't fit in regs stay on the stack and are reached by
* the callee via positive offsets from BP. The caller is
* responsible for cleaning them up after CALL. */
int ii = 0, fi = 0, stackslots = 0;
int ii = (sret_call_sz > 0) ? 1 : 0, fi = 0, stackslots = 0;
for (int i = 0; i < argcount; i++) {
if (widen[i]) {
/* Pop widened tagged slot into arg-register
@@ -4459,6 +4545,12 @@ cgexpr(Cg *c, Node *n, Local *locals)
}
}
}
/* sret hidden first-arg (#23): load &dest into RDI AFTER
* all user-arg pops have finished — the pop loop started
* its int-arg cursor at 1, so RDI was never written. */
if (sret_call_sz > 0)
ins2(c, A_LEAQ, amem(D_BP, sret_call_off),
areg(D_DI));
/* SysV: variadic callees require AL to hold the count of
* XMM regs used in the variable portion. We don't pass
* floats yet, so AL=0 covers every case we emit. */
@@ -6132,6 +6224,18 @@ cgstmt(Cg *c, Node *n, Local **locals, int *frame)
cg_structlit_fill_bp(c, locals, lu, n->rhs, off);
break;
}
/* sret receive (#23): plain TY_STRUCT >24B. The let's own
* slot IS the caller-prealloc dest; the call writes
* through hidden RDI directly into our slot, no AX/DX/CX
* shuffle. Set cg_sret_dest_off so the nested cgexpr →
* N_CALL path emits `LEAQ off(BP), RDI` before CALL. */
if (n->rhs && n->rhs->kind == N_CALL && lu
&& lu->kind == TY_STRUCT && sz > 24) {
cg_sret_dest_off = off;
cgexpr(c, n->rhs, *locals);
cg_sret_dest_off = 0;
break;
}
/* Whole-struct receive for sizes <=24B (call-result rhs).
* Counterpart of #4's cgreturn ABI: cgexpr leaves
* AX=bytes[0..7], DX=bytes[8..15], CX=bytes[16..23], zero-
@@ -6435,10 +6539,91 @@ cgstmt(Cg *c, Node *n, Local **locals, int *frame)
break;
}
}
/* sret return (#23): plain TY_STRUCT >24B. Callee writes
* the value through `*(@sretarg)` (the caller-prealloc
* dest passed in RDI at entry; saved to @sretarg in the
* prologue), then loads @sretarg into RAX and rets — the
* SysV sret discipline of "return the pointer". No
* AX/DX/CX shuffle, no scratch slot beyond @sretarg. */
if (n->lhs && cg_ret_type && cg_sret_arg_off != 0) {
Type *rt = cg_ret_type;
if (rt->kind == TY_NAMED) rt = rt->under;
/* `return f();` from a sret callee falls through the
* arm below (rhs is N_CALL, not N_IDENT/N_STRUCTLIT)
* and would silent-miscompile: cgexpr places inner's
* result in @sretscr but outer never copies into
* *@sretarg and never sets RAX. Fail loud per
* CLAUDE.md rule 7; the workaround `let r = f();
* return r;` is already wired and correct. */
if (rt && rt->kind == TY_STRUCT
&& (int)rt->size > 24
&& n->lhs->kind == N_CALL)
fatal("cgreturn: sret return-forwarding "
"for >24B struct not wired (task #23)");
if (rt && rt->kind == TY_STRUCT
&& (int)rt->size > 24
&& (n->lhs->kind == N_IDENT
|| n->lhs->kind == N_STRUCTLIT)) {
int sz = (int)rt->size;
if (n->lhs->kind == N_STRUCTLIT) {
/* Delegate to the shared *-relative
* fill helper. Same store sequence the
* ≤24B path emits, but the base reg is
* reloaded from @sretarg(BP) before each
* field store. Mirrors DST_PTR_LOCAL
* usage at N_ASSIGN N_DOT via_ptr. */
cg_structlit_fill(c, locals, rt,
n->lhs, DST_PTR_LOCAL,
cg_sret_arg_off, NULL, 0);
} else {
/* N_IDENT: word-copy from rhs slot to
* *(@sretarg). Whole 8B words via MOVQ;
* trailing partial words via MOVL/MOVB
* so the read stays inside the source
* slot's declared size. */
int rhsoff = localfind(*locals,
n->lhs->str);
ins2(c, A_MOVQ,
amem(D_BP, cg_sret_arg_off),
areg(D_BX));
int k = 0;
while (k + 8 <= sz) {
ins2(c, A_MOVQ,
amem(D_BP, rhsoff + k),
areg(D_AX));
ins2(c, A_MOVQ, areg(D_AX),
amem(D_BX, k));
k += 8;
}
while (k + 4 <= sz) {
ins2(c, A_MOVL,
amem(D_BP, rhsoff + k),
areg(D_AX));
ins2(c, A_MOVL, areg(D_AX),
amem(D_BX, k));
k += 4;
}
while (k < sz) {
ins2(c, A_MOVB,
amem(D_BP, rhsoff + k),
areg(D_AX));
ins2(c, A_MOVB, areg(D_AX),
amem(D_BX, k));
k += 1;
}
}
/* sret return: RAX = dest pointer. */
ins2(c, A_MOVQ,
amem(D_BP, cg_sret_arg_off), areg(D_AX));
ins2(c, A_MOVQ, areg(D_BP), areg(D_SP));
ins1(c, A_POPQ, areg(D_BP));
ins0(c, A_RET);
break;
}
}
/* Whole-struct return for sizes ≤24B. ABI: AX=bytes[0..7],
* DX=bytes[8..15], CX=bytes[16..23]. Sizes >24B are not
* wired (sret deferred); they fall through to the scalar
* path below and return only AX. Materialise rhs into a
* DX=bytes[8..15], CX=bytes[16..23]. Sizes >24B route
* through the sret arm above. Materialise rhs into a
* zero-padded 24B scratch slot, then emit AX/DX/CX loads
* unconditionally so the instruction shape is constant
* regardless of declared struct size. The receive side
@@ -6872,6 +7057,10 @@ cgfn(Cg *c, FILE *out, Node *fn)
nloops = 0;
cg_ret_type = fn->type ? fn->type->ret : NULL;
cg_retscr = 0;
cg_sret_arg_off = 0;
cg_sret_dest_off = 0;
cg_sretscr_off = 0;
cg_sretscr_sz = 0;
int frame = 0;
Local *locals = NULL;
@@ -6893,10 +7082,23 @@ cgfn(Cg *c, FILE *out, Node *fn)
subsp->to = areg(D_SP);
emit(c, subsp);
/* sret discipline (#23): plain TY_STRUCT return > 24B consumes
* RDI as a hidden first-arg dest pointer. Spill it to @sretarg
* before the user-param loop so cgreturn can write through it,
* and start the user-arg register counter at 1 to shift every
* declared arg right by one (SI/DX/CX/R8/R9/+stack). */
if (cg_sret_retsize(cg_ret_type) > 0) {
cg_sret_arg_off = local_alloc(c, &locals, "@sretarg",
8, &frame);
ins2(c, A_MOVQ, areg(D_DI),
amem(D_BP, cg_sret_arg_off));
}
/* spill incoming arg registers to local slots. Slice params
* occupy 24 bytes; float params land in XMM0..7 (counted
* separately from integer DI/SI/DX/CX/R8/R9). */
int argi = 0, fargi = 0;
int argi = (cg_sret_arg_off != 0) ? 1 : 0;
int fargi = 0;
Tparam *tp = fn->type ? fn->type->params : NULL;
for (Node *p = fn->list; p; p = p->next) {
if (p->str == NULL || strcmp(p->str, "...") == 0) {