Control Flow

A CPU executes instructions in order. Every if, loop, and function call you have ever written is built from just two primitives: a jump, and a test of the flags register. This lesson shows how the high-level shapes map onto them.

Labels and Unconditional Jumps

Labels

A label marks a position in the code and gives it a name, which is how you refer to an address without computing it. Labels that begin with a dot are local — they belong to the previous non-local label, so you can reuse .loop in many functions without collision.

start:                 ; global label
    xor  rax, rax
.loop:                 ; local label, scoped to `start`
    inc  rax
    cmp  rax, 5
    jl   .loop         ; jumps to start.loop
    ret                 ; `start` and `.loop` are both reachable here

other:                 ; a new global label
.loop:                 ; a DIFFERENT .loop — no conflict with the one above
    ret

jmp — Changing the Instruction Pointer

    jmp  label           ; jump to a named position
    jmp  rax             ; jump to the address held in a register (indirect)
    jmp  [table + rcx*8] ; jump to the address stored in memory (jump table)

jmp is how you leave the straight line of instructions. The indirect forms — through a register or through memory — are what make function pointers, jump tables, and virtual dispatch possible.

Conditional Jumps

A conditional jump does exactly one thing: it inspects the flags register and decides whether to move rip. It does not compare anything itself — that is cmp's job.

Condition (in C)Signed jumpUnsigned jumpMeaning of the mnemonic
==jejejump if equal (ZF = 1)
!=jnejnejump if not equal
<jljbless (signed) / below (unsigned)
<=jlejbeless-or-equal / below-or-equal
>jgjagreater (signed) / above (unsigned)
>=jgejaegreater-or-equal / above-or-equal
== 0jzjzjump if zero — same as je
negativejs—jump if sign flag set
overflowjojcjump if overflow / carry
The classic bug: choosing jg when the values are unsigned, or ja when they are signed. Both compile, both run, and one of them is silently wrong for negative or large values. Decide the signedness of your data before you write the jump.

Building if / else

High-level languages present if as a block that runs when a condition is true. Machine code inverts that: you jump past the block when the condition is false. Learning to think in negations is the single biggest mental adjustment in assembly.

    ; C:   if (rax > 10) { a(); } else { b(); }

    cmp  rax, 10
    jle  .else            ; note the NEGATED condition: if NOT greater, skip a()
    ; --- then-block: rax > 10 ---
    call a
    jmp  .end             ; must jump over the else-block
.else:
    ; --- else-block ---
    call b
.end:

Two habits avoid most control-flow bugs:

  • Negate the condition you jump on. Falling through should mean "the condition was true", because that is the common case.
  • Never forget the jump over the else-block. Omitting it runs both branches, and it will look correct in a small test.

Building Loops

A loop is the same conditional jump, aimed backwards. The reliable pattern — used by compilers and recommended here — is to test at the top and jump out, rather than to test at the bottom.

    ; C:   for (i = 0; i < 10; i++) { work(i); }
    xor  rcx, rcx            ; i = 0
.loop:
    cmp  rcx, 10             ; i < 10 ?
    jge  .done               ; no -> exit the loop
    ; --- loop body ---
    ; work(i) goes here, using rcx as the counter
    inc  rcx                 ; i++
    jmp  .loop               ; back to the test
.done:

Testing at the top means a loop with zero iterations simply skips the body, which matches the semantics of every mainstream language. If you test at the bottom (do { } while), the body always runs at least once — correct for that construct, wrong for a while.

The loop Instruction (and Why to Avoid It)

x86 has a dedicated loop instruction that decrements rcx and jumps if it is not yet zero:

    mov  rcx, 10
.repeat:
    ; ... body ...
    loop .repeat            ; rcx -= 1; if rcx != 0 jump back

It looks elegant but is a trap for beginners: loop is slower than dec + jnz on every modern CPU, it requires the counter to be in rcx specifically, and rcx is caller-saved — so it cannot survive a function call. Prefer the compare-and-jump pattern above.

Multi-Way Selection: Jump Tables

A chain of compares is fine for two or three cases. For many cases — a parser dispatching on an opcode, a virtual machine's interpreter loop — the idiomatic solution is a jump table: an array of code addresses you index directly.

section .rodata
    table   dq  .case0, .case1, .case2, .case3     ; 8 bytes per entry
    TABLE_N equ ($ - table) / 8                    ; number of cases

section .text
    ; rax holds the selector (0..3)
    cmp  rax, TABLE_N
    jae  .default                ; out of range -> default branch

    lea  rbx, [rel table]        ; rbx = address of the table
    jmp  [rbx + rax*8]           ; jump to the address stored at table[rax]

.case0:
    ; ... handle case 0 ...
    jmp  .end
.case1:
    ; ... handle case 1 ...
    jmp  .end
.case2:
    ; ...
    jmp  .end
.case3:
    ; ...
    jmp  .end
.default:
    ; ... handle anything else ...
.end:

Two details make this safe and fast. The bounds check (jae .default) is mandatory — without it, a bogus selector jumps into the middle of unrelated code. And the table holds addresses, so the elements must be dq (8 bytes) on x86-64.

Summary

  • A label is a target; jmp moves execution to it unconditionally.
  • Conditional jumps read the flags; cmp/test set them.
  • Signed and unsigned comparisons use different mnemonics — jg/jl vs ja/jb — even though both read the same flags.
  • An if/else is a negated conditional jump past the then-block; a loop is a conditional jump backwards.
  • Multi-way selection is a jump table of code addresses, always preceded by a bounds check.

Next: Subprograms & the Stack — how call and ret turn jumps into functions.