Skip to content

Repository files navigation

epic-cc

A whole-program C compiler for 8-bit Microchip PIC microcontrollers, written in Rust.

CI License: MIT Rust 1.97.1 clang 20.1.8 target PIC16F877A status: alpha

epic-cc takes .c files and emits Intel HEX you can flash. It uses clang as an out-of-process front end and implements a custom whole-program PIC14 backend, deliberately not an LLVM backend. It owns every stage from LLVM IR text down to the assembler, with no external assembler or linker in the shipping product.

$ cat add.c
volatile unsigned char in;
volatile unsigned char out;
void main(void) { out = in + 1; }

$ cargo run -p driver -- add.c -o add.hex --device p16f877a && cat add.hex
:020000040000FA
:10000000012800308A0005206300831203132008B2
:0E001000A2002208013EA3002308A100080060
:00000001FF

Contents


Why this target is hard

Parsing C is a solved problem. The difficulty of a PIC14 compiler is concentrated almost entirely in storage allocation, because the mid-range core violates nearly every assumption a conventional backend is built on.

Constraint Consequence for the compiler
One accumulator (W), 35 instructions, no register file Nothing for classical register allocation to allocate. Everything is W ⇄ memory.
4 banks of RAM selected via RP1:RP0 Every cross-bank access needs a BANKSEL. Minimising them is NP-hard even with fixed bank assignment.
16 bytes of common RAM (0x70-0x7F, mirrored into all banks) The only BANKSEL-free storage, half of what llvm-mos gets on the 6502.
8-level hardware call stack, not addressable No stack frames, no recursion, and call depth is a hard resource. Every local must be statically allocated and overlaid across the call graph.
Harvard architecture const tables live in program memory, reachable only through RETLW jump tables. LLVM IR assumes one flat address space.
368 bytes RAM / 8K words flash Code-size and RAM pressure are correctness concerns, not just quality ones.

Full detail, with the datasheet cross-references, is in docs/01-target-pic14.md.

The allocator is the compiler. That single sentence explains every architectural decision below.


Architecture

The compiler is a ten-stage pipeline, each stage its own crate. The load-bearing property is that every stage boundary is a diffable text artifact, so a miscompile can be bisected to a stage before anyone reads code.

flowchart LR
    C[".c files"] --> CLANG["clang -S -emit-llvm<br/>(out of process)"]
    CLANG --> LL[".ll text"]
    LL --> IRP["irparse"]
    IRP --> WP["wholeprog"]
    WP --> LEG["legalize"]
    LEG --> CG["callgraph"]
    CG --> AL["alloc"]
    AL --> ISEL["isel"]
    ISEL --> BK["banking"]
    BK --> PH["peephole"]
    PH --> ASM["asm"]
    ASM --> HEX[".hex"]
Loading
# Stage In → Out
1 driver .c files → invokes clang → .ll
2 irparse .ll text → our IR
3 wholeprog N modules → one merged module, externs resolved
4 legalize i16/i32/float ops → i8 sequences + runtime calls
5 callgraph merged IR → call graph, recursion check, ISR trees, stack-depth check
6 alloc call graph + locals → static addresses across 4 banks
7 isel IR → PIC14 instructions
8 banking BANKSEL / PAGESEL insertion
9 peephole pattern-driven cleanup
10 asm instructions → Intel HEX

The three decisions that shape everything

1. clang out-of-process, not an LLVM backend. clang emits LLVM IR as text; we parse the .ll and go our own way. We never link libLLVM and never touch SelectionDAG, GlobalISel, TableGen, or MCTargetDesc.

This is a cost argument, not a capability one. "LLVM cannot target accumulator machines" is false, and llvm-mos disproves it. But llvm-mos paid 22,421 lines of diff from upstream outside their own target directory, and llvm-pic attempted this exact target with three people over ~18 months, with mentorship from the llvm-mos team, and was archived in November 2025 without working CALL/GOTO and without having started on banking at all. Text in, text out sidesteps a permanent rebase against a 30-million-line C++ tree. (ADR-001)

2. Whole-program compilation, down to HEX. All .c files compile in one invocation. Locals cannot live on a stack, so frames are statically allocated and overlaid using the whole call graph, which requires whole-program visibility by construction. This is also why we own the assembler: 35 instructions and a fixed 14-bit encoding make it cheap, and it keeps allocation and encoding in one place. (ADR-002)

3. -target msp430 as a datalayout proxy. We are not generating MSP430 code. We want clang's ABI-independent type decisions, and MSP430's datalayout is a near-perfect match for PIC14: 8-bit char, 16-bit int, 16-bit pointers, byte alignment. Optimization runs at -O1, because -Oz emits arbitrary-width integers (i17) and intrinsics that a machine with no hardware multiply cannot lower pleasantly.


What it builds on

epic-cc is deliberately thin on runtime dependencies and thick on test-time oracles.

Project Role Where
clang (pinned 20.1.8) The C front end. Runs out-of-process, emits .ll text. The only build-time external dependency. ADR-001, ADR-007
gputils / gpasm (1.5.2) Test-time oracle. Our HEX must match gpasm's byte for byte. Never a build dependency. crates/asm/tests
llvm-mos Prior art, techniques only: static stack allocation and imaginary registers, reimplemented rather than ported. ADR-003
Docker (multi-stage) The whole toolchain, built from a digest-pinned ubuntu:22.04 base + the LLVM 20.1.8 source tarball + rust-toolchain.toml. clang's version is part of our input format, so a silent bump could change what the parser sees. docs/09-build-environment.md
cvise / creduce / csmith Available in the dev shell for test-case reduction work. docs/05-verification.md

On XC8: Microchip's XC8 is treated as a black-box differential oracle only. Compile the same source, compare observable behaviour. Its binaries are never disassembled or reverse-engineered; the licence forbids it and it is the slow path regardless (ADR-006). The dev shell detects an XC8 install if you have one, but the XC8 differential runner is designed, not yet wired into the test suite. Today's differential testing runs against host clang (see below). XC8 is never a build dependency and CI does not require it.

Licensing boundary: gputils and gpsim are GPL. They are invoked as external processes from the test harness and never linked into the compiler.


How correctness is verified

Verification was built before most of the compiler, so the oracle exists before the thing it judges. Four independent layers, all running in CI:

1. Our own PIC14 simulator

crates/sim is a deterministic PIC14 instruction-set simulator, embeddable directly in cargo test. Tests assert on RAM and internal state, and can inject an interrupt at an exact program counter to make ISR timing reproducible. Owning it keeps a GPL process boundary out of the inner test loop.

2. gpasm byte-for-byte cross-check

Our emitted .asm is assembled with real gpasm and the resulting Intel HEX must match our assembler's output exactly (a cross-check in crates/asm/tests). This isolates "our assembler is wrong" from "our codegen is wrong", two failure modes that otherwise look identical.

3. End-to-end acceptance programs

End-to-end acceptance programs in crates/driver/tests push real C through the entire pipeline and run the resulting HEX in the simulator, asserting hand-computed results. Each fixture documents its expected values and why the program is shaped the way it is:

Fixture Exercises
scalar.c 8/16-bit arithmetic, all icmp predicates, select
overlay.c Frame overlay: sibling functions sharing RAM
banked.c / banked_ptr.c Cross-bank access and BANKSEL correctness
ptr_probe.c, array.c, structs.c FSR/INDF indirect access, sret/byval
const_table.c Harvard const data via RETLW tables, past the 256-byte window
multi_page.c PCLATH discipline across flash page boundaries
interrupt.c ISRs, SFR access, context save, duplicated shared helpers
long.c / muldiv.c 32-bit long, soft mul/div/mod runtime
float.c IEEE-754 single soft-float, incl. round-to-nearest-even

4. Differential fuzzing with automatic reduction

crates/fuzz closes the loop that makes unsupervised work viable:

flowchart LR
    GEN["seeded generator<br/>(UB-free C subset)"] --> PIC["epic-cc → sim"]
    GEN --> HOST["host clang → native"]
    PIC --> DIFF{"checksums<br/>match?"}
    HOST --> DIFF
    DIFF -- no --> RED["greedy reducer"]
    RED --> FIX["minimal repro<br/>saved as fixture"]
    DIFF -- yes --> OK["next seed"]
Loading

The generator emits unsigned-only, layout-agnostic C with explicit-width types so PIC and host semantics provably coincide: no signed overflow, shifts always below width, nonzero divisors, field-wise struct access only. Every program is compiled twice and the checksums must match. A mismatch, a panic, or a non-halting run is a bug, and the greedy reducer minimises it to a saved reproducer. A 200-seed integer corpus and a 50-seed float corpus run under --ignored; a fast subset gates every commit.

Loud panics, never silent miscompiles. Every unsupported construct panics with a specific message rather than emitting wrong code. Recursion is rejected at compile time, and call depth is checked against the 8-level hardware stack.


Status

Alpha. The full integer, pointer, interrupt, long and soft-float spine is implemented and passing end-to-end. A fast subset of the suite gates every commit; the slow fuzz corpus runs behind --ignored.

Supported C surface

Feature State
Core C89 control flow, non-recursive calls
8-bit and 16-bit integers, all comparisons
Pointers, arrays, structs (sret / byval)
const data in flash (RETLW tables, >256 bytes)
Frame overlay across the call graph
Multi-bank RAM (BANKSEL) and multi-page flash (PCLATH)
Interrupts, SFR access, ISR-shared function duplication
32-bit long + soft mul/div/mod runtime
IEEE-754 single-precision soft-float
Unions ⛔ not yet
Recursion ⛔ by design: compile error, no escape hatch in v1

Known gaps

These are deliberate and tracked, not surprises:

  • Diagnostics are panics. Unsupported input aborts with a precise message instead of a user-facing error. Correct, but not yet friendly.
  • Device support is file-per-device TOML (crates/device/devices/*.toml, see ADR-019); adding a same-core part is a file add via scripts/gen-device.py from the Microchip DFP (https://packs.download.microchip.com/, PIC16Fxxx_DFP; the .atdf/.PIC itself is never committed, only the TOML it generates). p16f887 is the first exemplar alongside p16f877a and p18f4550.
  • BANKSEL minimisation is linear tracking, reset at every label, not the published CASES'06 2-approximation the design calls for.
  • Overlay allocation is call-graph-based, not interference-graph colouring. Common RAM currently holds fixed scratch/retval bytes rather than serving as a general imaginary register file.
  • .asm / .lst output is not yet exposed by the driver, which emits HEX only (the map is: --map <file>).
  • XC8 and gpsim oracles are designed but not wired into the suite.

Getting started

Everything runs inside a docker multi-stage build, so install nothing system-wide. The root Makefile is the entry point:

make image                        # build the dev image (first build is slow: compiles clang)
make shell                        # interactive dev shell with the whole toolchain

Inside the shell:

cargo test --workspace
bash scripts/ci-test.sh           # per-crate PASS/FAIL table (what CI runs)

Compile a C file to Intel HEX (from the host):

make compile                      # prints the HEX of the add.c example
make compile FILE=my/example.c
make pre-pr-check                 # takeoff ritual before opening a PR

Run the slow fuzz corpora:

cargo test -p fuzz -- --ignored

Pinned by the Dockerfile: rustc 1.97.1, clang 20.1.8 (source tarball), gpasm 1.5.2 (source), plus csmith, creduce and cvise. Gotchas and the caching story are in docs/09-build-environment.md.


Repository layout

crates/
  driver/      # stage 1:  clang invocation + full pipeline, plus the e2e acceptance suite
  irparse/     # stage 2:  LLVM IR text parser
  ir/          #           the IR data model (text in, text out)
  wholeprog/   # stage 3:  module merging
  legalize/    # stage 4:  wide/float ops -> i8 sequences + runtime calls
  callgraph/   # stage 5:  call graph, recursion check, stack-depth check
  alloc/       # stage 6:  static overlay allocation across 4 banks
  isel/        # stage 7:  instruction selection
  banking/     # stage 8:  BANKSEL / PAGESEL insertion
  peephole/    # stage 9:  pattern cleanup
  asm/         # stage 10: assembler -> Intel HEX (+ gpasm cross-checks)
  sim/         #           PIC14 instruction-set simulator
  fuzz/        #           differential generator, runner and reducer
docs/          # design conversation, ADRs, milestone plans
scripts/       # ci-test.sh, gen-device.py (DFP -> TOML), pre-pr-check, prose-diff
Dockerfile     # the pinned toolchain (multi-stage)

---

## Design documentation

The full design conversation is captured in `docs/` and is written to be sufficient on its
own.

**Start here:** [`docs/08-status-and-next-steps.md`](docs/08-status-and-next-steps.md)
(where we are), then [`docs/12-backend-design.md`](docs/12-backend-design.md) (the approved
consolidated backend spec).

| Doc | What it covers |
|---|---|
| [`00-charter.md`](docs/00-charter.md) | Goal, scope, non-goals |
| [`01-target-pic14.md`](docs/01-target-pic14.md) | The PIC14 architecture and exactly why it is hostile to C |
| [`02-prior-art.md`](docs/02-prior-art.md) | Survey: llvm-mos, llvm-pic, SDCC, XC8, gputils, gpsim, key papers |
| [`03-decisions.md`](docs/03-decisions.md) | ADRs, with rejected alternatives and rationale |
| [`04-pipeline-design.md`](docs/04-pipeline-design.md) | The ten-stage pipeline |
| [`05-verification.md`](docs/05-verification.md) | Oracles, simulator, differential testing, fuzzing, reduction |
| [`06-environment.md`](docs/06-environment.md) | Toolchain setup and reference material |
| [`07-references.md`](docs/07-references.md) | Books, papers, datasheets |
| [`09-build-environment.md`](docs/09-build-environment.md) | docker dev container, pinned versions, gotchas |
| [`10-spike-findings.md`](docs/10-spike-findings.md) | Feasibility spike: is `.ll` text a workable substrate? |
| [`11-pointer-const-findings.md`](docs/11-pointer-const-findings.md) | Feasibility spike: pointers via `FSR`/`INDF`, Harvard `const` |
| [`12-backend-design.md`](docs/12-backend-design.md) | **The approved backend spec** |
| [`13-`…`28-`](docs/) | Per-milestone implementation plans (harness → integer spine → pointers → interrupts → `long` → fuzzing → soft-float) |
| [`29-pic18-port-design.md`](docs/29-pic18-port-design.md) | The PIC18 port: why it is smaller than a second compiler, and its phases |
| [`30-distribution-design.md`](docs/30-distribution-design.md) | The docker toolchain and the release bundles |
| [`31-ecosystem-integration-design.md`](docs/31-ecosystem-integration-design.md) | Making epic-cc epic-hal's default toolchain, and reaching PlatformIO |

Working notes for contributors and agents are in [`CLAUDE.md`](CLAUDE.md).

---

## Non-goals

- **Separate compilation.** Whole-program is the point, because overlay allocation needs
  the full call graph.
- **Debugger / COFF / ELF output.** HEX, listing and map only.
- **Being an XC8 clone.** Differential testing against XC8 is a *verification technique*,
  not a design target. Beating XC8 free-mode optimization is a nice-to-have.
- **Reverse-engineering XC8.** Prohibited by its licence, and unnecessary
  ([ADR-006](docs/03-decisions.md)).

---

## License

MIT, see [LICENSE](LICENSE).

Two boundaries worth stating explicitly, because this project is a compiler that sits next
to other people's tools:

- **`gputils` and `gpsim` are GPL.** They are invoked as external processes from the test
  harness and never linked into the compiler, so they do not affect this project's
  licensing. Keep them behind that process boundary.
- **Microchip's datasheets, application notes and XC8 are Microchip's property**, are not
  vendored here, and XC8 is used only as a black-box oracle
  ([ADR-006](docs/03-decisions.md)). User-supplied Microchip material and reference books
  live under [`vendor/`](vendor/README.md), whose contents are gitignored.

About

Fully OSS PIC8 compiler written in Rust

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages