Which came first β the compiler or the source code? This project might just answer that.
A self-hosting C subset compiler written in C, inspired by c4. Small enough to compile itself. Complete enough to teach you everything about how a compiler turns characters into execution.
- Supports
int,char, and pointer types - Function definitions, calls, and recursion
- Control flow:
if/else,while,return,enum - Full unary and binary operators (bitwise, logical, comparison, arithmetic)
- Array access and type casting
- Built-in virtual machine that directly interprets bytecode
- Can compile itself (bootstrapping)
Source code (.c file)
β
βΌ
βββββββββββββββββββββ
β Lexer β next()
β β Tokenizes the character stream
β Handles: β
β identifiers β
β numbers β
β strings β
β keywords β
β operators β
βββββββββββ¬ββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββ
β Parser β Recursive descent
β β
β program() β Entry β loops over global declarations
β ββ global_declaration() β Variables / functions / enums
β β ββ enum_declaration() β Enum constants
β β ββ function_declaration() β
β β ββ function_parameter() β Parameter list
β β ββ function_body() β Function body
β β ββ statement() β Statements
β β ββ expression() β Expressions (precedence climbing)
β ββ ... β
β β
β Emits bytecode into text segment as it parses
βββββββββββ¬βββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββ
β Virtual Machine β eval()
β β Interprets bytecode from the text segment
β Registers: β
β pc / bp / sp / axβ
β β
β Memory segments: β
β text / data / stack
βββββββββββββββββββββ
| Instruction | Description |
|---|---|
IMM |
Load immediate value into ax |
LEA |
Load local variable address into ax |
LC |
Load char from address in ax |
LI |
Load int from address in ax |
SC |
Save ax as char to address on stack top |
SI |
Save ax as int to address on stack top |
PUSH |
Push ax onto the stack |
| Instruction | Description |
|---|---|
ADD / SUB / MUL / DIV / MOD |
Arithmetic |
OR / XOR / AND |
Bitwise operations |
SHL / SHR |
Bit shifts |
EQ / NE / LT / LE / GT / GE |
Comparisons |
| Instruction | Description |
|---|---|
JMP |
Unconditional jump |
JZ |
Jump if ax == 0 |
JNZ |
Jump if ax != 0 |
CALL |
Call function (saves return address) |
ENT |
Enter function (create stack frame) |
ADJ |
Adjust stack pointer (clean up arguments) |
LEV |
Leave function (restore frame and return) |
| Instruction | Maps to |
|---|---|
OPEN |
open() |
CLOS |
close() |
READ |
read() |
PRTF |
printf() |
MALC |
malloc() |
FREE |
free() |
MSET |
memset() |
MCMP |
memcmp() |
EXIT |
exit() |
During lexing, every identifier is stored in a flat integer array acting as a symbol table:
Each identifier occupies IdSize slots:
[ Token | Hash | Name | Type | Class | Value | BType | BClass | BValue ]
BType, BClass, and BValue temporarily shadow global variable metadata when entering a function scope, and are restored on exit. This is how the compiler handles local variable scoping without heap allocation per scope.
β οΈ This compiler runs in 32-bit mode and requires the-m32flag. macOS users: Xcode 12+ dropped 32-bit support β use Linux or Docker instead.
# Compile the compiler (note the -m32 flag)
gcc -m32 cc.c -o cc
# Or simply use the Makefile
make compile
# Use it to compile a C source file
./cc hello.c
# Enable debug mode (prints each bytecode instruction as it executes)
./cc -d hello.c# Run the built-in test suite
make test
# Equivalent to: ./cc test.cThe most interesting property of this compiler is that it can compile its own source code:
# One step β compile itself and run the tests
make bootstrap
# Equivalent to: ./cc cc.c test.c
# Or step by step:
# Step 1: use system gcc to produce the first binary
gcc -m32 cc.c -o cc
# Step 2: use cc to compile its own source
./cc cc.cThis is the engineering answer to the chicken-and-egg paradox: the very first compiler always has to be bootstrapped by an external tool. After that, it can sustain itself β compiling the next version of its own source, indefinitely.
It mirrors how production compilers like GCC and Clang work. The compiler you use today was compiled by yesterday's compiler.
test.c includes three test cases marked as expected failures, all related to how logical operators handle negative numbers:
test(1, c || c); // failed β || gives wrong result when c is negative
test(TRUE, b && c); // failed β && mishandles negative operands
test(TRUE, c && c); // failedThe root cause: the VM's JNZ (jump if not zero) instruction does not treat negative integers as truthy, which is non-standard β C requires any non-zero value to be true.
// Types
int x;
char c;
int *ptr;
// Control flow
if (x > 0) { ... } else { ... }
while (x > 0) { x = x - 1; }
// Functions
int add(int a, int b) {
return a + b;
}
// Enums
enum { False, True };
// Pointers & arrays
int arr[10];
*(arr + 2) = 42;
// Sizeof
int size;
size = sizeof(int);Source inspirations
- rswier / c4 β C in four functions
- lotabout / write-a-C-interpreter
- archeryue / cpc
- tch0 / JustAToyCCompiler
Articles
Books
- Niklaus Wirth β Compiler Construction
- The Elements of Computing Systems β Build a Modern Computer from First Principles (nand2tetris)