CMPUT-415 · Lesson 12 of 12 · 44 min

Capstone: an end-to-end compiler, JITs, correctness and the modern toolchain

This final lesson assembles the course's pieces into a working compiler, then looks outward at how production compilers are built, tested and trusted. We first compile Tern programs all the way to RISC-V assembly and run them on an emulator, so that every phase of lesson 1's pipeline is real. Then we look at just-in-time compilation, at how compilers are tested and even proved correct, at the security features compilers add to generated code, and at the toolchain of 2026, including the growing use of machine learning in and around compilers.

The Tern compiler, end to end

The program below is a complete compiler for an integer subset of Tern, a little over 200 lines including an emulator. The scanner and parser are compact versions of lessons 2, 3 and 5, producing an AST of tuples. A checking pass verifies that names are declared and calls have the right number of arguments, a small part of lesson 6's checker. Constant folding (lesson 11) runs on expressions. The code generator then emits RISC-V assembly directly from the AST, in the style of an unoptimizing compiler such as Clang at -O0: each function has a prologue and epilogue that follow the calling convention of lesson 9, every variable lives in a stack slot at a fixed offset from the frame pointer s0, and expression temporaries use the caller-saved registers t0 to t6, allocated by depth in the expression tree, the simplest register allocation that works. Comparisons use slt, xori, seqz and snez to produce 0 or 1; conditions branch with beqz. Calls evaluate their arguments, move them into a0 to a7, save any live temporaries around the call because t registers are caller-saved, and take the result from a0. Finally, a small emulator executes the assembly, so we can check that the compiled program computes the right answers.

An end-to-end Tern compiler: scanning, parsing, checking, folding, RISC-V code generation, and an emulator that runs the output
# Tern end to end: scanner, parser, checks, constant folding, RISC-V code
# generation and a small RISC-V emulator to run the result. Integer subset:
# functions, var, assignment, if/else, while, return, calls, print.
import re
def lex(src):
    spec = r"\s+|//[^\n]*|(\d+)|([A-Za-z_]\w*)|(==|!=|<=|>=|->|[-+*/%<>=(){},;:])|(.)"
    toks = []
    for m in re.finditer(spec, src):
        num, name, op, bad = m.groups()
        if bad: raise SyntaxError(f"unexpected character {bad!r}")
        if num: toks.append(("num", int(num)))
        elif name: toks.append(("kw" if name in ("fn", "var", "if", "else", "while", "return") else "id", name))
        elif op: toks.append(("op", op))
    return toks + [("eof", None)]
LB, RB = "{", "}"
BP = {"==": 1, "!=": 1, "<": 2, "<=": 2, ">": 2, ">=": 2, "+": 3, "-": 3, "*": 4, "/": 4, "%": 4}
class Parser:
    def __init__(p, toks): p.t, p.i = toks, 0
    def peek(p): return p.t[p.i][1]
    def next(p): p.i += 1; return p.t[p.i - 1]
    def expect(p, v):
        k, x = p.next()
        if x != v: raise SyntaxError(f"expected {v!r}, found {x!r}")
    def program(p):
        fns = []
        while p.t[p.i][0] != "eof": fns.append(p.func())
        return fns
    def func(p):
        p.expect("fn"); name = p.next()[1]; p.expect("("); params = []
        while p.peek() != ")":
            params.append(p.next()[1]); p.expect(":"); p.next()
            if p.peek() == ",": p.next()
        p.expect(")"); p.expect("->"); p.next()
        return ("fn", name, params, p.block())
    def block(p):
        p.expect(LB); body = []
        while p.peek() != RB: body.append(p.stmt())
        p.expect(RB); return body
    def stmt(p):
        v = p.peek()
        if v == "var":
            p.next(); name = p.next()[1]
            if p.peek() == ":": p.next(); p.next()
            p.expect("="); e = p.expr(); p.expect(";"); return ("var", name, e)
        if v == "if":
            p.next(); c = p.expr(); th = p.block(); el = []
            if p.peek() == "else": p.next(); el = [p.stmt()] if p.peek() == "if" else p.block()
            return ("if", c, th, el)
        if v == "while": p.next(); c = p.expr(); return ("while", c, p.block())
        if v == "return": p.next(); e = p.expr(); p.expect(";"); return ("ret", e)
        e = p.expr()
        if p.peek() == "=": p.next(); v2 = p.expr(); p.expect(";"); return ("set", e[1], v2)
        p.expect(";"); return ("expr", e)
    def expr(p, min_bp=0):
        k, v = p.next()
        if k == "num": left = ("num", v)
        elif v == "(": left = p.expr(); p.expect(")")
        elif v == "-": left = ("bin", "-", ("num", 0), p.expr(5))
        elif k == "id" and p.peek() == "(":
            p.next(); args = []
            while p.peek() != ")":
                args.append(p.expr())
                if p.peek() == ",": p.next()
            p.next(); left = ("call", v, args)
        elif k == "id": left = ("name", v)
        else: raise SyntaxError(f"unexpected {v!r}")
        while BP.get(p.peek(), 0) > min_bp:
            op = p.next()[1]; left = ("bin", op, left, p.expr(BP[op]))
        return left
def check(fns):                                   # names declared, arity of calls
    arity = {f[1]: len(f[2]) for f in fns}; arity["print"] = 1; errs = []
    def ex(e, env):
        if e[0] == "name" and e[1] not in env: errs.append(f"undeclared {e[1]!r}")
        if e[0] == "bin": ex(e[2], env); ex(e[3], env)
        if e[0] == "call":
            if arity.get(e[1]) != len(e[2]): errs.append(f"bad call to {e[1]!r}")
            for a in e[2]: ex(a, env)
    def blk(b, env):
        env = set(env)
        for s in b:
            if s[0] == "var": ex(s[2], env); env.add(s[1])
            elif s[0] == "set": ex(("name", s[1]), env); ex(s[2], env)
            elif s[0] in ("if", "while"): ex(s[1], env); blk(s[2], env); s[0] == "if" and blk(s[3], env)
            else: ex(s[1], env)
    for f in fns: blk(f[3], set(f[2]))
    return errs
FOLD = {"+": lambda a, b: a + b, "-": lambda a, b: a - b, "*": lambda a, b: a * b}
def fold(e):                                       # constant folding on the AST
    if e[0] == "bin":
        a, b = fold(e[2]), fold(e[3])
        if a[0] == b[0] == "num" and e[1] in FOLD: return ("num", FOLD[e[1]](a[1], b[1]))
        return ("bin", e[1], a, b)
    if e[0] == "call": return ("call", e[1], [fold(x) for x in e[2]])
    return e
class CodeGen:                                     # every variable in a frame slot (like -O0);
    def __init__(g): g.out, g.n = [], 0            # expression temporaries in t0..t6
    def emit(g, s): g.out.append(s)
    def label(g): g.n += 1; return f".L{g.n}"
    def func(g, f):
        _, name, params, body = f
        slots = {}
        def collect(b):
            for s in b:
                if s[0] == "var" and s[1] not in slots: slots[s[1]] = None
                if s[0] in ("if", "while"): collect(s[2]); s[0] == "if" and collect(s[3])
        for prm in params: slots[prm] = None
        collect(body)
        for k, v in enumerate(slots): slots[v] = -24 - 8 * k
        size = (16 + 8 * len(slots) + 15) // 16 * 16
        g.slots, g.size, g.ret = slots, size, g.label()
        g.emit(f"{name}:")
        for ins in (f"addi sp, sp, -{size}", f"sd ra, {size - 8}(sp)", f"sd s0, {size - 16}(sp)", f"addi s0, sp, {size}"):
            g.emit("    " + ins)
        for k, prm in enumerate(params): g.emit(f"    sd a{k}, {slots[prm]}(s0)")
        for s in body: g.stmt(s)
        g.emit(f"{g.ret}:")
        for ins in (f"ld ra, {size - 8}(sp)", f"ld s0, {size - 16}(sp)", f"addi sp, sp, {size}", "ret"):
            g.emit("    " + ins)
    def expr(g, e, d=0):                           # value of e into register t{d}
        r = f"t{d}"
        if d > 6: raise RuntimeError("expression too deep for t0-t6")
        if e[0] == "num": g.emit(f"    li {r}, {e[1]}")
        elif e[0] == "name": g.emit(f"    ld {r}, {g.slots[e[1]]}(s0)")
        elif e[0] == "call":
            for k, a in enumerate(e[2]):           # evaluate arguments, park them on the frame
                g.expr(a, d); g.emit(f"    addi sp, sp, -16"); g.emit(f"    sd {r}, 0(sp)")
            for k in reversed(range(len(e[2]))):
                g.emit(f"    ld a{k}, 0(sp)"); g.emit(f"    addi sp, sp, 16")
            for k in range(d): g.emit(f"    addi sp, sp, -16"); g.emit(f"    sd t{k}, 0(sp)")   # caller-saved
            g.emit(f"    call {e[1]}")
            for k in reversed(range(d)): g.emit(f"    ld t{k}, 0(sp)"); g.emit(f"    addi sp, sp, 16")
            g.emit(f"    mv {r}, a0")
        else:
            op = e[1]; g.expr(e[2], d); g.expr(e[3], d + 1); s = f"t{d + 1}"
            simple = {"+": "add", "-": "sub", "*": "mul", "/": "div", "%": "rem"}
            if op in simple: g.emit(f"    {simple[op]} {r}, {r}, {s}")
            elif op == "<": g.emit(f"    slt {r}, {r}, {s}")
            elif op == ">": g.emit(f"    slt {r}, {s}, {r}")
            elif op == "<=": g.emit(f"    slt {r}, {s}, {r}"); g.emit(f"    xori {r}, {r}, 1")
            elif op == ">=": g.emit(f"    slt {r}, {r}, {s}"); g.emit(f"    xori {r}, {r}, 1")
            elif op == "==": g.emit(f"    sub {r}, {r}, {s}"); g.emit(f"    seqz {r}, {r}")
            elif op == "!=": g.emit(f"    sub {r}, {r}, {s}"); g.emit(f"    snez {r}, {r}")
    def stmt(g, s):
        k = s[0]
        if k in ("var", "set"): g.expr(fold(s[2])); g.emit(f"    sd t0, {g.slots[s[1]]}(s0)")
        elif k == "expr": g.expr(fold(s[1]))
        elif k == "ret": g.expr(fold(s[1])); g.emit("    mv a0, t0"); g.emit(f"    j {g.ret}")
        elif k == "if":
            els, end = g.label(), g.label()
            g.expr(fold(s[1])); g.emit(f"    beqz t0, {els}")
            for x in s[2]: g.stmt(x)
            g.emit(f"    j {end}"); g.emit(f"{els}:")
            for x in s[3]: g.stmt(x)
            g.emit(f"{end}:")
        elif k == "while":
            top, end = g.label(), g.label()
            g.emit(f"{top}:"); g.expr(fold(s[1])); g.emit(f"    beqz t0, {end}")
            for x in s[2]: g.stmt(x)
            g.emit(f"    j {top}"); g.emit(f"{end}:")
def run(asm, entry="main"):                        # a tiny RV64 emulator for this subset
    lines = [l.strip() for l in asm]
    labels = {l[:-1]: i for i, l in enumerate(lines) if l.endswith(":")}
    R = {f"t{i}": 0 for i in range(7)} | {f"a{i}": 0 for i in range(8)} | {"sp": 1 << 20, "s0": 0, "ra": -1, "zero": 0}
    mem, out, pc, steps = {}, [], labels[entry], 0
    def off(x): o, b = re.match(r"(-?\d+)\((\w+)\)", x).groups(); return R[b] + int(o)
    while pc != -1:
        ins = lines[pc]; pc += 1; steps += 1
        if ins.endswith(":"): continue
        m, *a = re.split(r"[,\s]+", ins)
        if m == "li": R[a[0]] = int(a[1])
        elif m == "mv": R[a[0]] = R[a[1]]
        elif m == "addi": R[a[0]] = R[a[1]] + int(a[2])
        elif m == "xori": R[a[0]] = R[a[1]] ^ int(a[2])
        elif m in ("add", "sub", "mul", "div", "rem", "slt"):
            x, y = R[a[1]], R[a[2]]
            R[a[0]] = {"add": lambda: x + y, "sub": lambda: x - y, "mul": lambda: x * y,
                       "div": lambda: int(x / y), "rem": lambda: x - y * int(x / y),
                       "slt": lambda: int(x < y)}[m]()
        elif m == "seqz": R[a[0]] = int(R[a[1]] == 0)
        elif m == "snez": R[a[0]] = int(R[a[1]] != 0)
        elif m == "sd": mem[off(a[1])] = R[a[0]]
        elif m == "ld": R[a[0]] = mem[off(a[1])]
        elif m == "beqz": pc = labels[a[1]] if R[a[0]] == 0 else pc
        elif m == "j": pc = labels[a[0]]
        elif m == "call":
            if a[0] == "print": out.append(R["a0"])
            else: R["ra"] = pc; pc = labels[a[0]]
        elif m == "ret": pc = R["ra"]
    return R["a0"], out, steps
SRC = """
fn fib(n: int) -> int {
    if n < 2 { return n; }
    return fib(n - 1) + fib(n - 2);
}
fn gcd(a: int, b: int) -> int {
    while b != 0 { var t = a % b; a = b; b = t; }
    return a;
}
fn main() -> int {
    var i = 0;
    while i <= 10 { print(fib(i)); i = i + 1; }
    print(gcd(1071, 462));
    print(2 * 3 * 7);              // folded to 42 at compile time
    return 0;
}"""
fns = Parser(lex(SRC)).program()
errors = check(fns)
if errors: raise SystemExit(errors)
g = CodeGen()
for f in fns: g.func(f)
print(f"{len(fns)} functions, {sum(not l.endswith(':') for l in g.out)} instructions; code for gcd:")
start = g.out.index("gcd:"); end = g.out.index("main:")
print("\n".join(g.out[start:end]))
result, printed, steps = run(g.out)
print("program output:", printed)
print(f"main returned {result} after {steps} emulated instructions")
Output
3 functions, 130 instructions; code for gcd:
gcd:
    addi sp, sp, -48
    sd ra, 40(sp)
    sd s0, 32(sp)
    addi s0, sp, 48
    sd a0, -24(s0)
    sd a1, -32(s0)
.L5:
    ld t0, -32(s0)
    li t1, 0
    sub t0, t0, t1
    snez t0, t0
    beqz t0, .L6
    ld t0, -24(s0)
    ld t1, -32(s0)
    rem t0, t0, t1
    sd t0, -40(s0)
    ld t0, -32(s0)
    sd t0, -24(s0)
    ld t0, -40(s0)
    sd t0, -32(s0)
    j .L5
.L6:
    ld t0, -24(s0)
    mv a0, t0
    j .L4
.L4:
    ld ra, 40(sp)
    ld s0, 32(sp)
    addi sp, sp, 48
    ret
program output: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 21, 42]
main returned 0 after 13836 emulated instructions

The compiled program prints the first eleven Fibonacci numbers, the greatest common divisor of 1071 and 462, and 42, which was computed at compile time: the expression 2 × 3 × 7 never reaches the generated code. The assembly for gcd has the shape you would see from a real compiler at -O0: the arguments are stored to their slots on entry, every use of a, b or t reloads its slot, and the loop is a test at .L5 with a conditional branch out and an unconditional jump back. The whole run takes nearly 14,000 emulated instructions, most of them loads and stores that an optimizing compiler would remove.

It is instructive to list what this compiler does not do, because each omission maps onto a lesson. It never builds a CFG or SSA form (lessons 7 and 8), so it cannot optimize across statements: it reloads b from memory immediately after storing it. Its register allocation keeps no variable in a register across statements (lesson 10); a graph-coloring allocator would keep a, b and t in registers for the whole loop, removing most of the loads and stores. It selects instructions one node at a time instead of tiling (lesson 10): x − 1 becomes li and sub rather than one addi. It has no data-flow optimization (lesson 11). Adding those phases is the path from this compiler to an optimizing one, and with the tools of this course every step is well defined. Arrays, booleans as a separate type and full type checking are left out only to keep the program short.

The capstone compiler compared with a production compiler
PhaseCapstone implementationProduction equivalent
ScanningOne regular expression with alternativesHand-written DFA-like scanner (Clang, rustc)
ParsingRecursive descent with Pratt expressionsThe same technique, with error recovery and full positions
Semantic analysisDeclared names and call arityFull name resolution, type checking and inference
OptimizationConstant folding on expressionsAbout a hundred SSA passes at -O2
Instruction selectionOne pattern per AST nodeTree or DAG tiling from target descriptions
Register allocationTemporaries by expression depth, variables in memoryGraph coloring or greedy allocation with splitting
OutputAssembly text run on an emulatorObject files with relocations and debug information

Just-in-time compilation

Everything so far assumed compilation happens before the program runs. A just-in-time compiler translates code while the program executes, which changes the trade-offs. Compile time now counts as run time, so a JIT cannot afford heavy optimization everywhere; but it knows things an ahead-of-time compiler does not: which functions and loops are hot, which branches are taken, and, in dynamically typed languages, which types actually flow through each operation. Modern JITs exploit this with tiered compilation. Code starts in an interpreter or a fast baseline compiler that collects profiles; methods that become hot are recompiled by an optimizing compiler that uses the profiles to specialize. HotSpot interprets bytecode, then compiles with its C1 compiler, then with the aggressive C2; V8 interprets bytecode with Ignition, then uses Sparkplug, Maglev and finally TurboFan, whose back end has been rebuilt as Turboshaft, as functions get hotter.

Speculation is the key technique. If a JavaScript function's argument has always been a small integer, the optimizing JIT generates integer arithmetic guarded by a cheap check. If the check ever fails, because a string arrives, the code deoptimizes: it reconstructs the interpreter's view of the program state from metadata the compiler recorded, which is much like the stack maps of lesson 9, and continues in the interpreter, perhaps recompiling later with more general assumptions. On-stack replacement lets a long-running loop switch from interpreted to compiled code in the middle of its execution. Inline caches remember, at each property access or method call, the object shapes seen so far, turning dynamic lookups into a compare and a load. Meta-tracing systems such as PyPy take a different route, recording the operations executed along a hot loop and compiling that trace. Lighter techniques are spreading too: CPython's experimental JIT (since 3.13) uses copy-and-patch compilation, stitching together pre-compiled machine-code templates for each bytecode, which makes compilation extremely fast at the cost of optimization depth.

A diagram of tiered JIT compilation. Source code is parsed to bytecode, which starts in an interpreter that gathers type and branch profiles. Hot functions move right to a baseline compiler, and very hot ones to an optimizing compiler that speculates using the profiles. A curved arrow labeled deoptimize returns from optimized code to the interpreter when a speculation guard fails.
Tiered compilation: code moves to faster tiers as it gets hotter, and deoptimization falls back when speculation fails

How compilers are tested

Compilers are tested with enormous suites of programs: GCC's and LLVM's regression suites contain hundreds of thousands of tests, each typically a small program with the expected diagnostics or output, often checked with tools such as LLVM's FileCheck, which matches patterns in the compiler's IR or assembly output. Conformance suites test standards compliance, and benchmark suites such as SPEC CPU guard against performance regressions. Yet many compiler bugs are only found by generating programs automatically. Csmith (Xuejun Yang and colleagues, University of Utah, 2011) generates random C programs free of undefined behavior and compiles each with several compilers and optimization levels; any disagreement in output reveals a bug in at least one of them. It found hundreds of bugs in GCC and LLVM, including wrong-code bugs that silently miscompiled valid programs. Equivalence modulo inputs, by Vu Le and colleagues (2014), mutates existing programs in ways that should not change their output on given inputs, a technique that found hundreds more. When a failing program is found, test-case reduction tools such as C-Reduce shrink it to a minimal example a developer can understand.

The program below applies the same ideas to an optimizer for arithmetic expressions. It generates random expressions, compares the optimized result with direct evaluation on a few inputs, and when they disagree, shrinks the failing expression greedily, replacing it with smaller pieces as long as the bug still shows. One rewrite rule has a deliberate bug of a kind easy to make by hand.

Differential testing finds and minimizes a wrong rewrite rule in an expression optimizer
# Differential testing of an optimizer: generate random expressions, compare
# the optimized program's result with an unoptimized reference, and shrink
# any failing case to a minimal one. One rewrite rule below has a bug.
import random
random.seed(415)
def gen(depth):
    if depth == 0 or random.random() < 0.3:
        return ("num", random.randint(-5, 9)) if random.random() < 0.5 else ("var", random.choice("xyz"))
    return (random.choice("+-*"), gen(depth - 1), gen(depth - 1))
def evaluate(e, env):
    if e[0] == "num": return e[1]
    if e[0] == "var": return env[e[1]]
    a, b = evaluate(e[1], env), evaluate(e[2], env)
    return a + b if e[0] == "+" else a - b if e[0] == "-" else a * b
def simplify(e, buggy):
    if e[0] in ("num", "var"): return e
    op, a, b = e[0], simplify(e[1], buggy), simplify(e[2], buggy)
    if a[0] == b[0] == "num": return ("num", evaluate((op, a, b), {}))
    if op == "*" and ("num", 1) in (a, b): return b if a == ("num", 1) else a
    if op == "*" and ("num", 0) in (a, b): return ("num", 0)
    if op == "+" and b == ("num", 0): return a
    if op == "-" and a == b: return ("num", 0)
    if op == "-" and b[0] == "-":                 # a - (p - q)  ==>  (a - p) + q
        return ("-", ("-", a, b[1]), b[2]) if buggy else ("+", ("-", a, b[1]), b[2])
    return (op, a, b)
def fails(e, buggy):
    for env in ({"x": 3, "y": -2, "z": 7}, {"x": 0, "y": 5, "z": 1}):
        if evaluate(simplify(e, buggy), env) != evaluate(e, env): return True
    return False
def candidates(e):                                # smaller variants: a subtree, or a child shrunk
    if e[0] not in ("+", "-", "*"): return []
    op, a, b = e
    return [a, b] + [(op, s, b) for s in candidates(a)] + [(op, a, s) for s in candidates(b)]
def shrink(e, buggy):                             # greedy test-case reduction
    progress = True
    while progress:
        progress = False
        for cand in candidates(e):
            if fails(cand, buggy): e, progress = cand, True; break
    return e
def show(e):
    if e[0] == "num": return str(e[1]) if e[1] >= 0 else f"({e[1]})"
    if e[0] == "var": return e[1]
    return f"({show(e[1])} {e[0]} {show(e[2])})"
for buggy in (True, False):
    found = None
    for n in range(1, 3001):
        e = gen(5)
        if fails(e, buggy): found = (n, e); break
    label = "buggy optimizer" if buggy else "fixed optimizer"
    if not found: print(f"{label}: 3000 random programs, no differences"); continue
    n, e = found; small = shrink(e, buggy)
    print(f"{label}: mismatch on program {n} ({len(show(e))} characters)")
    print("   minimized:", show(small), " optimized to:", show(simplify(small, buggy)))
    print("   with x=3, y=-2, z=7:", evaluate(small, {"x": 3, "y": -2, "z": 7}), "expected,",
          evaluate(simplify(small, buggy), {"x": 3, "y": -2, "z": 7}), "computed")
Output
buggy optimizer: mismatch on program 8 (73 characters)
   minimized: (y - (6 - x))  optimized to: ((y - 6) - x)
   with x=3, y=-2, z=7: -5 expected, -11 computed
fixed optimizer: 3000 random programs, no differences

Within a handful of random programs, differential testing finds a mismatch, and reduction turns a 73-character expression into y − (6 − x), which exposes the bug at once: the rule rewrote a − (p − q) as (a − p) − q instead of (a − p) + q. After the rule is fixed, 3,000 random programs pass. Random testing gives no guarantee that bugs are absent, but in practice it is one of the most cost-effective ways to find them, and coverage-guided fuzzers such as libFuzzer and AFL++, used continuously by projects like OSS-Fuzz, apply it to every phase of real compilers, from scanners to optimizers.

Verified compilers

Testing finds bugs; proof can exclude them. CompCert, developed by Xavier Leroy and colleagues at Inria from 2005, is a compiler for most of C whose correctness is proved in the Coq (now Rocq) proof assistant: for every source program without undefined behavior, the generated assembly is proved to behave as the semantics of the source says. When Csmith was run against CompCert, it found no wrong-code bugs in the verified parts, while finding many in GCC and LLVM; the only bugs it found were in unverified components. CompCert is used commercially for safety-critical embedded software in aviation and nuclear industries. CakeML provides a verified compiler for a dialect of ML. Lighter-weight approaches are spreading: Alive2 (Nuno Lopes, John Regehr and colleagues) checks LLVM's peephole optimizations and individual optimization runs automatically with an SMT solver, and has found and helped fix many incorrect transformations in LLVM; translation validation checks each compilation's output against its input rather than proving the compiler correct once and for all.

Security features in generated code

Compilers also defend running programs against attack, an increasingly important part of their job. They do not make unsafe code safe, but they make memory-safety bugs harder to exploit and easier to find. Stack protectors place a secret canary value between local buffers and the saved return address and check it before returning, aborting if a buffer overflow has overwritten it. Position-independent executables let the operating system randomize where code is loaded (ASLR). Control-flow integrity (CFI) checks that indirect calls and returns go only to valid targets; hardware support such as Intel CET shadow stacks and Arm's pointer authentication and branch target identification is enabled through compiler flags, and RISC-V's control-flow integrity extensions (Zicfiss and Zicfilp) were ratified in 2024. Fortified library calls check buffer sizes known at compile time. Sanitizers such as AddressSanitizer and UndefinedBehaviorSanitizer instrument code during testing to detect out-of-bounds accesses and undefined behavior at run time. The listing below shows Clang adding a stack protector to a RISC-V function with a local buffer.

With -fstack-protector-strong, Clang stores a canary from __stack_chk_guard in the frame and checks it before returning
cat greet.c
clang --target=riscv64-linux-gnu -O2 -fstack-protector-strong -S greet.c -o - | sed -n "/^greet:/,/^\.Lfunc_end/p" | grep -v "^\s\+\." | grep -v "^#" | grep -v "^\.Lfunc_end"
Output
$ cat greet.c
char *strcpy(char *dst, const char *src);
int puts(const char *s);
void greet(const char *name) {
    char buf[16];
    strcpy(buf, name);   /* unsafe if name is long */
    puts(buf);
}
$ clang --target=riscv64-linux-gnu -O2 -fstack-protector-strong -S greet.c -o - | sed -n "/^greet:/,/^\.Lfunc_end/p" | grep -v "^\s\+\." | grep -v "^#" | grep -v "^\.Lfunc_end"
greet:                                  # @greet
	addi	sp, sp, -48
	sd	ra, 40(sp)                      # 8-byte Folded Spill
	sd	s0, 32(sp)                      # 8-byte Folded Spill
.Lpcrel_hi0:
	auipc	a1, %got_pcrel_hi(__stack_chk_guard)
	ld	s0, %pcrel_lo(.Lpcrel_hi0)(a1)
	ld	a2, 0(s0)
	mv	a1, a0
	sd	a2, 24(sp)
	addi	a0, sp, 8
	call	strcpy
	addi	a0, sp, 8
	call	puts
	ld	a0, 0(s0)
	ld	a1, 24(sp)
	bne	a0, a1, .LBB0_2
	ld	ra, 40(sp)                      # 8-byte Folded Reload
	ld	s0, 32(sp)                      # 8-byte Folded Reload
	addi	sp, sp, 48
	ret
.LBB0_2:
	call	__stack_chk_fail

An unprotected build of the same function needs only a 32-byte frame and saves just ra. With protection, the function loads the guard value, stores it in its frame next to the buffer, and before returning compares the frame's copy with the original, calling __stack_chk_fail if an overflow has changed it. The cost is a few instructions per protected function. These defenses matter because so much deployed software is written in C and C++; the long-term remedy, advocated by government security agencies since 2022, is to write new code in memory-safe languages such as Rust, Java, Go or Swift, whose compilers rule out these bugs by type checking and, in Rust's case, the borrow checker, itself a static analysis built on the data-flow and lifetime ideas of this course. CMPUT-331 (lesson 8) discusses memory safety, and CISSP-117 the governance of secure development.

The toolchain in 2026

A compiler is one component of a larger toolchain: preprocessors, assemblers, linkers (GNU ld, gold, LLVM's lld and the fast mold linker), debuggers relying on DWARF debug information, profilers, build systems and package managers. Language servers, which implement the Language Server Protocol introduced by Microsoft in 2016, reuse the compiler's front end to provide completion, navigation and diagnostics in editors as code is typed; clangd and rust-analyzer are examples, designed around incremental, demand-driven analysis so that a keystroke re-analyses only what changed. Compilation is increasingly distributed and cached: tools such as ccache, sccache and Bazel's remote execution avoid recompiling unchanged code across a team.

Machine learning has begun to influence compilers in several ways, and it is worth being precise about what is established. Learned cost models and heuristics have been deployed for specific decisions: Google's MLGO framework, for example, uses reinforcement learning to make inlining decisions for size and register-allocation eviction decisions in LLVM, and has been used in production builds. Machine learning compilers, which compile neural network models to GPUs and accelerators, are themselves a major field: XLA, TVM, Triton and MLIR-based stacks apply the classic ideas of IRs, lowering, fusion and scheduling to tensor programs. Large language models are used to write, explain and translate code, and research systems apply them to optimization and decompilation, but production compilers continue to rely on algorithms whose correctness can be argued, because a compiler must be right for every input. As of 2026, LLM assistants are most valuable as users of compilers: they generate code that compilers then check, and compiler diagnostics are an important signal for correcting them.

Where to go from here

Compiler construction connects to much of computer science. The theory of computation behind scanning and parsing continues in CMPUT-474. Program analysis, the generalization of lesson 11, underlies static bug finders, security analysis and verification. Language design, explored in CMPUT-325, depends on knowing what can be compiled efficiently. Performance engineering, GPU programming and machine learning systems (AI-480) rely on compilers for parallel hardware. Contributing to an open-source compiler such as LLVM, GCC, rustc or a WebAssembly engine is a well-trodden path into the field; each has documentation for newcomers and issues labeled as good first contributions.

Worked examples

Worked problem

Reading generated code

In the capstone compiler, what code does var t = a % b; generate, and how would an optimizing compiler improve it?

Show solution
  1. The generator loads a into t0 and b into t1 from their stack slots, computes rem t0, t0, t1, and stores t0 into t's slot.
  2. That is four instructions, three of them memory accesses.
  3. With a, b and t in registers, an optimizing compiler emits a single rem instruction.

Answer: ld, ld, rem, sd at -O0 style; one rem instruction after register allocation.

Worked problem

Speculation and deoptimization

A JIT compiles function add(a, b) { return a + b; } after observing only small integers. What does the optimized code look like, and what happens when add("x", 1) is called?

Show solution
  1. The optimized code checks that both arguments are small integers, then adds them with an integer add and checks for overflow.
  2. For add("x", 1), the type guard fails.
  3. The code deoptimizes: it rebuilds the interpreter frame and continues in generic code that performs string concatenation, possibly recompiling later with both cases.

Answer: Guarded integer arithmetic, with deoptimization to the interpreter when the guard fails.

Worked problem

Interpreting a fuzzing result

Differential testing reports that an optimizer changes the value of (x * 0) - (y - y) only when x is a floating-point NaN. Is the optimizer wrong?

Show solution
  1. In integer arithmetic, both x × 0 and y − y are 0, so simplifying to 0 is correct.
  2. In IEEE floating point, NaN × 0 is NaN and Inf − Inf is NaN, so the simplification changes results.
  3. The rewrite is valid only for integers, or for floating point under fast-math flags that permit it.

Answer: Yes for floating point by default; the rule must be restricted to integers or to fast-math mode.

Common mistakes

  • MistakeJudge a compiler only by its parser.

    InsteadCorrect code generation, register allocation and optimization are where most of the engineering lies.

  • MistakeAssume a JIT's optimized code is valid forever.

    InsteadSpeculative code relies on guards and deoptimization when assumptions fail.

  • MistakeTrust that a large regression suite catches wrong-code bugs.

    InsteadUse random program generation, differential testing and reduction as well.

  • MistakeTreat stack protectors as making C code memory-safe.

    InsteadThey make some exploits harder; memory-safe languages remove the bug classes.

  • MistakeAssume machine learning has replaced compiler algorithms.

    InsteadIt assists specific heuristics; correctness still rests on sound algorithms.

Summary and key terms

  • A complete compiler chains scanning, parsing, checking, optimization, code generation and output; the capstone runs real RISC-V assembly on an emulator.
  • Unoptimized code keeps variables in memory; CFGs, SSA, register allocation and data-flow optimization remove most of that cost.
  • JIT compilers use tiers, profiling, speculation with guards, deoptimization, on-stack replacement and inline caches.
  • Compilers are tested with regression suites, random program generation such as Csmith, differential testing and test-case reduction.
  • CompCert proves a C compiler correct; Alive2 and translation validation check optimizations automatically.
  • Compilers add security features such as stack protectors, CFI and sanitizers; memory-safe languages eliminate whole bug classes.
  • The 2026 toolchain includes LLVM and GCC, language servers, fast linkers and caching, with machine learning assisting specific decisions.
Just-in-time compiler
A compiler that translates code during program execution.
Tiered compilation
Running code first in cheap tiers and recompiling hot code with more optimization.
Deoptimization
Falling back from speculatively optimized code to a safe tier when an assumption fails.
On-stack replacement
Switching a running function between interpreted and compiled code mid-execution.
Inline cache
A per-call-site cache of observed types used to speed up dynamic dispatch.
Differential testing
Comparing outputs of several implementations or configurations on the same inputs.
Test-case reduction
Shrinking a failing input to a minimal one that still fails.
Verified compiler
A compiler with a machine-checked proof that it preserves program meaning.
Stack protector
A canary value checked before return to detect stack buffer overflows.
Language server
A process providing editor features using a compiler front end over a standard protocol.

Practice problems

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

  1. Why does the capstone compiler reload b immediately after storing it?

    Show answer

    It keeps every variable in memory and has no data-flow optimization or register allocation across statements.

  2. Which registers does the capstone use for expression temporaries, and why must it save them around calls?

    Show answer

    t0 to t6; they are caller-saved, so the callee may overwrite them.

  3. What information does a JIT have that an ahead-of-time compiler lacks?

    Show answer

    Run-time profiles: hot code, taken branches and observed types.

  4. What is copy-and-patch compilation?

    Show answer

    Generating code by copying pre-compiled machine-code templates and patching in operands, as in CPython's JIT.

  5. What does Csmith generate?

    Show answer

    Random C programs without undefined behavior, for differential testing of compilers.

  6. Why must Csmith avoid undefined behavior?

    Show answer

    Otherwise different outputs would not indicate a compiler bug.

  7. What has been proved about CompCert?

    Show answer

    That its generated code preserves the semantics of source programs without undefined behavior.

  8. What does a stack protector check before a function returns?

    Show answer

    That the canary in the frame still equals the guard value.

  9. Name a tool that checks LLVM optimizations with an SMT solver.

    Show answer

    Alive2.

  10. Give one established use of machine learning in LLVM.

    Show answer

    MLGO's learned inlining or register-allocation eviction heuristics.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz