Reading AArch64 assembly when you grew up on x86: seven small functions compiled for both targets (and for armv9-a, which changes nothing here). What to look for: the calling convention (x0-x7, two-register returns), the frame (stp/ldp of x29/x30), predication with csel/cset, immediates that need several instructions, scaled addressing, and the NEON registers showing up in a plain integer loop.
$ git clone https://github.com/adit4443ya/debug_labs.git && cd debug_labs
$ (cd aarch64 && ./build.sh) -> bin/01_x86.s, bin/01_a64.s, bin/01_a64_v9.s
$
$ SIDE BY SIDE (from the .s files; noise stripped):
$
$ clamp(x, lo, hi) = x<lo ? lo : x>hi ? hi : x
$ x86: movl %edx,%eax ; cmpl %edx,%edi ; cmovll %edi,%eax ; cmpl %esi,%edi ; cmovll %esi,%eax ; retq
$ a64: cmp w0, w2 ; csel w8, w0, w2, lt ; cmp w0, w1 ; csel w0, w1, w8, lt ; ret
$ Same shape. csel is AArch64's cmov: `csel dst, a, b, cond` = cond ? a : b.
$ The `w` registers are the 32-bit views of the 64-bit `x` registers.
$
$ big_const() = 0x1234567890ABCDEF
$ x86: movabsq $1311768467294899695, %rax ; retq (one 10-byte instruction)
$ a64: mov x0, #52719 // 0xcdef
$ movk x0, #37035, lsl #16 // 0x90ab
$ movk x0, #22136, lsl #32 // 0x5678
$ movk x0, #4660, lsl #48 // 0x1234
$ Fixed 32-bit instructions cannot carry a 64-bit immediate: four
$ 16-bit chunks (mov + movk). Constants live in code, or in a literal
$ pool loaded with adrp+ldr. This is why "just use a magic constant"
$ costs more on Arm, and why the compiler prefers bitmask immediates.
$
$ call_leaf(f, x) = f(x) + 1
$ x86: pushq %rax ; movq %rdi,%rax ; movq %rsi,%rdi ; callq *%rax ; incq %rax ; popq %rcx ; retq
$ a64: stp x29, x30, [sp, #-16]! // save frame pointer AND link register
$ mov x8, x0 ; mov x0, x1 ; mov x29, sp
$ blr x8 // branch-with-link to register
$ add x0, x0, #1
$ ldp x29, x30, [sp], #16 ; ret
$ AArch64 has no push/pop and no return address on the stack: `bl`
$ puts the return address in x30 (lr). A function that calls another
$ must save x30 itself (stp with pre-index), and `ret` is `br x30`.
$ Leaf functions never touch the stack for this: that is why `clamp`
$ has no prologue at all.
$
$ swap_pair({lo, hi}) -> {hi, lo}
$ x86: movq %rsi,%rax ; movq %rdi,%rdx ; retq
$ a64: mov x8, x0 ; mov x0, x1 ; mov x1, x8 ; ret
$ A 16-byte struct is passed and returned in TWO registers on both
$ ABIs (rdi:rsi / rax:rdx vs x0:x1 / x0:x1). Larger structs go through
$ memory (x8 holds the return address of a large struct on AArch64).
$
$ spill_many(out, a..i): 9 integer args
$ a64: add x8, x2, x1 ; ldr x9, [sp] ; add x10, x4, x3 ; stp x8, x10, [x0] ; ldr x10, [sp, #8] ...
$ Eight argument registers (x0-x7); the 9th and 10th come from the
$ caller's stack ([sp], [sp, #8]). x86-64 SysV has six (rdi rsi rdx
$ rcx r8 r9), so its spills start at the 7th. Note `stp x8, x10,
$ [x0]`: two 64-bit stores in one instruction (store pair).
$
$ sum_scaled(a, n, k): sum of a[i]*k over ints
$ a64 inner loop:
$ ldp q5, q6, [x8, #-16] // two 128-bit NEON loads (8 ints)
$ subs x10, x10, #8 // subtract and set flags
$ add x8, x8, #32
$ b.ne .LBB0_5
$ ... add v0.2d, v2.2d, v0.2d ; addp d0, v0.2d // horizontal reduce
$ The compiler vectorized with NEON (q/v registers, `.2d` = two 64-bit
$ lanes). `subs` + `b.ne` is the loop test: flags are set by the `s`
$ suffix, not by a separate cmp. `cbz`/`cbnz` (compare-and-branch on
$ zero) handle the n == 0 guard without touching flags at all.
$
$ THE ONE-PAGE DECODER RING (x86-64 -> AArch64):
$ rax..r15 (16 GPRs) x0..x30 (31 GPRs) + sp; w-regs = low 32 bits; xzr/wzr = zero register
$ rdi rsi rdx rcx r8 r9 args x0..x7 args (also x0..x7 returns); x8 = indirect result; x16/x17 scratch (ip0/ip1); x18 platform; x29 fp; x30 lr
$ push/pop/call/ret stp/ldp with pre/post-index; bl/blr set x30; ret = br x30
$ cmov csel / csinc / csinv / cset / cinc
$ test/cmp + jcc cmp / tst + b.cond; cbz/cbnz and tbz/tbnz branch without flags
$ lea add with shifted register; `ldr w0, [x1, x2, lsl #2]` scales in the address
$ movabs mov + movk x3, or adrp + ldr (literal pool)
$ xmm/ymm v0..v31 (NEON, 128-bit: .16b .8h .4s .2d); z0..z31 + p0..p15 with SVE (lab 02)
$ lock xadd / xchg / cmpxchg ldxr/stxr loops, or ldadd / swp / cas with LSE (lab 03)
$ mfence dmb ish; acquire/release live IN the load/store (ldar/stlr)
1. Add a function with a 24-byte struct argument: watch it move to memory (x8 for the result) on a64 while x86 also uses memory. 2. Compile with -march=armv9-a vs armv8-a and diff: for this file, identical. Add a popcount and a CRC to see feature-dependent code. 3. `clang --target=aarch64-linux-gnu -O0 -S`: every variable through the stack with `str`/`ldr` to [sp, #off]; count the instructions. ============================================================
#include <stdint.h>
#include <stddef.h>
long sum_scaled(const int *a, size_t n, int k) { long s = 0; for (size_t i = 0; i < n; i++) s += (long)a[i] * k; return s; } /* loop, NEON, subs/b.ne */
int clamp(int x, int lo, int hi) { return x < lo ? lo : x > hi ? hi : x; } /* csel vs cmov */
int is_zero_or_neg(long x) { return x <= 0; } /* cmp + cset */
uint64_t big_const(void) { return 0x1234567890ABCDEFull; } /* mov+movk x3 vs one movabs */
void spill_many(long *out, long a, long b, long c, long d, long e, long f, long g, long h, long i) { out[0] = a + b; out[1] = c + d; out[2] = e + f; out[3] = g + h; out[4] = i; } /* 8 regs then stack */
struct Pair { long lo, hi; };
struct Pair swap_pair(struct Pair p) { struct Pair r = { p.hi, p.lo }; return r; } /* two-register return */
long call_leaf(long (*f)(long), long x) { return f(x) + 1; } /* blr + lr save: stp x29,x30 */A lab is done when you can say what it showed on your machine — not when you have read it.
It decides which problems come first, which drills open, which labs you see and how the Learn surface is organised. Change it any time from the switch in the top bar, or press Escape to look around first.