Keyboard Shortcuts

Ctrl+K
Open search
Esc
Close search / dialog
↑
Back to top

The Calling Convention, and the Number That Was 128

26 minadvancedModule 1: The Call: Who Passes What, and in Which Register
26 min · The AArch64 Procedure Call Standard

Why this matters

You already know how to read a disassembly. The encoding course measured the field map; this page does not repeat a word of that. What it assumes you can do is look at a four-byte word and understand it. What it asks is the question that nobody ever asks, because there is no diagnostic for it:

THE QUESTION Function A was compiled by one compiler. Function B calls it, and B was compiled by another. A and B agree about which register holds argument number four. If they disagree, NOTHING HAPPENS. No diagnostic, no link error, no crash at the call site. A reads the wrong register, gets a plausible number, and computes a plausible wrong answer.

That is what a calling convention is: a contract that exists only so that a mistake in it is invisible. Every rule on this page is a rule about where a value lives across a boundary nobody can see.

The AArch64 version of that contract is the AAPCS64 — the Procedure Call Standard for the Arm 64-bit Architecture, published by Arm in the ARM-software/abi-aa repository. This course quotes release 2025Q4, issued 23 January 2026, and quotes it by section number, because "the standard says" without a section is a sentence a reader cannot check.

And this concept was written to teach a number. That number was 128. It is 16, and finding out why is most of this page.

The model: two counters, two banks, and a stack that starts at zero

The whole of the argument-assignment rule, for a simple case, is two sentences. Here they are, quoted:

AAPCS64  A.1   the Next General-purpose Register Number (NGRN)
              is set to zero
        A.2   the Next SIMD and Floating-point Register Number
              (NSRN) is set to zero
        A.4   the next stacked argument address (NSAA) is set
              to the current stack-pointer value (SP)

        C.9   If the argument is an Integral or Pointer Type,
              the size of the argument is less than or equal to
              8 bytes and the NGRN is less than 8, the argument
              is copied to the least significant bits in
              x[NGRN]. The NGRN is incremented by one.

        C.1   If the argument is an 8-bit, Half-, Single-,
              Double- or Quad-precision Floating-point or short
              vector type and the NSRN is less than 8, then the
              argument is allocated to the least significant
              bits of register v[NSRN]. The NSRN is
              incremented by one.
                

Four things to notice, and all four are the whole idea.

The result registers follow the same sentence the argument registers use, and the specification words it as a rule about arguments on purpose:

$ python3 a64abi.py --run | sed -n '/THE RESULT, AND THE INDIRECT/,/^$/p'
  O0   r1     mul x0, x8, x9  add sp, sp, #16  ret
  O0   r1d    fmul d0, d0, d1  add sp, sp, #16  ret
  O2   r1     add x0, x0, x0, lsl #1  ret
  O2   r1d    fmul d0, d0, d1  ret
  O2   rbig    add v2.2d, v0.2d, v2.2d  add v0.2d, v0.2d, v3.2d  ret
                

An integer result comes back in x0 and a double in d0 — the first argument register, not a dedicated one. The AAPCS64 puts them there with a sentence: the result is returned in the same registers as would be used for such an argument. There is no rax/xmm0 split on this architecture because there is no separate integer and vector return convention to reconcile; the two banks already exist for arguments and the result joins the bank it came from.

One exception, and it is the exception a backend must get right. A struct too large for a register comes back through memory the CALLER allocated, and the callee gets its address in x8:

$ python3 a64abi.py --run | grep -B1 -A4 'Indirect Result'
  ... A struct too large for a register is different: the caller
  allocates the memory, the callee gets its ADDRESS in x8, and
  `use_big` at -O2 is `mov x8, sp` followed by `bl rbig`. x8 is
  the Indirect Result Location Register and it is the one argument
  register with a job that is not passing an argument.
                

x8 is the Indirect Result Location Register. It is worth sitting with that: it is inside the argument-register range (0–7 are the ordinary ones, so x8 is the first register past them) and it is the one whose job is not to pass an argument. A reader who assumes results always come back in x0 will misread that prologue completely.

The audit: 36 of 36, by name, at four optimisation levels

A census would say “eight registers were written before the call.” An audit says which argument was in which register. The corpus makes that possible by giving every argument its own volatile global, so the disassembly literally names it — #24is argument three — and a volatile access cannot be deleted or moved, so the call survives optimisation.

$ python3 a64abi.py --run | sed -n '/4A\./,/4C\./p'
  4A.  NINE INTEGER ARGUMENTS.  caller `c9`, callee `i9`.
  level register args stacked, and where
  O0    0:x0  1:x1  2:x2  3:x3  4:x4  5:x5  6:x6  7:x7  8:sp+0
  O1    0:x0  1:x1  2:x2  3:x3  4:x4  5:x5  6:x6  7:x7  8:sp+0
  O2    0:x0  1:x1  2:x2  3:x3  4:x4  5:x5  6:x6  7:x7  8:sp+0
  Os    0:x0  1:x1  2:x2  3:x3  4:x4  5:x5  6:x6  7:x7  8:sp+0

  4B.  NINE DOUBLE ARGUMENTS.  caller `cd9`, callee `d9`.
  O0    0:d0  1:d1  2:d2  3:d3  4:d4  5:d5  6:d6  7:d7  8:stack@8
  O2    0:d0  1:d1  2:d2  3:d3  4:d4  5:d5  6:d6  7:d7  8:stack@8
                

And the mixed case, which is where the two counters stop being an abstraction and become an observation:

$ python3 a64abi.py --run | sed -n '/4C\./,/the specification that decides/p'
  O2   cm8  ints: 0->x0  1->x1  2->x2  3->x3
            fps : 0->d0  1->d1  2->d2  3->d3   stacked at: none
  O2   cm18 ints: 0->x0  1->x1  2->x2  3->x3  4->x4  5->x5  6->x6  7->x7
            fps : 0->d0  1->d1  2->d2  3->d3  4->d4  5->d5  6->d6  7->d7   stacked at: [0, 8]
                

Read cm8 first, because it cannot tell you the answer, and a corpus designed to prove a point that has no control is not a control. Four integers and four doubles, alternating: the integers went to x0–x3 and the doubles to d0–d3, in source order, interleaved. Both hypotheses predict that: “one sequence across both banks” and “two independent counters” agree on every assignment when nothing overflows.

So cm18 exists, with nine of each so that both ninths overflow. And the result is the interesting one: the 9th integer is at [sp + 0] and the 9th double is at [sp + 8] — the order they appear in the parameter list, laid down by C.17 processing the list left to right. The two sequences do not share a counter, so a mixed call can have arguments 8 and 9 in different banks at the same stack offset range, and it is the specification that decides which one goes first.

And then the sentence that is the whole reason this page exists as a page of its own:

  [MEASURED]  The 9th argument is at [sp + 0] at the moment of the
  call, which is what A.4 says: "the next stacked argument
  address (NSAA) is set to the current stack-pointer value
  (SP)". There is no return address on the stack to skip
  -- x30 holds it -- so the first stacked argument is at
  offset ZERO and the AAPCS64 says so in one sentence
  instead of in a diagram.
                

That is a difference in kind from x86-64, not in degree.On x86-64 the return address is pushed, so the first stack argument is at +8(%rsp) and a reader learns the offset and the reason in one diagram. Here the reason is a register — x30 — which is the subject of concept 4 — and so the offset is zero. It is also why every stack offset in this course is 8 lower than the x86-64 course printed, and a reader with both courses open should expect to subtract eight, repeatedly, for five pages.

And the number that was 128

Now the alignment rule, in the words of the document, in both the places it appears:

AAPCS64  5.2.2.1  Additionally, at any point at which memory is
          accessed via SP, the hardware requires that
          SP mod 16 = 0. The stack must be quad-word aligned.

        5.2.2.2  The stack must also conform to the following
          constraint at a public interface: SP mod 16 = 0.
          The stack must be quad-word aligned.
                

SIXTEEN. Not 128, not 64, not 8. Sixteen, in both places. The same rule appears in the SVE variant of the standard (ARM_100986_0000_00) in two more. A rule stated four times is a document telling you it matters four times.

And the reason is in the encoding, where a64asm measured it and this course can quote it without re-measuring. The largest scale anywhere in the A64 load/store family is 16, and it is the scale of the 128-bit pair.

  word                   scale      imm field  reach
  stp w0, w1, [sp]       x4         imm7 at 21:15 [-256, 252]
  stp d0, d1, [sp]       x8         imm7 at 21:15 [-512, 504]
  stp q0, q1, [sp]       x16        imm7 at 21:15 [-1024, 1008]
  str q0, [sp]           x16        imm12 at 21:10 0 .. 65520
  str d0, [sp]           x8         imm12 at 21:10 0 .. 32760
  stur q0, [sp]          x16        imm9 at 20:12 [-256, 255], BYTES
                

Those are the assembler's own ranges, read back from the artifact's section 3, and the maximum is 16 and it is the q pair's. So a 16-byte-aligned SP satisfies every scaled form in the family without exception, and the ABI asks for exactly 16.

The plan for this course said the rule was 128 bytes and that the reason was NEON's LDP q0, q1. The reason is right and the number is wrong by a factor of eight, because the reason implies the number. A stp q0, q1 needs 16 bytes of alignment and no more — which the assembler states in its own diagnostic, verbatim, when you ask for a misaligned one:

  stp q0, q1, [sp, #8]    REFUSED  -   index must be a multiple of 16
                                           in range [-1024, 1008].
                

Where 128 comes from at all: it is the x86-64 red zone's size, and the reason the number appears in AArch64 discussions is the APPLE platform ABI's 128-byte red zone — a different quantity, about a different region of memory, belonging to a different ABI. And a course that used the red zone's number for the alignment rule would also have had to conclude that AArch64 has a red zone, which is the opposite of the truth and is the finding concept 3 is built on. That conflation is retraction R3 and it is printed in full by the artifact.

Worked: the check, and the place a measurement is refused

The rule is SP mod 16 = 0 at a public interface, and a blis a public interface. So the check is: for every function, walk the SP deltas in order and test the running sum at every branch. That is strictly more than the specification asks for — a conditional branch inside a function is not a public interface and does not change SP — and a check that is stronger than the specification is worth more than one that matches it exactly, because it cannot be defeated by a case the specification does not mention.

$ python3 a64abi.py --run | sed -n '/lvl  function   SP deltas/,/rbig       -32 +32/p'
  lvl  function   SP deltas, in order            sum      mod 16   branches
  O0   i9         -80 +80                        0        0        no branch
  O0   m18        -144 +144                      0        0        no branch
  O0   rbig       -32 +32                        0        0        .LBB6_1@-32,.LBB6_2@-32,.LBB6_3@-32,.LBB6_1@-32
  O0   c9         -32 +32                        0        0        i9@-32
  O0   big_frame  -96 +96                        0        0        .LBB18_1@-96,.LBB18_2@-96,.LBB18_3@-96,...
  O0   many       -128 +128                      0        0        sink@-128,sink@-128,  ... (12 of them)
  O0   va         -320 +320                      0        0        .LBB24_1@-320,.LBB24_2@-320,...

  [MEASURED]  Every SP movement in the corpus at all four levels leaves
  SP a multiple of 16, and 0 of the 151 branches are made with SP not
  a multiple of 16.  That is an arithmetic identity over the immediates
  the compiler emitted, not a measurement of behaviour: nobody ran
  anything, and the sum is the check.
                

Zero misaligned out of 151. A fixed-width instruction stream has no length arithmetic to get wrong, so this really is arithmetic over emitted immediates and not a simulation — which is the one thing this course can offer that a machine would not improve on.

One trap in that table, and it is the kind that produces a confidently wrong census. The deallocation is often fused into the last load — ldr x0, [sp], #16 is one instruction that reads a value and gives the bytes back — so counting add sp, sp, #N alone undercounts the functions that deallocate that way. The first version of this table counted only the explicit form and reported nine functions with no deallocation at -O2, which was wrong for three of them. Concept 3 is where all five forms are measured, because the same trap is also a frame-allocation trap.

The place a measurement is refused

The AAPCS64 says "the hardware requires that SP mod 16 = 0". It does not say "the hardware will fault". Those are different sentences, and on the platform this compiles for they have different answers.

Whether a misaligned access through SP actually traps is decided by SCTLR_ELx.A, the alignment-check control bit, and a Linux EL0 process runs with that bit clear. So on Linux the requirement is a software contract that a compiler enforces, not a hardware trap. This course can measure the encoding of the instruction that reads the bit, and does:

  source                   word       what the second reader says
  mrs x0, sctlr_el1        0xd5381000  mrs x0, SCTLR_EL1
  msr sctlr_el1, x0        0xd5181000  msr SCTLR_EL1, x0

  [MEASURED-ON-BYTES]  The encoding of the instruction that reads the
  alignment-check bit is 0xd5381000.  The FAULT is not measured, is
  not claimed, and is named here as the boundary of this section
  rather than left for a reader to discover.
                

Read that block twice, because a limit printed in the place a reader would have expected a number is worth three limits printed at the end. The x86-64 ABI course could deliver a real SIGSEGV for its alignment rule; this one cannot, and pretending otherwise would be the exact failure the whole collection exists to name. The x86-64 frame concept has the fault, if you want the contrast — and the contrast is the interesting part: one ABI's rule is enforced by a bit, and the other ABI's rule is enforced by a compiler.

Apply it

  1. Read the ninth argument off a disassembly yourself. Compile anything with nine integer parameters at -O0 for aarch64-linux-gnu and find the call. (Expect the first two strs to write to [sp, #0] and [sp, #8], with no return-address slot to skip — and expect the prologues to look odd next to the x86-64 ones you already know, because every offset is 8 lower.)
  2. Break the two-counter rule and see what the compiler does. Write a C function taking a double as argument one and an int as argument two, and check which register each arrives in. (Expect d0 and x0 — NOT d0 and x1. The counters are independent, so a floating-point first argument does not consume an integer slot. Now write the same function with a leading char and check whether the double still lands in d0: it does, and the char consumes no integer slot either when it is passed in a register under AAPCS64's rules — which is where a reader should stop and read C.1 and C.9 again.)
  3. Ask the assembler the question this page answered, and compare the answer to the plan's.(Expect stp q0, q1, [sp, #8] to be refused with a diagnostic that names 16, and stp q0, q1, [sp, #1008] to be accepted while #1024 is not. 1008 is 63 × 16; 64 × 16 is one step past the end of the field. A scaled field has an UNSYMMETRIC reach, and the assembler prints the asymmetry rather than the maximum.)

Connect

Backwards, and one concept: the field map is where the three scales in the table above were measured, and the encoding course's cross-check is what makes those ranges trustworthy rather than remembered. This course's own artifact imports that decoder and prepends six models to it, because a decoder that cannot name stp q0, q1 cannot check the alignment rule, and the alignment rule is what this page is about. Coverage: 188 further instructions named at -O0, 115 at -O2, purely by adding those six.

Sideways, and this is the sibling reference: The x86-64 calling convention is the same contract with six integer registers instead of eight, one bank instead of two, and a pushed return address instead of a register. Reading the two side by side is the fastest way to stop treating either as “the ABI” — there is no the ABI, there is one per architecture, and the differences are the interesting part. The stack canary is a consequence of this page: it is a value that must survive a call, and the only region of memory that is guaranteed to survive a call is the frame.

Forwards. Concept 2 is where the question “which register” stops being a list and becomes a property of the register file — one register, two names, and the number 31 meaning three different things. Then concept 3 is the consequence of having no red zone, and the dynamic-linking course is where the stack is first set up, before any rule on this page has a chance to apply.

render_lesson_js(&mut page)