Strongly typed, C-like systems programming language built for resource-constrained 8-bit microprocessors.
- Getting Started: Key Features & Architecture
- Current Status & Limitations
- Toolchain Usage
- Language Specifications
- Binary Layout
- Third-Party Licenses
C02 compiles through a small pipeline of standalone tools, wired together by the c02c driver:
c02-frontend(Haskell) — tokenizer, recursive-descent parser,includeresolver, lexically-scoped semantic analyzer, and IR generator. Lowers.c02source into a self-contained three-address-code (TAC) intermediate representation — struct layouts with computed field offsets, globals/registers with hardware addresses baked in, and one flat instruction stream per function — then serializes it to a.oobject, one per translation unit.c02-ld(OCaml) — the linker. Merges one or more.oobjects into a single relocatable object and resolves the symbol namespace: it binds cross-module references (adeclforward declaration in one file to its definition in another), de-duplicates identicalregandstructdeclarations, applies the tentative-definition rule to globals, and rejects genuine conflicts (duplicate definitions, signature/type mismatches, namespace collisions). It never requires amain, so a merged object may be a library. Incremental compilation (-c) stops here, emitting the merged object.c02-as(C) — the code generator. Consumes the linked.oIR object and emits a 32 KB 65C02 ROM: bootstrap runtime, interrupt vectors, and flat zero-page allocation of locals, temporaries, and parameters (no slow stack-machine execution). Globals live in RAM ($0200+) and are initialized beforeJSR main; string literals go in a ROM data section with backpatch fixups. Every emit path is bounds-checked against the 32 KB limit, so overflow is a clear diagnostic rather than silent corruption.c02c— the driver that runs the frontend on each source, thenc02-ld, thenc02-as, managing intermediate.ofiles. This is the command you normally invoke.c02-objdump(Rust) — disassembler. Decodes a compiled.binback into annotated 65C02 assembly, resolving jump targets to named labels, with section-aware output (.text/.data), hex dumps, and ROM usage summaries. See c02-objdump.
The function-call ABI passes up to 8 parameters through a fixed 2-byte-per-param zero-page zone ($EF–$FE); a callee-saves convention (PHA/PLA over the ZP slots) preserves caller locals across calls and enables bounded recursion. Compiler implicit globals (__heap_start, __memory_top) are injected automatically.
The language is broadly in place: data movement and hardware-register I/O; if/else, while, for, break/continue; arithmetic, bitwise, shift, and comparison operators across u8/i8/u16/i16 (multiply/divide via __mul8/__div8/__mul16/__div16 software routines); pointers (&, *, ptr ± int), type casts, structs and field access, globals, string literals, and function calls with recursion. Multi-file programs are supported through include "..." headers and a bundled standard library (see Separate compilation below). docs/SPEC.md is the normative definition of the language and its exact semantics; docs/DEVIATIONS_hs_impl.md records where this implementation currently diverges from it (it is generally stricter than the original).
Not yet implemented: arrays (use pointer arithmetic, *(ptr + i), in the meantime) and compound bitwise/shift assignment (&=, |=, ^=, <<=, >>= — the arithmetic forms += -= *= /= %= work).
If you're exploring the codebase, the stages live in c02-frontend/ (Haskell), c02-ld/ (OCaml, the linker), and c02-as/ (C, the code generator). Issues and PRs are welcome.
The four stages need a C compiler (c02-as), GHC + Cabal (the Haskell c02-frontend), OCaml + dune (the c02-ld linker), and Rust (c02-objdump); Python 3 drives c02c and the tests.
sudo apt install build-essential curl python3 python3-pip -y
# GHC + Cabal for the Haskell frontend (ghcup is the usual installer)
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh
# OCaml + dune for the linker (c02-ld); opam ships the compiler
sudo apt install opam -y
opam init -y && eval $(opam env)
opam install dune -y
# Rust, for c02-objdump
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# py65 6502 emulator, for the runtime tests
pip install py65
git clone https://github.com/jackwthake/C02.git
cd C02
cabal update # first build only, to populate the Cabal package index
makec02c is the driver; it runs the frontend and the code generator for you.
c02c [OPTIONS] <FILE>...<FILE>: Input file(s) —.c02source and/or.oIR objects-h, --help: Show help message-o, --output: Output path (defaulta.out)-I <dir>: Add a directory to the header (include "...") search path; repeatable-c: Compile and link to a relocatable.oobject (a partial link — nomainrequired) instead of a full ROM--parse-only: Check syntax only, produce no output--dump-ast: Print the AST after parsing (no output file)--no-stdlib: Don't link the bundled standard library (or add its headers to the include path)--dump-ir: Print the IR (TAC) the code generator consumes (no output file)--strip-debug: Omit theC02Ssymbol table from the final ROM
Pass several sources and/or .o objects and c02c compiles each source, then links everything into one output. A .c02 references a symbol defined in another file with a decl forward declaration (decl fn f(u8 x) -> void;, decl u8 counter;).
c02c -c util.c02 -o util.o # compile + partial-link to a relocatable object
c02c main.c02 util.o -o program.bin # link that object into a full ROM
c02c main.c02 util.c02 -o program.bin # or compile and link several sources at once
c02c --dump-ir main.c02 util.c02 # inspect the linked IR before codegenRather than repeat those decls by hand in every file, collect them in a header (.c02h by convention) and pull them in with include "name"; at the top of each source that needs them:
// util.c02h
decl fn f(u8 x) -> void;
decl u8 counter;// main.c02
include "util.c02h";
fn main() -> void { f(counter); }Includes are resolved before analysis: each include is replaced by the named file's declarations, resolved recursively, with identical redeclarations de-duplicated (so diamond includes are harmless) and cycles terminated. Header lookup searches the including file's own directory first, then each -I <dir> in the given order — the first match wins. Headers carry only declarations; the matching definitions are still supplied by a linked object at link time, exactly as with hand-written decls. Diagnostics originating inside an included header are reported against that header's own file:line:col.
c02c links a small bundled standard library by default and adds its headers to the include path, so an include "stddef.c02h"; resolves out of the box with no -I needed. Pass --no-stdlib to opt out (the library's own build uses this to avoid linking against itself).
The full grammar, type system, and exact runtime semantics live in docs/SPEC.md — the normative reference. The program below is a representative taste: reg declarations pin hardware ports to absolute addresses, and fn main() -> void is the entry point.
It cycles LEDs connected to PORTB on a 65C02 breadboard — counting up from 0 to 255 and back down in an infinite loop — and compiles to a valid 32 KB ROM.
reg u8 PORTB @ 0x6000;
reg u8 DDRB @ 0x6002;
fn main() -> void {
DDRB = 0xFF; // Set all pins of PORTB as output
while(true) {
u8 i = 0;
for (; i < 255; ++i) {
PORTB = i;
}
PORTB = i;
for (; i > 0; --i) {
PORTB = i;
}
}
}c02c led_counter.c02 -o led_counter.bin # compile to 32K ROM
c02-objdump led_counter.bin # disassemble to inspect the outputEvery compiled binary is a flat 32 KB ROM image ($8000–$FFFF) loaded at a fixed base address. The layout is always the same regardless of program size — unused space is filled with $EA (NOP). See memmap.md for more info on memory boundaries.
$0000 ┬─────────────────────────────────────────────
│ Zero Page (see ZP table below)
$0100 ├─────────────────────────────────────────────
│ Hardware stack (6502 fixed; $01FF = top)
$0200 ├─────────────────────────────────────────────
│ User globals (RAM_START; allocated upward by allocate_globals)
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
│ __heap_start (u16, 2 bytes — compiler implicit)
│ __memory_top (u16, 2 bytes — compiler implicit)
├───────────────────────────────────────────── ← __heap_start value (first free byte)
│ (free for heap / dynamic use)
$3FFF ┴───────────────────────────────────────────── ← __memory_top value
To maximize compilation density and execution speed, the code generator reserves and maps lower RAM ($0000–$00FF, The Zero Page) to form a virtual register file:
| Address Range | Identifier | Purpose |
|---|---|---|
$00 |
FP |
Frame Pointer: Initialized to $01FF at startup. |
$02–$03 |
RET |
Return Register: Holds function return values (u8 in $02, u16 in $02:$03). |
$04–$DF |
r0–r219 |
Scratch Registers: Compiler-managed temporaries, locals, and globals. Allocated per-function from $04 upward, striding by type size (1 byte for u8/i8, 2 for u16/i16/pointers). |
$E0–$E7 |
— | 16-bit Arithmetic Helper Zone: Fixed slots for __mul16, __div16, __sdiv16 helpers. $E0:$E1 = arg1, $E2:$E3 = arg2, $E4:$E5 = result, $E6:$E7 = remainder. |
$E8–$EC |
— | 8-bit Arithmetic Helper Zone: Fixed slots for __mul8, __div8, __sdiv8 helpers. $E8 = arg1, $E9 = arg2, $EA = result, $EB = remainder, $EC = sign flags (bit 7 = negate quotient, bit 6 = negate remainder). |
$ED–$EE |
— | Reserved for future helpers. |
$EF–$FF |
a0–a7 |
Function ABI Zone: Fixed 2-byte slots for parameter passing. Caller populates before JSR; callee reads at entry. Supports up to 8 sixteen-bit parameters. |
$8000 ┬───────────────────────────────────────────── ← Reset vector target
│ Bootstrap (SEI · CLD · stack init · global init · JSR main · halt)
├─────────────────────────────────────────────
│ .text — function bodies (main first, then callees, then helpers)
├───────────────────────────────────────────── ← code/data boundary marker ($FFF8–$FFF9)
│ .data — null-terminated string literals
├─────────────────────────────────────────────
│ C02S symbol table (if --strip-debug not set)
| magic "C02S" · u16 count · [u16 addr · name\0] …
├─────────────────────────────────────────────
│ NOP fill ($EA bytes)
$FFF6 ├─────────────────────────────────────────────
│ Symbol table pointer (LE u16; $EAEA = absent)
$FFF8 ├─────────────────────────────────────────────
│ Code/data boundary marker (LE u16; first NOP-fill byte)
$FFFA ├─────────────────────────────────────────────
│ NMI vector (LE u16)
$FFFC ├─────────────────────────────────────────────
│ Reset vector (LE u16; always $8000)
$FFFE ├─────────────────────────────────────────────
│ IRQ vector (LE u16)
$FFFF ┴─────────────────────────────────────────────
The $FFF8–$FFF9 boundary word and the $FFF6–$FFF7 symbol-table pointer are read by c02-objdump to locate the .text/.data split and resolve function names. Older binaries that predate these fields have $EAEA at $FFF6 and are disassembled with auto-generated L0/L1/… labels as a fallback.
- Crafting Interpreters — the primary reference used throughout development.
- rui314/chibicc — structurally similar (recursive descent, etc.), found after starting this project; not directly followed, but worth a look.
| Dependency | License | Used By |
|---|---|---|
| clap | MIT / Apache-2.0 | c02-objdump CLI argument parsing |
| megaparsec | BSD-2-Clause | c02-frontend lexer/parser combinators |
| py65 | BSD | Test harness 65C02 emulator for runtime verification |