A small C compiler written in C. It lexes, parses, typechecks, builds an IR, and emits real x86-64 assembly. The goal is to make it compile itself.
C in x86-64 out
────── ──────────
int main() { _main:
int x = 2 + 3 * 4; tinyc movl $3, -4(%rbp)
return x; ───────────▶ imull $4, -4(%rbp)
} lex, parse, movl $2, -8(%rbp)
ir, codegen addl %r10d, -8(%rbp)
...
movl -12(%rbp), %eax
ret
Writing a compiler in the language it compiles is a nice forcing function. Every feature I add is one I can then use inside the compiler itself, and "can it build itself yet?" is a very concrete way to track progress.
Standard compiler pipeline, one stage per directory:
source.c out.s
│ ▲
▼ │
┌────────┐ ┌────────┐ ┌──────────┐ ┌──────┐ ┌─────────┐ ┌──────┐
│ lexer │──▶│ parser │──▶│ semantic │──▶│ IR │──▶│ codegen │──▶│ emit │
│ tokens │ │ AST │ │ analysis │ │ │ │ x86 AST │ │ .s │
└────────┘ └────────┘ └──────────┘ └──────┘ └─────────┘ └──────┘
token.c parser.c resolve + ir.c codegen.c emit.c
typecheck
- Lexer turns the source text into a stream of tokens.
- Parser builds an AST with the usual operator precedence and associativity.
- Semantic analysis resolves variables to unique identifiers (catching
redeclarations), validates function calls and parameter counts, and resolves
labels for
gotoand loops. - IR lowers the AST to a flat, temp-based intermediate form. This is where short-circuit logic and the like get turned into explicit branches.
- Codegen turns the IR into an x86 AST, picking stack slots and registers.
- Emit prints that as AT&T-syntax
.syou can hand to an assembler.
You can stop after any stage to see what it produced (see Build and run).
Module map
| Path | What it is |
|---|---|
src/main.c |
Driver: arg parsing and stage selection |
src/lexer/token.{c,h} |
Tokenizer |
src/ir/ir.{c,h} |
AST to IR lowering |
src/codegen/codegen.{c,h} |
IR to x86 AST |
src/codegen/x86/x86_ast.{c,h} |
x86 instruction model |
src/codegen/emit.{c,h} |
x86 AST to AT&T assembly text |
src/list.{c,h}, src/map.h, src/strlib/str.{c,h} |
Support data structures |
| ======= | |
src/strlib/str.{c,h} |
String utilities |
src/common/list.h |
Generic dynamic array (macro-based) |
src/common/optional.h |
Optional type wrapper |
src/common/ice.h |
Internal compiler error macro |
02ded9991889ec6ab2366ab405ba6e18c2de6f2c
It is a subset of C, not the whole language yet. Right now that covers:
intvariables, assignment, andreturnvoidas a return type- Functions: declarations, definitions with parameters, and calls
- Control flow:
if/else,for,while,do/while,break,continue switch/case/defaultwith fallthroughgotoand labels- Ternary conditional (
? :) - Full expression support: arithmetic (
+ - * / %), bitwise (& | ^ ~ << >>), comparison (== != < > <= >=), logical (&& || !) with short-circuit evaluation, unary minus and complement, and prefix/postfix increment/decrement - Compound assignment (
+= -= *= /= %= &= |= ^= <<= >>=) - Correct precedence, associativity, and unique labels for nested short-circuits
- Semantic analysis: variable resolution, typechecking (function arity), label
resolution for
gotoand loopbreak/continue
What is missing is most of the rest of the language: pointers, arrays, structs, strings, multiple types, and the preprocessor. Closing that gap is the road to self-hosting.
// sample.c
int main() {
int x = 2 + 3 * 4;
return x;
}make
./tinyc --codegen sample.c # writes sample.s next to the source.global _main
_main:
pushq %rbp
movq %rsp, %rbp
subq $12, %rsp
movl $3, -4(%rbp)
imull $4, -4(%rbp)
movl $2, -8(%rbp)
movl -4(%rbp), %r10d
addl %r10d, -8(%rbp)
movl -8(%rbp), %r10d
movl %r10d, -12(%rbp)
movl -12(%rbp), %eax
movq %rbp, %rsp
popq %rbp
retmake # builds ./tinyc
./tinyc sample.c # full pipeline, emits sample.s
./tinyc --lex file.c # stop after lexing
./tinyc --parse file.c # stop after parsing
./tinyc --ir file.c # stop after IR
./tinyc --codegen file.c # stop after codegen (emit .s)
./tinyc --helpThe input file has to end in .c.
make test # unit tests
make test-e2e # end-to-end tests (compile, assemble, run, check exit code)Unit tests cover the list structure, lexer, parser, AST, IR generation, codegen, variable scoping, and typechecking. End-to-end tests compile small C programs through the full pipeline, run the resulting binary, and verify the exit code against the system compiler.
PASS: test_emit_binop_all_ops
PASS: test_emit_logical_and_short_circuit
PASS: test_emit_logical_or_short_circuit
PASS: test_emit_relational_ops
PASS: test_emit_nested_short_circuit_unique_labels
All IR tests passed!