From c8148565503c69583b72bcb6f03b405ce4b84c3a Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Sun, 21 Jun 2026 11:50:18 +0900 Subject: [PATCH] wwstage: C-FFI variadic call codegen parity with cstage (#10) Mirror cstage's C-variadic call handling in the ww self-host: parse a bare `...` param (decl.ww), skip param-keyed desugar for it to avoid a nil-deref (check.ww), and emit AL = XMM-reg count plus CVTSS2SD promotion of f32 args in the variadic tail (cgenutil.ww, cgenexpr.ww). Closes the cat-A wwstage silent miscompile (AL=0, unpromoted f32 tail). Parse/check/cgen are one atomic align-up (parse alone miscompiles, so not bisect-splittable). 989_ffivariadic now runs dual-stage (cstage ww + wwstage ww_ww), 12/12; w6c==w6c_ww byte-identical. Byte-id alone is blind here (the bootstrap calls no float-bearing C variadic), so the ww_ww runtime rows are the real net. --- Makefile | 9 ++++-- lib/ww/syntax/decl.ww | 12 +++++++ selfhost/cmd/wcc/cgenexpr.ww | 43 +++++++++++++++++++++++-- selfhost/cmd/wcc/cgenutil.ww | 58 ++++++++++++++++++++++++++++++++-- selfhost/cmd/wcc/check.ww | 12 +++++++ test/wcc/989_ffivariadic_run.c | 44 ++++++++++++++++++++------ 6 files changed, 161 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 215b1a97..67d16566 100644 --- a/Makefile +++ b/Makefile @@ -835,8 +835,9 @@ $(BIN)/test_idxarg_run: test/wcc/989_idxarg_run.c \ # fixture is built self-contained (zero relocs + zero undef syms, verified via # readelf -r / nm) so w6l links it with no libc, then wrapped in libffifix.a # under $(OUT)/ffivariadic (the harness finds it via $(BIN)/../ffivariadic). -# cstage `ww` ONLY — wwstage AL is C2. Fixture flags are fixed (NOT $(CFLAGS), -# which is -O0/-std=c99) to match ken's verified self-contained build. +# Runs on BOTH cstage `ww` and wwstage `ww_ww` (C2). Fixture flags are fixed +# (NOT $(CFLAGS), which is -O0/-std=c99) to match ken's verified +# self-contained build. $(OUT)/ffivariadic/libffifix.a: test/wcc/data/ffivariadic/fixture.c @mkdir -p $(OUT)/ffivariadic $(CC) -O1 -fno-stack-protector -fno-asynchronous-unwind-tables \ @@ -845,7 +846,9 @@ $(OUT)/ffivariadic/libffifix.a: test/wcc/data/ffivariadic/fixture.c $(BIN)/test_ffivariadic_run: test/wcc/989_ffivariadic_run.c \ $(OUT)/ffivariadic/libffifix.a \ - $(BIN)/ww $(BIN)/w6c $(BIN)/w6a $(BIN)/w6l \ + $(BIN)/ww $(BIN)/ww_ww \ + $(BIN)/w6c $(BIN)/w6a $(BIN)/w6l \ + $(BIN)/w6c_ww $(BIN)/w6a_ww $(BIN)/w6l_ww \ $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< diff --git a/lib/ww/syntax/decl.ww b/lib/ww/syntax/decl.ww index f8957462..01bf19b5 100644 --- a/lib/ww/syntax/decl.ww +++ b/lib/ww/syntax/decl.ww @@ -119,6 +119,18 @@ fn parseparams(p: *parser) *node = { let pf: str = p.curfile; let pl: i32 = p.curline; let pc: i32 = p.curcol; + // C-style FFI variadic: a bare `...` param (no name/type), + // the last param. Tagged by str == "..." (distinct from the + // Hare-style `T...` marked on n.op below). Mirrors cmd/wcc/ + // parse.c:113-122; check.c keys cu->variadic on this form. + if (p.curkind == tkind.TK_ELLIPSIS) { + advance(p); + let cv: *node = newnode(nkind.N_PARAM, pf, pl, pc); + cv.str = "..."; + if (head == nil) { head = cv; tail = cv; } + else { tail.next = cv; tail = cv; }; + break; + }; let n: *node = newnode(nkind.N_PARAM, pf, pl, pc); // Param form: (IDENT|'_') ':' type. Anonymous-type-only params // (used in fn type expressions) aren't yet wired here. diff --git a/selfhost/cmd/wcc/cgenexpr.ww b/selfhost/cmd/wcc/cgenexpr.ww index 051e9b8d..774cbd6b 100644 --- a/selfhost/cmd/wcc/cgenexpr.ww +++ b/selfhost/cmd/wcc/cgenexpr.ww @@ -7774,12 +7774,22 @@ fn cgcall(c: *cgen, n: *syntax.node) void = { }; }; }; + // C-variadic (bare `...`) detection: drives the SysV §3.5.7 AL= + // XMM-count emit (below, before CALL) and the f32→f64 variadic- + // tail promotion in pushargsrev (#14). cvarnfixed is the fixed- + // param count, or -1 when the callee is not C-variadic. Mirror of + // cstage's `cu && cu->kind == TY_FN && cu->variadic` gate. + let cvarnfixed: i32 = -1; + { + let nfx: i32 = 0; + if (calleecvariadic(c, callee, &nfx)) { cvarnfixed = nfx; }; + }; // #38b: two-phase push — MEMORY-class (>48B tagged) args staged // first so they sit BELOW every register-class word; the pop loop // drains a strict prefix and never touches them. memwords feeds // the caller-cleanup ADDQ (with the mix guard below). - let memwords: i32 = pushargsrev(c, n.list, calleeparams, true); - let nargs: i32 = pushargsrev(c, n.list, calleeparams, false); + let memwords: i32 = pushargsrev(c, n.list, calleeparams, true, 0, cvarnfixed); + let nargs: i32 = pushargsrev(c, n.list, calleeparams, false, 0, cvarnfixed); // sret call (#23): callee returns plain TY_STRUCT > 24B. The // dest pointer lands in RDI; start intidx at 1 to skip RDI in // the user-arg pop loop and emit `LEAQ off(BP), DI` AFTER all @@ -7818,7 +7828,13 @@ fn cgcall(c: *cgen, n: *syntax.node) void = { let dparam: *syntax.node = calleeparams; let popped: i32 = 0; let stackslots: i32 = 0; + let argidx: i32 = 0; for (a != nil) { + // argidx is this arg's 0-based position; captured before any + // continue so the C-variadic f32-promotion check below tracks + // the push-side argidx for every arg shape (#14). + let curargidx: i32 = argidx; + argidx += 1; // #38b: MEMORY-class arg — its words sit below the pop // region and stay on the stack for the callee; nothing to // drain. Same param-keyed-else-arg-keyed detection as @@ -7879,6 +7895,12 @@ fn cgcall(c: *cgen, n: *syntax.node) void = { if (fk != 0) { let mov: str = "MOVSD"; if (fk == 1) { mov = "MOVSS"; }; + // #14: a C-variadic-tail f32 was promoted to f64 at push + // (CVTSS2SD + MOVSD), so its slot reloads MOVSD. Mirror + // cstage cmd/w6c/cgen.c promote_f32 pop. + if (cvarnfixed >= 0 && curargidx >= cvarnfixed && fk == 1) { + mov = "MOVSD"; + }; if (fpidx < 8) { emitline("\t"); emitline(mov); @@ -8200,6 +8222,23 @@ fn cgcall(c: *cgen, n: *syntax.node) void = { emitline("(BP), DI\n"); };}; }; + // SysV §3.5.7: a C-variadic call sets AL to the number of vector + // (XMM) regs used to pass the variable float args — the callee + // gates its xmm-save-area stores on `test %al,%al`, so a wrong AL + // makes va_arg(double) read garbage. fpidx is the XMM cursor + // (capped at 8 in the pop loop). Emitted after any sret-RDI LEAQ, + // right before CALL. Mirror cstage cmd/w6c/cgen.c (the imm→reg MOVQ + // idiom carries AL since MOVL-imm has no w6a encoding; AL = low byte, + // fpidx <= 8). Ref ref/qbe/amd64/sysv.c:384. + if (cvarnfixed >= 0) { + if (fpidx > 0) { + emitline("\tMOVQ\t$"); + emitint(fpidx: i64); + emitline(", AX\n"); + } else { + emitline("\tXORQ\tAX, AX\n"); + }; + }; if (isfnptrcall) { // Load fn-ptr field value into AX; CALL AX. We emit the // load AFTER the args have been popped (so AX/BX/etc diff --git a/selfhost/cmd/wcc/cgenutil.ww b/selfhost/cmd/wcc/cgenutil.ww index 3cc6555b..d4f75137 100644 --- a/selfhost/cmd/wcc/cgenutil.ww +++ b/selfhost/cmd/wcc/cgenutil.ww @@ -83,6 +83,46 @@ fn callee_variadic_param(c: *cgen, callee: *syntax.node, nfixed_out: *i32) *synt return findvariadicparam(ps, nfixed_out); }; +// calleecvariadic — true when the callee is a C-style variadic fn: a +// bare `...` param (str == "...", distinct from the Hare-style `T...` +// findvariadicparam keys on op == TK_ELLIPSIS). nfixed_out gets the +// count of fixed params before the `...`. Mirror of cstage's +// `cu->variadic` gate (cmd/w6c/cgen.c cgcall), which check.c:902 sets +// ONLY for the bare-`...` form — the bare `...` adds no Tparam, so +// nfixed is the fixed-param count. Drives the SysV §3.5.7 AL=XMM-count +// emit and the C-default-promotion of an f32 variadic-tail arg to f64 +// (#14). nfixed_out cannot be nil. +fn calleecvariadic(c: *cgen, callee: *syntax.node, nfixed_out: *i32) bool = { + *nfixed_out = 0; + if (callee == nil) { return false; }; + let ps: *syntax.node = nil; + if (callee.kind == syntax.nkind.N_IDENT) { + if (callee.str.len == 0) { return false; }; + ps = fnparamslookup(c, callee.str); + } else { if (callee.kind == syntax.nkind.N_DOT) { + if (callee.str.len == 0) { return false; }; + let cmod: str; + cmod.ptr = nil; cmod.len = 0; + if (callee.lhs != nil) { + if (callee.lhs.kind == syntax.nkind.N_IDENT) { + cmod = callee.lhs.str; + }; + }; + ps = fnparamslookupmod(c, callee.str, cmod); + }; }; + let p: *syntax.node = ps; + for (p != nil) { + if (p.kind == syntax.nkind.N_PARAM) { + if (syntax.streq(p.str, "...")) { + return true; + }; + *nfixed_out += 1; + }; + p = p.next; + }; + return false; +}; + // ---- expression cgen ------------------------------------------------- // pushargsrev — recursively walks the arg list, evaluates rightmost @@ -155,11 +195,17 @@ fn argtaggedwidensz(c: *cgen, arg0: *syntax.node, param: *syntax.node) i32 = { return pslot; }; -fn pushargsrev(c: *cgen, arg: *syntax.node, param: *syntax.node, memphase: bool) i32 = { +// argidx is this arg's 0-based position in the call's arg list; +// cvarnfixed is the fixed-param count of a C-variadic callee (-1 when +// the callee is not C-variadic). An f32 arg in the variadic tail +// (argidx >= cvarnfixed) is promoted to f64 here (#14), so its 8B stack +// slot holds a real double for the pop side and the callee's +// va_arg(double). +fn pushargsrev(c: *cgen, arg: *syntax.node, param: *syntax.node, memphase: bool, argidx: i32, cvarnfixed: i32) i32 = { if (arg == nil) { return 0; }; let nextparam: *syntax.node = nil; if (param != nil) { nextparam = param.next; }; - let rest: i32 = pushargsrev(c, arg.next, nextparam, memphase); + let rest: i32 = pushargsrev(c, arg.next, nextparam, memphase, argidx + 1, cvarnfixed); // Family C (#35): peel tagged→tagged casts FIRST so every gate // below keys on the operand — an identity cast reduces to the // ident fast path, a widening cast trips the widen branch with @@ -992,6 +1038,14 @@ fn pushargsrev(c: *cgen, arg: *syntax.node, param: *syntax.node, memphase: bool) cgexpr(c, arg); let mov: str = "MOVSD"; if (fk == 1) { mov = "MOVSS"; }; + // #14: an f32 in the C-variadic tail widens to f64 (C default + // argument promotion) — CVTSS2SD in X0, then MOVSD spills a + // full 8B double; the pop reloads MOVSD and counts it as one + // SSE reg. Mirror cstage cmd/w6c/cgen.c promote_f32 push. + if (cvarnfixed >= 0 && argidx >= cvarnfixed && fk == 1) { + emitline("\tCVTSS2SD\tX0, X0\n"); + mov = "MOVSD"; + }; emitline("\tSUBQ\t$8, SP\n"); emitline("\t"); emitline(mov); diff --git a/selfhost/cmd/wcc/check.ww b/selfhost/cmd/wcc/check.ww index f4816aed..602f6ca0 100644 --- a/selfhost/cmd/wcc/check.ww +++ b/selfhost/cmd/wcc/check.ww @@ -5610,6 +5610,18 @@ fn desugarcallargs(c: *checker, n: *syntax.node) void = { let nexta: *syntax.node = a.next; if (param != nil) { if (param.kind == syntax.nkind.N_PARAM) { + // C-style FFI variadic (bare `...`): str=="...", + // op != TK_ELLIPSIS, lhs == nil. The `...` absorbs + // every remaining arg untyped — mirror cstage + // check.c:1860 `if (!u->variadic) ...; continue`. + // MANDATORY: this param's lhs is nil, so the per-arg + // coercerunelit / checkarrlitfits / desugararrayslice + // calls below would deref nil on a rune-literal or + // array-literal variadic arg. + if (syntax.streq(param.str, "...") + && param.op != syntax.tkind.TK_ELLIPSIS) { + break; + }; if (param.op != syntax.tkind.TK_ELLIPSIS) { let atype: *syntax.node = exprtype(c, a, nil); // #29: a rune literal narrowing into an integer param diff --git a/test/wcc/989_ffivariadic_run.c b/test/wcc/989_ffivariadic_run.c index 8e715936..1c267ac3 100644 --- a/test/wcc/989_ffivariadic_run.c +++ b/test/wcc/989_ffivariadic_run.c @@ -13,8 +13,9 @@ * RUNTIME gate (byte-id can never see AL correctness): each row builds a ww * caller that calls the C fixture `double fixture(long n, ...)` (a * va_arg(double) summer, test/wcc/data/ffivariadic/fixture.c, linked from - * libffifix.a) and asserts the returned sum. cstage `ww` ONLY — wwstage AL - * is C2. + * libffifix.a) and asserts the returned sum. Runs on BOTH the cstage `ww` + * and wwstage `ww_ww` drivers (C2): AL is byte-id-blind, so a wwstage fi / + * f32-promotion divergence is caught only by a wrong runtime sum here. * * NON-VACUITY DEVIATION (reported to lead): the spec's `fixture(2,1.0,2.0)` * is VACUOUS on this box — with AL=0 the two skipped xmm slots happen to @@ -174,22 +175,45 @@ main(void) bin = absbin; } - char cdrv[1024], libdir[1024]; + char cdrv[1024], wdrv[1024], libdir[1024]; snprintf(cdrv, sizeof cdrv, "%s/ww", bin); + snprintf(wdrv, sizeof wdrv, "%s/ww_ww", bin); /* libffifix.a lives beside $(BIN) under $(OUT)/ffivariadic — the * Makefile builds it there as a prereq of this test binary. */ snprintf(libdir, sizeof libdir, "%s/../ffivariadic", bin); + /* C2: run each row on BOTH the cstage `ww` and the wwstage `ww_ww` + * driver. AL correctness is byte-id-blind, so a wwstage fi/promotion + * divergence is invisible to the 990-997 gates but caught here as a + * wrong sum (nonzero exit). wwstage is access-gated like the other + * dual-stage runtime tests (989_chainidx_run) so a cstage-only tree + * still runs the cstage rows. */ + struct { const char *name; const char *drv; int gated; } + drivers[] = { + { "cstage", cdrv, 0 }, + { "wwstage", wdrv, 1 }, + { NULL, NULL, 0 }, + }; + int n = (int)(sizeof rows / sizeof rows[0]); int total = 0, fail = 0; - for (int i = 0; i < n; i++) { - total++; - int got = run_build(cdrv, libdir, &rows[i], i); - if (got != rows[i].want_exit) { - fprintf(stderr, "ffivariadic[%s]: exit=%d want=%d\n", - rows[i].label, got, rows[i].want_exit); - fail++; + for (int d = 0; drivers[d].name; d++) { + if (drivers[d].gated && access(drivers[d].drv, X_OK) != 0) { + fprintf(stderr, "ffivariadic: skip %s (no %s)\n", + drivers[d].name, drivers[d].drv); + continue; + } + for (int i = 0; i < n; i++) { + total++; + int got = run_build(drivers[d].drv, libdir, &rows[i], i); + if (got != rows[i].want_exit) { + fprintf(stderr, + "ffivariadic[%s][%s]: exit=%d want=%d\n", + drivers[d].name, rows[i].label, got, + rows[i].want_exit); + fail++; + } } }