The Structure of a Compiler

A compiler is often drawn as a single arrow — source in, machine code out — but that arrow hides the most important design decision in the whole field. Look inside any serious compiler and you find it split into two halves that barely know each other exist. The first half, the analysis phase, reads the source program and takes it apart, checking that it is well-formed and building an internal picture of what it means. The second half, the synthesis phase, takes that internal picture and builds a target program out of it. Between them sits a carefully designed intermediate representation (IR) — the pivot on which the entire structure turns.

This analysis / synthesis split is not an accident of implementation. It is the single idea that lets a handful of people build compilers for many languages targeting many machines without doing m \times n separate projects. Understand why the compiler is shaped this way and every later phase — lexing, parsing, type checking, optimisation, register allocation — drops into an obvious place. This page is about the shape, not yet the phases.

Front end, middle end, back end

In practice the two halves are usually named as three. The front end is language-dependent and machine-independent: it knows everything about the source language — its keywords, its grammar, its type rules — and nothing about the target processor. The back end is the mirror image: machine-dependent and language-independent. It knows the target's registers, addressing modes and instruction set, and nothing about whether the program it is emitting began life as C, Rust or Fortran. In the middle sits the middle end (or "optimiser"), which is independent of both: it transforms IR into better IR — folding constants, deleting dead code, hoisting loop invariants — knowing neither the source syntax nor the target opcodes.

PartDepends on the source language?Depends on the target machine?Chief jobs
Front endYesNolexing, parsing, semantic analysis, IR generation
Middle endNoNomachine-independent optimisation of the IR
Back endNoYesinstruction selection, register allocation, scheduling, machine-dependent optimisation

The boundary that matters is the one drawn in the middle column and the middle row: the front end never mentions a register; the back end never mentions a keyword; and the optimiser mentions neither. Each part speaks only the IR to its neighbours.

The pipeline, drawn

Reveal the structure box by box. Watch how everything to the left of the IR is a property of the language, everything to the right is a property of the machine, and the IR is the neutral meeting point in between.

Read it left to right and the compiler tells you its own story: source text is analysed into meaning, that meaning is expressed as IR, the IR is polished, and the polished IR is synthesised into a program the machine can run. The dotted line down the centre is the front-end / back-end boundary — the most valuable line in the diagram, for the reason the next card explains.

Why the split exists: m \times n becomes m + n

Suppose your organisation must compile m source languages onto n target architectures. The naïve approach writes one dedicated compiler for each pairing: C-to-x86, C-to-ARM, Rust-to-x86, Rust-to-ARM, and so on — a full grid of m \times n compilers, each one a monolith duplicating the same optimisations. Add one new chip and you owe m new back ends; add one new language and you owe n new front ends.

The analysis / synthesis split dissolves the grid. Agree on one shared IR. Now each language needs exactly one front end that lowers it to the IR, and each machine needs exactly one back end that emits code from the IR. Any front end composes with any back end through the common IR, so you build

m \;+\; n \quad\text{components instead of}\quad m \times n.

This is retargetability, and it is the economic engine of the whole discipline. For m = 5 languages and n = 8 targets the difference is 13 components versus 40 — and the gap grows without bound. Better still, the expensive machine-independent optimiser is written once, in the middle, and every language on every target inherits it for free.

// Retargetability arithmetic: shared IR turns a grid into a cross. function monolithic(m: number, n: number): number { return m * n; // one bespoke compiler per (language, target) pair } function withSharedIR(m: number, n: number): number { return m + n; // m front ends + n back ends, glued by one IR } for (const [m, n] of [[2, 3], [5, 8], [10, 12]] as const) { const grid = monolithic(m, n); const cross = withSharedIR(m, n); console.log( `${m} languages x ${n} targets: monolithic=${grid} shared-IR=${cross} ` + `(saves ${grid - cross}, ${(grid / cross).toFixed(1)}x fewer)`, ); }

Where each responsibility lives

The split also tells you, unambiguously, where a given job belongs. Anything that depends on the source language's rules is front-end work; anything that depends on the target's hardware is back-end work; anything that depends on neither is middle-end work.

When a design argument breaks out over which phase should do some job, this principle usually settles it. "Should we constant-fold 2 + 3 into 5?" — that needs no knowledge of the source or the target, so it is middle-end work, done once for everyone. "Should we keep this value in a register or spill it to the stack?" — that is intimately about the target's register count, so it is back-end work.

The internet works because thousands of applications and thousands of physical networks all agree on one thin protocol in the middle — IP. Everything above it (HTTP, SSH, video) and everything below it (Ethernet, Wi-Fi, fibre) only has to speak to that single narrow layer, never to each other. Compilers borrow the same "hourglass" shape, and its waist is the shared IR.

LLVM made this explicit and industrial. Clang (C/C++/Objective-C), Rust, Swift, Julia and dozens of other front ends all lower to LLVM IR; from that single IR, LLVM's back ends emit x86, ARM, RISC-V, WebAssembly and more. Write a new language front end and you inherit every LLVM target and every LLVM optimisation the day you finish. GCC has the same shape with its GIMPLE and RTL IRs. The waist is narrow on purpose: the fewer assumptions the IR bakes in about source or target, the more front ends and back ends can meet there.

A frequent confusion is to picture the compiler as a straight chain of phases — lex, parse, check, optimise, codegen — and to assume the "front end vs back end" split is just the gap between two adjacent links in that chain. It is not. The front/back-end boundary is an axis of dependency (does this work need the source language, or the target machine?), whereas the phase list is a sequence of transformations over time. They are different pictures of the same compiler.

Concretely: the front end contains several phases (lexing, parsing, semantic analysis, IR generation), and the back end contains several more (instruction selection, register allocation, scheduling). The optimiser in the middle may itself be dozens of passes. So "front end" is a region of many phases defined by what they depend on, not a single phase. Keep the two mental models apart: dependency tells you where a job lives; the phase list tells you when it runs. We unpack the phase list on the next page.