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.

AArch64 stack frame
Figure 1. Frame this lab emits: saved x29/x30, callee-saved pair, then locals. Incoming stack arg sits above the saved lr.

Lab binary

Nine ints. First eight in x0x7 (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)

SlotRole in this labNotes
x0x7integer / pointer args 1–8return value in x0 (w0 if 32-bit)
x8indirect result / tempnot an arg here
x9x15caller-saved tempsIDA names them X9
x16,x17IP0/IP1, PLT veneersdo not treat as live across bl
x18platform registerLinux: unused; Windows/AArch64: TEB-ish. I do not clobber it in notes
x19x28callee-savedprologue stp pairs
x29frame pointerAAPCS64: optional but gcc -O0 always sets it
x30link registerreturn address; PAC signs this
sp16-byte alignedstp with ! pre-index is the usual grow
nzcvflagsnot part of the arg story

Floating point would be d0d7 / q0q7. 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, #1MOV 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: X0X7 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

  1. First instruction: stp x29, x30, [sp, #-N]! or pacibsp then that stp. N is the frame size, must be a multiple of 16.
  2. mov x29, sp — after this, stack args are [x29,#N].
  3. Argument map: x0x7 at entry, then [x29,#N+0], [x29,#N+8], …
  4. Callee-saved pairs stp x19, x20, [sp, #16] etc. If I see x19 used before a save, I am in a leaf or I misidentified the start.
  5. Return: mov w0, … / ldp x29, x30, [sp], #N / ret or retab.
  6. Never treat x16/x17 as 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-overflow is the CI signal for the lab bug class.
  • On PAC hardware, leave pacibsp/retab alone. Stripping PAC with ptrauth_strip in production code is a finding.
  • Crash triage: pc=0x4141… plus x30 matching the smash is a saved-lr overwrite, not a “weird jump”. Record x0x7 from 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'