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.
# 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")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 instructionsThe 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.
| Phase | Capstone implementation | Production equivalent |
|---|---|---|
| Scanning | One regular expression with alternatives | Hand-written DFA-like scanner (Clang, rustc) |
| Parsing | Recursive descent with Pratt expressions | The same technique, with error recovery and full positions |
| Semantic analysis | Declared names and call arity | Full name resolution, type checking and inference |
| Optimization | Constant folding on expressions | About a hundred SSA passes at -O2 |
| Instruction selection | One pattern per AST node | Tree or DAG tiling from target descriptions |
| Register allocation | Temporaries by expression depth, variables in memory | Graph coloring or greedy allocation with splitting |
| Output | Assembly text run on an emulator | Object 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.
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 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")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.
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"$ 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_failAn 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
- 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.
- That is four instructions, three of them memory accesses.
- 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
- The optimized code checks that both arguments are small integers, then adds them with an integer add and checks for overflow.
- For add("x", 1), the type guard fails.
- 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
- In integer arithmetic, both x × 0 and y − y are 0, so simplifying to 0 is correct.
- In IEEE floating point, NaN × 0 is NaN and Inf − Inf is NaN, so the simplification changes results.
- 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.
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.
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.
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.
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.
What does Csmith generate?
Show answer
Random C programs without undefined behavior, for differential testing of compilers.
Why must Csmith avoid undefined behavior?
Show answer
Otherwise different outputs would not indicate a compiler bug.
What has been proved about CompCert?
Show answer
That its generated code preserves the semantics of source programs without undefined behavior.
What does a stack protector check before a function returns?
Show answer
That the canary in the frame still equals the guard value.
Name a tool that checks LLVM optimizations with an SMT solver.
Show answer
Alive2.
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.
Create a free account to save your progress, take quizzes and earn a certificate.