ARM / AArch64 Assembly Basics
AArch64 is a clean, regular, load-store RISC architecture that runs the world's mobile and server silicon — learning it unlocks kernel development, compiler output, and correct concurrent programming on modern hardware.
Overview
AArch64 — the 64-bit execution state of the ARM architecture — is arguably the most important assembly language a systems programmer can learn today. It runs on every iPhone, every Android flagship, Apple's M-series laptops, AWS Graviton servers, Raspberry Pi 4 and 5, and an ocean of embedded controllers. When the Linux kernel boots on most new hardware, AArch64 is the machine language it speaks first.
This module treats AArch64 assembly as what it actually is: a beautifully regular, carefully designed ISA built from first principles. Unlike x86, which accumulated 45 years of backward-compatible cruft, AArch64 was designed with a clean slate in 2011 as part of the ARMv8-A architecture. Every design decision has a reason, and understanding those reasons will make you a better programmer even when you never write a single line of assembly yourself — because you will read compiler output, debug kernel crashes, and read architecture manuals for the rest of your career.
We start with the register file, then move to the memory model, then to the instruction set. Along the way we compare carefully with x86-64 so you can build intuition by contrast. By the end, you will be able to read real kernel disassembly, understand what the compiler did and why, and write small AArch64 programs from scratch.
One important orientation: assembly language is a thin textual skin over machine code. There is almost a one-to-one relationship between an assembly instruction and a binary encoding. When you write ADD X0, X1, X2, the assembler produces exactly four bytes. Understanding that concreteness — that every instruction is a fixed-width 32-bit word in AArch64 — is the first key insight.
The Linux kernel contains hundreds of thousands of lines of architecture-specific assembly, and AArch64 is one of the two primary targets (alongside x86-64). Every architecture port in arch/arm64/ — boot code, exception vectors, context switching, TLB flushes, atomic operations, cryptography — is written in AArch64 assembly or in C that you can only correctly read if you understand what the compiler emits. When a kernel oops prints a register dump, you need to know what X0 through X30 mean, what sp and pc are, and what the ESR_EL1 syndrome register is telling you.
Beyond the kernel, understanding AArch64 is the foundation for writing correct concurrent code on ARM systems, since ARM's memory model is weaker than x86's — loads and stores can be reordered in ways that will silently break lock-free code written by someone who only ever programmed for x86. The ldrex/stxr exclusive monitor, load-acquire/store-release instructions, and DMB/DSB/ISB barriers are not optional knowledge; they are what stands between a working kernel and a race condition that only appears on real hardware at 3 AM.
AArch64 Exception Level Transitions
AArch64 privilege levels and exception flow. Each upward arrow is a hardware exception (SVC/fault/IRQ/HVC/SMC); each downward arrow is ERET. ELR and SPSR at each level save the return PC and flags, enabling clean nested exception handling. Linux lives at EL1; user processes at EL0.
The concepts
01The AArch64 Register File
AArch64 gives you 31 general-purpose integer registers, each 64 bits wide. They are named X0–X30. You can also refer to the bottom 32 bits of any of them as W0–W30 — writing to a W register zero-extends into the full X register automatically, which is a deliberate design choice to avoid partial-register stalls that plague x86.
Two special registers complete the integer file:
- XZR / WZR: the zero register. Reading it always yields 0; writing to it discards the result. This eliminates a whole class of instructions (
CMPis reallySUBSwith the destination being XZR). - SP (stack pointer): not a general-purpose register in the usual sense. It must be 16-byte aligned when used as a memory address (the hardware enforces this at EL0). There is also WSP for 32-bit stack operations.
Separate from the integer file:
- PC (program counter): not directly readable or writable by most instructions. You cannot do
MOV X0, PC— instead theADR/ADRPinstructions let you compute PC-relative addresses. - LR (link register) = X30: stores the return address on a
BL(branch-and-link) call. - NZCV (flags register): four condition bits — Negative, Zero, Carry, oVerflow — set by instructions ending in
S(e.g.ADDS,SUBS,CMP).
The floating-point / SIMD side has V0–V31, each 128 bits. They are also accessible as D0–D31 (64-bit doubles), S0–S31 (32-bit floats), H0–H31 (16-bit half), and B0–B31 (8-bit). The SIMD unit and FPU share this register file.
Calling convention (AAPCS64): X0–X7 pass arguments and return values. X9–X15 are caller-saved temporaries. X19–X28 are callee-saved. X29 is the frame pointer. X30 is the link register. This is standardized by ARM's AAPCS64 ABI.
Compare with x86-64: x86 has only 16 general-purpose registers (RAX–R15), and they have legacy 8/16/32-bit aliases with complex partial-register forwarding rules. AArch64's orthogonal W/X duality is cleaner and faster.
// See register names in action
// Compile: aarch64-linux-gnu-gcc -O1 -S reg_demo.c
long add_and_zero(long a, long b) {
// a → X0, b → X1 (calling convention)
return a + b; // result in X0
}
// Compiler output (roughly):
// add_and_zero:
// ADD X0, X0, X1 // X0 = X0 + X1
// RET // return via LR (X30)
02Load-Store Architecture: Why Memory Operations Are Special
AArch64 is a load-store architecture. This is a fundamental RISC principle: arithmetic instructions operate only on registers. To work with memory, you must first load a value into a register, operate on it, then (if needed) store it back. There is no ADD [mem], reg like on x86.
This design has deep consequences:
- Pipelines love it. An instruction either touches registers (fast, predictable latency) or touches memory (slow, variable latency). Mixing them, as CISC does, makes hazard detection and out-of-order scheduling harder.
- Compilers love it. Register allocation is simpler when you know exactly which instructions touch memory.
- Programmers must be explicit. Every cache miss is a visible
LDRinstruction — profiling is easier.
Load instructions:
LDR X0, [X1] // load 8 bytes from address in X1 → X0
LDR W0, [X1] // load 4 bytes, zero-extend to 64 bits
LDRB W0, [X1] // load 1 byte, zero-extend
LDRSB X0, [X1] // load 1 byte, sign-extend to 64 bits
LDRH W0, [X1] // load 2 bytes (halfword)
Addressing modes:
LDR X0, [X1] // base register
LDR X0, [X1, #8] // base + immediate offset (max ±4096 bytes)
LDR X0, [X1, X2] // base + register offset
LDR X0, [X1, X2, LSL #3] // base + (X2 << 3) — scaled index
LDR X0, [X1, #8]! // pre-index: X1 += 8, then load from new X1
LDR X0, [X1], #8 // post-index: load from X1, then X1 += 8
Pre/post-index modes are how AArch64 efficiently implements push/pop on a stack. There is no PUSH/POP instruction — you use STR X0, [SP, #-16]! (push) and LDR X0, [SP], #16 (pop).
Pair instructions load or store two registers atomically in one instruction, critical for efficiency:
STP X29, X30, [SP, #-16]! // push frame pointer and link register
LDP X29, X30, [SP], #16 // pop both
Literal (PC-relative) loads allow loading from a constant pool near the instruction:
LDR X0, =0xDEADBEEF // assembler pseudo-instruction
// becomes: ADRP X0, page; ADD X0, X0, :lo12:constant_pool
// Function that loads an array element
// int get_elem(int *arr, int index) { return arr[index]; }
//
// AArch64 output:
// get_elem:
// LDR W0, [X0, W1, SXTW #2] // load arr[index]
// RET
//
// Breakdown:
// X0 = arr pointer
// W1 = index (32-bit)
// SXTW = sign-extend W1 to 64 bits
// #2 = shift left by 2 (multiply by sizeof(int) = 4)
// Net: address = arr + (int64_t)index * 4
long get_elem(int *arr, int index) {
return arr[index];
}
03Condition Flags and Conditional Execution
AArch64 has four condition flags stored in the NZCV system register:
- N (Negative): set when the result's MSB is 1 (result is negative in two's-complement).
- Z (Zero): set when the result is exactly zero.
- C (Carry): set when there is an unsigned overflow (carry out of bit 63).
- V (oVerflow): set when there is signed overflow (carry into but not out of bit 63, or vice versa).
Only instructions with the S suffix update flags: ADDS, SUBS, ANDS, CMP (which is SUBS XZR, ...), CMN (which is ADDS XZR, ...), TST (which is ANDS XZR, ...). Regular ADD, SUB, AND leave flags untouched. This is intentional — it means flag-setting is explicit and you can safely use arithmetic in the middle of a comparison sequence.
Condition codes (used by B.cond, CSEL, CCMP, etc.):
| Code | Meaning | Flags |
|---|---|---|
| EQ | Equal | Z=1 |
| NE | Not equal | Z=0 |
| CS/HS | Carry set / unsigned higher-or-same | C=1 |
| CC/LO | Carry clear / unsigned lower | C=0 |
| MI | Minus (negative) | N=1 |
| PL | Plus (non-negative) | N=0 |
| VS | Overflow set | V=1 |
| VC | Overflow clear | V=0 |
| HI | Unsigned higher | C=1 && Z=0 |
| LS | Unsigned lower or same | C=0 || Z=1 |
| GE | Signed greater-or-equal | N==V |
| GT | Signed greater-than | Z=0 && N==V |
| LE | Signed less-or-equal | Z=1 || N!=V |
| LT | Signed less-than | N!=V |
| AL | Always (default) | — |
Critical AArch64 difference from ARM32: In 32-bit ARM, almost every instruction could be predicated with a condition code suffix (ADDEQ, MOVNE, etc.). AArch64 removed general predication. Only a small set of instructions support conditions: B.cond, CSEL, CSINC, CSINV, CSNEG, CCMP, CCMN. This simplifies the hardware decoder considerably and was a deliberate ARMv8 design trade-off.
CSEL (conditional select) is the workhorse for branchless code:
CMP X0, X1
CSEL X2, X3, X4, LT // X2 = (X0 < X1) ? X3 : X4
This compiles a ternary expression without a branch, avoiding branch misprediction penalties.
// Branchless abs() using CSEL
// long my_abs(long x) { return x < 0 ? -x : x; }
//
// AArch64:
// my_abs:
// NEG X1, X0 // X1 = -X0
// CMP X0, #0
// CSEL X0, X1, X0, LT // X0 = (X0 < 0) ? X1 : X0
// RET
//
// No branch at all — CSEL makes this data-dependent, not control-dependent
long my_abs(long x) {
return x < 0 ? -x : x;
}
04Core Arithmetic and Logical Instructions
AArch64's arithmetic instructions are regular: three-operand, destination first. The operand width is determined by the register name (X = 64-bit, W = 32-bit).
Arithmetic:
ADD X0, X1, X2 // X0 = X1 + X2
ADD X0, X1, #42 // X0 = X1 + 42 (immediate, 12-bit, optionally shifted)
SUB X0, X1, X2 // X0 = X1 - X2
MUL X0, X1, X2 // X0 = X1 * X2 (low 64 bits)
UMULH X0, X1, X2 // X0 = high 64 bits of X1*X2 (unsigned)
SMULH X0, X1, X2 // signed version
UDIV X0, X1, X2 // X0 = X1 / X2 (unsigned)
SDIV X0, X1, X2 // signed divide
MSUB X0, X1, X2, X3 // X0 = X3 - X1*X2 (multiply-subtract, very useful)
Logical:
AND X0, X1, X2 // bitwise AND
ORR X0, X1, X2 // bitwise OR
EOR X0, X1, X2 // bitwise XOR
BIC X0, X1, X2 // X0 = X1 & ~X2 (bit clear)
MOVN X0, #0xFF // X0 = ~0xFF (move NOT immediate)
Shifts are modifiers on a source operand, not separate instructions (though shift-as-instruction aliases exist):
ADD X0, X1, X2, LSL #3 // X0 = X1 + (X2 << 3)
ADD X0, X1, X2, LSR #1 // logical shift right (unsigned)
ADD X0, X1, X2, ASR #2 // arithmetic shift right (sign-extending)
MOV is an alias for several instructions:
MOV X0, X1→ alias forORR X0, XZR, X1MOV X0, #imm→ alias forMOVZ(move zero: zero the register, set 16-bit field)MOVK X0, #imm, LSL #16→ move keep: set 16 bits without disturbing the rest
To load a 64-bit immediate, you need up to four MOVK / MOVZ instructions (four 16-bit chunks). Compilers often use ADRP + ADD for addresses instead, since those are PC-relative and relocatable.
Bit manipulation:
CLZ X0, X1 // count leading zeros (critical for log2, priority queues)
RBIT X0, X1 // reverse bits
REV X0, X1 // reverse bytes (endian swap 64-bit)
REV32 X0, X1 // reverse bytes within each 32-bit word
EXTR X0, X1, X2, #imm // extract: concatenate X1:X2, shift right by imm
No increment/decrement instructions. ADD X0, X0, #1 is the idiom. This uniformity is intentional.
// Count set bits (popcount) manually, showing RBIT+CLZ trick
// In practice: use __builtin_popcountl() which maps to hardware VCNT
int count_bits(unsigned long x) {
int count = 0;
while (x) {
count += x & 1;
x >>= 1;
}
return count;
}
// AArch64 assembly for a 5-instruction floor(log2(x)) using CLZ:
// CLZ X1, X0 // X1 = number of leading zeros in X0
// MOV X2, #63
// SUB X0, X2, X1 // X0 = 63 - CLZ(x) = floor(log2(x)) for x>0
// RET
05Branching, Procedure Calls, and the Link Register
AArch64 has a simple, orthogonal branching model.
Unconditional branch:
B label // PC-relative, ±128 MB range
BR X0 // branch to address in register (indirect)
Conditional branch:
B.EQ label // branch if Z==1
B.LT label // branch if signed less-than
CBZ X0, label // compare and branch if zero (no flag set needed)
CBNZ X0, label // compare and branch if non-zero
TBZ X0, #3, label // test bit 3 of X0, branch if zero
TBNZ X0, #3, label // test bit 3, branch if non-zero
CBZ/CBNZ and TBZ/TBNZ are extremely common in kernel code. They avoid setting flags and have ±1 MB (CBZ) or ±32 KB (TBZ) range, keeping hot loops tight.
Procedure calls:
BL label // branch-and-link: LR = PC+4, PC = label
BLR X0 // branch-and-link to address in register (indirect call)
RET // PC = LR (X30) — NOT a stack pop!
RET X0 // return to address in X0 (unusual but valid)
The link register model means that leaf functions (functions that don't call anyone else) never touch the stack at all. BL sets X30 = return address, the function does work, RET reloads PC from X30. This is faster than x86's CALL/RET which always push/pop the stack.
Non-leaf functions must save LR on the stack because the next BL would overwrite it:
my_func:
STP X29, X30, [SP, #-16]! // push frame pointer and LR
MOV X29, SP // set up frame pointer
// ... do work, call other functions ...
LDP X29, X30, [SP], #16 // pop frame pointer and LR
RET // return
PC-relative addressing (ADRP/ADD) is how AArch64 accesses global data. ADRP aligns the PC to a 4 KB page boundary and adds a 21-bit page offset, giving you the page of a symbol. ADD then adds the bottom 12 bits. Together they can reach any address within ±4 GB of the current PC — sufficient for position-independent code (PIE) and the kernel's virtual address space.
// Fibonacci: shows BL/RET, stack frame, and CBZ
// long fib(long n)
fib:
CMP X0, #1
B.LS .Lbase // if n <= 1, return n (base case)
STP X29, X30, [SP, #-32]! // push frame
MOV X29, SP
STR X0, [SP, #16] // save n
SUB X0, X0, #1 // arg = n-1
BL fib // fib(n-1)
LDR X1, [SP, #16] // reload n
STR X0, [SP, #24] // save fib(n-1)
SUB X0, X1, #2 // arg = n-2
BL fib // fib(n-2)
LDR X1, [SP, #24]
ADD X0, X0, X1 // fib(n-1) + fib(n-2)
LDP X29, X30, [SP], #32 // pop frame
RET
.Lbase:
RET // return n directly
06The Memory Model and Barriers: ARM's Weak Ordering
This is where ARM systems bite programmers who only know x86. x86-64 has a Total Store Order (TSO) memory model: loads are not reordered with other loads, stores are not reordered with other stores, and loads are not reordered with earlier stores. This means lock-free code written naively often works on x86 by accident.
ARM uses a weakly ordered memory model. The hardware can reorder loads and stores almost arbitrarily in the absence of explicit barriers, as long as single-threaded semantics are preserved. Two CPU cores reading and writing shared memory without barriers can observe each other's writes in an order completely different from program order.
Memory barrier instructions:
DMB ISH // data memory barrier, inner shareable domain
// all loads/stores before this are globally visible before those after
DSB ISH // data synchronization barrier — stronger: also waits for completion
ISB // instruction synchronization barrier: flushes pipeline, refetches
// required after writing to system registers or modifying page tables
Acquire/Release semantics (preferred over raw barriers):
LDAR X0, [X1] // load-acquire: no load/store after can be reordered before this
STLR X0, [X1] // store-release: no load/store before can be reordered after this
These map directly to C11's atomic_load(, memory_order_acquire) and atomic_store(, memory_order_release). They are the correct primitive for implementing mutexes and lock-free queues.
Exclusive access (for atomics and mutexes):
LDXR W0, [X1] // load exclusive: marks X1 as exclusive monitor
STXR W2, W0, [X1] // store exclusive: W2=0 if success, W2=1 if monitor lost
The exclusive monitor is hardware state: if another core writes to the same cache line between your LDXR and STXR, the STXR fails and you loop. This is the foundation of spinlock_t in the kernel:
// Simplified kernel spinlock acquire (arch/arm64/include/asm/spinlock.h)
static inline void arch_spin_lock(arch_spinlock_t *lock) {
unsigned int tmp;
arch_spinlock_t lockval, newval;
asm volatile(
"1: ldaxr %w0, %2\n" // load-acquire exclusive
" add %w1, %w0, #(1 << 16)\n"
" stxr %w3, %w1, %2\n" // store exclusive
" cbnz %w3, 1b\n" // retry if store failed
...
);
}
The ISB is required after MSR (write to system register). If you modify the page table base register (TTBR0_EL1) and forget ISB, subsequent instruction fetches may still use the old mapping — a hard-to-debug bug that only appears on real hardware.
// Correct producer/consumer with acquire/release
// (C11 atomics compile to LDAR/STLR on AArch64)
#include <stdatomic.h>
int data;
atomic_int ready = ATOMIC_VAR_INIT(0);
void producer(void) {
data = 42; // write shared data
// store-release: data write cannot reorder after this
atomic_store_explicit(&ready, 1, memory_order_release);
}
void consumer(void) {
// load-acquire: no load below can reorder before this
while (atomic_load_explicit(&ready, memory_order_acquire) == 0)
; // spin
// guaranteed to see data == 42 here
int val = data;
}
07Exception Levels and System Registers
AArch64 defines four Exception Levels (EL) that implement privilege separation, analogous to x86's rings but cleaner:
| Level | Name | Typical Use |
|---|---|---|
| EL0 | Unprivileged | User-space applications |
| EL1 | Privileged | OS kernel (Linux) |
| EL2 | Hypervisor | KVM, Xen, firmware |
| EL3 | Secure Monitor | TrustZone, ARM Trusted Firmware |
The Linux kernel runs at EL1. User programs run at EL0. When a user program makes a system call, takes a page fault, or gets an interrupt, the processor takes an exception, atomically saves state, and jumps to a vector in the VBAR_EL1 (Vector Base Address Register). The kernel's exception vector table is in arch/arm64/kernel/entry.S.
System registers are accessed via MRS/MSR (move register to/from system register):
MRS X0, MPIDR_EL1 // read CPU affinity (which core am I on?)
MRS X0, ESR_EL1 // exception syndrome: why did the exception happen?
MRS X0, FAR_EL1 // faulting address register: what address caused fault?
MRS X0, CurrentEL // what EL are we at right now?
MSR TTBR0_EL1, X0 // write page table base for EL0
ISB // required after TTBR write!
SPSR_EL1 (saved program status register) and ELR_EL1 (exception link register) hold the pre-exception NZCV/DAIF flags and the return PC, respectively. ERET restores both and returns from exception.
DAIF flags (the interrupt mask): D (debug), A (SError/abort), I (IRQ), F (FIQ). MSR DAIFSet, #0x2 masks IRQs; MSR DAIFClr, #0x2 unmasks them. The kernel does this in local_irq_disable() / local_irq_enable().
This model is why the kernel entry path in entry.S is so carefully written — it must save the entire register file (X0–X30, SP, PC, NZCV) into a struct pt_regs on the kernel stack before it can do anything else, and restore it precisely before ERET.
// Simplified kernel entry for SVC (system call) from EL0
// arch/arm64/kernel/entry.S (conceptual)
el0_sync: // EL0 synchronous exception handler
kernel_entry 0 // macro: save all registers to pt_regs on stack
mrs x25, esr_el1 // read exception syndrome register
lsr x24, x25, #ESR_ELx_EC_SHIFT // extract exception class
cmp x24, #ESR_ELx_EC_SVC64 // is it a system call?
b.eq el0_svc // yes: handle syscall
b el0_dbg // no: other exception
el0_svc:
bl do_el0_svc // C handler: looks up syscall table
// ... eventually ...
kernel_exit 0 // macro: restore registers, ERET back to EL0
08AArch64 vs x86-64: Key Differences
Understanding the contrast sharpens both. This is not a contest — both ISAs run the world — but the differences reveal design philosophy.
Instruction width:
- AArch64: all instructions are exactly 32 bits (4 bytes). Fixed-width instructions simplify fetch, decode, and disassembly enormously. You can always tell where instructions are.
- x86-64: variable-width, 1–15 bytes. Decoding x86 requires a complex finite state machine. This is why Intel's front-end has a dedicated Instruction Length Decoder.
Number of registers:
- AArch64: 31 general-purpose + 32 SIMD/FP = 63 architectural registers.
- x86-64: 16 general-purpose + 16 XMM/YMM/ZMM = 32 architectural (plus many implicit). More registers means less register spilling to memory, which means faster code. This matters enormously for compilers.
Condition code handling:
- AArch64: flags updated only by instructions with
Ssuffix. Most instructions don't touch flags. - x86-64: most arithmetic instructions implicitly update 6 flag bits (CF, PF, AF, ZF, SF, OF). This creates dependencies the CPU must track.
Memory access:
- AArch64: load-store only. No memory-to-memory or memory-to-register arithmetic.
- x86-64:
ADD RAX, [RBX]is a single instruction. This is CISC heritage.
Stack operations:
- AArch64: no
PUSH/POP. UsesSTR/LDRwith pre/post-index. Requires 16-byte stack alignment. - x86-64:
PUSH/POP, 8-byte alignment (though calling conventions require 16-byte at call boundary).
Calling convention:
- AArch64 (AAPCS64): 8 argument registers (X0–X7), return in X0/X1. Clean, symmetric.
- x86-64 (System V AMD64 ABI): 6 integer registers (RDI, RSI, RDX, RCX, R8, R9) and 8 FP (XMM0–7) with a confusing history.
Memory model:
- AArch64: weak ordering. Barriers required for multiprocessor correctness.
- x86-64: TSO (strong ordering). Many naive programs work correctly by accident.
Power efficiency: ARM's instruction regularity and load-store discipline means fewer transistors needed for decode, simpler out-of-order machinery, and naturally fits low-power microarchitectures. This is the primary reason ARM dominates mobile, not just licensing or cost.
// Same function: count zeros in array
// Compare the compiler output mentally
int count_zeros(int *arr, int n) {
int count = 0;
for (int i = 0; i < n; i++)
if (arr[i] == 0) count++;
return count;
}
// Try both on https://godbolt.org
// x86-64 -O2: note PUSH, memory-src CMP, variable-length encoding
// AArch64 -O2: note uniform 4-byte instructions, CBZ, LDP optimization
09Why ARM Dominates Mobile and Embedded
This is worth understanding deeply because it shapes the entire industry landscape.
Power = C × V² × f. Every transistor switching dissipates energy proportional to its capacitance, the square of the supply voltage, and the frequency. To use less power:
- Use fewer transistors (simpler decode logic)
- Use lower voltage (but this reduces noise margin, harder to scale)
- Use lower frequency (but this reduces throughput)
ARM's RISC philosophy directly attacks #1. A fixed-width instruction set needs a smaller, simpler decoder than x86's variable-length front-end. Fewer transistors means less switching activity. At the same process node, ARM cores can run at lower voltage for the same reliability.
big.LITTLE and DynamIQ: ARM pioneered heterogeneous multiprocessing where a chip has both large high-performance cores and small efficient cores. On an Apple A-series or Qualcomm Snapdragon, the "Efficiency" cores handle background tasks at very low power, while "Performance" cores wake up briefly for demanding work. The OS scheduler (Linux's EAS — Energy Aware Scheduler) migrates tasks based on power budgets. x86 introduced a similar concept (Alder Lake's P+E cores) much later.
The licensing model: ARM Ltd. licenses the ISA and reference designs. Qualcomm, Apple, Samsung, MediaTek, and hundreds of others design their own microarchitectures that implement the AArch64 ISA. Apple's M-series is AArch64 with extremely wide out-of-order execution, massive caches, and a unified memory architecture — but it runs the same machine code as a Cortex-A55 in a cheap phone. The ISA abstraction layer enables enormous ecosystem diversity.
Embedded advantages: ARM also dominates microcontrollers (Cortex-M series, which use the 32-bit Thumb-2 encoding, not AArch64). The Cortex-M0 has around 12,000 gates total — it fits on the tiniest die at the lowest cost. IoT devices, motor controllers, medical devices, automotive ECUs all run ARM.
Server disruption: AWS Graviton3 (Neoverse V1 cores), Ampere Altra, and Apple M-series prove AArch64 is competitive on server workloads too. The key: at the same power budget, you can fit more AArch64 cores than x86 cores, and for embarrassingly parallel workloads (web serving, ML inference), that wins.
10Reading Real Disassembly: Kernel Example
The best way to cement assembly knowledge is to read real code. Let's walk through a kernel function and decode every instruction.
Here is arm64_copy_from_user simplified to its core loop — the kernel function that copies bytes from user space into kernel space (called by every read() syscall).
// arch/arm64/lib/copy_from_user.S (simplified inner loop)
// On entry: X0=to (kernel ptr), X1=from (user ptr), X2=count
.Lcopy_loop:
subs x2, x2, #1 // x2 -= 1; sets flags
b.mi .Ldone // if x2 went negative (was 0), done
8: ldrb w3, [x1], #1 // load byte from user addr, post-increment X1
strb w3, [x0], #1 // store byte to kernel addr, post-increment X0
b .Lcopy_loop // repeat
.Ldone:
ret
Instruction by instruction:
SUBS X2, X2, #1: subtract immediate 1 from X2, set flags. TheSsuffix is critical — without itB.MIhas nothing to test.B.MI .Ldone: branch if Negative flag is set, i.e., X2 wrapped below zero (was 0 before the subtract).LDRB W3, [X1], #1: load byte (unsigned), zero-extend to 32 bits into W3 (and therefore X3), then X1 += 1. Post-index mode advances the pointer for free.STRB W3, [X0], #1: store byte, post-increment X0.B .Lcopy_loop: unconditional branch back.
The 8: label has special significance in the kernel: it is part of the exception fixup table mechanism. If LDRB faults (user pointer is invalid), the kernel's fault handler looks up this label in __ex_table and jumps to a fixup routine instead of killing the process with SIGSEGV. This is how copy_from_user safely handles bad user pointers.
Real production code uses LDP to copy 16 bytes at a time, with SIMD for larger transfers, but the structure is the same. Reading this loop, you now understand every opcode.
// Build and disassemble your own code:
// 1. Write a C function
// 2. Compile for AArch64 cross-target
// 3. Disassemble
// On a Linux host with cross-compiler:
// aarch64-linux-gnu-gcc -O1 -c myfunc.c -o myfunc.o
// aarch64-linux-gnu-objdump -d myfunc.o
// Or use Compiler Explorer online:
// https://godbolt.org (select AArch64 gcc, any version)
// Try this function and read every instruction:
long sum_array(long *a, int n) {
long s = 0;
for (int i = 0; i < n; i++) s += a[i];
return s;
}
Key insights
- ✦AArch64 instructions are all exactly 32 bits wide — this makes disassembly trivial and hardware decode simple, unlike x86's variable-length chaos.
- ✦The load-store discipline means every memory access is a visible, explicit LDR or STR — you can read performance and correctness directly from the assembly.
- ✦ARM has a weak memory model: correct multiprocessor code requires explicit LDAR/STLR or DMB barriers — code that works on x86 by accident will silently break on ARM.
- ✦X30 (the link register) is not a stack register: BL puts the return address there, and leaf functions return without ever touching the stack at all.
- ✦Writing to a W register (32-bit) automatically zero-extends into the full 64-bit X register — no partial-register stalls, no false dependencies.
- ✦Exception levels (EL0–EL3) give AArch64 a clean four-level privilege hierarchy: user, kernel, hypervisor, secure monitor — each with its own stack pointer and system registers.
- ✦CSEL (conditional select) is how AArch64 does branchless code — it replaced ARM32's pervasive instruction-level predication with a smaller set of dedicated conditional instructions.
- ✦ARM's ISA licensing model — letting Apple, Qualcomm, Samsung design their own microarchitectures — created a Cambrian explosion of efficient chips that could never happen under Intel's integrated model.
Common pitfalls
- ⚠Forgetting the S suffix on arithmetic instructions and then wondering why a conditional branch never triggers — CMP is SUBS with XZR; plain SUB sets nothing.
- ⚠Using 32-bit W registers for pointer arithmetic — pointer arithmetic must use 64-bit X registers; SXTW (sign-extend word) is required when mixing 32-bit indices with 64-bit pointers.
- ⚠Assuming ARM's memory model is as strong as x86's — lock-free algorithms that work on x86 will have data races on ARM without LDAR/STLR or DMB barriers.
- ⚠Forgetting ISB after writing to a system register — the pipeline must be flushed after TTBR, SCTLR, or VBAR writes or the old values remain in flight.
- ⚠Stack misalignment — AArch64 requires 16-byte stack alignment at all times, and the hardware will raise an alignment fault on misaligned SP usage; STP/LDP of two 8-byte registers is the natural way to maintain this.
- ⚠Confusing B (branch, no link) with BL (branch-and-link) — B is a jump, BL is a call that saves PC+4 into X30; using B to call a function silently destroys the return address.
- ⚠Trying to directly read or write PC — AArch64 forbids MOV X0, PC; use ADR or ADRP for PC-relative address computation instead.
- ⚠Ignoring the difference between DMB and DSB — DMB orders memory accesses but does not wait for completion; DSB is the stronger barrier needed before cache maintenance or TLB operations.
Practice
- 01medium
Write an AArch64 assembly function `long dot_product(long *a, long *b, int n)` that computes the dot product of two arrays without using any C. Assemble it with `aarch64-linux-gnu-as`, link it against a C `main` that calls it, and verify the result. Use CBZ for the loop termination condition.
- 02easy
Use Compiler Explorer (godbolt.org) to compile the following function at -O0, -O1, -O2, and -O3 for AArch64 gcc: `int collatz(int n) { int steps=0; while(n!=1){ if(n%2==0) n/=2; else n=3*n+1; steps++; } return steps; }`. Document every optimization you observe between levels. Specifically: does -O2 eliminate the modulo? Does it use CSEL? How does the loop structure change?
- 03hard
Implement a simple spinlock in AArch64 assembly: `void spin_lock(int *lock)` and `void spin_unlock(int *lock)`. The lock is 0 when free, 1 when held. Use LDAXR/STXR for the acquire and STLR for the release. Test it by calling both from C with two POSIX threads. Verify no data race with `-fsanitize=thread`.
- 04hard
Write a bare-metal AArch64 'hello world' that runs in QEMU without an OS: a 512-byte image that initializes the UART (PL011 at 0x09000000 on virt machine), prints 'Hello\n', then loops forever. Use `qemu-system-aarch64 -M virt -cpu cortex-a57 -nographic -kernel hello.bin`. No C library.
- 05hard
Cross-compile the Linux kernel for AArch64, boot it in QEMU, and find a kernel oops by writing a tiny kernel module that dereferences a NULL pointer. Read the oops output and decode the register dump: identify which register held the bad address, which instruction faulted (use the PC), and what ESR_EL1 syndrome tells you about the fault type.
Resources
The authoritative spec — thousands of pages, but the AArch64 instruction reference appendix is the only ground truth for encoding, flags, and semantics.
Indispensable: paste C, select AArch64 gcc or clang, instantly see assembly. The fastest way to build intuition about what compilers emit.
ARM's own free learning guide — gentler than the full manual, covers registers, instructions, and the memory model in tutorial form.
Bootlin's cross-referenced source browser — read entry.S, head.S, and the spinlock headers to see real production AArch64 assembly.
Bridges the gap between spec and practice — covers caches, MMU, interrupts, and SIMD in an accessible format.
The official ABI document — required reading before writing any code that interoperates with C.
LWN's classic article series on ARM from a kernel developer's perspective — covers the memory model and exception model accurately.
Essential for running AArch64 code without real hardware — covers the virt machine type, GDB stub, and bare-metal setup.
Checkpoints — you can now…
- ✓You can name all 31 general-purpose registers, explain W vs X naming, and describe the role of XZR, SP, LR, and PC.
- ✓You can write a correct AArch64 function prologue and epilogue using STP/LDP, explain why 16-byte alignment is required, and trace what happens to X30 on nested calls.
- ✓You can read a load instruction with pre-index, post-index, scaled-register, and base+offset addressing modes and state the effective address for each.
- ✓You can explain why `SUB X0, X1, X2` does not affect conditional branches but `SUBS X0, X1, X2` does, and write the equivalent of a C `if (a < b)` using CMP + B.LT.
- ✓You can write a correct lock-free flag using LDAR/STLR and explain why plain LDR/STR would be wrong on a multi-core ARM system.
- ✓You can open Compiler Explorer, paste a C loop, select AArch64, and explain every instruction in the output including any CSEL, CBZ, or MADD instructions the compiler chose.
- ✓You can describe the four exception levels, explain what happens in hardware when a user program executes SVC, and name the three registers the CPU automatically updates (ELR_EL1, SPSR_EL1, SP_EL1).
- ✓You can list three concrete ways AArch64 differs from x86-64 and explain the microarchitectural or historical reason for each difference.