thecodex.expert  ·  The Codex Family of Knowledge
How Languages Work

Compilation

How a compiler takes text you wrote and produces a program a computer can run — the six-stage pipeline that every compiled language uses.

How Languages Work Requires: How Computers Work ~22 min read Last verified:
Canonical Definition

Compilation is the process by which a compiler translates source code written in a high-level programming language into a lower-level representation — typically machine code or an intermediate representation — by performing lexical analysis, syntactic parsing, semantic analysis, optimisation, and code generation as a series of sequential transformation passes over the source text.

One sentence that captures it

A compiler is a translator: it reads code you wrote for human eyes and produces code a processor can execute — and it does this before your program runs, not while it runs.

The translation problem

When you write x = 5 + 3 in C or Rust, the processor has no idea what that means. Processors speak machine code — binary instructions like "load the value 5 into register R1, load 3 into register R2, add them, store the result." A compiler bridges that gap.

The job of a compiler sounds simple: take text in one language, produce output in another. But the source text has to be understood completely — every variable, every function call, every loop — before a single byte of machine code can be produced. That understanding happens in stages, each building on the last.

Stage 1 — Lexing: turning text into words

The first thing a compiler does is break the source text into tokens — the smallest meaningful units. The string int x = 42; is not four characters to a compiler — it is four tokens: a keyword (int), an identifier (x), an assignment operator (=), and a number literal (42), followed by a semicolon.

This stage is called lexical analysis (or lexing, or tokenisation). Whitespace and comments are stripped. What remains is a clean sequence of tokens. Think of it as the compiler learning to read individual words before it can understand sentences.

Stage 2 — Parsing: understanding structure

Tokens become a tree. The expression 2 + 3 * 4 is not just three numbers and two operators in a row — it has structure: the multiplication happens before the addition, so the tree has * as a child of +. This tree is called an Abstract Syntax Tree (AST).

Parsing enforces the grammar of the language. If you write int = x 42; instead of int x = 42;, the parser rejects it — the token sequence doesn't match any valid grammar rule.

Real-world analogy

Parsing is like diagramming a sentence. "The cat sat on the mat" has a subject (cat), a verb (sat), and a prepositional phrase (on the mat). The compiler does the same for code: it identifies what is a declaration, what is an expression, what is a conditional, and how they nest inside each other. The resulting tree represents the grammar structure of your program.

Stage 3 — Semantic analysis: checking meaning

The AST might be grammatically valid but still wrong. Adding a number to a function, using a variable before declaring it, calling a function with the wrong number of arguments — these are grammatically legal in terms of token sequences, but meaningless. Semantic analysis catches them.

This is where type checking happens in statically typed languages. The compiler walks the AST, assigns types to every expression, and verifies that operations are applied to compatible types. The error "cannot add int and string" comes from this stage.

Stage 4 — Optimisation: making it faster

Once the compiler understands what the program does, it looks for ways to produce the same result more efficiently. It might replace x * 2 with x << 1 (a left-bit-shift, which is faster). It might eliminate code that can never execute. It might move a constant calculation out of a loop so it's done once instead of thousands of times.

Optimisations preserve the meaning of the program — the output must be identical to what the original code would produce — while reducing execution time or memory use.

Stage 5 — Code generation: producing machine code

The final stage translates the optimised representation into actual machine instructions for a specific processor. A program compiled for an Intel x86-64 processor produces different binary output than the same program compiled for an ARM processor, even though the source code is identical. This is why compiled programs are platform-specific.

What languages use compilation

C, C++, Rust, Go, and Swift compile directly to machine code. The resulting program runs without any other software — just the operating system. Java and Kotlin compile to bytecode for the Java Virtual Machine (JVM), which is an intermediate step: bytecode is not machine code but is lower-level than source code, and the JVM executes it at runtime (often with further just-in-time compilation to machine code). TypeScript compiles to JavaScript, which is then interpreted or JIT-compiled in the browser.

The key characteristic of compilation is that the translation happens before the program runs. You run the compiler once, producing an executable file. That file is what you distribute and run — the compiler is no longer involved.

The compiler pipeline in detail

The standard compilation pipeline described in Aho, Lam, Sethi, and Ullman's Compilers: Principles, Techniques, and Tools (the "Dragon Book") consists of a front end, a middle end, and a back end:

  • Front end: lexical analysis → syntactic analysis → semantic analysis → IR generation. Language-specific.
  • Middle end: IR optimisation passes. Language-agnostic; operates on the intermediate representation.
  • Back end: instruction selection → register allocation → code generation. Architecture-specific.

This separation allows compilers like LLVM to support many source languages (via separate front ends) and many target architectures (via separate back ends), sharing the middle-end optimisation work. Clang is LLVM's C/C++ front end. Rust's compiler (rustc) uses LLVM as its back end.

Lexical analysis in detail

The lexer (scanner) reads source characters and produces a token stream. Tokens are described by regular expressions — each token type (identifier, integer literal, string literal, keyword, operator) is matched by a pattern. The lexer is typically implemented as a finite automaton derived from the regular expression patterns.

Consider: result = x + 42;

Token stream lexer-output.txt
IDENTIFIER  "result"
ASSIGN      "="
IDENTIFIER  "x"
PLUS        "+"
INTEGER     "42"
SEMICOLON   ";"

Syntactic analysis and the AST

The parser takes the token stream and builds an Abstract Syntax Tree according to the language's context-free grammar — typically specified in BNF or EBNF notation. The AST abstracts away syntactic details (parentheses, semicolons) and retains only the structural meaning.

For result = x + 42, the AST looks like:

AST structure ast.txt
AssignStatement
├── left:  Identifier("result")
└── right: BinaryExpr(op="+")
           ├── left:  Identifier("x")
           └── right: IntegerLiteral(42)

Common parsing algorithms: recursive descent (handwritten, top-down; used in GCC, Clang, rustc), LR parsing (bottom-up, table-driven; used in many generated parsers), and PEG parsers (Parsing Expression Grammars; used in some modern languages). Most production compilers use hand-written recursive descent for its error-recovery flexibility.

Semantic analysis and the symbol table

The semantic analysis phase walks the AST and builds a symbol table — a mapping from identifier names to their types, scopes, and locations. It performs:

  • Scope resolution: which declaration does each identifier reference?
  • Type checking: are operations applied to compatible types?
  • Control flow analysis: does every code path return a value? (for non-void functions)
  • Definite assignment: is every variable used after being initialised?

In Rust, the borrow checker is a semantic analysis pass that enforces ownership rules — verifying that references are valid and no data races exist. This happens entirely at compile time, producing zero runtime overhead.

Optimisation passes

The middle end operates on an Intermediate Representation (IR) — a low-level but architecture-independent form. LLVM's IR is called LLVM IR; GCC's is called GIMPLE. Common optimisation passes include:

  • Constant folding: evaluate 2 + 3 at compile time → 5
  • Dead code elimination: remove code the program can never reach
  • Loop-invariant code motion: move computations that don't change inside a loop to before the loop
  • Inlining: replace a function call with the function body (eliminates call overhead)
  • Common subexpression elimination: compute a + b once if used multiple times
  • Vectorisation: transform scalar loops into SIMD instructions

Optimisation levels in GCC and Clang: -O0 (none), -O1 (basic), -O2 (standard), -O3 (aggressive), -Os (optimise for size). Higher levels trade compilation time for runtime performance.

Code generation and linking

Instruction selection maps IR operations to processor instructions. Register allocation decides which values to keep in CPU registers (fast) versus spill to memory (slow) — this is an NP-hard problem in general; heuristics like graph colouring are used. Instruction scheduling reorders instructions to reduce pipeline stalls on superscalar processors.

The compiler produces object files (.o on Linux, .obj on Windows) — machine code with unresolved external references. The linker combines object files and resolves references to produce an executable. Static linking bundles library code into the executable. Dynamic linking leaves references to shared libraries (.so / .dll) to be resolved at load time.

Commonly confused
Compilation and interpretation are not opposites of a binary choice. Many languages use both: Java compiles to bytecode (a compilation step), and the JVM interprets bytecode or JIT-compiles it to machine code (an interpretation/compilation step at runtime). CPython compiles Python source to .pyc bytecode before interpreting it. The distinction is a spectrum, not a binary.
A compiler is not the same as a linker. A compiler translates source code to object code for one translation unit. A linker combines multiple object files and resolves references between them to produce a final executable. gcc hello.c -o hello invokes both automatically, which is why the distinction is often invisible to beginners.
Compiled languages are not always faster than interpreted ones. Compilation enables aggressive ahead-of-time optimisation, which typically produces faster code. But a well-designed bytecode VM with JIT compilation (V8 for JavaScript, HotSpot for Java) can achieve performance close to ahead-of-time compiled code for long-running programs, because the JIT observes actual runtime behaviour and optimises for it.
How this connects
Confused with

Formal language theory foundations

Compiler theory is grounded in formal language theory. A programming language's syntax is formally described by a context-free grammar (CFG) — a set of production rules of the form A → α, where A is a non-terminal symbol and α is a string of terminal and non-terminal symbols. The language defined by a CFG is the set of all strings derivable from the start symbol.

The Chomsky hierarchy (Noam Chomsky, 1956) classifies formal grammars by generative power: regular grammars (Type 3, recognisable by finite automata — sufficient for lexical analysis), context-free grammars (Type 2, recognisable by pushdown automata — sufficient for syntax), context-sensitive grammars (Type 1), and unrestricted grammars (Type 0, Turing-complete). Programming language syntax is context-free; some semantic constraints (e.g. variable scoping) require context-sensitive analysis handled by the semantic pass, not the parser.

LLVM IR: a concrete intermediate representation

LLVM IR is a typed, SSA-form (Static Single Assignment) intermediate representation. In SSA form, each variable is assigned exactly once — allowing many optimisations to work by simple inspection of definition-use chains without complex dataflow analysis. The following C code:

C add.c
int add(int a, int b) {
    return a + b;
}

Compiles to LLVM IR approximately as:

LLVM IR add.ll
define i32 @add(i32 %a, i32 %b) {
entry:
  %result = add i32 %a, %b
  ret i32 %result
}

LLVM IR is architecture-independent. The same IR can be compiled to x86-64, ARM, WebAssembly, or any other target that has an LLVM back end.

Register allocation: graph colouring

Register allocation assigns program variables to a finite set of processor registers. The classical approach models this as a graph colouring problem: build an interference graph where nodes are variables and edges connect variables that are simultaneously live (in use); a valid register assignment is a graph colouring using k colours where k is the number of available registers. Graph colouring is NP-complete in general (Karp, 1972), so compilers use heuristics — Chaitin's algorithm (1982) is the classical approach; linear scan allocation (Poletto and Sarkar, 1999) is widely used in JIT compilers for speed.

Specification reference

The authoritative reference for compiler construction is: Aho, A. V., Lam, M. S., Sethi, R. & Ullman, J. D. (2006). Compilers: Principles, Techniques, and Tools (2nd ed.). Pearson. Known as the "Dragon Book." The LLVM project's documentation at llvm.org/docs/ is the primary reference for LLVM IR and the LLVM compilation framework.

Key papers

  • Backus, J. et al. (1957). "The FORTRAN automatic coding system." Proceedings of the Western Joint Computer Conference. — The first optimising compiler; demonstrated that machine-code performance was achievable from high-level source.
  • Chaitin, G. J. et al. (1981). "Register allocation via coloring." Computer Languages, 6(1), 47–57.
  • Poletto, M. & Sarkar, V. (1999). "Linear scan register allocation." ACM TOPLAS, 21(5).
  • Lattner, C. & Adve, V. (2004). "LLVM: A compilation framework for lifelong program analysis & transformation." CGO 2004.
How this connects
Formal basis
Source confidence: High Last verified: Primary source: Aho et al., Compilers (Dragon Book), 2006

Sources

1
Aho, A. V., Lam, M. S., Sethi, R. & Ullman, J. D. (2006). Compilers: Principles, Techniques, and Tools (2nd ed.). Pearson Education. — The primary reference for all compiler construction: lexical analysis, parsing, semantic analysis, optimisation, and code generation.
2
Lattner, C. & Adve, V. (2004). "LLVM: A compilation framework for lifelong program analysis & transformation." Proceedings of the International Symposium on Code Generation and Optimisation (CGO 2004). IEEE. — Original LLVM paper; reference for IR design and the separation of front/middle/back ends.
3
Backus, J. W. et al. (1957). "The FORTRAN automatic coding system." Proceedings of the Western Joint Computer Conference, pp. 188–198. ACM. — Historical primary source: the first practical optimising compiler.
4
Chaitin, G. J., Auslander, M. A., Chandra, A. K., Cocke, J., Hopkins, M. E. & Markstein, P. W. (1981). "Register allocation via coloring." Computer Languages, 6(1), 47–57. — Classical graph-colouring register allocation algorithm.
5
LLVM Project. LLVM Language Reference Manual. llvm.org/docs/LangRef.html. — Authoritative reference for LLVM IR syntax, semantics, and type system.