Beginner

Control Flow in Assembly

Learn how Assembly handles control flow using comparison instructions, conditional jumps, and loop patterns in x86 NASM

In high-level languages, control flow is structured: if, else, while, for. In Assembly, none of these keywords exist. The processor only knows how to do two things with the instruction pointer: execute the next instruction, or jump somewhere else. Every conditional and every loop is built from comparisons, processor flags, and jump instructions.

That makes Assembly control flow simultaneously simpler and more error-prone than what you may be used to. Simpler, because there are only a handful of primitives. More error-prone, because nothing prevents you from jumping into the middle of a function, falling off the end of a loop, or forgetting that a system call clobbered your counter register.

This tutorial walks through how x86 assembly expresses the building blocks of control flow: the cmp instruction, the flags it sets, the conditional jumps that read those flags, and the looping idioms built from them.

Comparison and Flags

The CPU has a special register called EFLAGS that stores result bits from arithmetic and comparison operations. The two flags that matter most for control flow are:

FlagMeaning
ZF (Zero Flag)Set when the result of an operation is zero
SF (Sign Flag)Set when the result is negative (high bit set)

The cmp a, b instruction performs a - b internally, sets the flags based on the result, and discards the difference. You then use a conditional jump to read those flags and branch.

MnemonicJumps if…Meaning
je / jzZF = 1equal / zero
jne / jnzZF = 0not equal / not zero
jlSF ≠ OFsigned less than
jgZF = 0 and SF = OFsigned greater than
jleZF = 1 or SF ≠ OFsigned less or equal
jgeSF = OFsigned greater or equal
jmpalwaysunconditional jump

A Complete Control Flow Example

The program below demonstrates an if/else if/else chain followed by a counted loop. Change the test number near the top to see different branches taken.

Create a file named control_flow.asm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
section .data
    pos_msg     db "Number is positive", 10
    pos_len     equ $ - pos_msg

    neg_msg     db "Number is negative", 10
    neg_len     equ $ - neg_msg

    zero_msg    db "Number is zero", 10
    zero_len    equ $ - zero_msg

    loop_msg    db "Loop iteration", 10
    loop_len    equ $ - loop_msg

    done_msg    db "Done", 10
    done_len    equ $ - done_msg

section .text
    global _start

_start:
    ; ---- if / else if / else ----
    mov eax, 7              ; the value we are testing

    cmp eax, 0
    jg  .positive           ; if eax > 0 goto .positive
    jl  .negative           ; else if eax < 0 goto .negative
    ; fall through: eax == 0

    mov eax, 4
    mov ebx, 1
    mov ecx, zero_msg
    mov edx, zero_len
    int 0x80
    jmp .after_if

.positive:
    mov eax, 4
    mov ebx, 1
    mov ecx, pos_msg
    mov edx, pos_len
    int 0x80
    jmp .after_if

.negative:
    mov eax, 4
    mov ebx, 1
    mov ecx, neg_msg
    mov edx, neg_len
    int 0x80
    ; fall through to .after_if

.after_if:
    ; ---- counted loop: print loop_msg 3 times ----
    mov ecx, 3              ; loop counter

.loop_start:
    push ecx                ; save counter (sys_write clobbers ecx)

    mov eax, 4
    mov ebx, 1
    mov ecx, loop_msg
    mov edx, loop_len
    int 0x80

    pop ecx                 ; restore counter
    dec ecx                 ; counter--
    jnz .loop_start         ; if counter != 0, loop again

    ; ---- print "Done" ----
    mov eax, 4
    mov ebx, 1
    mov ecx, done_msg
    mov edx, done_len
    int 0x80

    ; ---- exit ----
    mov eax, 1
    xor ebx, ebx
    int 0x80

What the Code Does

The conditional block uses two comparisons against zero. jg takes the positive branch; if that fails, jl takes the negative branch; if both fail, execution falls through to the zero case. Each branch ends with jmp .after_if so the code rejoins a single path — Assembly has no else keyword, only jumps that simulate one.

The loop is built from three pieces: an initialization (mov ecx, 3), a body that does the work, and a tail that decrements the counter and conditionally jumps back to the top. Because int 0x80 clobbers ecx, the loop pushes and pops the counter around the system call. Forgetting that detail is one of the most common bugs when first writing assembly loops.

Local Labels

Labels that begin with a dot (.positive, .loop_start) are local to the nearest non-local label above them — in this case _start. This lets you reuse short names like .loop_start in different functions without collisions.

Running with Docker

1
2
3
4
5
# Pull the NASM assembly image
docker pull esolang/x86asm-nasm:latest

# Assemble, link, and run
docker run --rm -v $(pwd):/code -w /code esolang/x86asm-nasm x86asm-nasm control_flow.asm

Expected Output

Number is positive
Loop iteration
Loop iteration
Loop iteration
Done

Loop Patterns at a Glance

The example above shows the most common idiom: decrement-and-jump-if-nonzero. A few other patterns you will see in real code:

  • While loop — test at the top with cmp and jge/jle/etc., then jmp back at the bottom.
  • Do-while loop — body first, then a single cmp + conditional jump at the bottom. One instruction shorter than a while loop, but the body always runs at least once.
  • loop instruction — x86 has a dedicated loop label instruction that decrements ecx and jumps if non-zero in a single opcode. It is shorter to write but, on modern CPUs, often slower than the explicit dec + jnz pair used above.

Key Concepts

  • cmp sets flags, it doesn’t branch. The branch happens on the next conditional jump instruction, which reads those flags.
  • Conditional jumps come in signed and unsigned flavors. jg/jl are signed; ja/jb are unsigned. Using the wrong one on negative numbers silently produces wrong results.
  • Fall-through is a tool, not a bug. Letting one branch fall through to the next saves a jmp and is idiomatic when the branches share trailing code.
  • System calls clobber registers. On Linux 32-bit, int 0x80 does not preserve ecx, edx, etc. Save anything you need around the call with push / pop.
  • Labels structure your code, not the assembler’s output. A label is just a name for an address; there is no enforced block scope. Discipline replaces syntax.
  • Local labels (.name) prevent collisions. They are scoped to the enclosing global label, so short, descriptive names can be reused freely across functions.
  • dec + jnz is the canonical counted loop. It naturally counts down to zero, where jnz exits — no separate comparison needed.

Running Today

All examples can be run using Docker:

docker pull esolang/x86asm-nasm:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining