CodeRigor

FREE LESSON

x86-64 control flow: flags, jumps, setcc and cmov

Read and write the instructions behind every if, loop and switch: cmp/test and the flags, signed versus unsigned condition codes, setcc and cmov for branch-free code, rotated loops and jump tables.

Every if, for, ?:, && and switch in your C++ becomes a comparison followed by something that reads the result. This lesson covers:

  • how cmp and test set the flags;
  • signed vs unsigned condition codes — the place where types live in asm;
  • three ways to consume a comparison: jcc, setcc, cmovcc;
  • how compilers shape loops (rotation) and switches (jump tables vs compare trees).

1. Flags

Most arithmetic instructions set four flags in rflags as a side effect:

flag set when used by
ZF zero the result is 0 e/z, ne/nz
SF sign the result's top bit is 1 s, ns, signed conditions
CF carry an unsigned add carried out / a subtraction borrowed unsigned conditions
OF overflow the result overflowed as a signed number signed conditions

Two instructions exist only to set flags:

  • cmp a, b computes a − b and throws the result away;
  • test a, b computes a & b and throws it away. test rdi, rdi is the idiom for "compare with zero" (ZF = is zero, SF = is negative).

mov, lea, cmov, setcc and jmp don't touch the flags, which is why compilers can schedule them between a cmp and the instruction that reads it.

2. Condition codes: the same bits, two readings

After cmp a, b, a condition code asks a question about a versus b:

question signed (SF, OF) unsigned (CF, ZF)
a < b l (less) b (below)
a <= b le be
a > b g (greater) a (above)
a >= b ge ae
a == b e (ZF = 1) e
a != b ne ne

The instruction doesn't know the types: cmp edi, esi is the same for int and unsigned. Signedness is decided by which letter follows. That's why this matters so much when you read or write assembly:

C++
extern "C" int slt(int a, int b) { return a < b; }            // cmp edi, esi ; setl al
extern "C" int ult(unsigned a, unsigned b) { return a < b; }  // cmp edi, esi ; setb al
Same bits, two orders
Sixteen 4-bit patterns on a clock. Unsigned conditions (b, a) order them by the outer index, 0 to 15; signed ones (l, g) by the value inside, −8 to 7. 1111 is 15 above 1 — and −1 less than 1.
Same bits, two orders
Sixteen 4-bit patterns on a clock. Unsigned conditions (b, a) order them by the outer index, 0 to 15; signed ones (l, g) by the value inside, −8 to 7. 1111 is 15 above 1 — and −1 less than 1.

With a = −1, b = 1: signed, −1 < 1 is true. Unsigned, −1 is 4 294 967 295, which is not below 1. Same bits, opposite answers:

C++
#include <cstdio>

extern "C" int lt_signed(long a, long b);
extern "C" int lt_unsigned(long a, long b);
asm(R"(
    .text
    .intel_syntax noprefix
    .globl lt_signed
lt_signed:
    xor eax, eax
    cmp rdi, rsi
    setl al
    ret
    .globl lt_unsigned
lt_unsigned:
    xor eax, eax
    cmp rdi, rsi
    setb al
    ret
    .att_syntax prefix
)");

int main() {
    std::printf("signed   -1 < 1: %d\n", lt_signed(-1, 1));
    std::printf("unsigned -1 < 1: %d\n", lt_unsigned(-1, 1));
}

3. Three ways to use a comparison

Branch: jcc label. Jump if the condition holds, otherwise fall through. Cheap when the branch predictor guesses right, expensive (~15–20 cycles) when it doesn't.

Materialise a bool: setcc r8. Writes 0 or 1 into an 8-bit register. Because it only writes the low byte, compilers zero the full register first — and before the cmp, since xor changes the flags:

Assembly
pos:                       ; return x > 0;
    xor   eax, eax
    test  edi, edi
    setg  al
    ret

Select: cmovcc dst, src. Copies src into dst if the condition holds. No branch, so nothing to mispredict; both values must already be computed.

C++
extern "C" long smax(long a, long b) { return a > b ? a : b; }
Assembly
smax:
    cmp     rdi, rsi
    mov     rax, rsi
    cmovge  rax, rdi       ; signed >=
    ret

Compilers choose between a branch and a cmov heuristically: cmov when both sides are cheap and the condition is unpredictable; a branch when one side is expensive or the condition is predictable. You'll return to this in the branchless lesson.

4. The range-check trick

lo <= x && x <= hi looks like two comparisons and a branch. Subtract lo from everything, then compare unsigned: values below lo wrap around to huge numbers and fail the same single test.

C++
extern "C" int is_digit(int c) { return c >= '0' && c <= '9'; }
Subtract lo, then one unsigned compare
Everything below '0' wraps to a huge unsigned number, so c - '0' <= 9 (unsigned) rejects both sides of the range at once.
Subtract lo, then one unsigned compare
Everything below '0' wraps to a huge unsigned number, so c - '0' <= 9 (unsigned) rejects both sides of the range at once.
Assembly
is_digit:
    sub   edi, 48          ; c - '0'
    xor   eax, eax
    cmp   edi, 9
    setbe al               ; unsigned (c - '0') <= 9
    ret

5. Loops: rotation

A for loop tests its condition before every iteration. Compiled literally that's two jumps per iteration (one back to the test, one out). Compilers rotate the loop: one guard before it, and the test at the bottom.

C++
extern "C" long sum(const long* a, long n) {
    long s = 0;
    for (long i = 0; i < n; ++i) s += a[i];
    return s;
}
Assembly
sum:
    test  rsi, rsi            ; guard: n <= 0 → skip the loop
    jle   .L10
    lea   rdx, [rdi+rsi*8]    ; end pointer
    xor   eax, eax
.L9:                          ; body
    add   rax, QWORD PTR [rdi]
    add   rdi, 8
    cmp   rdi, rdx            ; test at the bottom
    jne   .L9                 ; one taken branch per iteration
    ret
.L10:
    xor   eax, eax
    ret
Loop rotation: test at the bottom
Compiled literally, every iteration takes two jumps. Rotated, the guard runs once and each iteration takes a single jne back to the body.
Loop rotation: test at the bottom
Compiled literally, every iteration takes two jumps. Rotated, the guard runs once and each iteration takes a single jne back to the body.

Notice also that the index disappeared: GCC walks a pointer to an end pointer (strength reduction). When you write a loop by hand, keep the guard — a bottom-tested loop without it runs once even when n == 0.

6. switch: jump tables and compare trees

A dense switch (cases close together) becomes a jump table: one unsigned bounds check, then an indirect jump through an array of addresses.

Assembly
    cmp   edi, 5              ; one unsigned check covers x < 0 too
    ja    .Ldefault
    lea   rdx, .L18[rip]      ; table of 32-bit offsets
    mov   edi, edi
    movsxd rax, DWORD PTR [rdx+rdi*4]
    add   rax, rdx
    jmp   rax                 ; indirect jump
A jump table: bounds check, then index
cmp edi, 5; ja .Ldefault rejects x > 5 and (unsigned) every negative x. Then .L18[x] holds the offset of the case to jump to.
A jump table: bounds check, then index
cmp edi, 5; ja .Ldefault rejects x > 5 and (unsigned) every negative x. Then .L18[x] holds the offset of the case to jump to.

A switch that only returns constants may even become a plain table lookup (CSWTCH in GCC's output). A sparse switch (case 1, 100, 1000, 10000) becomes a binary tree of cmp/je/jg instead:

A sparse switch becomes a binary search over the cases
switch (x) { case 1: … case 100: … case 1000: … case 10000: … }: at most three comparisons instead of a 10 000-entry table.
A sparse switch becomes a binary search over the cases
switch (x) { case 1: … case 100: … case 1000: … case 10000: … }: at most three comparisons instead of a 10 000-entry table.

7. Worked example

C++
extern "C" long count_below(const long* a, long n, long x) {
    long c = 0;
    for (long i = 0; i < n; ++i)
        if (a[i] < x) ++c;
    return c;
}

GCC turns the if into arithmetic — no branch inside the loop:

Assembly
.L39:
    xor   edx, edx
    cmp   rcx, QWORD PTR [rdi]    ; x vs a[i]
    setg  dl                      ; x > a[i]  ⇔  a[i] < x   (signed)
    add   rdi, 8
    add   rax, rdx                ; c += (a[i] < x)
    cmp   rsi, rdi
    jne   .L39

Note the operands are swapped (x vs a[i]) so the condition flips from l to g. Always read the condition with the operand order of the cmp.

8. Faded example

C++
extern "C" unsigned long umax(unsigned long a, unsigned long b) { return a > b ? a : b; }
Assembly
umax:
    cmp     rdi, rsi
    mov     rax, rsi
    cmov??  rax, rdi
    ret

(Answer: an unsigned condition — cmovae or cmova; GCC emits cmovnb, which is the same as cmovae.)

Key points

  • cmp a, b = flags of a − b; test a, b = flags of a & b.
  • l/le/g/ge are signed; b/be/a/ae are unsigned. The types live in the condition code, not the instruction.
  • jcc branches, setcc makes a 0/1 byte (zero the register first), cmovcc selects without branching.
  • Loops are rotated: guard, body, bottom test. Dense switches use jump tables.

Watch & read

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

How compilers lower switch statements: jump tables, binary search trees of compares and bit tests — this skill's switch section.

Simple loops at -O2: loop rotation, the compare-and-branch at the bottom, and how to read it.

Loop unswitching: hoisting a loop-invariant branch out of a loop by duplicating the loop.

MIT 6.172 on translating C control flow (if, loops) into x86-64 compares and conditional jumps.

Godbolt's classic tour of what compilers do with loops and conditions at -O2; watch the loop and counting sections.