CodeRigor

FREE LESSON

AArch64 control flow: NZCV, b.cond, csel and ccmp

Read and write the instructions behind every if, loop and switch on AArch64: the NZCV flags, signed versus unsigned condition codes, cbz/tbz shortcuts, the conditional-select family (csel, cset, cinc, csneg), ccmp chains for && without branches, rotated loops and jump tables.

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:

NZCV after a compare
cmp a, b computes a − b and keeps only these four bits. Hover a flag for its meaning.
NZCV after a compare
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:

The carry trap: cmp 1, 2
1 − 2 borrows, so AArch64 clears C (x86 would set CF). "Unsigned lower" (lo) is C = 0.
The carry trap: cmp 1, 2
1 − 2 borrows, so AArch64 clears C (x86 would set CF). "Unsigned lower" (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).

C++
#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

Text
  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 x30

cbz/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.

Assembly
bit3:                       // void bit3(unsigned x) { if (x & 8) g(); }
    tbnz x0, 3, .L18
    ret
.L18:
    b    g                  // tail call: g returns straight to our caller
tbnz x0, 3: branch on one bit
if (x & 8) tests bit 3 — tbz/tbnz take a bit number, not a mask, and leave NZCV alone.
tbnz x0, 3: branch on one bit
if (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:

Assembly
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, ge

Branch-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:

Assembly
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
    ret
ccmp chains two comparisons into one set of flags
If the first test failed, ccmp doesn't compare at all: it loads the immediate 4 (Z set), which makes gt false.
ccmp chains two comparisons into one set of flags
If the first test failed, ccmp doesn't compare at all: it loads the immediate 4 (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:

Assembly
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
    ret
A rotated loop: the test sits at the bottom
One taken branch per iteration (bne .L3). The guard sends n ≤ 0 straight to .L4 (return 0), so the body never runs for an empty range.
A rotated loop: the test sits at the bottom
One taken branch per iteration (bne .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:

Assembly
    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   x1
A jump table of byte offsets
Instructions are 4 bytes, so a one-byte offset × 4 reaches 1 KiB of case code — a compact table.
A jump table of byte offsets
Instructions are 4 bytes, so a one-byte offset × 4 reaches 1 KiB of case code — a compact table.

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

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Use the AArch32 examples to understand compare flags and conditional branches. This lesson applies the model to AArch64 b.cond, cbz/tbz and csel; AArch32 conditional-instruction syntax is not an AArch64 substitute.