Intermediate Representation

Between the AST and final machine code sits an intermediate representation (IR) — a lower-level, typically language- and target-independent form that makes both optimization and multi-target code generation dramatically simpler than working directly from either the AST or final assembly.

An intermediate representation (IR) is a form of the program that's lower-level than the source language's AST but higher-level (and more abstract) than any one specific CPU's actual machine code — a kind of universal middle ground a compiler translates the AST into before optimizing and eventually generating real machine instructions.

Without an IR, a compiler supporting N source languages and M target CPU architectures would essentially need N × M different translation paths. With a shared IR, you only need N front ends (source language to IR) and M back ends (IR to specific machine code) — a dramatic reduction in engineering effort, and exactly the architecture real infrastructure like LLVM uses.

Every serious optimizing compiler uses some form of IR internally — LLVM IR (used by Clang, Rust's compiler, Swift's compiler and others), Java bytecode and .NET's Common Intermediate Language (CIL) are all forms of intermediate representation, even though the latter two are also directly executable by a virtual machine, which is a distinct additional role covered in the future Interpreters, Bytecode & Runtimes module.

Think of IR like a universal shipping container standard. Instead of every manufacturer designing custom-shaped cargo for every specific ship, plane, or truck (an impossibly large number of combinations), everything gets packed into standard shipping containers first — and then any truck, train, or ship that knows how to handle standard containers can carry cargo from any manufacturer. The IR is the standard container format sitting between many different "manufacturers" (source languages) and many different "carriers" (target CPU architectures).

The single nested source expression is broken down into a sequence of simple instructions, each performing exactly one operation and storing its result in a temporary (t1) or a named variable — this simple, uniform shape is exactly what makes IR easy for later optimization and code-generation passes to analyze and transform mechanically.

Three-address code is a common style of IR where every instruction has at most one operator and up to three operands (typically two sources and one destination) — deliberately simple and uniform, making it easy to analyze and transform mechanically, unlike an AST's deeply nested, language-specific tree shapes.

A basic block is a straight-line sequence of IR instructions with exactly one entry point (at the top) and one exit point (at the bottom, typically a branch or jump) — no branching in or out except at those two boundaries. A Control Flow Graph (CFG) connects basic blocks together according to how control can actually flow between them (via conditional or unconditional jumps), forming the backbone data structure most optimizations and analyses operate on.

Static Single Assignment (SSA) form is a widely used IR discipline where every variable is assigned to exactly once in the program's text — if a value needs to change, a new versioned variable name is introduced instead (x1, x2, x3...) — which dramatically simplifies many optimization and analysis algorithms, since a variable's single definition is always unambiguous and easy to locate.

LLVM IR is a specific, extremely widely used real-world IR (used by Clang for C/C++, and by many other compilers including Rust's and Swift's) — it's typed, in SSA form, and has both a human-readable text format and a compact binary bitcode format, and is a genuinely useful thing to read directly when trying to understand what a compiler actually does with a piece of source code.

Assuming IR is just "lower-level source code" — it's specifically designed to be uniform and mechanically analyzable in ways source code (with its rich, language-specific grammar) is not; the whole point is to strip away everything language-specific except the actual computation being performed.

Confusing IR with bytecode — some IRs (like Java bytecode, .NET CIL) are also directly executable by a virtual machine and are IR and bytecode at once, but many IRs (like LLVM IR in typical native-compilation use, or three-address code inside a traditional compiler) are purely internal and never directly executed by anything — this distinction is developed fully in the future Interpreters, Bytecode & Runtimes module.

Underestimating how much of a modern optimizing compiler's actual logic lives at the IR level rather than the AST level — most of the interesting optimization work (the subject of the next section) happens on IR, specifically because of its uniform, mechanically analyzable shape.

Using a tool like Compiler Explorer, or a compiler's own IR-dumping flags (like Clang's -emit-llvm), to look at the actual IR generated for a piece of source code, to build real intuition for what a compiler does between parsing and final code generation.

Recognizing SSA-form variable names (x1, x2, x3...) if you ever inspect real compiler IR output, understanding that each represents a genuinely distinct single-assignment version of a logically same-named source variable.

Understanding that choosing LLVM (or another IR-based toolkit) as a compiler's back end is precisely what lets a new source language's compiler support many CPU architectures (x86, ARM, RISC-V, and more) essentially for free, without writing a separate code generator for each.

Why SSA form specifically helps optimization Because every variable has exactly one definition point in SSA form, many optimizations that would otherwise require complex reasoning about which of several possible assignments a use actually refers to (data-flow analysis) become dramatically simpler — a use's single corresponding definition can often be found directly, rather than needing an expensive, separate analysis pass to determine it, which is precisely why nearly every modern optimizing compiler converts to SSA form internally before running its main optimization passes.

Phi functions at control-flow merge points SSA form needs a special mechanism at points where control flow merges (like after an if/else, where a variable might have been assigned different SSA versions along each branch) — a phi (Φ) function that says, in effect, "this new SSA version equals x1 if control arrived from the first branch, or x2 if it arrived from the second." Phi functions have no direct machine-code equivalent; they're a purely IR-level bookkeeping device that must be eliminated (typically converted back into ordinary variable copies or register assignments) before final code generation.

Multiple IR levels in one compiler Many production compilers don't use just one IR — they progressively lower the program through several IRs of decreasing abstraction (for example, Rust's compiler uses HIR, then MIR, then LLVM IR before finally reaching machine code), each level suited to different kinds of analysis and optimization, with earlier, higher-level IRs retaining more source-language-specific structure useful for certain checks, and later, lower-level IRs being closer to what a real CPU can execute.

Why do compilers use an intermediate representation instead of generating machine code directly from the AST?

An IR provides a uniform, language-independent and largely target-independent form that both optimizations and code generators can be written against once, rather than needing separate logic for every combination of source language and target architecture — dramatically reducing the engineering effort for a compiler supporting multiple languages and/or targets.

What is SSA form, and why is it useful?

A form of IR where every variable is assigned exactly once (new versions are created rather than reassigning), which makes many optimization and analysis algorithms significantly simpler, since a value's single definition point is always unambiguous.

What is a basic block?

A straight-line sequence of instructions with exactly one entry point and one exit point — no jumps into or out of the middle of it. Basic blocks connected according to possible control flow form a Control Flow Graph, the structure most compiler optimizations actually operate on.

What is LLVM IR, and why is it significant?

A widely used, typed, SSA-based intermediate representation with both a human-readable and a binary format. It's significant because many different compilers for different source languages (Clang for C/C++, and compilers for Rust, Swift, and others) all target it, letting them share LLVM's single, powerful optimization and code-generation back end rather than each building their own.

Source:  z = (a + b) * c

IR (three-address code):
  t1 = a + b
  z  = t1 * c