Catalog · How the catalog works
crenshaw-compiler
Jack Crenshaw's 'Let's Build a Compiler!' tutorial implemented in Free Pascal — v1 (Ch 1-10, 68k asm) and v2 (Ch 11-16, TINY 2.1 + stack VM)
- ★ 2 on GitHub
Anyone may press Interesting, signed in or not. It helps others find this project.
README
Crenshaw: Let's Build a Compiler in Free Pascal
An implementation of Jack Crenshaw's classic tutorial series "Let's Build a Compiler!" (1988–1995), rewritten for modern Free Pascal. Crenshaw was a software engineer and longtime columnist at Embedded Systems Programming who wrote a sixteen-part series walking the reader, from first principles, through the construction of a small but complete recursive-descent compiler. The original targeted Turbo Pascal and emitted Motorola 68000 assembly. This project has two implementations living side by side: v1 is a faithful port of chapters 1–10, preserving the pedagogical one-file-per-chapter structure and still emitting 68k assembly; v2 covers chapters 11–16 and takes the final TINY 2.1 language all the way through a unit-based compiler and a stack virtual machine so programs actually execute.
Directory layout
crenshaw/
????????? README.md This file
????????? Makefile Top-level build/test driver
????????? ci.sh Runs the complete test matrix
???
????????? cradle.pas Ch 1 ??? I/O cradle
????????? expression.pas Ch 2???3 ??? expression parser -> 68k
????????? interpreter.pas Ch 4 ??? expression interpreter
????????? control.pas Ch 5 ??? IF/WHILE/FOR/LOOP/REPEAT/BREAK
????????? boolean.pas Ch 6 ??? boolean expressions, relational ops
????????? kiss.pas Ch 7 ??? lexical scanner, keyword table
????????? tiny.pas Ch 10 ??? single-file TINY 1.0 compiler
????????? verify.sh v1 test harness (21 asserts)
????????? test1.tny Sample v1 TINY sources
????????? test2.tny
????????? test3.tny
???
????????? v2/
????????? tinytypes.pas Unit: shared types (TTokKind, TOpcode, TSymbol, ...)
????????? scanner.pas Unit: lexical scanner (Ch 11???12)
????????? symtab.pas Unit: symbol table, scopes
????????? codegen.pas Unit: bytecode emitter, jump patching
????????? vm.pas Unit: stack VM executing the bytecode
????????? tiny2.pas Program: parser + driver (TINY 2.1)
????????? verify.sh v2 demo suite (12 .tny tests)
????????? demo1.tny..demo7.tny Feature demos
????????? adversarial/ 11 tests probing error paths
????????? stress/ 15 tests for perf/scale (run.sh)Prerequisites
- Free Pascal Compiler 3.2.2 or later. Earlier 3.0.x releases are
untested. Install with:
brew install fpc # macOS sudo apt install fpc # Debian/Ubuntu - A POSIX shell (
bashorzsh) forverify.sh,ci.sh, andMakefile. - No other dependencies — no emulator, no assembler, no linker scripts.
Building
v1 (chapters 1–10)
Each chapter is a self-contained program. Build any one of them individually:
cd crenshaw
fpc -Mfpc cradle.pas
fpc -Mfpc expression.pas
fpc -Mfpc interpreter.pas
fpc -Mfpc control.pas
fpc -Mfpc boolean.pas
fpc -Mfpc kiss.pas
fpc -Mfpc tiny.pasThe -Mfpc flag selects FPC's native dialect (rather than ObjFPC/Delphi mode),
which matches Crenshaw's Turbo Pascal idioms most closely.
v2 (chapters 11–16, TINY 2.1)
v2 is split into Pascal units. Compiling the main program is enough — FPC
follows uses clauses and builds the dependency graph automatically:
cd crenshaw/v2
fpc -Mfpc tiny2.pasThis produces tiny2, the TINY 2.1 compiler-plus-VM executable. Feed it a
source file:
./tiny2 demo1.tnyTop-level Makefile
From the repo root:
make all # build every v1 chapter and v2
make test # run the complete test matrix (v1 + v2 + adversarial + stress)
make clean # remove binaries and .o/.ppu artifactsChapter-by-chapter breakdown
| Ch | Title | File | What it adds | Status |
|---|---|---|---|---|
| 1 | Introduction / The Cradle | cradle.pas |
Basic I/O helpers (GetChar, Match, Abort, Emit), skeleton program |
✓ |
| 2 | Expression Parsing | expression.pas |
Single-digit Term/Expression, + - * /, emits 68k moves/adds |
✓ |
| 3 | More Expressions | expression.pas |
Parentheses, unary minus, recursive nesting | ✓ |
| 4 | Interpreters | interpreter.pas |
Same grammar, evaluates directly using an A..Z variable table |
✓ |
| 5 | Control Constructs | control.pas |
IF/ELSE, WHILE, FOR, LOOP, REPEAT, BREAK with label gen |
✓ |
| 6 | Boolean Expressions | boolean.pas |
Relational ops, Crenshaw-style `! & | ~`, short-circuit via jumps |
| 7 | Lexical Scanning | kiss.pas |
Multi-char identifiers, whitespace/newline handling, keyword table | ✓ |
| 8 | A Little Philosophy | (none) | Retrospective essay — no code. Read-only chapter. | ⚠ essay |
| 9 | A Top View | (none) | Design discussion for the full language — no standalone program | ⚠ essay |
| 10 | Introducing "TINY" | tiny.pas |
Full TINY 1.0 compiler in one file, emits 68000 assembly | ✓ |
| 11 | Lexical Scan Revisited | v2/scanner.pas |
Distributed scanner, { ... } comments, integrated with parser |
✓ |
| 12 | Miscellany | v2/scanner.pas |
Multi-char relational operators <=, >=, <>, line tracking |
✓ |
| 13 | Procedures | v2/tiny2.pas |
PROCEDURE/FUNCTION, params, locals, RETURN, call frames |
✓ |
| 14 | Types | v2/tiny2.pas |
LONG/INT, WORD, BYTE, CHAR, promotion + narrowing warnings |
✓ |
| 15 | Back to the Future | (retrospective) | Review of design choices — no new code in this chapter | ⚠ essay |
| 16 | Unit Construction | v2/*.pas |
The v2 tree itself: scanner, symtab, codegen, vm as separate units | ✓ |
Chapters 8, 9, and 15 are Crenshaw's prose chapters; they have no accompanying program. All code chapters are complete.
TINY 2.1 language reference
TINY 2.1 is the language accepted by v2/tiny2. It extends the original
Crenshaw TINY with typed variables, procedures and functions, short-circuit
boolean operators, and proper WRITE/WRITELN semantics.
EBNF grammar
program = "PROGRAM" ident ";" block "." ;
block = { var-decl | proc-decl | func-decl } "BEGIN" statements "END" ;
var-decl = "VAR" type ident { "," ident } ";" ;
type = "INT" | "LONG" | "WORD" | "BYTE" | "CHAR" ;
proc-decl = "PROCEDURE" ident [ "(" param-list ")" ] ";"
{ var-decl } "BEGIN" statements "END" ";" ;
func-decl = "FUNCTION" type ident [ "(" param-list ")" ] ";"
{ var-decl } "BEGIN" statements "END" ";" ;
param-list = param { "," param } ;
param = type ident ;
statements = statement { ";" statement } ;
statement = assignment
| if-stmt
| while-stmt
| read-stmt
| write-stmt
| return-stmt
| halt-stmt
| call-stmt
| empty ;
assignment = ident "=" expression ;
if-stmt = "IF" expression statements
[ "ELSE" statements ]
"ENDIF" ;
while-stmt = "WHILE" expression statements "ENDWHILE" ;
read-stmt = "READ" "(" ident { "," ident } ")"
| "READCH" "(" ident ")" ;
write-stmt = "WRITE" "(" write-arg { "," write-arg } ")"
| "WRITELN" "(" [ write-arg { "," write-arg } ] ")"
| "WRITECH" "(" expression ")" ;
write-arg = expression | string-literal ;
return-stmt = "RETURN" [ expression ] ;
halt-stmt = "HALT" ;
call-stmt = ident [ "(" [ expression { "," expression } ] ")" ] ;
expression = or-expr ;
or-expr = and-expr { ("OR" | "XOR") and-expr } ;
and-expr = rel-expr { "AND" rel-expr } ;
rel-expr = add-expr [ rel-op add-expr ] ;
rel-op = "=" | "<>" | "!=" | "<" | "<=" | ">" | ">=" ;
add-expr = mul-expr { ("+" | "-") mul-expr } ;
mul-expr = unary { ("*" | "/" | "%") unary } ;
unary = [ "-" | "+" | "NOT" | "!" ] primary ;
primary = number
| char-literal
| ident [ "(" [ expression { "," expression } ] ")" ]
| "(" expression ")" ;
ident = letter { letter | digit | "_" } ;
number = digit { digit } ;
char-literal = "'" any-char "'" ;
string-literal = '"' { any-char-except-quote } '"' ;Reserved keywords
PROGRAM BEGIN END VAR
PROCEDURE FUNCTION RETURN
IF ELSE ENDIF
WHILE ENDWHILE
READ WRITE WRITELN READCH WRITECH
HALT
AND OR XOR NOT
INT LONG WORD BYTE CHARKeywords are recognised case-insensitively (the scanner uppercases them) but most examples use uppercase for historical fidelity to the Crenshaw style.
Types
| Type | Width | Signedness | Notes |
|---|---|---|---|
LONG |
32 bits | signed | Default integer type |
INT |
32 bits | signed | Alias for LONG |
WORD |
16 bits | signed | Stored full-width on the stack |
BYTE |
8 bits | signed | Stored full-width on the stack |
CHAR |
8 bits | unsigned | For READCH/WRITECH and character data |
Promotion rule. The result of any binary arithmetic or relational
operation has the type of the widest operand. Mixing BYTE + LONG
produces a LONG. No implicit narrowing: assigning a wider value to a
narrower variable (e.g. a LONG expression into a WORD) compiles, but the
compiler emits a warning of the form
warning: narrowing assignment from LONG to WORD at line 42and the runtime performs a truncating store.
Operators and precedence
Listed lowest to highest. All operators in a row are left-associative (unary operators are prefix and right-associative).
| Level | Operators | Description |
|---|---|---|
| 1 | OR, XOR |
Logical or / exclusive |
| 2 | AND |
Logical and |
| 3 | =, <>, !=, <, <=, >, >= |
Relational |
| 4 | +, - |
Additive |
| 5 | *, /, % |
Multiplicative, modulo |
| 6 | unary -, unary +, NOT, ! |
Prefix |
NOT and ! are synonyms (! is retained from Ch 6); likewise <> and
!=. Arithmetic on signed integers uses two's-complement wrap-around on
overflow — there are no runtime checks.
Short-circuit evaluation
AND and OR short-circuit: the right operand is not evaluated when the
left already determines the result. XOR evaluates both operands, since
both are always needed.
IF p <> 0 AND p.value > 0 ... { p.value never read when p = 0 }(Note: TINY 2.1 has no record syntax — the example is illustrative of the ordering guarantee only.)
Sample program
PROGRAM hello;
VAR LONG i;
VAR LONG n;
FUNCTION LONG square(LONG x);
BEGIN
RETURN x * x
END;
BEGIN
WRITELN("Hello from TINY 2.1");
n = 5;
i = 1;
WHILE i <= n
WRITE(i, " squared is ", square(i));
WRITELN();
i = i + 1
ENDWHILE;
WRITELN("done.")
END.Expected output:
Hello from TINY 2.1
1 squared is 1
2 squared is 4
3 squared is 9
4 squared is 16
5 squared is 25
done.VM ISA specification
The v2 virtual machine is a classic stack machine with a separate call stack
frame model. Each instruction is one opcode byte followed by zero or one
32-bit signed operand. Values on the operand stack are 32-bit signed
integers; sub-word loads/stores sign-extend (or zero-extend for CHAR) on
read and truncate on write.
Notation: [a b c ...] describes the stack top-down, so c is the topmost
element. → separates the pre-state from the post-state.
Control flow
| Opcode | Args | Stack effect | Description |
|---|---|---|---|
HALT |
0 | — | Stop the VM cleanly |
JMP |
1 | — | Unconditional jump to absolute bytecode offset |
JZ |
1 | [v] → [] |
Pop; jump if v = 0 |
JNZ |
1 | [v] → [] |
Pop; jump if v ≠ 0 |
CALL |
1 | — | Call procedure at offset (no return value) |
CALLF |
1 | [] → [r] |
Call function at offset; result left on stack |
ENTER |
1 | — | Reserve N local slots on the call stack |
LEAVE |
0 | — | Discard locals, restore caller frame |
RET |
0 | — | Return from procedure |
RETV |
0 | [r] → [] |
Return from function; r is left for the caller |
Memory
| Opcode | Args | Stack effect | Description |
|---|---|---|---|
PUSH |
1 | [] → [k] |
Push 32-bit immediate |
LOADG |
1 | [] → [v] |
Load LONG global at index N |
LOADGW |
1 | [] → [v] |
Load WORD global (sign-extend to 32) |
LOADGB |
1 | [] → [v] |
Load BYTE global (sign-extend) |
LOADGC |
1 | [] → [v] |
Load CHAR global (zero-extend) |
STOREG |
1 | [v] → [] |
Store LONG to global N |
STOREGW |
1 | [v] → [] |
Store WORD (truncate) |
STOREGB |
1 | [v] → [] |
Store BYTE (truncate) |
STOREGC |
1 | [v] → [] |
Store CHAR (truncate, 8-bit) |
LOADL |
1 | [] → [v] |
Load LONG local / parameter at frame offset N |
LOADLW |
1 | [] → [v] |
Load WORD local |
LOADLB |
1 | [] → [v] |
Load BYTE local |
LOADLC |
1 | [] → [v] |
Load CHAR local |
STOREL |
1 | [v] → [] |
Store LONG to local N |
STORELW |
1 | [v] → [] |
Store WORD to local |
STORELB |
1 | [v] → [] |
Store BYTE to local |
STORELC |
1 | [v] → [] |
Store CHAR to local |
Arithmetic
| Opcode | Args | Stack effect | Description |
|---|---|---|---|
ADD |
0 | [a b] → [a+b] |
Signed addition |
SUB |
0 | [a b] → [a-b] |
Signed subtraction |
MUL |
0 | [a b] → [a*b] |
Signed multiplication |
DIV |
0 | [a b] → [a/b] |
Signed truncated division |
MOD |
0 | [a b] → [a%b] |
Signed remainder |
NEG |
0 | [a] → [-a] |
Unary negation |
Division by zero traps and halts the VM with an error message and the faulting instruction pointer.
Logical (bitwise, operating on full 32-bit values)
| Opcode | Args | Stack effect | Description |
|---|---|---|---|
AND |
0 | [a b] → [a&b] |
Bitwise AND |
OR |
0 | [a b] → [a|b] |
Bitwise OR |
XOR |
0 | [a b] → [a^b] |
Bitwise XOR |
NOT |
0 | [a] → [~a] |
Bitwise NOT |
Because TINY 2.1 treats non-zero as true and zero as false, these double as
logical operators for the straight-line (non-short-circuiting) cases. The
parser emits jump-threaded code for AND/OR to implement short-circuit.
Relational (push 1 for true, 0 for false)
| Opcode | Args | Stack effect | Description |
|---|---|---|---|
CEQ |
0 | [a b] → [a=b] |
Equal |
CNE |
0 | [a b] → [a≠b] |
Not equal |
CLT |
0 | [a b] → [ab] |
Greater than |
CLE |
0 | [a b] → [a≤b] |
Less than or equal |
CGE |
0 | [a b] → [a≥b] |
Greater than or equal |
I/O
| Opcode | Args | Stack effect | Description |
|---|---|---|---|
READ |
1 | — | Read an integer from stdin into global/local N |
READCH |
1 | — | Read one character from stdin into slot N |
WRITE |
0 | [v] → [] |
Write v as a decimal integer, no newline |
WRITEI |
0 | [v] → [] |
Synonym of WRITE, used in typed contexts |
WRITELN |
0 | — | Emit a newline |
WRITECH |
0 | [v] → [] |
Write low 8 bits of v as an ASCII character |
PRINTS |
1 | — | Print the string literal in the constant pool at N |
READ/READCH take a destination operand; the compiler encodes whether it
refers to a global or a local via a type bit in the operand. String literals
used by WRITE(...) and WRITELN(...) are emitted into a constant pool
and printed with PRINTS.
Calling convention
Arguments are pushed left to right by the caller. CALL/CALLF saves the
return address and jumps. The callee immediately executes ENTER M to
reserve M local slots, then uses LOADL/STOREL at fixed offsets:
high addresses
+-----------------+
| param 1 | local offset -(N+1)
| param 2 | local offset -N
| ... |
| param N | local offset -2
+-----------------+
| return address |
+-----------------+
| saved FP | <-- caller's frame pointer
+-----------------+
| local 1 | local offset 0
| local 2 | local offset 1
| ... |
| local M | local offset M-1
+-----------------+ <-- SP on entry to body
low addressesLEAVE pops the M locals and restores FP. RET pops the return address and
resumes the caller. RETV is identical to RET except that the topmost
stack value is preserved across the frame teardown so the caller sees it as
the function's result.
Testing
The project ships with four test suites totalling sixty-plus cases. All
pass on the reference build (fpc 3.2.2 on macOS arm64).
| Suite | Driver | Count | What it checks |
|---|---|---|---|
| v1 assembly | verify.sh |
21 | Text asserts on emitted 68k from each Ch 1–10 program |
| v2 demos | v2/verify.sh |
12 | .tny end-to-end: compile + run, compare stdout |
| v2 adversarial | v2/adversarial/run.sh |
11 | Error paths: syntax errors, type clashes, runtime trap |
| v2 stress | v2/stress/run.sh |
15 | Deep recursion, long programs, large literals |
| Total | 59+ |
Running everything:
make test # rebuild + run all suites
./ci.sh # same, used by continuous integrationIndividual suites can be run directly:
cd crenshaw && ./verify.sh
cd crenshaw/v2 && ./verify.sh
cd crenshaw/v2/adversarial && ./run.sh
cd crenshaw/v2/stress && ./run.shAll tests currently pass.
Known limitations
This is a pedagogical project, not a production language. The following are deliberately out of scope:
- No arrays, records, pointers, or floating point. The only types are the four integer widths.
- No strings as first-class values. String literals appear only as
arguments to
WRITE/WRITELN; you cannot assign them to variables, read them, or concatenate them. - No nested procedures or functions. Every routine is declared at the top level of the program block.
- No forward declarations or mutual recursion. A routine can only call routines that have already been parsed.
- Single compilation unit. There is no
IMPORTor module system at the TINY level; each source file is a standalone program. - v1's 68000 assembly is not executed. There is no bundled emulator; verification inspects the emitted text only.
- Single-error recovery. The compiler halts on the first syntax or type error. There is no error recovery or "continue after error" mode.
- No optimisation. Bytecode is emitted naively; the VM makes no attempt at inlining, constant folding, or register-like caching.
History and references
- Jack Crenshaw's original tutorial lives at [https://compilers.iecc.com/crenshaw/](https://compilers.iecc.com/crenshaw/)) Sixteen installments, written between 1988 and 1995 and still one of the friendliest introductions to hand-written compilers in existence.
- Crenshaw targeted Turbo Pascal on DOS and emitted assembly for the Motorola 68000, which was then the dominant CPU in high-end embedded systems and workstations.
- This project ports the text to Free Pascal 3.2.2, keeping v1 as close
to the original idioms as possible (one file per chapter,
-Mfpcdialect, 68k output preserved for inspection) and retargeting v2 to a stack virtual machine so that the final TINY 2.1 language can actually execute its programs without an external assembler or emulator. - The structure of v2 into separate Pascal units (
tinytypes,scanner,symtab,codegen,vm,tiny2) is itself the worked example for Chapter 16, Unit Construction.

