Basic Blocks and Control-Flow Graphs

Imagine a compiler staring at a few hundred lines of three-address code. It wants to know whether a variable is ever used after it is assigned. It also wants to know whether a loop body can be hoisted out of a branch. Answering questions like these over a flat, linear list of instructions is unmanageable — jumps and labels scatter control everywhere. The fix is to first chop the instruction stream into straight-line chunks called basic blocks. Then the jump structure is rebuilt explicitly as a graph, the control-flow graph (CFG). This single restructuring step turns "a pile of instructions with goto's" into "a graph whose nodes are simple and whose edges carry all the control complexity." It is the load-bearing data structure underneath nearly every classical compiler optimization. The CFG matters because almost nothing downstream works without it. Data-flow analyses (liveness, reaching definitions, available expressions) are defined as equations over CFG nodes and edges. Register allocation needs live ranges, which are computed on the CFG. Loop optimizations need to recognize loops, which are cycles in the CFG. Even instruction scheduling and profile-guided optimization reason about "basic block frequency" and "edge probability." In short, the CFG is the coordinate system in which optimization is expressed. The big picture is deceptively simple: identify points where control can only enter at the top and only leave at the bottom (basic blocks). Then draw an edge for every possible jump between them. The subtlety lives entirely in getting the boundaries exactly right. A single misplaced leader can silently merge two blocks that should be separate, corrupting every analysis built on top. This article gives a precise definition of basic blocks and leaders. It also presents a leader-finding and block-construction algorithm, and the rule for building CFG edges. It includes a full complexity and correctness argument, and a worked trace on a small function with a loop and a branch. It further covers the classic pitfalls (exception edges, unreachable code, call instructions) that trip up real implementations. It sets up the machinery — Data-Flow Analysis Framework, Static Single Assignment (SSA) Form, and Loop Optimizations and Loop-Invariant Code Motion — that all consume the CFG as input.

Key takeaways
  • A basic block is a maximal straight-line instruction sequence: single entry (top), single exit (bottom), no internal jumps or jump targets.
  • Blocks are found via leaders: instructions that start a new block (first instruction, jump targets, instructions right after a jump/branch).
  • The control-flow graph has one node per basic block and an edge `B → B'` whenever control can flow from the end of `B` to the start of `B'`.
  • Construction is a single linear pass to find leaders, followed by a linear pass to slice blocks and wire edges — O(n) in the number of instructions.
  • Correctness rests on an invariant: every instruction lies in exactly one block, and every possible run-time control transfer is represented by exactly one CFG edge.
  • The CFG is the substrate for Data-Flow Analysis Framework, loop detection, and Static Single Assignment (SSA) Form construction.
The core insight

A basic block is the largest chunk of code that always executes as a unit, and the control-flow graph is exactly the wiring diagram of how those chunks can hand off control to one another.

The idea

A basic block is a maximal sequence of consecutive three-address code instructions such that control enters only at the first instruction and leaves only after the last instruction, with no branching in or out in between. Concretely: if execution reaches any instruction in the block, it is guaranteed to execute every instruction in the block, in order, exactly once, before leaving. This "all-or-nothing, straight-line" property is what makes a basic block the right unit of granularity for local optimizations (constant folding, common-subexpression elimination within the block) and the right node type for global analyses that operate between blocks. The key to finding basic blocks is the notion of a leader — an instruction that must be the first instruction of some basic block. An instruction is a leader if it is (1) the first instruction of the whole program (or procedure), (2) the target of some conditional or unconditional jump, or (3) the instruction immediately following a conditional or unconditional jump (including immediately after a `return`). Once leaders are identified, each basic block consists of a leader together with all instructions up to, but not including, the next leader (or end of program). The control-flow graph is then built by treating each basic block as a node and drawing a directed edge `B_i → B_j` whenever control can pass from the last instruction of `B_i` directly to the first instruction of `B_j` — either because `B_i` ends in a jump to `B_j`'s leader, or because `B_i` simply falls through (its last instruction is not an unconditional jump, and `B_j` follows it textually). Two designated blocks, `ENTRY` and `EXIT`, are conventionally added: `ENTRY` has an edge to the block containing the procedure's first instruction, and every block that can end execution (falls off the end, or returns) has an edge to `EXIT`. This gives every analysis a single well-defined start and end point to anchor fixpoint computations, which becomes essential once you reach Data-Flow Analysis Framework.

How it works

Construction happens in two clean passes over the instruction list, both linear in the number of instructions.

Pass 1 — find leaders. Scan the instruction sequence once. Mark the first instruction as a leader. Whenever an instruction is a jump (conditional or unconditional) to some target `L`, mark `L` as a leader, and also mark the instruction textually following the jump as a leader (because that is where control resumes if the jump is conditional and not taken, or simply because a fresh block must start after any jump regardless of whether it is taken).

Pass 2 — build blocks. Walk the instructions again; each leader starts a new block, and every following non-leader instruction is appended to the current block until the next leader is reached.

Pass 3 — wire edges. For each block `B` ending in instruction `s`:

  • if `s` is `goto L`, add edge `B → block(L)`;
  • if `s` is `if cond goto L`, add edge `B → block(L)` and `B → succ(B)` (fall-through, the block that follows `B` textually);
  • if `s` is `return` (or the block is the program's last block with no jump), add edge `B → EXIT`;
  • otherwise (`s` is an ordinary instruction, not a control transfer), add a fall-through edge `B → succ(B)`.

Finally, add `ENTRY → B_first`, where `B_first` is the block containing the first instruction. ``` function BUILD_CFG(instrs[1..n]): // --- Pass 1: find leaders --- isLeader[1..n] ← all false isLeader[1] ← true // first instruction always starts a block for i ← 1 to n: if instrs[i] is Jump(target) or CondJump(cond, target): isLeader[ index_of(target) ] ← true if i + 1 <= n: isLeader[i + 1] ← true // instruction after any jump starts a block // --- Pass 2: slice into blocks --- blocks ← [] current ← [] for i ← 1 to n: if isLeader[i] and current is not empty: blocks.append(current) current ← [] current.append(instrs[i]) if current is not empty: blocks.append(current) leaderIndexToBlock ← map from each leader's instruction index to its block // --- Pass 3: wire edges --- ENTRY, EXIT ← fresh sentinel nodes edges ← { (ENTRY, blocks[0]) } for k ← 0 to len(blocks) - 1: B ← blocks[k] last ← B.lastInstruction match last: case Jump(target): edges.add( (B, leaderIndexToBlock[index_of(target)]) ) case CondJump(cond, target): edges.add( (B, leaderIndexToBlock[index_of(target)]) ) if k + 1 < len(blocks): edges.add( (B, blocks[k+1]) ) // fall-through case Return: edges.add( (B, EXIT) ) case _: // ordinary instruction, falls through if k + 1 < len(blocks): edges.add( (B, blocks[k+1]) ) else: edges.add( (B, EXIT) ) // falls off the end of the program return (blocks, edges, ENTRY, EXIT) ``` The diagram below shows the shape produced: straight-line blocks as boxes, jump/branch/fall-through edges as arrows, with `ENTRY` and `EXIT` as sentinels.

Figure
A control-flow graph: blocks are straight-line code, edges are all possible jumps
ENTRY B1: i = 0 s = 0 L1: if i<n goto B2 B2: t = a[i] s = s + t i = i + 1 goto L1 B3: r = s EXIT true false back edge (goto L1)

Complexity & correctness

Time. Pass 1 scans every instruction once, doing O(1) work per instruction (checking whether it is a jump, marking at most two leader flags) — O(n). Pass 2 walks the instructions once more, appending each to the current block — O(n). Pass 3 iterates over the blocks, and does O(1) work per block for wiring at most two outgoing edges, so it is O(number of blocks) ≤ O(n). Total: O(n) time, where n is the number of three-address instructions, and this is asymptotically optimal since every instruction must be inspected at least once. Building a lookup from label/target to instruction index (for `index_of` and `leaderIndexToBlock`) is done with a hash map or array indexed by label, adding no more than O(n) preprocessing.

Space. The algorithm stores the leader bit array (O(n)), the list of blocks (O(n), since blocks partition the instructions), and the edge set (O(number of blocks), since each block has at most two outgoing edges: a jump target and a fall-through). So total space is O(n).

Correctness argument. The central invariant to verify is: > Partition invariant: every instruction belongs to exactly one basic block, and within a block, control flow is strictly sequential — the block's first instruction is the only possible entry point, and only the last instruction can transfer control elsewhere. This holds by construction. Every instruction index gets assigned to exactly one block in Pass 2, because leaders are visited in increasing order and each non-leader instruction is appended to the block started by the most recent leader — this is a partition, not a cover, since the `if isLeader[i] and current is not empty` guard ensures a new block starts exactly at leader positions and nowhere else. No instruction interior to a block can be a jump target, because if it were, Pass 1 would have marked it as a leader, forcing it to start its own block — contradiction. No instruction interior to a block can itself be a jump, because if it were, Pass 1 would mark the next instruction as a leader, again forcing a block boundary immediately after it — so a jump can only be the last instruction of its block. The second invariant is about edges: > Edge completeness: for every pair of instructions `(s, s')` such that control can transfer directly from `s` to `s'` at run time, if `s` is the last instruction of block `B` and `s'` is the first instruction of block `B'`, then the CFG contains the edge `B → B'`, and conversely every CFG edge corresponds to a real possible transfer. Pass 3's case analysis is exhaustive over the three ways a block can end: an unconditional jump (single successor, the target), a conditional jump (exactly two successors: target and fall-through, matching the two run-time outcomes of the branch), or an ordinary instruction (one successor, the textual next block, since control simply proceeds). Each case is handled, and each adds exactly the edges that correspond to real transfers — no more, no fewer. The `ENTRY`/`EXIT` sentinels are added exactly once and connect to exactly the blocks that can legitimately be first or last at run time. Hence the CFG is a faithful, exact model of every intraprocedural control transfer in the program.

Tradeoffs. The construction as given is intentionally conservative: a conditional jump always gets two outgoing edges even if static analysis could prove one branch dead — that refinement is left to later passes (constant propagation, unreachable-code elimination), not to CFG construction itself, which must remain a sound over-approximation of all run-time behavior. Precision is deliberately sacrificed for soundness and cheapness: O(n) construction is worth it because the CFG is rebuilt or incrementally patched many times during a compilation, and any unsoundness here (a missing edge) would silently break every analysis downstream.

Worked example

Consider this three-address code for summing an array (line numbers added for reference): ``` 1: i = 0 2: s = 0 3: L1: if i < n goto 5 4: goto 8 5: t = a[i] 6: s = s + t 7: i = i + 1 8: ... // wait — fix below ``` To make the trace honest and non-trivial (loop and branch, not just a straight loop), use this slightly richer version with an `if` inside the loop body that skips negative elements: ``` 1: i = 0 2: s = 0 3: L1: if i < n goto 4 else goto 10 4: t = a[i] 5: if t < 0 goto 7 6: s = s + t 7: i = i + 1 8: goto 3 9: (unreachable filler, see pitfalls) 10: r = s 11: return r ```

Pass 1 (leaders). Start: instruction 1 is a leader (first instruction). Scan for jumps:

  • Instr 3 is `if...goto 4 else goto 10` → mark 4 and 10 as leaders; mark instruction after the jump (4) as leader too (already done).
  • Instr 5 is `if t<0 goto 7` → mark 7 as leader; mark instruction after (6) as leader.
  • Instr 8 is `goto 3` → mark 3 as leader; mark instruction after (9) as leader.

Leader set: `{1, 3, 4, 6, 7, 9, 10}`.

Instruction #1234567891011
Leader?✓✓✓✓✓✓✓

Pass 2 (slice into blocks).

BlockInstructions
B11, 2 (`i=0`, `s=0`)
B23 (`if i
B34, 5 (`t=a[i]`, `if t<0 goto 7`)
B46 (`s = s + t`)
B57, 8 (`i=i+1`, `goto 3`)
B69 (unreachable — a dangling leader with no predecessor)
B710, 11 (`r=s`, `return r`)

Pass 3 (edges).

Block ends withEdges added
B1 (falls through)B1 → B2
B2 (`if...goto 4 else goto 10`)B2 → B3 (taken), B2 → B7 (not taken — note: this "else" form still yields exactly two successors, same logic as fall-through)
B3 (`if t<0 goto 7`)B3 → B5 (taken), B3 → B4 (fall-through)
B4 (falls through)B4 → B5
B5 (`goto 3`)B5 → B2 (back edge — this is the loop!)
B6 (no predecessor edges in)B6 → B7 (falls through, but never reached)
B7 (`return`)B7 → EXIT
—ENTRY → B1

The resulting graph has a back edge `B5 → B2`, which is precisely the structural signature of a loop — the thing Loop Optimizations and Loop-Invariant Code Motion looks for when identifying natural loops. It also has a genuinely unreachable block `B6`, flagged honestly rather than swept under the rug, which is exactly the kind of edge case a naive implementation gets wrong (see pitfalls below).

Common pitfalls & traps

PitfallWhy it happensConsequence
Forgetting to mark the instruction after a jump as a leaderOnly marking jump targets as leadersMerges two semantically distinct blocks, corrupting all downstream data-flow equations
Treating a conditional jump as having one successorCopy-pasting the unconditional-jump caseLoses the fall-through path entirely — analyses become unsound (miss a real execution path)
Ignoring calls to functions that may not return (`exit`, `longjmp`, exceptions)Treating `call f` as an ordinary fall-through instructionAdds a spurious fall-through edge to unreachable code, or misses an edge to an exception handler
Silently dropping unreachable blocksAssuming every leader has a predecessorLater passes (e.g., dead-code elimination) need those blocks present in the CFG to correctly remove them — deleting too early hides bugs
Off-by-one in "instruction after the jump" when the jump is the last instructionIndex arithmetic not bounds-checkedArray-out-of-bounds or accidental leader marking past the end of the program
Rebuilding the whole CFG after every micro-edit instead of patching itTreating the CFG as immutableO(n) cost repeated many times during iterative optimization passes — wasteful, though not incorrect

Pioneers & history

Basic blocks and their graph structure emerged from the earliest systematic work on program optimization, when researchers realized that program analysis required a coarser unit than the single instruction.

Origins · 1 minFrances E. Allen and how this was discovered
FE
Frances E. Allen
1932–2020 · IBM Research, pioneer of program optimization and control-flow analysis

Frances Allen's work in the 1960s and 1970s formalized control-flow graphs, dominance, and interval analysis as the foundation for automatic program optimization, work recognized with the 2006 A.M. Turing Award — the first awarded to a woman.

History
1966
— Allen's early papers formalize control-flow analysis and program graphs for optimizing compilers at IBM.
1970
— Allen and Cocke develop interval analysis and dominator-based techniques for loop detection over control-flow graphs.
1971
— Basic-block and flow-graph formulations become standard in optimizing compiler research (Allen–Cocke program-optimization framework).
1977
— Data-flow analysis over control-flow graphs is systematized as a general lattice-theoretic framework (Kildall).
1986
— The CFG-based framework is codified as standard compiler curriculum material in the Aho–Sethi–Ullman "Dragon Book."

Where this matters

Every production optimizing compiler — GCC, LLVM, the JVM's HotSpot JIT, .NET's RyuJIT, Go's compiler — builds a CFG (often per function, sometimes per translation unit) as one of its earliest intermediate-representation-building steps, immediately after generating three-address code or an equivalent linear IR. LLVM's IR is explicitly organized as basic blocks grouped into functions, with `br` and `ret` instructions as the only legal block terminators, directly encoding the "single entry, single exit, terminator-only control transfer" discipline described here. JIT compilers in web browsers (V8, SpiderMonkey) build lightweight CFGs on hot functions at run time to drive speculative optimizations, then must be able to cheaply patch or discard the CFG when a speculation fails ("deoptimization"). Static analysis and security tools (control-flow integrity checking, taint analysis, symbolic execution engines like KLEE) rely on the same CFG structure to enumerate feasible execution paths. Profile-guided optimization annotates CFG edges with observed branch frequencies to decide code layout, inlining, and which paths deserve aggressive optimization. In every one of these systems, the CFG built by exactly the leader/block/edge algorithm above is the substrate that later stages — Data-Flow Analysis Framework for liveness and reaching definitions, Static Single Assignment (SSA) Form for renaming variables uniquely per definition, and loop-optimizations for recognizing and transforming natural loops — all consume without modification to its basic shape.

Ask the tutor: How do dominators and natural loops get computed from a control-flow graph, and why do back edges matter for that?

Why not just optimize directly on the flat instruction list instead of building a CFG?

Optimizations like constant propagation, liveness analysis, and common-subexpression elimination need to reason about "what values are available at this point, considering all the ways control could have arrived here." On a flat list with jumps, "all the ways control could have arrived" is an implicit, hard-to-query property scattered across scattered goto targets. The CFG makes predecessors and successors explicit as graph edges, so an analysis can simply ask "what are this block's predecessors?" and get an O(1) answer via adjacency lists, rather than re-scanning the whole program for jumps that target it. This is what makes the standard iterative data-flow algorithms in the Data-Flow Analysis Framework efficient and easy to state.

Does every basic block need at least one instruction?

Yes in the strict definition — an empty basic block is degenerate and usually merged away or never created in a correct implementation, since the leader-based construction always slices a block starting at a leader and ending right before the next leader, guaranteeing at least the leader instruction itself is present. Some compiler intermediate representations do introduce deliberately empty "landing pad" blocks for structural reasons (e.g., as a clean merge point for two branches), but these are a design choice layered on top of the core algorithm, not a consequence of the leader definition itself.

How are function calls handled when building basic blocks?

In the simplest treatment used for intraprocedural analysis, a `call` instruction is treated as an ordinary instruction that falls through to the next instruction — the CFG here models control flow within one function, treating the callee as a black box that always returns. This is sound as long as the callee cannot terminate the program, throw an uncaught exception, or perform a non-local jump. When those possibilities matter (as in languages with exceptions or setjmp/longjmp), a more refined CFG adds extra edges from the call site to exception handlers or omits the fall-through edge for calls known never to return, which is exactly the kind of soundness detail flagged in the pitfalls table.

What happens to a basic block that has no predecessors, like the "unreachable" block in the worked example?

It is still included in the CFG as a legitimate node with zero incoming edges (aside from the mandatory scan that might reach it structurally). Keeping it visible, rather than silently deleting it during construction, matters because a later, dedicated pass — unreachable/dead code elimination — needs to see and reason about it explicitly, often by observing that it is unreachable from `ENTRY` via a graph traversal. Building the CFG and eliminating dead code are kept as separate concerns for modularity and correctness auditing.

Is the control-flow graph the same thing as the call graph?

No, and conflating them is a common beginner mistake. A CFG models control flow within a single function or procedure — its nodes are basic blocks of that function's instructions. A call graph models control flow between functions — its nodes are whole functions or procedures, and its edges represent "function A calls function B." Interprocedural optimizations (like inlining) use the call graph to decide which functions to fold together, and then rebuild or merge the CFGs of the affected functions afterward.

Can two different basic blocks ever have edges to the same successor block?

Absolutely, and this is completely normal — it is how `if/else` merge points and loop exits are represented. For example, both branches of an `if` statement typically have an edge to the same "join" block that follows the `if`, and both the loop-continuation test's true and false outcomes may lead into shared downstream code. The CFG is a general directed graph (not a tree), and this convergence of edges is precisely what makes analyses like available-expressions and dominance interesting and non-trivial.

Why does building the CFG typically come right after generating three-address code rather than working directly on the source AST?

Three-address code has already linearized all control constructs (if/while/for) down to explicit labels and conditional/unconditional jumps, which is exactly the vocabulary the leader-finding rule needs. Working directly on a syntax tree would require re-deriving jump targets from nested `if`/`while` structure for every language construct, duplicating logic that the earlier lowering to three-address code already handled uniformly. This is why the prerequisite of understanding Intermediate Representations (three-address code) comes before CFG construction in the compiler pipeline.

Where this leads