A 35-Year-Old Compiler Tutorial That Still Resonates
Jack Crenshaw's "Let's Build a Compiler" series ran from 1988 to 1995, written in Pascal and targeting Motorola 68000 assembly. By any measure, the technologies involved are archaic. Yet the tutorial remains a frequent reference point in Hacker News threads and community discussions. To understand its enduring appeal, it helps to go beyond reading and actually work through the material — in a modern context.
A recent effort to translate the tutorial's compilers into Python, emitting WebAssembly instead of 68000 assembly, is documented in a public repository. The accompanying TUTORIAL.md maps each section of the original series to the corresponding Python code, making it possible to follow Crenshaw's lessons while running the examples on contemporary tooling.
What the Tutorial Builds
The input language in the tutorial is "KISS" — Crenshaw's minimal design. By part 13, the language supports procedures, while loops, and parameters passed both by value and by reference. A representative program looks like this:
var X=0
{ sum from 0 to n-1 inclusive, and add to result }
procedure addseq(n, ref result)
var i, sum { 0 initialized }
while i < n
sum = sum + i
i = i + 1
end
result = result + sum
end
program testprog
begin
addseq(11, X)
end
.
The WebAssembly text emitted by the Python port for this program shows some subtlety in handling the by-reference parameter — an issue explored in more depth in a companion post on the WebAssembly C ABI. Overall, the generated code has essentially no optimization applied; it's straightforward, literal translation.
(module
(memory 8)
;; Linear stack pointer. Used to pass parameters by ref.
;; Grows downwards (towards lower addresses).
(global $__sp (mut i32) (i32.const 65536))
(global $X (mut i32) (i32.const 0))
(func $ADDSEQ (param $N i32) (param $RESULT i32)
(local $I i32)
(local $SUM i32)
loop $loop1
block $breakloop1
local.get $I
local.get $N
i32.lt_s
i32.eqz
br_if $breakloop1
local.get $SUM
local.get $I
i32.add
local.set $SUM
local.get $I
i32.const 1
i32.add
local.set $I
br $loop1
end
end
local.get $RESULT
local.get $RESULT
i32.load
local.get $SUM
i32.add
i32.store
)
(func $main (export "main") (result i32)
i32.const 11
global.get $__sp ;; make space on stack
i32.const 4
i32.sub
global.set $__sp
global.get $__sp
global.get $X
i32.store
global.get $__sp ;; push address as parameter
call $ADDSEQ
;; restore parameter X by ref
global.get $__sp
i32.load offset=0
global.set $X
;; clean up stack for ref parameters
global.get $__sp
i32.const 4
i32.add
global.set $__sp
global.get $X
)
)
One oddity: the global variable X appears to be implicitly returned by the generated main function. This is a deliberate testing convenience, letting the compilers validate their output by running the generated WASM and checking results.
Why the Approach Still Works
Crenshaw's tutorial holds up because of two structural decisions, not just its friendly prose.
First, it builds a hand-written recursive-descent parser incrementally, from the first chapter onward. At the time of its original publication, the prevailing wisdom was that serious parser work meant lex and yacc. Watching a clean recursive-descent parser take shape without any table-driven machinery was, for many readers, a revelation. For the author of the Python port, it changed how they approached parsing for nearly two decades afterward.
Second, the tutorial generates working assembly code almost immediately, rather than front-loading parsing, type checking, and semantic analysis as traditional courses often did. For developers who had seen courses spend 90% of their time on front-end concerns and never reach code generation, this was a refreshing change of pace. But the approach has limits, which become visible as the series progresses.
Where the Syntax-Directed Approach Strains
The tutorial follows a syntax-directed translation strategy: code is emitted while parsing, with no explicit intermediate representation or compiler phases. This is ideal for getting started, but it becomes awkward once types enter the picture. By part 14, it's hard to avoid the conclusion that knowing an expression's type before generating code for it would simplify matters considerably.
It's not known whether this growing complexity contributed to Crenshaw ending the series after part 14, but the possibility is real. The code Crenshaw produces is clearly sub-optimal, and he notes this repeatedly — yet improving it within the syntax-directed framework is genuinely difficult. A more scalable path would have been to introduce an AST at that point, with simple type checking and analysis performed on the tree before code generation.
Still, for what it sets out to do — demystify the core of a compiler and get to running code fast — the tutorial is hard to beat. The Python/WebAssembly port is a modest update that lets today's readers follow along without dusting off a Pascal compiler and a 68000 emulator.



