An aggregate argument is a pointer by construction on aarch64
ABIA64CdeclArgSlot (compiler/abi.inc:521) advances the next-stacked-argument
address by a fixed 8 per argument. AAPCS64 wants an aggregate <= 16 bytes in
one or two consecutive X registers, an aggregate of 1-4 identical float members
(an HFA) in that many V registers, and anything larger passed by reference to a
caller-made copy — the caller copies, and that indirection is part of the ABI
rather than a shortcut.
Readers, all inheriting the one-slot assumption:
| file | line | arm |
|---|---|---|
ir_codegen.inc |
1538 | callee spill |
ir_codegen_aarch64.inc |
3443, 3454 | direct call |
ir_codegen_aarch64.inc |
3630, 3641 | indirect call |
That is the same three-arm shape x86-64 and i386 had, and the same trap: fixing the direct arm and not the indirect one produces a gate that passes because the subject contains no call through a function pointer.
THE FIRST DELIVERABLE IS AN ORACLE
Do not start with the classifier. The parent ticket's entire finding is that
a calling convention cannot be judged from inside one implementation: pxx agreed
with itself about passing a struct as a pointer for as long as the code existed,
and every test passed. On x86-64 and i386 the disagreement became visible only
when a gcc-compiled main was linked against a pxx-compiled object and the
fields were read across the boundary.
No such link is constructible here. Measured 2026-09-01: no
aarch64-linux-gnu-gcc, and the same for arm32 and riscv32. Options, cheapest
first:
- A cross toolchain.
gcc-aarch64-linux-gnu+qemu-aarch64, both already depended on elsewhere in this tree for the cross test rungs. Installing is the owner's call (it leaves this machine). This is the one that makes the fix ordinary work. - A hand-written assembly caller, assembled by pxx's own aarch64 backend
from the AAPCS64 document. It tests the reading of the document, not
agreement with the platform — genuinely weaker, but not worthless, and it is
what
test-c-abi-glibc-oracledoes in spirit. - A glibc entry point taking a struct by value, under qemu. The parent ticket already looked: the routines this corpus calls take scalars and varargs, so the substitute oracle does not extend.
Why it is not urgent
Nothing reaches it today. aarch64 has no stack argument passing for the three C-ABI call kinds at all ([[bug-a-aarch64-has-no-stack-argument-passing-for-the-three-c-abi-call-kinds]]), so a program that would expose the NSAA advance mostly fails earlier and more loudly. That is a reason to sequence the two, not a reason to defer both — and it is worth noticing that the louder bug is protecting the quieter one from being observed.
Split out of [[bug-a-c-a-by-value-struct-parameter-is-passed-as-a-pointer-to-every-c-abi-callee]] when that closed for x86-64 and i386.
2026-09-03 — WHAT THE MISSING ORACLE ACTUALLY NEEDS, measured
This ticket and bug-a-an-aggregate-argument-is-a-pointer-by-construction-on-aarch64
both say the first deliverable is an oracle and neither says what is missing.
Measured on plexus so the next reader does not re-derive it. A mixed link needs
three pieces; two of them are already here.
| piece | aarch64 / arm32 / riscv32 | how checked |
|---|---|---|
| a foreign C compiler that emits an object | PRESENT — clang 21.1.8 | clang --target=aarch64-linux-gnu -c produces ELF 64-bit LSB relocatable, ARM aarch64 with no sysroot and no headers, which is all an ABI probe needs |
| a runner | PRESENT — qemu-aarch64, qemu-arm, and the rest under /usr/bin/qemu-* |
tools/run_target.sh already uses them |
| a LINKER for those architectures | ABSENT | ld -V lists only elf_x86_64 elf_i386 elf32_x86_64 elf_iamcu i386pep i386pe; no ld.lld, no *-linux-gnu-gcc of any flavour |
So the blocker is one tool, not a toolchain. That is worth stating precisely because "we need a cross toolchain" reads as an expensive ask and is the reason this has sat: the compiler and the runner are installed, and what is missing is the thing in the middle.
Two routes neither of which works today, both checked rather than assumed:
- pxx links it. pxx writes aarch64 executables already, so it has the ELF
writer. It cannot consume a foreign object:
pascal26 foo.o outanswerserror: unexpected character— object files are not an accepted input, and--emit-objitself lists "general objects: x86-64, i386, xtensa, riscv32", so aarch64 and arm32 cannot even be emitted (that isfeature-a-object-output-for-arm32-and-aarch64, p45, and it is a genuine second blocker for the pxx-emits/foreign-links direction — but NOT for the clang-emits/something-links direction, which needs only the linker). - GNU ld with an emulation flag.
ld -m aarch64linuxis rejected; this binutils is not the multiarch build.
What I did NOT verify, stated as the unmeasured half: that lld (or
binutils-multiarch) would in fact close it. It is the obvious candidate and I
could not test it because it is not installed, and installing it is an apt
operation — the owner's, not an agent's. Everything above IS measured.
So the actionable form of "this needs an oracle first" is: ask the owner for a
linker that targets aarch64/arm32/riscv32, and the rest of the rig is buildable
from parts already on the box. test-c-abi-mixed-link is already written to
skip a target it cannot build and to go RED if every target skips, so extending
its target list costs nothing until the linker exists.
CORRECTION, same day, to the note above. It concludes that the missing piece
is a LINKER. That is true of a static mixed LINK and it is not the answer to "is
there an oracle", because a mixed CALL is enough and it already works:
~/.cache/pxx-cross/{aarch64,arm32}/lib/ holds a complete glibc that
run_target.sh already puts on QEMU_LD_PREFIX, pxx already emits dynamic
imports for those targets, and a pxx caller into gcc-built glibc is a real ABI
boundary with the callee's own output as the observable. Worked instance, found
that way within the hour:
bug-a-aarch64-passes-a-variadic-float-in-an-fp-register-so-glibc-reads-zero
— [0.00] against arm32's and x86-64's [3.50], where the pxx-vs-pxx version of
the same program is self-consistently wrong. Read that ticket's ORACLE section
before concluding this one is blocked. The linker is still needed for the other
direction (a pxx-compiled CALLEE receiving from gcc-compiled code), and riscv32
and xtensa are covered by neither — both refuse dynamic symbols outright.
2026-09-03 (frankB) — THE ORACLE EXISTS. The premise above is wrong, and the defect is now measured against it
This ticket's summary says "there is no gcc cross for aarch64 on this box, so
no mixed link is constructible and the only available verification is
pxx-against-pxx". The clause about a LINK is true and the conclusion about an
ORACLE is false. clang is a cross compiler by construction and needs nothing
installed:
clang --target=aarch64-linux-gnu -O1 -S -o - probe.c
Declare the callees extern and never define them — you are reading the CALL
SITE, so nothing links and nothing runs. llvm-objdump-21 (installed) reads
pxx's own aarch64 ELF for the other column. Full method, and the arm32/riscv32
rows, in devdocs/dev/differential-probes.md, "A CROSS-TARGET ABI ORACLE EXISTS
ON THIS BOX, AND IT IS NOT A CROSS GCC".
Measured, five shapes, one program, aarch64
Each call takes the aggregate by value plus a distinct integer tail, which is what makes the SLOT COUNT visible and not just the bytes:
| shape | size | clang (AAPCS64) | pxx today |
|---|---|---|---|
struct {int a, b;} |
8 | x0 packed, tail w1 |
x0 = &tmp, tail x1 |
struct {long a, b;} |
16 | x0, x1, tail w2 |
x0 = &tmp, tail x1 |
struct {long a, b, c;} |
24 | POINTER x0, tail w1 |
x0 = &tmp, tail x1 |
struct {double x, y;} (HFA) |
16 | d0, d1, tail w0 |
x0 = &tmp, tail x1 |
struct {float x, y, z;} (HFA) |
12 | s0, s1, s2, tail w0 |
x0 = &tmp, tail x1 |
pxx emits memcpy to a 16-byte temp and then x0 = &temp for every row —
one pointer slot by construction, exactly as the ticket says.
FOUR OF FIVE ROWS ARE WRONG AND THE FIFTH IS RIGHT BY ACCIDENT. A 24-byte struct really does go indirectly on AAPCS64, so "always a pointer" happens to be correct there — and a big struct is precisely the shape a single hand-written probe would use. This is CLAUDE.md's "choose a probe whose right answer differs from the default" seen from the other side: the bug's answer collides with the psABI on the one case most likely to be tested. Any probe for this must vary SIZE and MEMBER TYPE.
Note the third column of the clang rows: after an HFA the tail integer lands in
w0, because the GP and FP banks allocate independently. A fix that puts an HFA
in d0,d1 but keeps advancing the GP index will place the tail in w2 and be
wrong in a way no single-argument probe can see.
What this unblocks and what it does not
The oracle is here, so the "first deliverable is an oracle" clause is discharged
and the work is now the classifier itself: ABIA64CdeclArgSlot advancing a fixed
8 per argument has to become an AAPCS64 aggregate classification (HFA of 1-4
identical FP members → that many FP registers; aggregate <= 16 bytes → one or two
X registers; larger → indirect, one slot), and its three readers inherit it.
It is a PLACEMENT oracle, not an OUTCOME one. It reads the call site and never runs, so it cannot catch a placement that is right and read back wrong. The running-program oracle on this target is the glibc dynamic call, and it cannot reach this ticket at all, because no libc entry point takes a large aggregate by value. Both halves are needed and neither substitutes for the other.
Step 1 of 2, landed: the classifier, proven before anything reads it
ABIA64RecordClass in abi.inc answers the AAPCS64 question for a by-value
composite:
- HFA first, because it is not a size question.
{double,double}and{long,long}are both 16 bytes and go to different banks; only the member types separate them.ABIA64WalkHfaflattens nested records and static arrays and requires every member to be the SAME fundamental FP type, at most four of them, withsize = count * TypeSlotSizeso padding cannot masquerade as homogeneity. - otherwise
size <= 16→ 1 or 2 X registers holding the aggregate's own bytes, - otherwise indirect — one GP slot holding a pointer.
- and it REFUSES (
-1) rather than guessing on: alignment > 8 (AAPCS64 rounds NGRN up to an even number for a 16-aligned composite and that is not modelled yet, so an answer would place the argument one register early), an unknown size,tyExtended(pxx's is 8 bytes and C'slong doubleis 16 on this ABI — the same refusalABISysVMergeScalarmakes, for the same reason), a dynamic array member, or a builtin record id.
It has no production consumer yet, deliberately. PXXDBG=a.a64cls prints it
per C record parameter — the exact shape a.sysvcls already has for SysV, whose
own comment says the point is that the classifier can be checked against an
oracle before any codegen is built on it, rather than debugging the classifier
and the marshalling at the same time. test/caarch64_aggregate_class.c asserts
eleven shapes in make test-core.
Every asserted row disagrees with what the compiler currently does. A classifier
that did nothing would print REFUSE on all eleven, so the guard cannot pass on
an inert one.
Verified against clang, row by row — and the .expected's provenance
The .expected was PRODUCED by the probe and then CHECKED against
clang --target=aarch64-linux-gnu -O1 -S; it is not a transcript of clang's
output, and the test says so, because claiming otherwise would be the same error
as writing today's answer into a .expected and calling it an oracle.
Tail-argument register per shape (which names how many slots of each bank the aggregate ahead of it consumed), clang 21.1.8:
| shape | size | clang tail | classifier |
|---|---|---|---|
{int} |
4 | w1 |
1 GP |
{int,int} |
8 | w1 |
1 GP |
{int,int,int} |
12 | w2 |
2 GP |
{long,long} |
16 | w2 |
2 GP |
{long,long,long} |
24 | w1 + mov x0, sp |
INDIRECT |
{double} |
8 | w0 |
HFA 1 x double |
{double,double} |
16 | w0 |
HFA 2 x double |
{float,float,float} |
12 | w0 |
HFA 3 x single |
{float,float,float,float} |
16 | w0 |
HFA 4 x single |
{float x5} |
20 | w1 + mov x0, sp |
INDIRECT |
{double,int} |
16 | w2 |
2 GP |
hfa5 is the row that shows the member LIMIT is four and not the size, and
mix is 16 bytes with a double first and still not an HFA.
Step 2, and the two facts it must not lose
The by-value switch is ABICRecordParamByValue, which returns False for aarch64
today, so a C record parameter is marked IsRef and both sides agree on a
pointer. Flipping it moves the caller and the callee at once, because it is one
decision — the variadic-float attempt on this same target proved what a
caller-only half does, and that lesson is one commit old.
Two placement facts measured from the same oracle that classification does not model and step 2 must:
- An HFA that does not fit the remaining FP bank goes to the stack ENTIRELY.
Five
{double,double}arguments put four ind0..d7and the fifth wholly on the stack. Same all-or-nothing ruleABISysVArgPlacestates for SysV, and the same failure if restated per slot: the first half placed, the second stranded. - The banks allocate independently, so the tail integer after an HFA lands in
w0. A fix that places the HFA ind0,d1and still advances the GP index puts that tail inw2— wrong in a way NO SINGLE-ARGUMENT PROBE CAN SEE. Every probe for step 2 needs a trailing scalar.
2026-09-04 (frankB) — STEP 2 LANDED. The three readers, the by-value switch, and what the outcome test cannot see
In one sentence: ABICRecordParamByValue now answers for
aarch64, the placement walk carries a full per-argument DESCRIPTION rather than
one Boolean, and all three readers — the callee spill, the direct call and the
indirect call — take their answer from it.
The five rows, re-measured against clang after the fix
Same probe, same oracle (clang --target=aarch64-linux-gnu -O1 -S for the call
site, llvm-objdump-21 plus pxx's own .map for the other column):
| shape | size | clang (AAPCS64) | pxx BEFORE | pxx NOW |
|---|---|---|---|---|
{int,int} |
8 | x0 packed, tail w1 |
x0 = &tmp, tail x1 |
ldr x0,[x9], tail x1 |
{long,long} |
16 | x0, x1, tail w2 |
x0 = &tmp, tail x1 |
ldr x0,[x9], ldr x1,[x9,#8], tail x2 |
{long,long,long} |
24 | POINTER x0, tail w1 |
x0 = &tmp, tail x1 |
mov x0, x9, tail x1 |
{double,double} |
16 | d0, d1, tail w0 |
x0 = &tmp, tail x1 |
ldr d0,[x9], ldr d1,[x9,#8], tail x0 |
{float,float,float} |
12 | s0, s1, s2, tail w0 |
x0 = &tmp, tail x1 |
ldr s0/s1/s2, tail x0 |
Five of five. The tail register moves on four of the five rows, which is the column that reports the bank state and the one no single-argument probe has.
Two things that were nearly wrong, both caught by measuring
1. isAgg cannot be derived from nRegs and size. The first marshaller
asked (nRegs > 1) or (size <> 8). struct {int a, b;} is ONE GP register and
EIGHT bytes — character for character a scalar's description — so it went down
the scalar path and passed the ADDRESS. Four of five rows were right and the
one that was wrong is the SMALLEST aggregate there is. The fact is now carried,
not inferred, exactly as ABISysVArgPlace already carries it on x86-64.
2. >= 0 IS RIGHT FOR SysV AND WRONG FOR AAPCS64, and the sign is the whole
bug. Both classifiers use 0 for "not in registers" and the two ABIs mean
opposite things by it. SysV's 0 is MEMORY — the aggregate's own bytes are in
the stack argument area, so the slot IS the object and by-value is right.
AAPCS64's 0 is INDIRECT — a POINTER to a caller-made copy, so the slot is a
pointer and the callee reads through it, which is the by-REFERENCE parameter
pxx already had. Copying the SysV test verbatim made va_list (struct __pxx_va_elem[1], 24 bytes, indirect class) stop being a pointer parameter,
and every crtl routine forwarding one took a by-value slot: a plain
printf("hello %d %.2f") SEGFAULTED on aarch64 while x86-64 stayed green. The
predicate reads > 0 on this target and says why.
What the new test proves, and what it CANNOT
test/caarch64_aggregate_byval.c covers every class, bank exhaustion in both
directions, and the same functions through a function POINTER, each call with a
trailing integer. It is wired on five targets — only one is aarch64; the other
four are the control that a change to the shared C-ABI predicate did not
disturb them. It matches gcc exactly on all five.
AND IT PASSES ON THE PRE-FIX COMPILER, BYTE FOR BYTE. That is measured, not suspected, and it is this defect's class rather than a flaw in the rows: caller and callee were both built by pxx and both used the one-pointer-slot convention, so they agreed and every value arrived intact. An outcome test over a pxx-only program is PHYSICALLY UNABLE to observe a wrong calling convention. A green row there says "pxx still agrees with itself".
So the file is a REGRESSION guard. The proof is the placement table above.
Still open, and it is the same gap this ticket has always had
The remaining half is a mixed LINK — a pxx-compiled CALLEE receiving from clang-compiled code — which needs an aarch64 linker this box does not have (see the 2026-09-03 note; the compiler and the runner are both present and only the linker is missing). The indirect-call arm shares one description and one walk with the direct arm, which is why it is not separately proven: there is no shape here that reaches it with an aggregate — a Pascal program calling through a cdecl fnptr into a foreign callee that takes a struct by value — and inventing one that only pxx can execute would prove pxx agrees with itself again.
Log
- 2026-09-04 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit f95f85532.