The cdecl indirect-call arm never sets up the hidden aggregate-result register on aarch64 and arm32
Reproducer — C, and the same thing in Pascal
int printf(const char *, ...);
struct P3 { int x, y, z; };
static struct P3 mk3(int s){ struct P3 p; p.x=7; p.y=11; p.z=13; return p; }
int main(void){
struct P3 (*fp)(int) = mk3;
struct P3 a = mk3(0); printf("direct %d %d %d\n", a.x, a.y, a.z);
struct P3 b = fp(0); printf("indirect %d %d %d\n", b.x, b.y, b.z);
return 0; }
Measured at e36f2f04c + the offset-zero fix, binary 4c547865c4b7:
x86_64 rc=0 direct 7 11 13 | indirect 7 11 13
i386 rc=0 direct 7 11 13 | indirect 7 11 13
riscv32 rc=0 direct 7 11 13 | indirect 7 11 13
aarch64 rc=139 direct 7 11 13 | SIGSEGV
arm32 rc=139 direct 7 11 13 | SIGSEGV
direct is correct everywhere; only the indirect call dies. Size is not the
variable — 1 int, 2 ints, 3 ints, a bare char, and an 8-int array all
segfault identically on both targets, and all five are correct on riscv32.
It is NOT a C-frontend defect, and the control that says so
The obvious control — the same program in Pascal — is correct on aarch64 and
arm32, which reads as "the backend can do this, so the C frontend is what is
broken". That reading is wrong, and it is wrong because the control was drawn
from the wrong population: an ordinary Pascal function(s: Integer): TP3
fn-pointer is not cdecl. Add the keyword and nothing else:
type TMk = function(s: Integer): TP3; cdecl;
PASCAL cdecl aarch64 SIGSEGV
PASCAL cdecl arm32 SIGSEGV
PASCAL cdecl riscv32 indirect 7 11 13
PASCAL cdecl x86_64 indirect 7 11 13
Identical to the C matrix, four targets out of four. The convention selects the broken arm; the language only decides how often you land on it.
Cause
IR_CALL_IND branches on CProcUsesCAbi(procIdx), which is exactly
ProcCdecl[procIdx]. The IR is the same shape in both languages —
call_ind / lea <temp> / copy_rec, verified identical node-for-node
between the C and Pascal dumps — and the lea of the unnamed temp is the
hidden destination, reached through IRCallDest[node], not through IRA/IRB.
ir_codegen_riscv32.inc:3973handlesIRCallDestat the top ofIR_CALL_IND, above the cdecl branch. Green.ir_codegen_aarch64.inc: the cdecl arm (~3877-3990) pushes callee and args, walksABIA64VecWalkN,ldr x16/blr x16, drops the block, and handles only a float or narrow-int return. It never mentionsIRCallDest. The internal arm below it (~3997) does, popping the destination into x8, andIR_VIRTUAL_CALL(~4034) does too.ir_codegen_arm32.inc: same shape, same omission; its internal arm loads the destination into r0.
So the callee's EmitAggregateDestStash prologue reads an uninitialised x8/r0
and stores through it.
Fix
Mirror the internal arm inside the cdecl arm on both backends: push
IRCallDest[node] deepest (below the callee word), load it into x8 / r0
immediately before the blr/blx, and add one more 16-byte (aarch64) /
4-byte (arm32) slot to both the reach check and the post-call add sp.
EmitCallArgRegsA64's xtra2 parameter already expresses exactly this for the
internal arm; the cdecl arm hand-rolls its block and needs the slot added by
hand.
The register choice is the part to get right, and riscv32 is the precedent
Do not read "add IRCallDest" as mechanical. The cdecl arm's own comment says
it is for a dlsym'd C function, and for a genuinely external AAPCS callee the
hidden aggregate pointer is x8 on aarch64 but r0 on arm32, with every
declared argument shifted by one. That is NOT what the callee here wants: the
callee reached through a C function pointer is pxx-compiled, and its prologue
(EmitAggregateDestStash) reads pxx's INTERNAL destination register.
The two green targets both resolve it the same way, and they resolve it in favour of the internal convention:
- riscv32 handles
IRCallDestat the top ofIR_CALL_IND, above the cdecl branch, and its own comment is explicit that the register is the same one the direct path uses "deliberately, because the callee prologue has no idea how it was reached, so an indirect call that used a different register would be a second convention for one thing." - x86-64 is green through the identical
ProcCdeclbranch.
So the fix mirrors the INTERNAL arm inside the cdecl arm (x8 on aarch64, r12 on
arm32 — arm32's internal arm uses r12, not r0), rather than implementing the
external AAPCS sret shape. Anything else grows a second convention for one
thing, which is the shape
devdocs/dev/normalise-dont-special-case.md warns about.
The alternative fix — restore the guard the arm's comment says it already has,
so C-mode indirect calls take the internal path at all — is worth pricing
first: CProcUsesCAbi is exactly ProcCdecl[procIdx] with no language test,
so the comment describes an intent the code does not implement. If that guard is
the real missing piece, it fixes both backends in one place. Measure which
before editing either.
Blast radius
Larger than the tickets it was found under. Every C function pointer gets
ProcCdecl := True from CParseFnSigGroup, so any C callback returning a
struct by value is dead on aarch64 and arm32 — a callback shape busybox, lua and
sqlite all use. It is invisible to the dev loop because that runs on x86-64,
where the same arm is correct.
Found by
Adding cross-target coverage for
[[bug-c-a-field-past-the-first-eight-bytes-of-an-indirect-call-s-struct-result-reads-back-as-offset-zero]].
test/c_fnptr_struct_result_fields.c is wired on x86-64, i386 and riscv32 with
aarch64 and arm32 named as deliberately absent, citing this ticket; add them to
that Makefile row when this closes.
Resolution
Fixed on both backends, mirroring the internal arm rather than implementing the external AAPCS sret shape — the option this ticket priced above, and the one x86-64 and riscv32 already chose.
aarch64 (ir_codegen_aarch64.inc): the destination is pushed DEEPEST, below
the callee word, and read back into x8 by offset immediately before the
blr. Deepest-and-by-offset rather than evaluated late, because
IREmitNodeAarch64 leaves its result in x0 and by that point x0 holds argument
0 — that is the one thing x86-64 does differently, and only because rax is not
an argument register there. The reach check and the post-call add sp each
take one more 16-byte slot.
arm32 (ir_codegen_arm32.inc): it costs no stack at all. The callee already
rides in an EIGHT-byte slot above the block — eight for the alignment the blx
needs, four actually used — so the destination goes in the pad at [sp, #blk+4]
and every drop is arithmetically unchanged. Because r12 is where the internal
convention puts the destination, the callee moves to r9 for that case only,
which is the register the internal indirect arm already uses for a callee. With
no destination the emitted sequence is byte-identical to what it always was, so
the previously-green path does not move.
Measured, all five targets, both languages:
C repro Pascal `cdecl` repro
x86_64 7 11 13 7 11 13
i386 7 11 13 7 11 13
riscv32 7 11 13 7 11 13
aarch64 7 11 13 (was SIGSEGV) 7 11 13 (was SIGSEGV)
arm32 7 11 13 (was SIGSEGV) 7 11 13 (was SIGSEGV)
test/c_fnptr_struct_result_fields.c now runs on all five rather than three,
and test/test_cdecl_fnptr_aggregate_result.pas is new: the same backend arm
reached with no C in the picture, since the C test alone cannot tell a
convention defect from a frontend one. That file runs unmodified under FPC with
an identical transcript, so it carries a second oracle that fails differently.
Its row 2 is a NON-cdecl control and is not padding — a fix that repaired the
cdecl arm by disturbing the internal one would pass row 1 and break every
proc-variable call in the RTL.
Gate. The wide suite went RED on make test, and it is NOT this change:
tools/assert_no_leak.sh test_ssvarrec26 200 refuses at
allocs=33, a row that landed yesterday in 1cac1742a. Ablated — stash both
backend files, rebuild (converged, binary 80a3f9e73673), identical census,
still exit 1 — and filed as
[[bug-a-the-shortstring-array-of-const-leak-assertion-cannot-run-its-subject-allocates-33-times]].
Everything else in that run passed, including make test-nilpy and the FPC
seed canary.
Log
- 2026-09-05 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 0f7286818.