11.3 Control-Flow Translation for Branches and Loops
Statements such as if, while, and for are structured in the source language. IR expresses them as basic blocks connected by conditional branches, unconditional branches, and terminators. Lowering must turn nesting into a control-flow graph (CFG) without losing which paths can execute.
An if-else normally creates a condition block, two branch blocks, and a join. A branch that returns early does not flow to the join, so the CFG—not indentation—decides which values and phi inputs are required.
Lowering control flow is easiest when every helper has an explicit continuation. A statement-lowering routine receives the block where execution currently is, creates any blocks it needs, and returns the block where normal execution continues. If every path terminates with return or throw, there may be no continuation at all. This avoids inventing false edges merely to keep emitting instructions linearly.
Lowering a branch
For if (score >= 60) pass(); else retry();, a simple shape is:
entry:
%c = icmp.ge %score, 60
br %c, then, else
then:
call @pass()
br join
else:
call @retry()
br join
join:
...Every reachable block must end in exactly one terminator. The builder should refuse to emit instructions after ret, throw, or an unconditional branch in the same block.
An if without an else still needs a false destination. The join block serves that role:
entry:
br %condition, then, join
then:
call @work()
br join
join:
; execution continues hereIf the then block ends with return, it does not branch to join; only the false edge reaches the continuation. This difference later determines whether values need phi nodes.
Loops are blocks plus targets
A pre-tested loop typically has preheader → header → body → latch → header, with an exit edge from the header. A do-while enters the body before its first test. A for loop usually gives its update expression a separate block.
These distinctions matter for continue: in a while, it normally targets the condition header; in a for, it must target the update block before returning to the header. break targets the loop exit. A stack of active break and continue targets lets nested loops lower without special cases.
Here is the skeleton for for (i = 0; i < n; i++) body(i):
preheader:
store 0, @i
br header
header:
%i0 = load @i
%keep = icmp.lt %i0, %n
br %keep, body, exit
body:
call @body(%i0)
br update
update:
%i1 = load @i
%i2 = add %i1, 1
store %i2, @i
br header
exit:
...A continue inside body targets update; a break targets exit. In an SSA-based lowerer, the repeated loads and stores may instead become a loop-carried phi in header, but the control-flow shape remains the same.
Verify topology, not pretty printing
Good tests assert graph properties: all blocks terminate, every branch target exists, unreachable blocks are intentional, continue reaches the correct update or test block, and break leaves the innermost active loop. CFG verification catches errors that an attractive IR listing can hide.
Nested loops make target management especially important. On entering a loop, push its {breakTarget, continueTarget} pair; on leaving, pop it. A break inside an inner loop then selects the top pair rather than accidentally exiting the outer loop. Languages with labeled breaks can search this stack for a matching label.
Finally, distinguish unreachable code from an unfinished block. Code after an unconditional return is unreachable and should not be appended to the terminated block. The compiler may omit it, place it in a separately marked unreachable block for diagnostics, or warn the user—but it must never create an impossible fallthrough edge.