← board

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.

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:

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