Keyboard Shortcuts

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

One Register, Two Names, and Three Things Called 31

25 min · The AArch64 Procedure Call Standard

Why this matters

The previous concept answered which register argument four goes in, and the answer was a list. This concept is about what happens when you stop treating that list as a set of 31 equally-ordinary things, because on this architecture the register file has a property that makes several of those answers traps rather than facts.

THE SHAPE OF THE TRAP x86-64 has THIRTY-TWO 64-bit registers. Writing 32 bits leaves the top half alone, and the 32-bit operations live in a SEPARATE file with different names. AArch64 has THIRTY-ONE 64-bit registers. There is no second file. `w0` and `x0` are the SAME 64 bits, and writing `w0` DESTROYS the top 32.

Every C programmer on this platform has been told “AArch64 is like x86-64 but with 31 registers and the arguments come in x0–x7.” That sentence is the source of a specific, silent, and very hard bug, and this page is the measurement of it. It is a correctness trap, not a curiosity: the wrong behaviour here is not a wrong name, it is a wrong value, and it only shows up on the inputs that need the top half.

There is a second trap in the same concept and it is about the number 31 — sp, xzr, and not a general register at all, depending on which of three fields you read it out of. The encoding course measured that the register field is five bits wide and that a field map cannot express the difference, because the difference is not in the field. It is in the group.

The model: one register, one bit, and a rule you already use without knowing

Here is the whole experiment. Six instructions, assembled twice each — once with w and once with x — and the two words XOR-ed together. Three of the six are arithmetic and three are a move, so the result is a property of the register file and not of two lucky opcodes.

$ python3 a64abi.py --run | sed -n '/the XOR: one bit/,/^$/p'
  the w form             the x form             w word       x word       xor
  mov w0, #1             mov x0, #1             0x52800020   0xd2800020   0x80000000
  add w0, w0, #1         add x0, x0, #1         0x11000400   0x91000400   0x80000000
  sub w0, w0, w1         sub x0, x0, x1         0x4b010000   0xcb010000   0x80000000
  mov w0, w1             mov x0, x1             0x2a0103e0   0xaa0103e0   0x80000000
  mov w0, #-1            mov x0, #-1            0x12800000   0x92800000   0x80000000
  mov w0, wzr            mov x0, xzr            0x2a1f03e0   0xaa1f03e0   0x80000000

  [MEASURED-ON-BYTES]  6 pairs, 1 differing bit in every pair, and it
  is bit 31 -- `sf`, the size field.
                

Six pairs, one differing bit in every pair, and it is bit 31 every time. That is the sf field, and it is the only thing that separates the two names. So the model is: there are 31 registers, they are 64 bits wide, and w<n> is not a different register — it is the same 64 bits with a promise attached that bits 63:32 are set to zero on write.

Now the row that makes it a correctness trap rather than a curiosity, and it is the fifth one:

  mov w0, #-1            mov x0, #-1            0x12800000   0x92800000

  the w form leaves x0 = 0x00000000FFFFFFFF
  the x form leaves x0 = 0xFFFFFFFFFFFFFFFF
                

The same immediate, in the same field, meaning two different things. The top half of a register has no name of its own, so a 32-bit write to it is a zero extension — and a zero extension is not free here in the way it is on x86-64. On this architecture it is not even an instruction: it is what the other bit of the same word does for free. There is no movl %eax, %eax needed, because sf = 0is the zeroing.

Why this is worth a concept. A decoder that models w0–w30 and x0–x30 as two 32-register banks gets the register numbers right and the semantics wrong, and it gets them wrong silently: every value it computes is a valid value, and the wrong one is exactly the value a 32-bit programmer would have got on a machine with two banks. That is retraction R2, it is asserted as text by the harness, and it is the reason a decoder that “handles both widths” is not automatically right.

The rule you already use without knowing: the AAPCS64 says an integer result comes back in x0, and a C int function returning a 64-bit value in the same source works fine — because the callee wrote x0 with a 64-bit write and the caller read it with a 64-bit read. The trap is the other direction: a callee that writes w0 and a caller that reads x0 agree only about the bottom 32 bits, and the top half the caller reads is whatever the callee's previous 32-bit write left there. Which is zero, because the callee zeroed it. Both sides are correct; the value is not what a reader of the source would predict.

Register number 31 is three different things

Same five bits, read out of the same words, in three slots. Here is the table, with the second reader's own name for each row beside ours:

$ python3 a64abi.py --run | sed -n '/Register NUMBER 31 IS THREE/,/^$/p'
  source                   word       Rd/Rt    Rn       what the reader calls it
  add sp, sp, #16          0x910043ff 31       31       add  sp, sp, #0x10
  str w0, [sp, #12]        0xb9000fe0 0        31       str  w0, [sp, #0xc]
  cbz w31, T               0x3400001f 31       0        cbz  wzr, 0x0 <.text>
  cbz x31, T               0xb400001f 31       0        cbz  xzr, 0x0 <.text>
  add x1, xzr, x1          0x8b0103e1 1        31       add  x1, xzr, x1
  mov x0, sp               0x910003e0 0        31       mov  x0, sp
  mov x0, xzr              0xaa1f03e0 0        31       mov  x0, xzr
  add wsp, wsp, #16        0x110043ff 31       31       add  wsp, wsp, #0x10
                

Read the Rd and Rn columns. In add sp, sp, #16 the number 31 in both slots means the stack pointer. In str w0, [sp, #12] the 31 in Rn means the stack pointer again — and that row is the same encoding shape as mov x0, xzr, where the 31 in Rn means zero. Same five bits. Same architecture. Two answers, and the difference is which encoding group the word is in.

And the last row of the table is the one that settles what a decoder may do. add wsp, wsp, #16 is 0x110043ff, and add sp, sp, #16 is 0x910043ff — the same register, the same immediate, the same bits, differing only in bit 31. There is no wsp register. The name is a spelling the assembler will accept for the same 64-bit thing, and if you think of it as a 32-bit stack pointer you will compute a frame size that is wrong by a factor of two.

  str w0, [x31, #12]      REFUSED  -  invalid operand for instruction
                

The assembler will not let you name it in a base slot at all. In that slot, 31 is not a general register, and the assembly language does not pretend otherwise. So: a decoder that prints x31 in a base slot is not making a naming choice — it is reading a field the encoding has already given a different meaning, and there is no encoding in which that field means x31.

Two rows deserve a second reading, because they look like one instruction and are not even in the same group:

Both move a constant into x0. Same five-bit field, same architecture, two groups, two names, and the assembler prints the two names without the reader ever seeing that the field is shared. If you have been reading mov as “the instruction that loads a constant” and add and orr as two other instructions, this is where that model breaks: mov is not an instruction at all, it is a name with two meanings depending on which encoding group the assembler picked.

Where the two banks meet

One more row worth having, because it connects this concept to the argument registers and to the unwind table. The AAPCS64 numbers the registers r0–r30 in the specification, and the specification's own convention for 31 is not one register. SP is callee-saved (§6.1.1 lists r19-r29 and SP), which is why a callee may move it and must put it back, and the .eh_frame at the top of every AArch64 object says DW_CFA_def_cfa: reg31 +0 — reg31, in the CFI, is the stack pointer, using the same number as the instruction field that means zero. That is the hinge between this concept and the last one, and it is why the two pages are adjacent.

Worked: reading the field map, and what it cannot express

The encoding course measured the field map by assembling a sweep of variants and OR-ing the XOR, and its table has a row for the register number: Rd is bits[4:0]. That is true in every group — and it is the same five bits with three different meanings, which is why a field map cannot express the difference. A field map says which bits hold the number. It has nothing to say about what the number means, because the meaning is a property of the group and not of the field.

So the practical rule, and it is the one this course's own decoder is built on:

A REGISTER NAME IS NOT A PROPERTY OF A FIELD A field map can tell you Rd is bits[4:0]. It cannot tell you 31 in Rd is sp. It cannot tell you 31 in Rn is zero in a logical op and 31 in Rn is sp in an add. The group decides. Every model in the decoder therefore calls reg(n, ...) WITH the permission its own group has, rather than with one default: reg(n, allow_sp=True) add/sub, load/store reg(n, allow_sp=False) logical, data-processing and CBZ/CBNZ special-cases 31 to wzr/xzr entirely, because a compare has no "stack pointer" form.

That is retraction R15 from the encoding course, this time as design advice. A flag with a default is a flag nobody checks: the encoding course's logical-immediate model called the register-name function with its default permission, printed orr x9, sp, #0x8000000000000001 for a word the disassembler calls mov x9, #-0x7fffffffffffffff, and every value in the corpus still computed to something valid. A logical OR against the stack pointer is a different VALUE from a logical OR against zero — and the decoder wrote down a register that does not exist in that instruction.

Now the test that tells you whether you have understood this concept, because it is the one a compiler actually generates and the one nobody checks by hand. In the corpus:

$ llvm-objdump-21 --triple=aarch64 -d abi_O2.o | grep -c '\bw0\b\|\bx0\b'
  ... and the same count for wsp, xzr, sp, x31 in every slot
                

x0 and w0 appear constantly and alternate in the same function — a 64-bit add followed by a 32-bit compare, a pointer-sized arithmetic followed by an int truncation. Every one of those alternations is a zeroing of the top half, and the disassembly never says so, because there is no instruction that says so. A reader who is looking for evidence of truncation in the disassembly will not find it, because the evidence is the absence of a bit and the absence of a bit is invisible in a listing. This is the one place in this course where the correct answer to “how do I check this?” is “by the model, not by the bytes.”

Apply it

  1. Reproduce the one-bit table, then find the group where it is not one bit. Assemble mov w0, #1 / mov x0, #1, add, sub, mov w0, w1, mov w0, #-1 and mov w0, wzr in both widths, and XOR each pair. (Expect six pairs, every XOR exactly 0x80000000. Then assemble str w0, [x1] / str x0, [x1] / ldrb / ldrh and read bits[31:30] instead of bit 31: 0xb9000020, 0xf9000020, 0x39400020, 0x79400020 — a TWO-bit size field with four values (00 byte, 01 half, 10 word, 11 double), not a one-bit sf. So the width is one bit in the arithmetic and logical groups and two in the load/store groups, which is the same finding as the register-31 table in a different place: a field map marks bits, and the meaning of a field is a property of the group that owns it.)
  2. Write the bug this concept is about, and watch it not crash.(Expect: a C function whose parameters are long and whose return type is int works, silently, on a value above 231. Then inspect the disassembly and find the w/x alternation that causes it. The lesson is not “AArch64 is broken” — it is that the C language defines the conversion, the compiler obeyed, and the listing has nothing to show you because the zeroing is a bit and not an instruction.)
  3. Ask the assembler the question the table answers, and note that it refuses to lie.(Expect str w0, [x31, #12] to be REFUSED with “invalid operand for instruction” — the assembler will not name a general register in a slot where 31 is not one. And expect mov x0, sp and mov x0, xzr to be two different words in two different groups. The base register of an x-form or w-form load/store, or the first source of an ADD, is sp; the first source of an ORR is xzr. A decoder that has one table of register names for all five slots is wrong in at least one of them.)

Connect

Backwards, one concept. The calling convention gave you x0–x7 and d0–d7 as places; this concept is what those places are made of, and it is the reason the AAPCS64 can say “the result is returned in the same registers as would be used for such an argument” without a second sentence — because an integer argument and an integer result are the same 64 bits and a w/x mistake in either direction has the same consequence.

Across the architecture, and this is the closest neighbour: The encoding course's field map is the table this page is a footnote to, and the conditional-select inversion is the sharpest place the register-31 rule bites, because csinc x0, xzr, xzr, NE uses 31 in a source slot that must be zero in a group where 31 would otherwise be sp. The x86-64 integer concepts are the direct contrast: 32 registers, a separate 32-bit file, and a 32-bit write that leaves the top half alone.

Forwards. Concept 3 needs the number 31 as sp and nothing else, and its first question is what a function may do to the region below that number. Concept 4 adds the other sixteen registers — and the interesting part of that list is that the callee-saved set is the set whose names the CFI also quotes, so reg31 from this page is the same number as the DW_CFA_def_cfa: reg31 in concept 5.

render_lesson_js(&mut page)