CMPUT-415 · Lesson 1 of 12 · 37 min

What compilers do and how they are built

A compiler translates a program written in one language, the source language, into an equivalent program in another, the target language, usually a lower-level one that a machine can execute. That one sentence hides a remarkable amount of engineering. A production compiler such as GCC or Clang is millions of lines of code, accepts programs that are themselves millions of lines long, must never change what a correct program means, reports helpful errors for incorrect ones, and still produces machine code that is often faster than what a skilled person would write by hand. This course is about how that is done. We build, lesson by lesson, a compiler for a small language called Tern, and along the way meet the theory that makes each phase reliable: regular expressions and automata for scanning, context-free grammars for parsing, type systems for checking, graphs and lattices for optimization, and graph coloring for register allocation.

You have met parts of this story before. CMPUT-325 (lessons 2, 7, 8 and 10) introduced grammars, recursive-descent parsing, type inference and garbage collection from the point of view of language design, and its capstone built an interpreter for a language called Mini. CMPUT-229 (lessons 5, 7 and 8) showed RISC-V machine code, calling conventions and the four stages of the C toolchain from the hardware side. This course sits between them: it is about the translator itself, and about doing translation well. Where an earlier course already explains an idea, we cite it and build on it rather than repeating it.

Translators: compilers, interpreters and hybrids

An interpreter executes a program directly, reading its representation and carrying out each operation as it goes. A compiler translates the whole program ahead of time and leaves execution to something else. The distinction is less sharp than textbooks once suggested, because almost every modern implementation is a hybrid. CPython compiles source code to bytecode and then interprets the bytecode. Java compiles source to JVM bytecode ahead of time; the JVM interprets that bytecode at first, then compiles the frequently executed parts to machine code while the program runs. JavaScript engines such as V8 parse source, interpret a bytecode, and recompile hot functions with progressively more aggressive optimizing compilers. A just-in-time (JIT) compiler is simply a compiler that runs during execution, so it can use information, such as the actual types flowing through a function, that an ahead-of-time compiler cannot see.

How some widely used language implementations divide translation and execution (2026)
ImplementationTranslationExecution
GCC, Clang (C, C++)Ahead of time, source to native machine codeDirectly by the processor
rustc, Go, SwiftAhead of time to native code (rustc and Swift through LLVM)Directly by the processor
CPythonSource to bytecode, cached in .pyc filesBytecode interpreter; an experimental JIT since 3.13
HotSpot JVM (Java, Kotlin)javac to bytecode; tiered JIT at run timeInterpreter, then C1 and C2 compiled code
V8 (JavaScript)Parser to bytecode; several JIT tiersIgnition interpreter, then Sparkplug, Maglev and TurboFan code
WebAssembly enginesAhead of time to Wasm; Wasm to native in the browserBaseline and optimizing compilers in the engine

Why compile at all, when interpreting is simpler? Speed is the obvious answer: an interpreter pays a dispatch cost on every operation, decoding what to do next, while compiled code simply does it. A compiler can also analyze the whole program, find errors before it runs, and transform it in ways an interpreter has no time for. Interpreters win on start-up time, portability and simplicity, and they make debugging and dynamic features easy. The hybrids above exist because each approach is best for part of a program's life.

The phases of a compiler

Compilers are organized as a pipeline of phases, each transforming one representation of the program into another. The classic division, which goes back to the first FORTRAN compiler (1957) and was codified in the textbooks of the 1970s and 1980s, has three parts. The front end understands the source language: it reads characters, groups them into tokens, recognizes the grammatical structure, and checks that the program obeys the language's rules. The middle end, or optimizer, improves the program in a form independent of both source and target. The back end maps the improved program onto a particular machine, choosing instructions, assigning registers and laying out memory.

A left-to-right pipeline of boxes. Source text enters the front end, which contains lexical analysis, parsing and semantic analysis, producing an abstract syntax tree. The middle end lowers it to an intermediate representation and applies optimization passes. The back end performs instruction selection, register allocation and emission of target code. Arrows show the representation passed between phases: tokens, tree, IR, machine code.
The phases of a compiler and the representation passed between them

Lexical analysis (scanning) turns a stream of characters into a stream of tokens: identifiers, keywords, numbers, operators and punctuation, with whitespace and comments discarded. Syntax analysis (parsing) checks that the tokens form a grammatically valid program and builds a tree that records its structure. Semantic analysis checks the rules that grammars cannot express conveniently: every name is declared before use, types match, functions are called with the right number of arguments. The output of the front end is an annotated abstract syntax tree. The middle end lowers that tree into an intermediate representation (IR), typically a sequence of simple instructions grouped into a control-flow graph, and runs optimization passes over it. The back end selects machine instructions, allocates the unlimited temporaries of the IR to a handful of physical registers, and emits assembly or an object file.

The program below is a complete compiler for arithmetic expressions in about fifty lines. It has a scanner, a recursive-descent parser, a code generator for a stack machine, and the stack machine itself, so we can watch an expression pass through every phase.

A complete miniature compiler: scanning, parsing, code generation and execution of an arithmetic expression
# A complete (tiny) compiler for arithmetic: source text -> tokens -> AST ->
# stack-machine code -> execution. Every real compiler has these same phases.
import re
SRC = "2 + 3 * (10 - 4) / 2"

def lex(src):                                   # phase 1: characters -> tokens
    toks = []
    for num, op, bad in re.findall(r"\s*(?:(\d+)|([-+*/()])|(\S))", src):
        if bad: raise SyntaxError(f"unexpected character {bad!r}")
        toks.append(("NUM", int(num)) if num else ("OP", op))
    return toks + [("EOF", None)]

def parse(toks):                                # phase 2: tokens -> tree
    pos = 0
    def peek(): return toks[pos]
    def take():
        nonlocal pos; pos += 1; return toks[pos - 1]
    def expr():                                 # expr := term (('+'|'-') term)*
        node = term()
        while peek() in (("OP", "+"), ("OP", "-")):
            node = (take()[1], node, term())
        return node
    def term():                                 # term := atom (('*'|'/') atom)*
        node = atom()
        while peek() in (("OP", "*"), ("OP", "/")):
            node = (take()[1], node, atom())
        return node
    def atom():
        kind, val = take()
        if kind == "NUM": return val
        if val == "(":
            node = expr(); assert take() == ("OP", ")"), "expected )"; return node
        raise SyntaxError(f"unexpected {val!r}")
    tree = expr(); assert peek()[0] == "EOF"; return tree

def gen(node, code):                            # phase 3: tree -> instructions
    if isinstance(node, int):
        code.append(("PUSH", node))
    else:
        op, a, b = node
        gen(a, code); gen(b, code)
        code.append(({"+": "ADD", "-": "SUB", "*": "MUL", "/": "DIV"}[op],))
    return code

def run(code):                                  # the "machine"
    st = []
    for ins in code:
        if ins[0] == "PUSH": st.append(ins[1]); continue
        b, a = st.pop(), st.pop()
        st.append({"ADD": a + b, "SUB": a - b, "MUL": a * b, "DIV": a // b}[ins[0]])
    return st.pop()

toks = lex(SRC); tree = parse(toks); code = gen(tree, [])
print("tokens:", " ".join(str(v) for k, v in toks if k != "EOF"))
print("tree:  ", tree)
print("code:  ", "; ".join(" ".join(map(str, i)) for i in code))
print("result:", run(code))
Output
tokens: 2 + 3 * ( 10 - 4 ) / 2
tree:   ('+', 2, ('/', ('*', 3, ('-', 10, 4)), 2))
code:   PUSH 2; PUSH 3; PUSH 10; PUSH 4; SUB; MUL; PUSH 2; DIV; ADD
result: 11

The parser has already resolved precedence: the tree multiplies 3 by the parenthesized difference before dividing and adding, and the generated code reflects that order. Even this toy shows two properties of every real compiler. First, each phase has a narrow interface, a list of tokens, a tree, a list of instructions, so phases can be written, tested and replaced independently. Second, meaning is preserved: the code computes exactly what the expression denotes. Note too what is missing: there are no variables, so no symbol table; one type, so no type checker; no control flow, so no graph; and a stack machine, so no register allocation. The rest of the course adds each of these.

Why the structure matters: m front ends and n back ends

Separating the front, middle and back ends is not just tidy; it changes the economics of building compilers. Without a shared intermediate representation, supporting m source languages on n processors would need m × n separate compilers. With one, it needs m front ends and n back ends that meet in the middle, and every optimization written once for the IR benefits every pair. This is the design of GCC, whose front ends for C, C++, Fortran, Ada, Go and others share the GIMPLE and RTL representations, and above all of LLVM, begun by Chris Lattner and Vikram Adve at the University of Illinois in 2000. LLVM's IR is a well-documented, typed, static single assignment language (lesson 8) that front ends for C, C++, Rust, Swift, Julia, Zig and many others generate, and that back ends for x86-64, AArch64, RISC-V, WebAssembly, GPUs and more consume. Its success is why building a credible compiler for a new language is now a project for a small team rather than a large company.

The phases of a compiler and where this course covers them
PhaseInputOutputMain techniquesLesson
Lexical analysisCharactersTokensRegular expressions, finite automata2
ParsingTokensParse tree or ASTContext-free grammars, LL and LR parsing3, 4
Semantic analysisASTAnnotated ASTSymbol tables, scopes, type checking and inference5, 6
IR generationAnnotated ASTThree-address code, CFGSyntax-directed translation7
SSA constructionCFGSSA formDominators, dominance frontiers8
OptimizationIRBetter IRData-flow analysis, lattices11
Code generationIRAssemblyTree tiling, graph coloring, scheduling9, 10

The Tern language

Tern is the language we compile in this course. It is deliberately small, but it has the features that make real compilation interesting: integer and boolean types, arrays, local variables, functions with parameters and recursion, if and while statements, and the usual operators with conventional precedence. Its syntax is in the C family, with types written after names as in Rust, Go and TypeScript. Here is a complete Tern program.

A Tern program: a loop, arrays, a recursive-style function and the print built-in (listing)
fn fact(n: int) -> int {
    var r: int = 1;
    while n > 1 {
        r = r * n;
        n = n - 1;
    }
    return r;
}

fn main() -> int {
    var a: [int] = array(5);
    var i = 0;
    while i < len(a) {
        a[i] = fact(i + 1);
        i = i + 1;
    }
    print(a[4]);       // 120
    return 0;
}

Tern's rules are those you would expect. Every variable is declared with var before use, either with an explicit type or with an initializer from which the type is inferred, as in var i = 0. Conditions of if and while must be booleans; there is no automatic conversion from integers as in C. The built-ins array(n), len(a) and print(x) create an integer array of length n, return its length and print a value. Every function declares its return type, and the program starts at main. Unlike CMPUT-325's Mini, which was expression-oriented and functional, Tern is imperative: it has mutable variables and loops, because those are what make control-flow graphs, SSA form and register allocation worth studying.

A short history

The first compilers were written when most experts doubted that machine-generated code could compete with hand-written assembly. Grace Hopper's A-0 system (1952) linked subroutines automatically, and she argued tirelessly that programs should be written in something closer to English. John Backus's team at IBM delivered the FORTRAN I compiler in 1957 after some 18 person-years of work; its optimizer was so good that it won over skeptics and established high-level languages. The 1960s brought formal grammars, with Backus–Naur Form introduced for ALGOL 60, and parsing theory: Donald Knuth defined LR parsing in 1965. In the 1970s, Frances Allen and John Cocke at IBM laid the foundations of program optimization with control-flow graphs and data-flow analysis; Allen became the first woman to receive the Turing Award, in 2006, for this work. Tools such as lex and yacc (Bell Labs, 1975) made scanners and parsers routine, and GCC (1987) gave the world a free, retargetable compiler. Static single assignment form (IBM, late 1980s, published 1991), the LLVM project (2000 onward) and JIT compilers for Java and JavaScript define the modern era.

Compilers are also a remarkable example of theory paying off in practice. Regular languages and finite automata, context-free grammars and pushdown automata, lattices and fixed points, and graph coloring were all studied as mathematics before, or alongside, their use in compilers. CMPUT-474 treats the theory of automata and formal languages rigorously; here we use those results as tools.

Bootstrapping and self-hosting

A compiler is itself a program, so it must be written in some language and compiled by some compiler. A compiler written in its own source language is called self-hosting: GCC is written in C and C++, rustc in Rust, the Go compiler in Go. The obvious puzzle, how the first one was compiled, is solved by bootstrapping. One writes a minimal compiler for a subset of the language in an existing language, uses it to compile a better compiler written in the subset, and repeats until the full compiler compiles itself. GCC's build still demonstrates this: it compiles itself in three stages and checks that stage 2 and stage 3 produce identical object code, a strong test that the compiler translates its own source correctly.

Self-hosting raises a subtle security question that Ken Thompson described in his 1984 Turing Award lecture, Reflections on Trusting Trust. A compiler binary could be modified to insert a back door whenever it compiles a login program, and to reinsert that modification whenever it compiles a compiler, so that no trace remains in any source code. The defense, called diverse double-compiling, compiles the compiler's source with an independent compiler and checks that the results match; it is one reason reproducible builds matter for software supply chains (CISSP-117 discusses supply chain security at the governance level).

What makes a good compiler

Correctness comes first: a compiler must preserve the meaning of every valid program, and a compiler bug is far worse than a slow compiler because programmers trust the translation and rarely suspect it. After correctness come the qualities users notice. Diagnostics should identify the real problem, point to the right place, and suggest a fix; modern compilers such as rustc and Clang invest heavily in this. Compile time matters for large programs and for interactive tools; build systems compile millions of lines many times a day. Generated code should be fast, small and predictable. And the compiler should be maintainable, because languages, processors and security requirements keep changing.

These goals conflict. Aggressive optimization costs compile time and can make debugging harder, because the code no longer corresponds line by line to the source. That is why compilers offer optimization levels such as -O0 (fast compilation, easy debugging), -O2 (the usual release setting) and -O3 or -Os (more speed or less size). Language definitions also leave room for the compiler: in C, signed integer overflow is undefined behavior, which lets the optimizer assume it never happens. That assumption enables real speed-ups, such as simplifying loop bounds, but it surprises programmers whose code relied on wrap-around. Understanding what the compiler may and may not assume is part of writing correct low-level code.

The command below compiles a small C function to RISC-V assembly at two optimization levels with Clang, keeping only the instructions. At -O0 the compiler keeps every variable in memory, loading and storing it on each use so a debugger can inspect it; at -O2 the loop is rewritten to keep everything in registers.

Clang compiles a summing loop to RISC-V: the count of -O0 instructions, then the full -O2 code
cat sum.c
clang --target=riscv64 -O0 -S sum.c -o - | grep -E "^\s+[a-z]" | grep -v "^\s*\." | wc -l
clang --target=riscv64 -O2 -S sum.c -o - | grep -E "^\s+[a-z]" | grep -v "^\s*\."
Output
$ cat sum.c
int sum_to(int n) {
    int s = 0;
    for (int i = 1; i <= n; i++)
        s += i;
    return s;
}
$ clang --target=riscv64 -O0 -S sum.c -o - | grep -E "^\s+[a-z]" | grep -v "^\s*\." | wc -l
28
$ clang --target=riscv64 -O2 -S sum.c -o - | grep -E "^\s+[a-z]" | grep -v "^\s*\."
	blez	a0, .LBB0_2
	slli	a1, a0, 1
	addi	a2, a0, -1
	addi	a0, a0, -2
	slli	a0, a0, 32
	slli	a2, a2, 32
	mulhu	a0, a2, a0
	srli	a0, a0, 1
	add	a0, a0, a1
	addiw	a0, a0, -1
	ret
	li	a0, 0
	ret

The unoptimized version has 28 instructions, including a loop that runs n times and loads and stores s and i from the stack on every iteration. The optimized version has no loop at all. Clang has recognized that the loop computes a closed-form sum and replaced it with arithmetic that runs in constant time regardless of n, an optimization called scalar evolution, applied by LLVM's induction-variable analysis. A compiler that can do this has understood the loop in a precise mathematical sense. By lesson 11 you will know how such analyses are built.

The compiler ecosystem in 2026

As of late 2026, the dominant ahead-of-time compilers are GCC (version 16 released in 2026) and LLVM/Clang (version 22 released in early 2026), with Microsoft's MSVC on Windows. LLVM also underpins rustc, Swift, Zig, Julia and the GPU compilers of most vendors, and its MLIR framework (multi-level IR, from Google, 2019) is used to build compilers for machine learning, from TensorFlow and PyTorch graphs down to accelerator code. WebAssembly, a portable low-level target for browsers and servers, reached version 3.0 in September 2025, adding garbage-collected types, tail calls, exception handling and 64-bit memories, which makes it a practical target for languages such as Kotlin, Dart and OCaml as well as C and Rust. CPython 3.13 (2024) added an experimental copy-and-patch JIT compiler, still optional in 3.14. JIT compilation therefore now spans Java, JavaScript, Python, .NET and Lua. The fundamentals in this course apply to all of them.

Compiler construction is also increasingly used outside compilers proper. Database systems compile SQL queries to machine code; machine learning frameworks compile model graphs; build systems, linters, formatters and language servers all reuse the front-end techniques of scanning, parsing and semantic analysis; and security tools apply the static analyses of lesson 11 to find bugs. Large language model assistants now help write code, but they rely on the same tools to check it: a model's suggestion is only useful once a compiler has parsed, type-checked and translated it.

Worked examples

Worked problem

Tracing the phases

Trace the expression 8 - 2 - 1 through the miniature compiler. What tree and code does it produce, and what is the result?

Show solution
  1. The scanner produces the tokens 8, -, 2, -, 1.
  2. The loop in expr builds left-associative trees: first (−, 8, 2), then (−, (−, 8, 2), 1).
  3. Code generation is post-order: PUSH 8; PUSH 2; SUB; PUSH 1; SUB.
  4. Running it gives 8 − 2 = 6, then 6 − 1 = 5.

Answer: The tree is (−, (−, 8, 2), 1) and the result is 5. A right-associative parse would wrongly compute 8 − (2 − 1) = 7.

Worked problem

The m × n argument

A company supports 5 languages on 4 processors. How many translators are needed with and without a shared IR?

Show solution
  1. Without an IR, each language–processor pair needs its own compiler: 5 × 4 = 20.
  2. With an IR, it needs 5 front ends plus 4 back ends: 9 components.
  3. Adding a sixth language costs 4 new compilers without an IR, but 1 new front end with one.

Answer: 20 compilers without an IR versus 9 components with one; the gap widens as either number grows.

Worked problem

Interpreter or compiler?

A configuration language is used to evaluate a few hundred short expressions when a server starts. Should its implementers write a compiler to native code?

Show solution
  1. The work is small and done once, so execution speed barely matters.
  2. Start-up time and simplicity matter more; an interpreter starts immediately.
  3. Compilation to native code would add complexity and compile time without a visible benefit.

Answer: An interpreter (perhaps over a simple tree or bytecode) is the better choice; compile only where hot code dominates running time.

Common mistakes

  • MistakeTreat compilers and interpreters as opposites.

    InsteadMost implementations are hybrids, compiling to bytecode or IR and interpreting or JIT-compiling it.

  • MistakeThink of the parser as the whole front end.

    InsteadThe front end also scans and performs semantic analysis such as name resolution and type checking.

  • MistakeAssume optimization only makes code faster.

    InsteadOptimizations trade speed, size, compile time and debuggability, and they rely on what the language lets the compiler assume.

  • MistakeAssume a compiler can be trusted because its source looks right.

    InsteadA compromised binary can survive recompilation; reproducible builds and diverse double-compiling address this.

  • MistakeDesign a new compiler from scratch for every language and processor.

    InsteadUse a shared IR such as LLVM, so m front ends and n back ends replace m × n compilers.

Summary and key terms

  • A compiler translates a source program into an equivalent target program; it must preserve meaning above all.
  • Interpreters execute programs directly; modern implementations combine bytecode, interpretation and JIT compilation.
  • The front end scans, parses and checks; the middle end optimizes an intermediate representation; the back end selects instructions, allocates registers and emits code.
  • A shared IR reduces m × n compilers to m front ends plus n back ends; LLVM made this the norm.
  • Self-hosting compilers are built by bootstrapping, and their trustworthiness depends on reproducible builds.
  • Good compilers balance correctness, diagnostics, compile time and the quality of generated code.
  • Tern, the course language, is a small imperative language with integers, booleans, arrays, functions, if and while.
Compiler
A program that translates a source program into an equivalent program in a target language.
Interpreter
A program that executes another program directly from a representation of it.
Just-in-time compiler
A compiler that translates code while the program is running, often using run-time information.
Front end
The phases that understand the source language: scanning, parsing and semantic analysis.
Back end
The phases that produce target code: instruction selection, register allocation and emission.
Intermediate representation
A program representation between source and target, designed for analysis and transformation.
Token
A meaningful unit of source text, such as an identifier, keyword, number or operator.
Bootstrapping
Building a self-hosting compiler by compiling successively more capable versions with each other.
Self-hosting
Written in the language it compiles.
Optimization level
A compiler setting that selects how much effort is spent improving generated code.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Name the three main parts of a compiler and what each produces.

    Show answer

    Front end (annotated AST), middle end (optimized IR), back end (target code).

  2. Why can a JIT compiler sometimes outperform an ahead-of-time compiler?

    Show answer

    It can use run-time information, such as observed types and hot paths, to specialize code.

  3. In the miniature compiler, which phase decides that * binds more tightly than +?

    Show answer

    The parser, through its grammar of expr and term.

  4. What does CPython compile Python source code to?

    Show answer

    Bytecode, which its virtual machine interprets.

  5. How many components does a shared IR require for 3 languages and 6 targets?

    Show answer

    3 front ends plus 6 back ends, 9 in total.

  6. What is GCC's three-stage bootstrap checking?

    Show answer

    That the compiler built by itself twice (stages 2 and 3) produces identical object code.

  7. Give one reason to compile with -O0.

    Show answer

    Faster compilation and easier debugging, because variables stay in memory.

  8. Why does C's undefined behavior help the optimizer?

    Show answer

    It may assume the undefined situation never occurs, such as signed overflow, and simplify code accordingly.

  9. What did Frances Allen contribute to compilers?

    Show answer

    Foundations of program optimization: control-flow graphs and data-flow analysis.

  10. In Tern, may an integer be used as an if condition?

    Show answer

    No; conditions must have type bool.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz