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, 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. Incremental compilation (-c) stops here.c02-as(C) — the code generator. Consumes a.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, 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.
This repository is a rewrite: the frontend (c02-frontend) is Haskell, replacing the original C compiler — which still ships as bin/cc02 for reference. The toolchain builds non-trivial programs end-to-end — .c02 source compiles to valid 32 KB 65C02 ROMs — verified by the emulator test suite under test/emu (py65). See the CHANGELOG for the current version.
The single-file 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. 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 two stages live in c02-frontend/ (Haskell) and c02-as/ (C, the code generator). Issues and PRs are welcome.
The three stages need a C compiler (c02-as), GHC + Cabal (the Haskell c02-frontend), 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
# 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)-c: Incremental compile — stop after the frontend and emit a.oIR object--parse-only: Check syntax only, produce no output--dump-ast: Print the AST after parsing (no output file)--dump-ir: Print the IR (TAC) the code generator consumes (no output file)--strip-debug: Omit theC02Ssymbol table from the final ROM
Incremental compilation:
c02c -c hello_world.c02 -o hello_world.o # compile to an IR object
c02c --dump-ir hello_world.c02 # inspect the IR before codegenThe 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 |