Keyboard Shortcuts

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

Four Bytes, and a Mnemonic That Is Not In There

24 minadvancedModule 1: The Surface: What the Instructions Are
24 min · AArch64: Encoding From The Ground Up

Why this matters

Almost everything written about AArch64 instruction encoding opens by telling you the length is fixed, and then moves on, and the reason to spend a concept on it is that the fixed length is not a detail of this encoding — it is what makes the encoding easy to decode and hard to encode. Every other design decision in the architecture is a consequence of a decision to spend 32 bits on every instruction, and you cannot evaluate any of them until you know what that purchase cost.

The purchase is not money. It is range: if every instruction is four bytes, then every field has to fit in 32 bits, and there is no escape to a second word. AArch64 therefore has a 12-bit add-immediate where x86-64 has an 8-bit one with a 32-bit escape, a 19-bit conditional branch where x86-64's is 8-bit-and-relative-to-16-bits, and a 21-bit PC-relative address — and each of those numbers is a consequence, not a choice. The next two concepts are about those fields. This one is about the constant underneath all of them.

The model: two properties, one of them free

Property one: the length is a constant

Five object files, four optimisation levels plus a hand-written corpus, 868 instructions:

$ ./a64dec.py --section=2 | sed -n '/file  /,/VERDICT/p'
  file              section   bytes    %4    insns   bytes/insn  chain
  a64.o             .text     540      0     135     4.0000      EXACTLY
  corpus_O0.o       .text     1352     0     338     4.0000      EXACTLY
  corpus_O1.o       .text     496      0     124     4.0000      EXACTLY
  corpus_O2.o       .text     588      0     147     4.0000      EXACTLY
  corpus_Os.o       .text     496      0     124     4.0000      EXACTLY

  868 instructions over 5 code sections.  Every section is a
  multiple of four, every walk ends exactly on the section end, and
  bytes/insn is 4.0000 in every row including the hand-written one.
  VERDICT: fixed width, on this corpus
                

Two columns matter more than the rest. The %4 column is zero in all five rows, so no section has a ragged tail. And chain closed EXACTLY is the artifact walking each section from its first byte to its last and landing on the end rather than near it — which is the property that has no analogue in the previous course. A decoder that guesses a length wrong in a variable-length encoding is reading garbage forever after, and there is no resynchronisation point. Here the next instruction starts four bytes on whether or not the previous one made sense.

So here is what it buys, stated as the negative space rather than the positive claim, because every item is a class of bug this decoder cannot have:

And what it costs, because the list above is one-sided

Four bytes is four bytes for nop, which does nothing. The x86-64 encoding has a one-byte nop and a two-byte ret; this one pays four bytes each. The artifact measured the whole of that and the answer is not “AArch64 is worse”:

$ ./a64dec.py --section=13 | sed -n '/-O0  a64/,/-Os  a64/p'
    -O0  a64  1352 bytes /  338 insns   x86  1174 bytes /  393 insns   ratio 1.152  AArch64 LARGER
    -O1  a64   496 bytes /  124 insns   x86   610 bytes /  200 insns   ratio 0.813  AArch64 smaller
    -O2  a64   588 bytes /  147 insns   x86   738 bytes /  236 insns   ratio 0.797  AArch64 smaller
    -Os  a64   496 bytes /  124 insns   x86   384 bytes /  151 insns   ratio 1.292  AArch64 LARGER

  THE RESULT, and it is not the one this course was drafted around.
  AArch64 is LARGER at 2 of the 4 levels.
                

Two of four. The first draft of this page asserted “AArch64 loses on code density”, and the measurement retracts it rather than softening it — the ratio changes sign with the optimisation flag, so it was never a property of the architecture. The defensible sentence is: fixed width costs code size when the code is spill-heavy and saves it when the code is not, and the -O0 row shows why in one line. AArch64 emitted fewer instructions than x86-64 (338 against 393) and still produced more bytes (1352 against 1174), which can only be true if the instructions it did not emit were cheap on the other side — and at -O0 they are, because every spilled parameter is a 4-byte store against a 2-or-3-byte one.

Property two: the mnemonic is not stored

What the encoding stores is an opcode and operands. The name you type is the assembler's, and the two are related by a table the architecture defines. Every row below was measured by assembling both lines and comparing the two 32-bit words — the only way to say what an alias actually stands for rather than what it looks like:

$ ./a64dec.py --section=2 | sed -n '/52824680/,/19 of 22/p'
    52824680  mov w0, #0x1234      == movz w0, #0x1234    MOVZ with hw = 0
    6b01001f  cmp w0, w1           == subs wzr, w0, w1    the flag-writing form, with
                                                       the destination thrown away
    3100041f  cmn w0, #1           == adds wzr, w0, #1    the same, on the other side
    4b0103e0  neg w0, w1           == sub w0, wzr, w1     the zero register as a SOURCE
    2a2103e0  mvn w0, w1           == orn w0, wzr, w1     NOT is OR with all ones -- and
                                                       the ZERO is the FIRST operand
    6a01001f  tst w0, w1           == ands wzr, w0, w1
    2a0103e0  mov w0, w1           == orr w0, wzr, w1     a MOVE IS AN OR with a zero
    9100001f  mov sp, x0           == add sp, x0, #0      register 31 is the STACK POINTER
    910003e0  mov x0, sp           == add x0, sp, #0      in this group
    1b027c20  mul w0, w1, w2       == madd w0, w1, w2, wzr   a MULTIPLY IS a three-source
                                                         with an addend of zero
    93407c20  sxtw x0, w1          == sbfm x0, x1, #0, #31   a sign extend, as a FIELD
    d3407c20  uxtw x0, w1          == ubfm x0, x1, #0, #31
    531d7020  lsl w0, w1, #3       == ubfm w0, w1, #29, #28  a shift is a bitfield whose
                                                            two halves are adjacent
    d65f03c0  ret                  == ret x30             and the link register is 30, so
                                                         it is the DEFAULT
    d503201f  nop                  == hint #0             TWENTY-SEVEN FIXED BITS and a
                                                         5-bit hint number
    11400420  add w0, w1, #0x1000  == add w0, w1, #1, lsl #12   one imm12 field, with a
                                                              SHIFT on it
    1a9f17e0  cset w0, eq          == csinc w0, wzr, wzr, ne   THE TRAP: the condition is
                                                                 INVERTED
    1a9f07e0  cset w0, ne          == csinc w0, wzr, wzr, eq
    5a9f13e0  csetm w0, eq         == csinv w0, wzr, wzr, ne
    1a811420  cinc w0, w1, eq      == csinc w0, w1, w1, ne   Rn == Rm, and again INVERTED
    5a81b420  cneg w0, w1, ge      == csneg w0, w1, w1, lt
    10ffffc0  adr x0, T            != 90000000 adrp x0, T  NOT AN ALIAS, and the row is
                                                          here for that reason

  19 of 22 pairs are the same 32 bits.
                

Read that for the architecture rather than for the aliases. cmp is a subs with a destination nobody keeps. mvn is an orn. mov is an orr. mul is a madd with a zero addend. lsl is a bitfield whose two halves happen to be adjacent. Every one of those is a name the architecture added on top of a smaller set of real encodings, and every one of them costs nothing: same four bytes, same decode cost, one more mnemonic for a human.

That is the general fact, and it runs both ways: the mnemonic is free and the mnemonic is also where the errors live. The row for mvn has a trap in it that is worth naming. The correct spelling is orn w0, wzr, w1 — zero first — and the natural guess orn w0, w1, wzr assembles to 2a3f0020, a different word. The two sources are in a fixed order in the encoding and the assembler does not accept the other one, so a reader who has the alias right and the operand order wrong gets a plausible instruction that is not the one they meant.

And one row is not an alias at all. adr x0, T and adrp x0, T are one bit apart — bit 31, which is sf in every other group in the architecture — and the difference in reach is 4,096. That is concept 2's subject, and it is the first of three places where bit 31 means something other than what its name suggests.

What the two properties buy together

Combine them and you get the thing no other 32-bit architecture in this collection has: an instruction whose length you know before you know what it is. That is what lets a decoder be a decision tree over a fixed width instead of a length table followed by an opcode table, and it is why this course's artifact is 21 models and the ISA course's decoder has a length arithmetic with a resynchronisation story.

Three things that are cheaper to state than to prove

This course has a habit the courses before it established, and it is worth showing before the worked example rather than after the last concept. Each of the three was asserted in a draft of this course. Each was measured. Each was retracted in public.

All seventeen retractions are printed in full by the artifact and asserted as text by the harness, so a later edit cannot quietly delete one.

Worked: reading one word, out loud

The artifact prints a derivation for every field it reads, which is the part a disassembler cannot do and the reason a disassembler is not evidence:

$ ./a64dec.py --why 1204cc00
    0x1204cc00   and      w0, w0, #0xf0f0f0f0
      class field bits[28:25] = 1001  ->  Data Processing -- Immediate
      . N = 0 and opc = 0, so the word spells AND.  There is no invert bit:
        clang emits AND for `bic w0,w0,#0xff` by negating the CONSTANT to
        0xffffff00, and the inversion is arithmetic, not encoding.
      . LOGICAL IMMEDIATE: len = the position of the highest set bit of
        N:NOT(imms) = 0b0001100, so esize = 1 << 3 = 8 bits
      . LOGICAL IMMEDIATE: S = imms & 0b111 = 3 is the WIDTH of the run of
        ones (S+1 = 4 of them); R = immr & 0b111 = 4 is how far RIGHT that
        run is rotated inside the element
      . LOGICAL IMMEDIATE: a run of 4 ones is 0b1111; rotate it right by 4
        within 8 bits and you get 0b11110000 = 0xf0
      . LOGICAL IMMEDIATE: repeat that element 8 times: the 64-bit pattern
        is 0xf0f0f0f0f0f0f0f0
      . LOGICAL IMMEDIATE: the destination is 32 bits, so the answer is the
        low 32 of that pattern: 0xf0f0f0f0
      . this decode READ 32 of the 32 bits; 0 were not needed to name the
        instruction
                

Every line is arithmetic on the bits and every line is checkable by hand with the constants beside it. That is the whole difference between a decoder and a disassembler: llvm-objdump-21 printed and w0, w0, #0xf0f0f0f0 for the same word in one line and cannot tell you how, and the twelve bits it used are not readable in its output at all. Concept 3 is entirely about those twelve bits.

And the derivation is printed by the recorded run, not only by --why on a command line. That is a rule rather than a preference: check_quotes.py reads only a64dec.out, so a figure this page quotes has to be in the file the harness can see. A page that quotes a derivation no harness can find is a page whose numbers are only checkable by the machine that wrote them.

One more thing this page can now state that the previous course could not. Register 31 means different things in different groups, and this is not a subtle point: in add and sub it is the stack pointer, in the logical groups it is the zero register, in a load's data slot it is the zero register while in a load's base slot it is the stack pointer, and in cbz's register slot it is the zero register. The rows above show mov sp, x0 as add sp, x0, #0 and mov w0, w1 as orr w0, wzr, w1 — both use register 31, and in the first it is the stack pointer and in the second it is zero. A decoder that decides this once gets a plausible answer in the common case and a wrong one exactly where a compiler is most likely to emit the instruction. That is retraction R15 and it was found by a cross-check, not by reading a manual.

Apply it

  1. Count the lengths yourself.llvm-objdump-21 --triple=aarch64 -d any AArch64 object and count the bytes on each line. (Expect exactly seven distinct instruction lengths on the x86-64 side of the same C and exactly one on the AArch64 side, and expect the x86 distribution to be dominated by one and three bytes. The AArch64 row is a single bar at 100% and that is the whole visual argument for the concept.)
  2. Break the alias in half and see which half you had. Assemble mov w0, w1 and orr w0, wzr, w1, then orr w0, w1, wzr. (Expect the first pair to be identical bytes and the third to be a different word — and expect the assembler to accept all three without complaint, because orr w0, w1, wzr is a perfectly good instruction that is not the one you meant. This is the general form of the mvn row on this page: a wrong operand order in an alias is a plausible wrong instruction, not a diagnostic.)
  3. Make the length lie and see what breaks. In a64dec.py, change decode_text to advance by 3 instead of 4. (Expect the section 2 table to report chain closed NO and a non-zero %4, the section 12 cross-check to collapse, and the poisoned number to move. This is the completion criterion in miniature: the only claim in this course you can break with a one-character edit, and breaking it is how you learn that the claim was a measurement.)

Connect

Backwards, isa-length derived the variable-length property this page is the negation of, and the ELF course's section-header concept is where the reader that walks these 868 words comes from — struct.unpack_from at 0x28 and the section array at 0x3a, with no library. elf-section-vs-segment is why the artifact finds .text by NAME and by the execute flag rather than by index.

Forwards, the field map is where bits[28:25] is measured, the immediates is where those twelve unreadable bits are unpacked, the conditions is where the cset row at the bottom of the alias table becomes a whole concept, and the cross-check is where the register-31 bug from this page is found.

render_lesson_js(&mut page)