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/wviews,xzrandsp; - 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
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
wNregister zeroes bits 32–63 ofxN.
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.
#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).
#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:
// 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
ret4. Addressing modes
All addresses are base (an x register or sp) plus something:
[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)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.
// 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:
// 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: retx0 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:
movz+movk:movzsets one 16-bit chunk (zeroing the rest), eachmovk("keep") inserts another chunk atlsl 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
movz writes one chunk and zeroes the others; each movk keeps the rest and fills one more.movn(move NOT) for values that are mostly ones:0xFFFFFFFFFFFF1234is onemovn x0, 0xedcb, which GCC prints asmov x0, -60876.Logical immediates:
orr/and/eoraccept a 13-bit encoded immediate that describes a repeating rotated run of ones — 0x00FF00FF00FF00FF, 0x5555555555555555, 0xFFFF0000… are all single instructions (mov x0, 71777214294589695is reallyorr 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:
// int g; int getg() { return g; }
getg:
adrp x0, g
ldr w0, [x0, :lo12:g]
retg 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 |
(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.