Every if, loop and switch on AArch64 comes down to the same two steps as
on x86: an instruction sets flags, and a later instruction reads them.
The flags are fewer, the conditional instructions are more powerful, and a
few shortcuts skip the flags altogether. This lesson assumes you've seen
x86-64 control flow; the differences are the interesting part.
1. NZCV: four flags
The flags live in the NZCV part of PSTATE:
cmp a, b computes a − b and keeps only these four bits. Hover a flag for its meaning.Unlike x86, ordinary arithmetic does not set flags. add, sub, and
leave NZCV alone; only the s-suffixed forms (adds, subs, ands) and
the compare instructions write them:
| instruction | computes | keeps the result? |
|---|---|---|
cmp a, b |
a − b (alias of subs xzr, a, b) |
no |
cmn a, b |
a + b ("compare negative") |
no |
tst a, b |
a & b (alias of ands xzr, a, b) |
no |
subs d, a, b |
a − b |
yes, in d |
So a loop counter can count down with subs and branch on the result
without a separate compare — and an add between a cmp and its branch
can't destroy the flags (on x86 it would).
The carry trap. After a subtraction, x86 sets CF when there was a
borrow. AArch64 sets C when there was no borrow (it computes
a + ~b + 1 and reports the carry out). So "unsigned lower" is C clear:
lo) is C = 0.2. Condition codes
Fifteen conditions read NZCV. As on x86, signed and unsigned comparisons use different conditions:
meaning (after cmp a, b) |
signed | unsigned |
|---|---|---|
| a < b | lt (N ≠ V) |
lo / cc (C = 0) |
| a <= b | le (Z or N ≠ V) |
ls (C = 0 or Z) |
| a > b | gt (!Z and N = V) |
hi (C = 1 and !Z) |
| a >= b | ge (N = V) |
hs / cs (C = 1) |
| a == b / a != b | eq / ne |
eq / ne |
Plus mi/pl (N set/clear), vs/vc (V set/clear) and al (always).
#include <cstdio>
extern "C" int less_signed(long a, long b);
extern "C" int less_unsigned(long a, long b);
asm(R"(
.text
.globl less_signed
less_signed:
cmp x0, x1
cset w0, lt // signed
ret
.globl less_unsigned
less_unsigned:
cmp x0, x1
cset w0, lo // unsigned
ret
)");
int main() {
// -1 is 0xffff…ffff: the smallest signed value here, the largest unsigned one.
std::printf("signed: %d unsigned: %d\n", less_signed(-1, 1), less_unsigned(-1, 1));
}The instruction doesn't know whether your values are signed. The
condition code you pick is the only place signedness exists — choose lt
for long and lo for unsigned long, or code like umin(2^63, 5)
silently returns the wrong value.
3. Branches
b label always (±128 MiB)
b.cond label if cond (±1 MiB) GCC prints it without the dot: ble, bne, bhi…
cbz x0, label if x0 == 0 no flags involved
cbnz x0, label if x0 != 0
tbz x0, #n, l if bit n of x0 is 0 bit number, not a mask
tbnz x0, #n, l if bit n of x0 is 1
br x1 to the address in x1 (jump tables, tail calls through pointers)
bl label / blr x1 call: return address in x30 (lr)
ret to x30cbz/cbnz and tbz/tbnz are the cheap shortcuts: a null check is
cbz x0, .Lnull, and if (x & 8) is tbnz x0, 3, … (8 = 1 << 3). Neither
touches NZCV.
bit3: // void bit3(unsigned x) { if (x & 8) g(); }
tbnz x0, 3, .L18
ret
.L18:
b g // tail call: g returns straight to our callerif (x & 8) tests bit 3 — tbz/tbnz take a bit number, not a mask, and leave NZCV alone.The sign of a 64-bit value is bit 63, so tbz x0, 63, 1f means "skip if
non-negative".
4. Conditional select: branch-free code
x86 has setcc and cmov. AArch64 has a whole family, all of the form
op d, a, b, cond = cond ? a : f(b):
| instruction | result | common alias |
|---|---|---|
csel d, a, b, c |
c ? a : b |
— |
csinc d, a, b, c |
c ? a : b + 1 |
cset d, c = csinc d, xzr, xzr, !c (0/1); cinc d, a, c (a + 1 if c) |
csinv d, a, b, c |
c ? a : ~b |
csetm d, c (0 / −1 mask); cinv |
csneg d, a, b, c |
c ? a : −b |
cneg d, a, c |
Because it takes three registers and can transform the second value, one
instruction often does the work of x86's mov + cmov or setcc +
movzx + add:
smax: cmp x0, x1 // a > b ? a : b
csel x0, x0, x1, ge
ret
count: cmp x2, x4 // if (a[i] < x) ++c;
cinc x0, x0, gt // no branch at all
abs: cmp x0, 0 // a < 0 ? -a : a
csneg x0, x0, x0, geBranch-free code costs the same no matter what the data is; a branch is almost free when it's predictable and expensive (10–20 cycles) when it isn't. Compilers pick csel when both values are cheap to compute.
5. ccmp: && and || without branches
ccmp a, b, #nzcv, cond means: if cond holds for the current flags,
compare a with b; otherwise set NZCV to the 4-bit immediate. That chains
comparisons:
both: // return a > 0 && b > 0;
cmp x0, 0
ccmp x1, 0, 4, gt // a > 0 ? flags(b − 0) : NZCV = 0b0100 (Z)
cset w0, gt
ret4 (Z set), which makes gt false.The immediate is the "already failed" answer: it must make the final
condition false. For gt that's any value with Z set (or N ≠ V) — 4
(0b0100, Z) is the usual choice. 0 would mean "N = V and not zero",
which satisfies gt: a ≤ 0 would return 1.
|| works the same with the opposite sense — a == 3 || b == 7 becomes
cmp x0, 3 / ccmp x1, 7, 4, ne / cset w0, eq: only if the first test
failed (ne) compare again; otherwise force Z (eq true). x86 has nothing like
it.
6. Loops
A for loop compiles to a rotated loop: the test moves to the bottom
(one taken branch per iteration), plus a guard before the first iteration:
sum: // long s = 0; for (i = 0; i < n; ++i) s += a[i];
cmp x1, 0
ble .L4 // guard: n <= 0 → return 0
mov x2, x0
add x1, x0, x1, lsl 3 // end = a + n*8
mov x0, 0
.L3:
ldr x3, [x2], 8 // x3 = *x2; x2 += 8 (post-index)
add x0, x0, x3
cmp x2, x1
bne .L3 // bottom test
ret
.L4:
mov x0, 0
retbne .L3). The guard sends n ≤ 0 straight to .L4 (return 0), so the body never runs for an empty range.Two AArch64 habits show up: post-index addressing [x2], 8 loads and
advances the pointer in one instruction (pre-index [x2, 8]! advances
first), and a counter can count down with subs so the branch needs no
cmp. Drop the guard and the body runs once for n = 0.
At -O0 the same loop keeps i and s in stack slots and reloads them
every iteration — that's what you'll see stepping through debug builds.
7. switch: compare trees and jump tables
A small or sparse switch becomes a tree of cmp/b.eq. A large dense
one becomes a jump table; GCC on AArch64 stores compact byte offsets:
cmp w0, 11
bls .L20 // unsigned: negative op wraps and is rejected too
ret
.L20:
adrp x1, .L4 // table address
add x1, x1, :lo12:.L4
ldrb w1, [x1, w0, uxtw] // one-byte offset for case op
adr x0, .Lrtx4 // base
add x1, x0, w1, sxtb #2 // base + offset*4 (instructions are 4 bytes)
br x1One unsigned bounds check covers both "too big" and "negative". A switch
that maps cases to constants may not branch at all — GCC turns it into a
load from a lookup table (CSWTCH).
8. x86 side by side
| C++ | x86-64 | AArch64 |
|---|---|---|
a < b (signed) → bool |
cmp / setl al / movzx |
cmp / cset w0, lt |
a > b ? a : b |
cmp / cmovg |
cmp / csel …, gt |
if (c) ++n |
setcc + add |
cinc |
a > 0 && b > 0 |
two setcc + and, or a branch | cmp / ccmp / cset |
if (!p) |
test / je |
cbz |
if (x & 8) |
test / jne |
tbnz x0, 3 |
| unsigned less | CF set (jb) |
C clear (lo) |
The ideas transfer one to one; the spelling — and the meaning of the carry flag after a subtraction — does not.