Lesson 3 of 9 · 22m
Control Flow: Finding the Shape of the Code
Assembly is flat; programs are trees and loops. Learn the five control-flow shapes and how disassemblers actually find the code — including where they get fooled.
Assembly is a flat list of instructions; programs are trees, loops, and dispatch tables. Most of static analysis is pattern-matching jumps back into the ifs, whiles, and switches the programmer actually wrote. Learn five shapes and the control-flow graph stops being noise.
Basic blocks and the CFG
A basic block is a run of instructions with one way in and one way out — it ends at a jump, a call, or a ret. The control-flow graph is basic blocks as nodes and jumps as edges. Everything you'll ever recognize is a shape in that graph.
The five shapes
; if/else — a diamond: compare, jump over one arm, both arms rejoin
cmp eax, 5
jle ELSE
; ... then-arm ...
jmp JOIN
ELSE:
; ... else-arm ...
JOIN:
; while — a back-edge: a jump that goes *up* the listing
LOOP:
cmp ecx, edx
jge DONE
; ... body ...
inc ecx
jmp LOOP ; backward jump = loop
DONE:
; switch — one indirect jump through a table of addresses
movsxd rax, DWORD [rbp-0x4]
jmp QWORD [rax*8 + 0x402000] ; rax indexes a case-address tableThe odd one out is cmov: the compiler can fold a small if/else into a conditional move with no branch at all. When you expect a diamond and find straight-line code, that's why — ternaries compile this way constantly.
How disassemblers find the code
- Linear sweep (
objdump -d): decode every byte of.textin order. Fast, and fooled the moment data sits between functions — those bytes get decoded as instructions and the stream desyncs. - Recursive descent (Ghidra, IDA): follow
jmp/calltargets, stop atretand unconditional jumps. Handles inline data, but indirect jumps —jmp rax, jump tables — leave blocks undiscovered until analysis proves where they point.
That's what Ghidra's minute of auto-analysis is doing: resolving every indirect target it can, including switch tables. Where it fails you get ?? bytes and unreachable regions — which is itself information: usually a jump table sitting in .rodata that you can define by hand.
Field notes
- Function boundaries are inferred: prologues, call targets, and the
int3/noppadding compilers insert between functions. Strip the symbols and the boundary detection is heuristic — occasionally wrong. - Tail calls —
jmp other_functionwhere you'd expectcall— look like a jump into the middle of nowhere.return f(x)at-O2does this deliberately to reuse the stack frame. - Bytes right after a
retthat no jump reaches are almost always padding or data, not dead logic. Don't spend an hour reversing an alignment sled.