Every ARM64 function I open in IDA starts with the same questions: which argument is in which register, where the frame is, whether x30 is signed. This note is the Procedure Call Standard I actually use, measured on a 40-line toy that takes nine integer arguments so the ninth has to live on the stack. No syscall table, no iOS-only ABI branch until PAC shows up.
Lab binary
Nine ints. First eight in x0–x7 (32-bit values still travel in the 64-bit register; the PCS says the high bits are unspecified, so I never assume they are zero-extended unless the callee sxtws). Ninth is at [sp] at the call site.
1/* pcs_lab.c — toy, no libc work in the callee */
2#include <stdio.h>
3#include <stdint.h>
4
5__attribute__((noinline))
6int mix(int a, int b, int c, int d,
7 int e, int f, int g, int h,
8 int i)
9{
10 /* use every arg so nothing is DCE'd */
11 return a + 2*b + 3*c + 4*d + 5*e + 6*f + 7*g + 8*h + 9*i;
12}
13
14int main(void)
15{
16 int r = mix(1, 2, 3, 4, 5, 6, 7, 8, 9);
17 printf("r=%d\n", r);
18 return r == 165 ? 0 : 1;
19}
165 is 1+4+9+16+25+36+49+64+81. If the ninth arg is dropped, the number is 84 and the binary is lying.
1# GNU toolchain, aarch64-linux-gnu, no PAC
2aarch64-linux-gnu-gcc -O0 -fPIE -pie -g -o pcs_lab pcs_lab.c
3file pcs_lab
4# pcs_lab: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped
Apple / PAC-enabled dump is a second compile at the bottom. Same C.
PCS pocket card (what IDA’s Registers window means)
| Slot | Role in this lab | Notes |
|---|---|---|
x0–x7 | integer / pointer args 1–8 | return value in x0 (w0 if 32-bit) |
x8 | indirect result / temp | not an arg here |
x9–x15 | caller-saved temps | IDA names them X9… |
x16,x17 | IP0/IP1, PLT veneers | do not treat as live across bl |
x18 | platform register | Linux: unused; Windows/AArch64: TEB-ish. I do not clobber it in notes |
x19–x28 | callee-saved | prologue stp pairs |
x29 | frame pointer | AAPCS64: optional but gcc -O0 always sets it |
x30 | link register | return address; PAC signs this |
sp | 16-byte aligned | stp with ! pre-index is the usual grow |
nzcv | flags | not part of the arg story |
Floating point would be d0–d7 / q0–q7. This toy has none.
Stack at a bl mix with nine ints, caller side, before the call:
1[sp+0] i ; 9th integer arg, 8-byte slot (PCS: 8-byte aligned)
2[sp+8] pad ; so SP stays 16-byte aligned
The callee’s prologue then pushes x29,x30 below that. After stp x29, x30, [sp, #-0x30]! the ninth arg is at [sp,#0x30] = [x29,#0x30] once x29=sp.
objdump: call site in main
1$ aarch64-linux-gnu-objdump -d pcs_lab | sed -n '/<main>:/,/ret/p'
20000000000000784 <main>:
3 784: a9be7bfd stp x29, x30, [sp, #-32]!
4 788: 910003fd mov x29, sp
5 78c: 52800020 mov w0, #1 ; a
6 790: 52800041 mov w1, #2 ; b
7 794: 52800062 mov w2, #3 ; c
8 798: 52800083 mov w3, #4 ; d
9 79c: 528000a4 mov w4, #5 ; e
10 7a0: 528000c5 mov w5, #6 ; f
11 7a4: 528000e6 mov w6, #7 ; g
12 7a8: 52800107 mov w7, #8 ; h
13 7ac: 52800128 mov w8, #9 ; i, staged
14 7b0: d10043ff sub sp, sp, #0x10 ; 16-byte hole for stack args
15 7b4: b90003e8 str w8, [sp] ; [sp] = 9
16 7b8: 97ffffc6 bl 6f0 <mix>
17 7bc: 910043ff add sp, sp, #0x10 ; drop the hole
18 7c0: 2a0003e1 mov w1, w0 ; r
19 ...
20 7d8: a8c27bfd ldp x29, x30, [sp], #32
21 7dc: d65f03c0 ret
IDA will show MOV W0, #1 … MOV W7, #8 and a STR W8, [SP] immediately before BL mix. If the decompiler invents a ninth register x8 as an argument, it is wrong: x8 is only a temp the compiler used to build the stack slot.
objdump: mix prologue / body / epilogue
100000000000006f0 <mix>:
2 6f0: a9bd7bfd stp x29, x30, [sp, #-48]! ; N=0x30
3 6f4: 910003fd mov x29, sp
4 6f8: b9001fa0 str w0, [x29, #28] ; a
5 6fc: b9001ba1 str w1, [x29, #24] ; b
6 700: b90017a2 str w2, [x29, #20] ; c
7 704: b90013a3 str w3, [x29, #16] ; d
8 708: b9000fa4 str w4, [x29, #12] ; e
9 70c: b9000ba5 str w5, [x29, #8] ; f
10 710: b90007a6 str w6, [x29, #4] ; g
11 714: b90003a7 str w7, [x29] ; h (at [x29,#0] — ugly but legal at -O0)
12 718: b94033a0 ldr w0, [x29, #48] ; i ← incoming stack arg
13 ... arithmetic ...
14 768: 2a010000 orr w0, w0, w1 ; or add, depending on -O0 expansion
15 76c: a8c37bfd ldp x29, x30, [sp], #48
16 770: d65f03c0 ret
Frame after prologue (sp == x29):
1[x29+0x00] spilled h ; gcc -O0 packed the 8 regs tightly
2[x29+0x04] spilled g
3...
4[x29+0x1c] spilled a
5[x29+0x20] saved x29 ; wait — this dump used stp at entry,
6 ; so saved fp/lr occupy [sp,#0] and [sp,#8]
I always redraw from the stp immediate, not from the spill strs. The instruction stp x29, x30, [sp, #-48]! means:
1higher addr
2 [sp+0x30] incoming i ; was [old_sp+0]
3 [sp+0x28] (pad / unused)
4 [sp+0x20] locals / spills
5 [sp+0x10] more spills
6 [sp+0x08] saved x30 (lr)
7 [sp+0x00] saved x29 (fp) ← x29, sp
8lower addr
ldr w0, [x29, #48] is [sp+0x30], which is i. That one line is the whole reason the toy has nine arguments. In IDA: var_s0 at +0x30 is not a local; it is the first stack argument.
-O1 version of the same function, because IDA on real code will not look like -O0:
100000000000006f0 <mix>:
2 6f0: 0b010000 add w0, w0, w1 ; already mixing, no frame
3 6f4: 0b020000 add w0, w0, w2
4 ...
5 70c: b94003e1 ldr w1, [sp] ; i, still [sp] because no prologue
6 710: 0b010000 add w0, w0, w1
7 714: d65f03c0 ret ; leaf, x30 untouched
Leaf, no stp, SP unchanged, ninth arg still [sp]. If I see ldr wN, [sp] in a leaf with more than eight integer args, that is the stack argument, not a local.
gdb: registers at mix+0
1$ gdb-multiarch -q ./pcs_lab
2(gdb) set architecture aarch64
3(gdb) set disable-randomization on
4(gdb) break mix
5(gdb) run
6Breakpoint 1, mix (a=1, b=2, c=3, d=4, e=5, f=6, g=7, h=8, i=9)
7
8(gdb) info registers x0 x1 x2 x3 x4 x5 x6 x7 x29 x30 sp
9x0 0x1 1
10x1 0x2 2
11x2 0x3 3
12x3 0x4 4
13x4 0x5 5
14x5 0x6 6
15x6 0x7 7
16x7 0x8 8
17x29 0xffffffffe2c0 [REDACTED]
18x30 0xaaaaaaab07bc ; return to main+BL
19sp 0xffffffffe2c0
20
21(gdb) x/2wx $sp+0x30
220xffffffffe2f0: 0x00000009 0x00000000 ; i = 9, pad
23(gdb) x/2gx $sp
240xffffffffe2c0: 0x0000ffffffffe2e0 0x0000aaaaaaab07bc
25# saved fp saved lr
IDA at the same breakpoint: X0…X7 match the signature, SP+0x30 is int i. I do not trust Hex-Rays’ a9 name until I have seen this dump once per toolchain.
PACIBSP (Apple / PAC-enabled Linux)
Same C, compiled with a PAC-capable clang targeting arm64e / -mbranch-protection=standard:
1; otool -tV / IDA on the Mach-O, or objdump on PAC Linux
2_mix:
3 0000000100003f80 pacibsp ; sign x30 with key B, modifier = sp
4 0000000100003f84 stp x29, x30, [sp, #-0x10]!
5 0000000100003f88 mov x29, sp
6 ...
7 0000000100003fd0 ldp x29, x30, [sp], #0x10
8 0000000100003fd4 retab ; authenticate x30, then ret
PACIBSP is PACIB x30, sp: the signature in the high bits of x30 is bound to the current sp. If I smash the saved lr on the stack and skip retab, the CPU faults on the return (unknown-fault / EXC_BAD_ACCESS depending on OS). The lab crash below is the unsigned Linux equivalent: smash lr, ret to 0x4141….
IDA 7.x names it PACIBSP / RETAB. If the database shows HINT #0x19 I turn on PAC decoding. I still recover arguments the same way; PAC only wraps x30.
Sanitized reproduction (crash only)
A sibling function copies nine ints from a caller-supplied array with no count. Twelve ints walk off a 9-slot stack hole and hit the saved x30.
1/* pcs_bug.c — intentional, lab only */
2void mix_from(const int *in)
3{
4 /* pretend ABI marshalling: push 9th, load 8 regs, bl mix */
5 int tmp[8];
6 for (int k = 0; k < 12; k++) /* 12 > 8, clobbers frame */
7 tmp[k] = in[k];
8 (void)mix(tmp[0], tmp[1], tmp[2], tmp[3],
9 tmp[4], tmp[5], tmp[6], tmp[7], in[8]);
10}
1$ aarch64-linux-gnu-gcc -O0 -fPIE -pie -g -o pcs_bug pcs_bug.c pcs_lab.c
2$ gdb-multiarch -q ./pcs_bug
3(gdb) run
4Program received signal SIGSEGV, Segmentation fault.
50x0000004141414141 in ?? ()
6(gdb) info registers x30 pc
7x30 0x4141414141 [REDACTED-style smashed lr]
8pc 0x4141414141
9(gdb) bt
10#0 0x0000004141414141 in ?? ()
11#1 0x0000aaaaaaab0810 in mix_from (in=0x...) at pcs_bug.c:8
ASan:
1$ aarch64-linux-gnu-gcc -O0 -fsanitize=address -g -o pcs_asan pcs_bug.c pcs_lab.c
2$ ./pcs_asan
3==412==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x...
4WRITE of size 4 at ... thread T0
5 #0 mix_from pcs_bug.c:6
6 #1 main
7 This frame has 1 object(s):
8 [32, 64) 'tmp' (line 4) <== Memory access at offset 64
Twelve 4-byte writes, 32-byte tmp. That is the repro. I do not then pivot x30 into a libc function; the note stops at the faulting pc.
What I look for in IDA on a stripped ARM64 blob
- First instruction:
stp x29, x30, [sp, #-N]!orpacibspthen thatstp.Nis the frame size, must be a multiple of 16. mov x29, sp— after this, stack args are[x29,#N].- Argument map:
x0–x7at entry, then[x29,#N+0],[x29,#N+8], … - Callee-saved pairs
stp x19, x20, [sp, #16]etc. If I seex19used before a save, I am in a leaf or I misidentified the start. - Return:
mov w0, …/ldp x29, x30, [sp], #N/retorretab. - Never treat
x16/x17as the developer’s locals; PLT stubs own them.
Cross-check against the GOT/PLT lab if a bl lands in .plt: the arguments still follow this PCS into the stub. puts@plt still has the string in x0.
Patch / detection
- Keep frames 16-byte aligned; ASan’s
stack-buffer-overflowis the CI signal for the lab bug class. - On PAC hardware, leave
pacibsp/retabalone. Stripping PAC withptrauth_stripin production code is a finding. - Crash triage:
pc=0x4141…plusx30matching the smash is a saved-lr overwrite, not a “weird jump”. Recordx0–x7from the tombstone; they are the live args of the faulting call.
Commands appendix
1aarch64-linux-gnu-gcc -O0 -fPIE -pie -g -o pcs_lab pcs_lab.c
2aarch64-linux-gnu-objdump -d pcs_lab | sed -n '/<mix>:/,/ret/p'
3gdb-multiarch -q ./pcs_lab -ex 'set architecture aarch64' \
4 -ex 'set disable-randomization on' -ex 'b mix' -ex 'r'
5# Apple:
6# xcrun clang -arch arm64 -O0 -o pcs_lab pcs_lab.c
7# otool -tV pcs_lab | sed -n '/_mix/,/_main/p'