← board

A by-value struct parameter is passed as a POINTER to every C-ABI callee

ABIParamSlotIsPointer (compiler/abi.inc) answers True for tyRecord, so a by-value struct parameter occupies one pointer-sized slot everywhere in the C ABI. Both sides of a pxx-only program agree, so nothing in the suite fails. Across a real C boundary the callee dereferences the caller's data.

Repro — three runs, and the third is the control

/* pxx side */ struct Pair { int a; int b; };
               int take_pair(struct Pair p) { return p.a * 100 + p.b; }
/* gcc side */ struct Pair p = {3, 7}; printf("take %d\n", take_pair(p));
both sides result
gcc + gcc (-m32 and native) take 307 — the oracle
pxx + pxx (i386) take 307 — self-consistent, which is why this was invisible
gcc caller -> pxx callee SEGFAULT, x86-64 and i386

Control, same link shape, scalar parameters instead of a struct: take_ints(3,7) -> ints 307. So the link, the object and the calling sequence are all fine; the struct is the variable.

Why no existing test sees it

test-c-abi-cross's three subjects are all pxx-compiled on both sides — deliberately, since they were built to catch a convention change, which is self-consistent by construction either way. test-c-abi-glibc-oracle does cross a real boundary, but only with scalars and a variadic tail through glibc's dprintf; no glibc entry point in it takes a struct by value. The gap is the same one this ticket's family keeps rediscovering: a self-consistent pair cannot judge a convention. frankA's sharper form, from the probe that could not fail: a differential oracle only covers the population where the two implementations can actually disagree, and a calling convention is agreed by construction inside one implementation — so self-consistency is not evidence about an interface. The corollary is the gate this ticket needs: the new subject must be a MIXED LINK, and no amount of strengthening a pxx-vs-pxx subject substitutes for it.

Scope — bigger than the one line

Changing the predicate is not the fix. The psABI wants the aggregate's own bytes classified: SysV x86-64 splits into eightbytes with INTEGER/SSE classes (and MEMORY past two), AAPCS64 has the HFA/HVA rule plus an 8-byte-slot copy past the banks, AAPCS32 has its own. ABIA64CdeclArgSlot currently advances NSAA by exactly 8 per stack argument, which is correct only while every slot is a pointer. The callee spill (EmitParamSpillsForTarget) needs the mirror.

Returns are NOT in scope and appear to be right: RetViaHiddenDest implements the hidden-destination convention and cee_pairsum matches gcc on four targets.

A consequence for whoever takes this, recorded here rather than in an audit note because it is a property of the FIX and not of today's code (frankA): ABIA64CdeclArgSlot advancing NSAA by exactly 8 per stack argument is correct only while every slot is a pointer. It is right today for that reason, and it becomes wrong on the first commit that classifies aggregates properly. The direct arm, the indirect arm and EmitParamSpillsForTarget all read that one oracle, so the advance and the three readers move together or not at all.

Found by

frankA asked whether the ldr x9 single-word move in the aarch64 stack half truncates a by-value aggregate spilled past the register bank, built the case, and measured it MATCHING — because both sides were pxx. The negative result was sound and the hypothesis was unreachable: the move is correct by construction precisely because the slot is a pointer. Chasing why it could not fail is what found this.

Gate — BUILT, and RED

make test-c-abi-mixed-link (deliberately NOT wired into any suite while this ticket is open; wire it in when it closes). test/c_abi_mixed_link_pxx.c is compiled by pxx to an object; test/c_abi_mixed_link_main.c is compiled by gcc and links against it. Both directions, because they fail independently: take_* is a pxx CALLEE reading what gcc laid down, relay_* is a pxx CALLER laying down what a gcc callee reads. Shapes hit the SysV boundaries — 1 eightbyte / 2 eightbytes / MEMORY past 16 / all-SSE / mixed INTEGER+SSE / sub-word / an aggregate arriving after the GP bank is nearly full.

Current state: 2 of 2 targets measured, FAIL x86_64, FAIL i386, both a segfault. gcc-on-both-sides gives the expected text, so the values are gcc's and not ours.

x86-64 and i386 only, and that is a hard limit, not a shortcut: there is no gcc cross for arm32, aarch64 or riscv32 on this box, so no mixed link is constructible for them. The glibc substitute oracle does not extend here either — it needs a glibc entry point that takes a struct by value.

What the investigation added (2026-08-31, later)

Instruction-level confirmation. objdump of the pxx object:

take_p2:  mov %rdi,-0x8(%rbp)      ; store the incoming register
          mov -0x8(%rbp),%rax
          movslq (%rax),%rax       ; DEREFERENCE it

gcc puts the struct's eight bytes in rdi (0x0000000700000003 here), so the callee dereferences data as an address. Not inference: that is the emitted code.

The duplication blocker named in ir_codegen.inc is GONE, and its warning was stale in the more dangerous direction. That comment said cparser.inc carried a second full SysV classification and "do not fix one of these two without the other", citing cparser.inc:11282. The collapse was already done (refactor-a-collapse-the-c-frontend-sysv-prologue-copy, in done/), and the cited line had drifted onto unrelated va_arg code — a real line that was not the thing it named. Corrected in place: there is now one SysV prologue, so this fix has one site, not two.

i386 has a PRIOR gap that must be closed first. i386 refuses a by-value record in any convention, not just the C one — a plain Pascal function TakeP2(p: TP2) gives target i386: only ordinal/pointer parameters supported yet (ir_codegen.inc:1279, since 2797638e5, 2026-08-21). x86-64, arm32, aarch64 and riscv32 all compile and run that same Pascal. So i386 needs by-value aggregates at all before it can have them correctly.

ROOT CAUSE, and it is a DELIBERATE DESIGN, not an oversight

Settled with gdb (-g, then disassembleobjdump -d reads nothing from a pxx executable, which carries no section headers; gdb reads the program headers). The IR is identical for both frontends (lea sym=p then field), so the divergence is entirely in how the symbol is FLAGGED.

cparser.inc:11107 marks every C struct parameter isRef := True, on purpose, and says so:

"A C record param is by-value but passed via the by-ref ABI (an 8-byte pointer slot the callee derefs); the caller copies the record to a temp and passes &temp (IRLowerCallArg), giving true by-value with correct field access for records of ANY size (the inline 8-byte slot could not hold >8B)."

That is a coherent scheme. It delivers correct by-value semantics at every size — it is simply not the ABI gcc implements. isRef then makes ABIParamSlotHoldsValueAddr true, and IR_LEA lowers to mov (load the pointer) instead of lea (address of the slot).

Pascal's rule is different and size-dependent (pasparser_proc.inc:2284, "Records larger than a qword are passed by reference"):

<= 8 bytes > 8 bytes
Pascal by VALUE in a register (isRef False -> lea) by reference (mov)
C by reference, ALWAYS by reference
SysV wants eightbyte-classified in registers, up to 16 MEMORY: bytes on the stack

Measured, x86-64, same 8-byte {int a, b;}:

Pascal TakeP2:  mov %rdi,-0x8(%rbp) ; lea -0x8(%rbp),%rax ; movslq (%rax)
C      take_p2: mov %rdi,-0x8(%rbp) ; mov -0x8(%rbp),%rax ; movslq (%rax)

One instruction apart. Pascal's arm is SysV-correct here by construction; the C arm dereferences the data gcc put in rdi. Pascal's TakeP4/TakeP6/TakeD2 all use mov, so Pascal is equally non-psABI above 8 bytes — it just happens to agree below it.

So this is not "restore a broken path", it is "replace a working convention with the psABI one" on the C side, and extend it above 8 bytes on both. That is a larger and more deliberate change than the original filing implied, and it is why the estimate belongs in the ticket rather than in someone's head.

THE OBVIOUS FIRST ATTEMPT, TRIED AND MEASURED — it does not work

Do not repeat it. cparser.inc:11107 changed from True to RecSize(precid[i]) > 8, so C's rule matches Pascal's at the 8-byte boundary. Self-host converged; binary c93061a625bd. Then, on x86-64:

And it corrects the "safe split" advice this ticket used to give. That advice said a <= 8-byte record occupies ONE slot under both schemes, so switching it cannot shift a later argument. The claim is TRUE and it is NOT the safety property — nothing shifted above, and the values were still garbage. The binding constraint is that the caller and the callee must agree about what the slot CONTAINS, and the caller's half lives in shared IR lowering (IRLowerCallArg, ir.inc:3250), not in the per-parameter flag. So the pair moves together or not at all, and size is not a seam you can cut along.

i386 nuance, correcting an earlier note in this ticket. i386 refuses by-value records in the Pascal convention (ir_codegen.inc:1279), but C structs compile and run correctly on i386 today — precisely BECAUSE the C frontend routes them through the by-ref ABI. The deliberate design is what makes i386 work at all, so "fix i386's prior gap first" is a real prerequisite and not an aside: on i386 this change cannot even be attempted until by-value records exist there.

Why this was not started here: partial aggregate classification is worse than none. The precedent is in this repo — a two-of-three xtensa state "turned the data loss into active corruption", because a caller pushing two words and a spill consuming one shifts every later parameter rather than failing. The same applies per-target here, so it lands whole or not at all.

THE ROOT CAUSE IS A CONFLATION, AND IT IS NOT THE isRef FLAG (2026-09-01, frankC)

Verified by reading BOTH halves, not inferred from one:

site what it assumes
caller ir_codegen.inc:5769-5772argIsSse/argIsStack/argRegIdx/argNodeArr, all array[0..127] indexed by ARGUMENT one argument occupies exactly one register-or-stack slot
callee EmitParamSpillsForTarget (ir_codegen.inc:1279) — for i := 0 to nparams-1, intIdx/sseIdx incremented once per param the same

The concept "argument" and the concept "ABI slot" are the same variable everywhere. SysV needs one argument to occupy 0, 1 or 2 register slots, or ceil(size/8) stack slots for MEMORY. No assignment to isRef, and no change to ABIParamSlotIsPointer, can express that in a structure with one index.

This is why THE OBVIOUS FIRST ATTEMPT above failed the way it did, and the failure is now explained rather than merely recorded: flipping the flag moved the CALLEE onto the value convention while the caller kept passing one slot containing an address, because passing two slots is not something the caller's arrays can represent. The garbage values in that table are an address read as field bytes — exactly what a one-slot caller and a two-slot callee produce.

It also makes frankA's NSAA note precise. ABIA64CdeclArgSlot advancing NSAA by exactly 8 per stack argument is not a separate hazard; it is the SAME conflation on aarch64, in the oracle rather than in the backend arrays.

So the order of work is the reverse of what this ticket implied. Not "implement SysV classification, then fix the fallout" but:

  1. Split argument from slot — the caller's arrays become slot-indexed with an argument->slots mapping; the callee spill walks slots. Inert by construction while every argument still maps to exactly one slot, so it lands GREEN on its own and is verifiable by the existing suite showing no change. This is the whole risk of the ticket, and it is separable.
  2. Then classification is a table lookup in abi.inc, read by both halves, which is the small half.

Step 1 is a refactor that DELETES a case rather than adding one, which is the shape root-cause-over-microfix.md predicts. It is also the only part that can be landed and proven without the mixed-link gate going green, so it is the right first commit and it is NOT a partial aggregate classification -- nothing observable changes until step 2.

STEP 1 IS DONE for x86-64 (47c95af42). Both cdecl arms -- direct (IR_CALL) and indirect (IR_CALL_IND) -- classify into slot-indexed arrays and emit by iterating SLOTS, with slotArg[k] naming the argument feeding slot k. slotArg is the identity, so nSlots = nArgs and no emitted byte moved: 15 images byte-identical across x86_64/i386/aarch64/arm32 (tools/argslot_inertness.sh). argIsStack[]/argRegIdx[] deleted after grep-verifying no readers remained. Giving an argument more than one slot is now a change in the classification loop and nowhere else.

What step 2 still needs, in the order the code will demand it:

  1. abi.inc gains the SysV eightbyte classifier (INTEGER/SSE up to 16 bytes, MEMORY beyond) and both arms read it in the classification loop.
  2. The caller must evaluate an argument ONCE and split the result. The emit loops evaluate once per SLOT, and that is only equivalent to once per argument while slotArg is injective. Flagged in place at both loops.
  3. The CALLEE half, EmitParamSpillsForTarget, has the same conflation (for i := 0 to nparams-1, intIdx/sseIdx advanced once each) and needs the same split before it can consume multi-slot arguments.
  4. ABIA64CdeclArgSlot's fixed 8-byte NSAA advance is this conflation on aarch64 and moves with its three readers.

Not to be re-derived from the isRef flag, which is a symptom.

A PREREQUISITE THIS TICKET DID NOT NAME (2026-09-01, frankC)

blocked-by wired above, and it is not paperwork: step 2 cannot be correct until bug-a-a-record-parameters-type-is-not-resolved-when-its-slot-is-sized is fixed.

AllocParam sizes a by-value record parameter's frame slot through ParamValueSize, which reads Syms[idx].RecName -- and that is REC_NONE for 41 of the 52 record parameters in compiler.pas, so RecSize returns its 8-byte fallback. That ticket correctly records this as NOT a miscompile today: every answer the function could give later is <= the 8 it reserves, so the slot is over-allocated and never under-read.

Step 2 inverts that argument. A register-classified aggregate needs a slot of RecSize -- up to 16 bytes -- and the callee spill writes the second eightbyte at Syms[idx].Offset + 8. With the 8-byte fallback that write lands outside the parameter's slot, in the neighbouring one. Over-allocation becomes under-allocation the moment the convention changes, silently, and it would be one of those the fixedpoint cannot see: compiler.pas is Pascal, not C.

This is the same shape as frankA's NSAA note recorded above -- a quantity that is right today BECAUSE of the current convention and becomes wrong on the first commit that changes it. Two now, in one ticket, from two people, found the same way: by asking what a present-tense fact depends on.

How far that prerequisite actually reaches — READ, NOT EXECUTED

The 41-of-52 figure is from compiler.pas, which is Pascal. On the C path the id looks resolved: precid[nparams] := CTypeBaseRec is set at parse time (cparser.inc:10699, commented "record id for a tyRecord param") and LastTypeRecId := precid[i] sits on the line immediately before the AllocParam call (cparser.inc:11161). ParamValueSize reads Syms[idx].RecName, which AllocParam fills from LastTypeRecId, so for a C record parameter it should be the real id rather than REC_NONE.

If that holds, the prerequisite blocks the PASCAL half of this change and not the C half — and this ticket's gate (test-c-abi-mixed-link) is C-only. That would make step 2 for the C ABI reachable without fixing the sizing bug first, while leaving blocked-by correct for the ticket as a whole.

It is a conclusion from reading two lines, not from running anything. No instrumented build was made and no slot size was observed; the two citations are what it rests on. Whoever takes step 2 should measure it before relying on it — the cheap way is to make one C record parameter non-isRef and check the frame slot is RecSize and not 8. Recorded at this strength on purpose rather than promoted to a finding.

THE PREREQUISITE WAS MEASURED, AND IT REACHES THE C HALF BY A DIFFERENT ROUTE (2026-09-01, frankC)

The section above recorded, at deliberately low strength, a conclusion from reading two lines: that precid is resolved on the C path, so the REC_NONE sizing bug blocks the Pascal half and not this ticket's C-only gate. It said "whoever takes step 2 should measure it before relying on it". Measured now, with a PXXDBG=a.cparamslot probe at the AllocParam call (cparser.inc, kept — it costs nothing when the topic is off):

C struct RecSize precid RecName slot
{int a,b} 8 26 26 8
{int a,b,c} 12 27 27 8
{long a,b} 16 28 28 8
{double x,y} 16 29 29 8
{char buf[40]} 40 30 30 8
{int a} 4 31 31 8

The reading was RIGHT and the conclusion drawn from it was WRONG. precid = RecName on every row and RecSize returns gcc's true size, so the id really is resolved on the C path and the 41-of-52 REC_NONE problem really is Pascal-only. But the prerequisite still binds the C half, through a mechanism neither the blocking ticket nor this one had named:

ParamValueSize (symtab.inc) stores a record inline only when RecSize(...) <= 8. Above that it falls through to ABIParamSlotIsPointer, which answers True for tyRecord, and the slot is TARGET_PTR_SIZE. So flipping isRef to False leaves a 12- or 16-byte by-value record with an 8-byte frame slot, and the callee spill's second eightbyte at Syms[idx].Offset + 8 lands in the neighbouring parameter exactly as feared. The <= 8 cap is not a bug today: it is the size at which the current convention switches to by-reference, so the two agree by construction. It becomes an under-allocation on the commit that changes the convention.

Same shape as the other two hazards in this ticket, and now there are three: a quantity that is correct today BECAUSE of the convention being replaced. The NSAA advance (frankA), the REC_NONE fallback (frankC), and now the <= 8 inline cap. All three were found by asking what a present-tense fact depends on, and none of them is reachable by testing the current compiler.

Correction to the plan, therefore: step 2's first move is not the classification loop. It is ParamValueSize learning to size a register-classified aggregate at ceil(RecSize/8)*8, because every later piece writes into that slot. It is also inert while nothing sets isRef := False, so it lands and is provable on its own like step 1 was.

A note on how this was found, because it was nearly missed. The probe was written to answer the REC_NONE question, and on that question the answer is "resolved, no problem". The slot=8 column was printed only because it was one more field to print. The question the ticket asked was answered YES and the prerequisite held anyway -- the id was never the binding constraint, and verifying the named suspect would have cleared the case. This is the the-name-is-not-the-thing pattern in a prerequisite rather than an identifier: the blocker was real, and it was filed under the wrong reason.

STEP 2 IS DONE FOR x86-64 (2026-09-01, frankC, 747d3479f)

make test-c-abi-mixed-link PASSES for x86_64: all 13 rows, both directions, every SysV boundary the subject was built around -- 1 eightbyte, 2 eightbytes, INTEGER+SSE, all-SSE, MEMORY past 16 bytes, and a struct arriving after five integers so the bank ACCOUNTING is exercised and not just the classifier. It had segfaulted since the day it was written.

i386 is still RED and untouched. It needs its own arm, and the note above about by-value records not existing there yet still stands. Landing x86-64 alone is consistent with "whole per target, or not at all" -- the gate reports per target and nothing about i386 changed.

What went in, in the order the code demanded:

  1. ParamValueSize lost its <= 8 cap (f64044118) -- the convention rule wearing a size rule's clothes, described in the section above.
  2. ProcParamRecId populated from the C frontend (dbe2fcf72). C was the only frontend leaving it empty. It is the carrier the classifier needs, and Syms[Params[i].SymIdx].RecName cannot substitute: a PROTOTYPE allocates no param syms, which is exactly the relay_* direction.
  3. ABISysVArgPlace (f39e158dd), validated against gcc -S for all seven shapes -- bank AND index, not just classification.
  4. The callee spill reads it (6e622be95), which introduced a regression fixed in 747d3479f; see regression-test-core-cva-arg-pointer-pointee-b201 in done/.
  5. The caller emits eightbytes, and ABICRecordParamByValue decides (747d3479f).

The three hazards this ticket recorded all came true, and a fourth appeared. ParamValueSize's cap bound the C half after all. The caller's evaluate-once warning was real: the fix evaluates an aggregate at its HIGHEST slot and pushes high->low, because per-slot evaluation would run a side effect in the argument twice. The NSAA advance is still ahead, on aarch64. The fourth was not a present-tense quantity but a stale READER -- intIdx kept its name while the oracle took over its meaning.

Still open for this ticket:

And the gate itself grew a hole while nobody was looking, which is worth recording next to the fix rather than in a commit message: the acceptance criterion this ticket names went GREEN for x86_64 while a whole call shape was still wrong, because the subject contained no indirect call. Every visible instrument agreed — gate PASS, fixedpoint held, the earlier regression fixed. The only thing between that and a false claim in this file was normalise-dont-special-case's instruction to grep for the sibling arm, which was followed as ritual and not as suspicion. Read a PASS as being about the shapes the subject contains, never about the change you made.

STEP 2 IS DONE FOR i386 TOO, AND THE GATE IS ENROLLED (2026-09-01, frankC)

test-c-abi-mixed-link: PASS x86_64
test-c-abi-mixed-link: PASS i386
test-c-abi-mixed-link: 2 of 2 targets measured, 0 skipped

15 rows now, both directions, on both targets. The acceptance criterion this ticket named is met, so it closes. The gate is in the limited and full tiers as of the same day — the Makefile said "wire it in when that ticket closes" and that is not paperwork: an unenrolled check asserts nothing while reporting success.

i386 took four things, and only the first was the aggregate ABI:

  1. The i386 aggregate ABI itself (bb4773a19) — cdecl has no register arguments, so every aggregate is ceil(size/4) 4-byte stack slots, in the callee spill, the direct caller and the indirect caller. All of it gated on ProcCdecl: Pascal passes a <=8-byte record with IsRef=False and the internal caller still pushes an ADDRESS for it, so an ungated tyRecord and not IsRef test would have accepted Pascal's records into the C path and broken the language. Caught by an assertion, not by review.
  2. The address of an external routine on i386 (frankA, 91c293722) — the two function-pointer rows could not COMPILE for i386 before it, which took the target from 13 informative rows to a compile failure that said nothing about the other 13. A gate can be made less useful by adding coverage.
  3. TypeFieldAlign — see [[bug-a-an-8-byte-scalar-is-over-aligned-inside-a-struct-on-i386]]. The last three rows were not a marshalling defect at all: pxx and gcc agreed about how to pass the struct and disagreed about where the field WAS. It was reachable only after this ticket's fix, because a struct behind a pointer never has its fields read across the boundary — the defect was hidden by a second bug standing in front of it, not by a missing test.
  4. A dead guard in the gate itself, found while enrolling it: the ran != want check could not fire, because every continue incremented ran first. It had read as coverage since the day it was written.

The blocked-by edge was routed around, not satisfied

This ticket is blocked-by: [[bug-a-a-record-parameters-type-is-not-resolved-when-its-slot-is-sized]], which is still open and still true. AllocParam still takes a record parameter's identity from the global LastTypeRecId, and that is still REC_NONE for most of them.

It stopped blocking because the ABI path no longer asks it. ProcParamRecId is a durable per-param carrier written at REGISTRATION, and the classifier reads that instead — which it had to, for a reason unrelated to the blocker: a PROTOTYPE allocates no param syms at all, so Syms[Params[i].SymIdx].RecName has nothing to be right or wrong about in the relay_* direction. The parallel carrier was not a workaround for the blocker; the blocker simply stopped being on the path.

So do not read this closing as evidence about that ticket. The over-allocation it describes is unchanged, and it now has one fewer consumer whose breakage would have surfaced it.

What remains, and it is one thing

ABIA64CdeclArgSlot's fixed 8-byte NSAA advance and its three readers, on aarch64 — the last place an aggregate argument is a pointer by construction rather than by classification. It is filed separately rather than held here, because it has no oracle: there is no gcc cross for aarch64, arm32 or riscv32 on this box, so no mixed link is constructible for any of them. Fixing it would mean writing the psABI from the document and verifying it against pxx-on-pxx, which is the exact self-consistency this whole ticket exists to show is worthless for a calling convention.

Log