FForge
← Labsaarch64-01AArch64 and SVE●○○○

The same C, read on x86-64 and AArch64

aarch64/01_cross_read.cneeds: tools/a64.sh (clang cross target + sysroot), asm only
what this lab teaches

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.

evidence to leave with
Seven functions side by side and the decoder ring
first, get the code
run this
$ git clone https://github.com/adit4443ya/debug_labs.git && cd debug_labs
then build it
run this
$ (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)
take it further

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. ============================================================

read the source · aarch64/01_cross_read.c
#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 */
◇
Finish the lab: write the evidence note

A lab is done when you can say what it showed on your machine — not when you have read it.