CodeRigor

FREE LESSON

AArch64 registers, loads and constants

Read and write AArch64 data movement: x/w registers and the zero register, sized and sign-extending loads, the register-offset, pre- and post-index addressing modes, building 64-bit constants with movz/movk, and bitfield extracts.

AArch64 (ARM's 64-bit architecture — Apple silicon, AWS Graviton, phones, Raspberry Pi 4/5) is a load/store machine: arithmetic only ever works on registers, and memory is touched only by load and store instructions. After x86-64, where almost any instruction can take a memory operand, that sounds limiting. In practice it makes compiler output easier to read: every instruction does one thing, every instruction is 4 bytes, and the addressing modes are few and regular.

This lesson covers the vocabulary of every later AArch64 lesson:

  • the 31 general registers, their x/w views, xzr and sp;
  • sized loads and stores, and which ones sign- or zero-extend;
  • the addressing modes: immediate offset, register offset with shift or extend, and pre-/post-index with writeback;
  • how a 64-bit constant is built (movz/movk, movn, logical immediates);
  • globals via adrp + :lo12:;
  • bitfield instructions: ubfx, sbfx, bfi.

Everything in this track is compiled with aarch64-linux-gnu-g++ and runs under qemu in user mode. The runnable snippets really execute AArch64 code. In the Playground, pick AArch64 in the compiler explorer to see the listings, and hover a mnemonic for its meaning.

GCC prints AArch64 assembly as op dst, src1, src2 — the destination first, like Intel syntax on x86. Immediates may appear with or without # (add x0, x0, 3 and add x0, x0, #3 are the same instruction).

1. The registers

The 31 general registers, by role
Hover a register for its job. sp and xzr share encoding 31; pc isn't a general register at all.
The 31 general registers, by role
Hover a register for its job. sp and xzr share encoding 31; pc isn't a general register at all.

Each wN is the low 32 bits of xN. There are no 8- or 16-bit register names: bytes and halfwords exist only as memory sizes.

xzr and sp share the same encoding (register number 31); the instruction decides which one it means. That's why mov x0, sp and mov x0, xzr are different instructions, and why some instructions can use sp but not xzr or vice versa. You rarely need to care — but it explains the zero register: str xzr, [x0] stores a zero without needing a register holding 0.

2. 32-bit writes zero the top half

The same rule as x86-64, and for the same reason (no partial-register dependencies):

Every write to a wN register zeroes bits 32–63 of xN.

w0 is the low half of x0, and writing it clears the rest
start / 2
Step through write_w below: after mov w0, 7 the upper 32 bits are zero, not the old ones.
w0 is the low half of x0, and writing it clears the rest
Step through write_w below: after mov w0, 7 the upper 32 bits are zero, not the old ones.

So uxtw x0, w0 (an alias of mov w0, w0 in effect) is how an unsigned becomes an unsigned long, and sxtw x0, w0 copies bit 31 upwards for int → long. As on x86, an int argument only defines the low 32 bits of its register; the top may be junk, so widening is never free to skip.

C++
#include <cstdio>

extern "C" unsigned long write_w();
extern "C" long sign_ext(int x);
asm(R"(
    .text
    .globl write_w
write_w:
    mov x0, -1          // all 64 bits set
    mov w0, 7           // 32-bit write: upper half cleared
    ret
    .globl sign_ext
sign_ext:
    sxtw x0, w0         // int -> long
    ret
)");

int main() {
    std::printf("%#lx\n", write_w());       // 0x7
    std::printf("%ld\n", sign_ext(-5));     // -5
}

3. Loads and stores

instruction moves extension
ldr xN, [..] / str xN 8 bytes —
ldr wN, [..] / str wN 4 bytes zero (32-bit write)
ldrsw xN, [..] 4 bytes sign, to 64
ldrh wN / strh 2 bytes zero
ldrsh wN (or xN) 2 bytes sign
ldrb wN / strb 1 byte zero
ldrsb wN (or xN) 1 byte sign
ldp/stp two registers loads/stores a pair

So return *p; for a const signed char* returning int is ldrsb w0, [x0], and for const unsigned short* it's ldrh w0, [x0]. Compare x86's movsx/movzx: on AArch64 the extension is part of the load.

Plain char is unsigned on AArch64 Linux. The C++ standard leaves the signedness of char to the platform; x86-64 Linux says signed, AArch64 Linux says unsigned. int c = *p; with const char* p is ldrb on ARM and movsx on x86, and (char)200 is 200 on ARM, −56 on x86. Portable code that cares uses signed char / unsigned char (or std::byte).

C++
#include <cstdio>

int main() {
    char c = (char)200;
    std::printf("%d %d\n", c, c < 0);   // 200 0 on AArch64; -56 1 on x86-64
}

ldp/stp load or store two registers from consecutive memory, which compilers use for pairs of fields, for spilling registers in prologues, and for unrolled loops:

Assembly
// long sum2(const long* p) { return p[0] + p[1]; }
sum2:
    ldp   x1, x0, [x0]      // x1 = p[0], x0 = p[1]
    add   x0, x1, x0
    ret

4. Addressing modes

All addresses are base (an x register or sp) plus something:

Text
 [x0]                 x0
 [x0, 16]             x0 + 16                    immediate offset
 [x0, x1, lsl 3]      x0 + (x1 << 3)             register offset, scaled
 [x0, w1, sxtw 2]     x0 + (sign-extend w1) << 2 32-bit index, signed
 [x0, w1, uxtw 2]     x0 + (zero-extend w1) << 2 32-bit index, unsigned
 [x0, 16]!            x0 += 16, then use x0      pre-index (writeback)
 [x0], 16             use x0, then x0 += 16      post-index (writeback)
ldr x0, [x0, x1, lsl 3]: base plus a scaled index
With long* a in x0 and i = 3 in x1, the address is x0 + (3 << 3) = x0 + 24 — exactly &a[3].
ldr x0, [x0, x1, lsl 3]: base plus a scaled index
With long* a in x0 and i = 3 in x1, the address is x0 + (3 << 3) = x0 + 24 — exactly &a[3].

The scale in a register-offset load can only be 0 or the access size: lsl 3 for an 8-byte load, lsl 2 for 4 bytes. That covers a[i]; a 16-byte struct stride needs a separate add x1, x0, x1, lsl 4 first. Most data-processing instructions accept a shifted second operand, so that add does the multiply-by-16 for free.

Assembly
// long element(const long* a, long i)      { return a[i]; }
    ldr   x0, [x0, x1, lsl 3]
// int elem32(const int* a, int i)          { return a[i]; }
    ldr   w0, [x0, w1, sxtw 2]   // the int index is sign-extended in the load
// long second_of(const Pair* p, long i)     { return p[i].second; }   // Pair = {long, long}
    add   x1, x0, x1, lsl 4
    ldr   x0, [x1, 8]

The sxtw inside the address is exactly the widening an int index needs: x86 needs a separate movsxd for it. With an unsigned index you get uxtw.

Writeback modes update the base register as a side effect. GCC uses pre-index for pointer-walking loops and for pushing frame records:

Assembly
// const char* end(const char* p) { while (*p) ++p; return p; }
end:
    ldrb  w1, [x0]
    cbz   w1, 2f
1:  ldrb  w1, [x0, 1]!      // x0 += 1, then load the byte at the new x0
    cbnz  w1, 1b
2:  ret
Pre-index walks the pointer: ldrb w1, [x0, 1]!
start / 4
Each iteration adds 1 to x0 before loading, so the loop needs no separate add.
Pre-index walks the pointer: ldrb w1, [x0, 1]!
Each iteration adds 1 to x0 before loading, so the loop needs no separate add.

and stp x29, x30, [sp, -32]! (allocate 32 bytes and store the frame record) / ldp x29, x30, [sp], 32 (load it back and free the space).

5. Building constants

Every instruction is 32 bits wide, so a 64-bit constant can't be an operand. There are three tricks, tried in order of cost:

  1. movz + movk: movz sets one 16-bit chunk (zeroing the rest), each movk ("keep") inserts another chunk at lsl 16/32/48. At most four instructions for any constant:

    // return 0x123456789ABCDEF0;
    mov   x0, 57072              // movz x0, 0xdef0
    movk  x0, 0x9abc, lsl 16
    movk  x0, 0x5678, lsl 32
    movk  x0, 0x1234, lsl 48
    
Four 16-bit chunks, one instruction each
start / 4
movz writes one chunk and zeroes the others; each movk keeps the rest and fills one more.
Four 16-bit chunks, one instruction each
movz writes one chunk and zeroes the others; each movk keeps the rest and fills one more.
  1. movn (move NOT) for values that are mostly ones: 0xFFFFFFFFFFFF1234 is one movn x0, 0xedcb, which GCC prints as mov x0, -60876.

  2. Logical immediates: orr/and/eor accept a 13-bit encoded immediate that describes a repeating rotated run of ones — 0x00FF00FF00FF00FF, 0x5555555555555555, 0xFFFF0000… are all single instructions (mov x0, 71777214294589695 is really orr x0, xzr, #0x00ff00ff00ff00ff). 0x123456789ABCDEF0 is not such a pattern, which is why it needs four instructions. Arithmetic immediates (add, sub, cmp) are much smaller: 12 bits, optionally shifted left by 12.

Compilers also have a fourth option, a literal pool (ldr x0, =value puts the constant in memory next to the code), which costs a load; GCC prefers the mov sequences.

6. Globals: adrp + :lo12:

Code is position independent and instructions are 4 bytes, so a global's address is built in two steps: adrp gets the address of its 4 KiB page relative to the pc (±4 GiB range), and the low 12 bits go into the load's offset:

Assembly
// int g;  int getg() { return g; }
getg:
    adrp  x0, g
    ldr   w0, [x0, :lo12:g]
    ret
adrp finds the page, :lo12: adds the offset inside it
For g at 0x00411a3c: adrp produces 0x00411000 (the pc-relative 4 KiB page), the load adds 0xa3c.
adrp finds the page, :lo12: adds the offset inside it
For g at 0x00411a3c: adrp produces 0x00411000 (the pc-relative 4 KiB page), the load adds 0xa3c.

That's the AArch64 equivalent of x86's RIP-relative mov eax, [rip+g].

7. Bitfields: ubfx, sbfx, bfi

Shifts and masks of contiguous bits are single instructions:

C++ AArch64
(x >> 4) & 0xff (unsigned) ubfx w0, w0, 4, 8 — extract 8 bits from bit 4, zero-extend
(x << 20) >> 24 (signed int) sbfx w0, w0, 4, 8 — same, sign-extend
(x & ~0xF0) | ((y & 0xF) << 4) bfi w0, w1, 4, 4 — insert 4 bits of y at bit 4
(x & 0xff) << 4 ubfiz w0, w0, 4, 8 — insert into zeros
ubfx w0, w0, 4, 8: take 8 bits starting at bit 4
One instruction for (x >> 4) & 0xff: x = 0xabc gives 0xab. sbfx would copy bit 11 (the field's top bit) upwards instead of zeros.
ubfx w0, w0, 4, 8: take 8 bits starting at bit 4
One instruction for (x >> 4) & 0xff: x = 0xabc gives 0xab. sbfx would copy bit 11 (the field's top bit) upwards instead of zeros.

uxtb, uxth, sxtb, sxth, sxtw are just bitfield extracts starting at bit 0, with friendlier names.

8. Reading tip

When an AArch64 listing looks long, look for the loads and stores first — they're the only instructions that touch memory, and they tell you the data layout ([x0, 8] is the second 8-byte field). Then the arithmetic in between is just register shuffling. The build task asks you to write a handful of these by hand: after that, compiler output reads like prose.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Ten-minute crash course in AArch64: registers, ldr/str, a hello world on Linux.