COMPILER DESIGN / 4. INTERMEDIATE CODE GENERATION
Intermediate Code Generation
Three-Address Code and IR — language-independent representation for optimization
EXPLANATION
After semantic analysis, the compiler translates the AST into an Intermediate Representation (IR) — a form that is: - Lower-level than the source language (closer to machine code) - Higher-level than actual assembly (still machine-independent) - Easy to optimize - Easy to translate to different target architectures Why have an IR at all? The two-phase benefit: - N source languages × M target architectures = N×M compilers without IR - With IR: N front-ends + M back-ends = N+M components - LLVM is the prime example: Clang (C front-end) + Rust + Swift all compile to LLVM IR, which then targets x86, ARM, RISC-V, WebAssembly Three-Address Code (TAC): The most common IR form. Each instruction has at most ONE operator and THREE addresses (operands). Every complex expression is broken into a sequence of simple assignments. Forms: - x = y op z (binary operation) - x = op y (unary operation) - x = y (copy) - goto L (unconditional jump) - if x goto L (conditional jump) - x = y[i] (array access) - x[i] = y (array assignment) - x = call f, n (function call with n args) - param x (push argument) - return x Temporaries: TAC introduces temporary variables (t1, t2, t3...) to hold intermediate values. These map to registers during code generation. SSA (Static Single Assignment) form: Modern compilers (LLVM, GCC) use SSA — every variable is assigned exactly once. Φ (phi) functions merge values from different control flow paths. - Makes dataflow analysis much simpler - Makes most optimizations easier to implement - Every LLVM IR instruction is in SSA form Basic Blocks and Control Flow Graph (CFG): - Basic block: maximal sequence of instructions with no branches in or out (except at start/end) - CFG: directed graph where nodes = basic blocks, edges = possible control flow - Optimizations work on the CFG — dead code = unreachable blocks LLVM IR — the real-world IR: LLVM IR is typed, in SSA form, and explicitly manages memory via alloca/load/store. Clang, Rust, Swift, Kotlin/Native all compile to LLVM IR. You can inspect it with "clang -emit-llvm -S file.c".
DIAGRAM
AST for: result = 2 + 3 * x - y
ASSIGN
/ result -
/ + y
/ 2 *
/ 3 x
THREE-ADDRESS CODE:
t1 = 3 * x ; t1 is a temporary
t2 = 2 + t1 ; t2 holds 2 + 3*x
t3 = t2 - y ; t3 holds full expr
result = t3 ; final assignment
SSA FORM:
t1_1 = 3 * x_1
t2_1 = 2 + t1_1
t3_1 = t2_1 - y_1
result_1 = t3_1 (each variable assigned exactly once)
CONTROL FLOW GRAPH for if-else:
┌─────────────────┐
│ Block 0 │
│ t1 = x > 0 │
│ if t1 goto B1 │
└───────┬─────────┘
│
┌────┴────┐
↓ ↓
┌──────┐ ┌──────┐
│ B1 │ │ B2 │
│ y=x+1│ │ y=0 │
└──┬───┘ └──┬───┘
└────┬────┘
↓
┌──────┐
│ B3 │
│ret y │
└──────┘CODE