A small C compiler, written in Rust, built for learning. It compiles a growing subset of C directly to native macOS/ARM64 binaries — with no LLVM, no clang, no assembler, no linker anywhere in the pipeline. Source text goes in; a signed, executable Mach-O binary comes out.
int fib(int n) {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
int main() {
printf("fib(10) = %d\n", fib(10));
return 0;
}$ charlic fib.c -o fib
$ ./fib
fib(10) = 55Everything the produced binary needs was built by this project: the lexer, the parser, the type checker, the intermediate representation, the AArch64 instruction encodings, the Mach-O executable format, the dynamic-linking structures (GOT, stubs, dyld bind opcodes), and even the SHA-256 hashes in the ad-hoc code signature that macOS requires before it will run any arm64 binary.
Most "write a compiler" resources stop at an interpreter, or generate
assembly and quietly call cc to finish the job. That's fine, but it hides
the two most instructive parts of a real compiler: how source becomes
machine code, and how machine code becomes a file the OS will actually
run. charlic does both, in the open, in small readable pieces.
The project was built in stages, each one a working compiler for a
slightly bigger language. book/ retells that build as a 15-chapter
tutorial: one stage per chapter, from the preprocessor to the signed
Mach-O binary.
The codebase is small (~22,000 lines of Rust, plus ~9,000 lines of tests). Read it in pipeline order — the same order data flows through it:
source text
→ preprocessed src/preproc.rs
→ tokens src/token.rs, src/lexer.rs
→ AST src/ast.rs, src/parser.rs
→ typed AST src/sema/ (scopes, symbol table, type checking)
→ IR src/ir.rs, src/lower.rs
→ optimized IR src/opt/ (passes, SSA, regalloc, vectorizer)
→ machine code src/codegen.rs, src/backend/aarch64.rs
→ executable src/obj/macho.rs (+ src/sha2.rs for the signature)
| Stage | Files | One-line summary |
|---|---|---|
| Preprocessor | preproc.rs |
#include/#define/#if: text → expanded text |
| Lexer | token.rs, lexer.rs |
Source text → flat list of tokens |
| Parser | ast.rs, parser.rs |
Tokens → tree (recursive descent) |
| Sema | sema/mod.rs, sema/symtab.rs |
Names resolved, types checked, conversions inserted |
| Lowering | lower.rs, ir.rs |
Typed tree → flat three-address IR with basic blocks |
| Scalar optimizer | opt/fold.rs, copyprop.rs, instcombine.rs, dce.rs, jumpthread.rs, licm.rs, inline.rs, addrix.rs, frameaddr.rs |
IR → better IR, one small pass at a time |
| SSA | opt/ssa.rs, gvn.rs, sccp.rs, adce.rs |
mem2reg + φ-nodes; the SSA-window passes; back out before regalloc |
| Register allocation | opt/regalloc.rs |
Temps → registers/spill slots (linear scan) |
| Vectorizer | opt/loopana.rs, vectorize.rs, interchange.rs |
Provably-parallel loops → 4-wide NEON + scalar epilogue |
| Codegen | codegen.rs, backend/aarch64.rs |
IR → AArch64 machine code |
| Object writer | obj/macho.rs, sha2.rs |
Machine code → signed Mach-O executable |
| Driver | main.rs |
CLI, pipeline wiring, and the mini-linker |
| Diagnostics | diag.rs |
file.c:3:14: error: ... with carets |
Suggested reading path, with the concept each file teaches:
src/preproc.rs— text in, text out: macro expansion with rescanning ("blue paint"),#/##, conditional compilation. Bigger than the parser, and a masterclass in following a spec exactly.src/lexer.rs— the classic "chop text into words" pass. Nothing scary: keywords, integer literals (decimal/hex/octal), strings with escapes, comments.src/parser.rs— recursive descent parsing. Each C precedence level is one small function (additive,multiplicative, …), so C's famously confusing operator precedence becomes a literal reading of the grammar.src/ast.rs— what a "syntax tree" actually looks like: a few enums.src/sema/— the first semantic pass: a scope-stack symbol table, C's type rules (promotions, usual arithmetic conversions), and the crucial idea of making implicit things explicit (see "decay" and "conversion" in the appendix).src/ir.rs+src/lower.rs— the middle of the compiler: why an IR exists at all (it is the firewall between "C semantics" and "machine details"), howif/whilebecome basic blocks, and why&&/||are branches, not operations.src/opt/— the optimizer, one small pass per file. Start withfold.rs(constant folding) anddce.rs, thenssa.rsfor the SSA roundtrip (mem2reg, φ-nodes, and destruction back to copies), andregalloc.rsfor linear-scan register allocation. The capstone is the vectorizer:loopana.rs(is this loop provably parallel?),vectorize.rs(widen it four-wide),interchange.rs(make matmul's loop the right shape first).src/codegen.rs+src/backend/aarch64.rs— instruction selection over the allocated IR, and hand-built instruction encodings (each one checked against the system assembler in unit tests).src/obj/macho.rs— the deep end: what an executable file is. Segments, sections, symbol tables, the dyld export trie, lazy binding, and ad-hoc code signing.src/main.rs— how it all fits together, including the tiny linker that patches call sites and addresses.
$ cargo test # 264 tests: unit + end-to-end
$ cargo build --release
$ ./target/release/charlic examples/calc.c -o calc # compile
$ ./calc # run (a calculator reading stdin)
$ ./target/release/charlic examples/calc.c --emit-tokens # inspect the token stream
$ ./target/release/charlic examples/calc.c --emit-ast # inspect the syntax tree
$ ./target/release/charlic examples/calc.c --emit-ir # inspect the IR (great for learning)
$ objdump -d calc # disassemble the output
$ otool -l calc # inspect the Mach-O structureThere is a small joke in the demo choice: calc.c is a recursive-descent
expression interpreter — so what you just ran is a compiler, written in
Rust, compiling an interpreter, written in C, so the interpreter can
evaluate 1+2*3 for you. Turtles all the way down, and every layer
yours.
charlic produces fully working, code-signed executables — no assembler,
no linker, no runtime dependencies beyond libSystem. The examples/
directory holds real programs compiled entirely by charlic, several of
them making raw system calls through the hosted stdlib: tcp_server /
tcp_client (a line-based echo server over socket/bind/listen/
accept/connect), chat_server (multi-client chat in one process,
no threads, via poll), file_copy (raw fd I/O with open/read/
write), plus hexdump, word_count, mandelbrot, sort, and
calc. Build them all with make -C examples.
The benchmark suite lives under benchmarks/: thirteen small kernels,
each probing one code shape (branch-heavy permutation, matrix multiply,
reductions, byte loops), timed against clang at four optimization
levels with checksums verified against the unoptimized build:
$ REPETITIONS=100 benchmarks/run-fannkuch.shThe harness compares charlic -O0, charlic's default optimized mode, and
clang -Oz/-O2/-O3, while checking the same deterministic output from every
binary. See benchmarks/README.md.
--emit-ir is the best learning tool in the project. For example,
while (i < 3) { ... } becomes recognizably the textbook loop shape:
b2:
t0 = [f8]
br t0 < 3 ? b3 : b4 ; header: condition
b3: ; body
...
jmp b2
b4: ; exit
ret t6
- Types:
char,short,int,longand theirunsignedforms,float,double,void, pointers (int *,char **, …), arrays, structs (struct P { int x; char y; },.and->, natural layout), nested by value (struct L { struct P a; }),enum, andtypedefaliases sizeof, integer literals (decimal/hex/octal, withu/lsuffixes), floating-point literals (2.5,2.5e2), character literals ('a','\n'— an int in C, per §6.4.4.4p10), string literals with adjacent- literal concatenation ("a" "b"is"ab", §6.4.5p5)- Global variables (
int g = 5;in__data,int z;zero-filled in__bss), with constant-expression initializers (int g = 3 + 4;,long b = 1 << 40;), string-pointer globals (char *msg = "hi";— a dyld-rebased relocation), array initializers (int a[] = {1, 2, 3};— size inferred, missing elements zeroed per C11 §6.7.9p21), and struct/union initializers (struct P p = {1, 2};, nested, arrays of structs — folded to an exact byte image, padding included) - Declarations: multi-declarators (
int a, *b = &a, c[10];),typedef(file and block scope, with the lexer-hack disambiguation),enumconstants,voidfunctions and barereturn; - Full expression language: arithmetic, comparison, logical
&&/||/!(with correct short-circuit semantics), the ternaryc ? a : b, prefix and postfix++/--(pointer-aware:p++steps by the pointee size), bitwise& | ^ ~and shifts<< >>(arithmeticasrvs logicallsrby signedness), compound assignment+=…, the comma operator, casts(T)e, assignment as an expression - Statements: declarations,
if/else,while,do/while,for,break/continue,switch/case/default(with fall-through), blocks with scoping and shadowing - Functions: up to 8 parameters per register bank (integers in
x0–x7, floats ind0–d7— separate AAPCS budgets), recursion, mutual recursion, forward references; array parameters may omit their size (char *argv[]— a pointer in disguise, per C11 §6.7.6.3p7). Prototypes declare a signature without a body (int puts(char *s);), at file scope or block scope (extern int f(int x);) — calls to a declared-but-never-defined function bind to an external symbol via dyld, exactly likeprintf. The old-style empty listint f();keeps its C meaning: unspecified parameters — no arity check, default promotions (C11 §6.7.6.3p14). Variadic functions (int sum(int n, ...)) with the fullva_listmachinery:va_start/va_arg/va_end/va_copy— on Apple arm64 every variadic argument is an 8-byte stack slot, so ava_listis just achar *walking the caller's frame - Pointers:
&x,*p,a[i], pointer arithmetic with element-size scaling, pointer differences, array-to-pointer decay - Structs: member access via
.and->, brace initializers (struct P p = {1, 2};— tail zeroed), nested brace initializers (struct L l = { {1, 2}, {3, 4} };, arrays of structs, structs holding arrays — partial nests zero-filled, no §6.7.9p20 brace elision), plus full by-value semantics — assignment (a = b;is a copy), initializers, arguments (chunked into registers per the AAPCS), and returns (≤16 bytes inx0/x1, larger through the hiddenx8result pointer). Flexible array members (struct S { int n; int a[]; }) size the tail at allocation:malloc(sizeof(struct S) + n * sizeof(int)) - Floating point:
floatstorage with promotion todoublein every expression (C11 §6.3.1.8), arithmetic/comparisons/conversions per the usual arithmetic conversions,doubleargs/returns ind0–d7, and%fthrough printf (default argument promotions included) - Unsigned arithmetic: the full §6.3.1.8 signed/unsigned mixing rules
(
-1 < 1uis false),udiv/lsrvssdiv/asr, carry-based unsigned comparisons, zero- vs sign-extension on loads and widenings, and%u - I/O:
printfwith%d,%c,%ld,%s,%f, plus any libc function you prototype (int puts(char *s);— calls the real libSystem through dyld's lazy binding) - The preprocessor: object-like
#define/#undef,#include, conditional compilation (#if/#ifdef/#ifndef/#elif/#else/#endif+defined()), and function-like macros with multi-line calls, argument prescan,#stringize,##token paste, variadic macros (...,__VA_ARGS__), and the header-realism pack (#error/#warning,#pragma once,__LINE__/__FILE__, computed#include) — plus-I DIR/-IDIRuser include paths.printfis no longer a builtin; it arrives frominclude/stdio.hlike any other prototype, the shipped headers carry real#ifndefinclude guards, anderrnois the standard#define errno (*__error()) const: checked in sema (store through a pointer-to-const, assigning to a const variable, and discarding a qualifier down a pointer chain are all diagnostics), then erased before the IR — the generated code is bit-identical. String literals areconst char *- A hosted stdlib:
stdio,stdlib,string,unistd,fcntl,errno,time,sys/socket,netinet/in— real declarations bound from libSystem by dyld.malloc/free, file I/O (open/read/write/close), POSIX timing (clock_gettime), and TCP networking (socket/bind/listen/accept/connect/send/recv) all work;errnois*__error(), never a global - Unions: all members at offset zero, size the largest member — type-punning makes little-endian byte order observable C
static: internal linkage at file scope (honored in the Mach-O symtab and export trie), and block-scopestaticlocals — the counter that remembers, storage in__data/__bssunder a mangled name (structs and unions included)- Any number of arguments: the register banks (x0–x7 / d0–d7) overflow
into the caller's stack frame, one 8-byte slot per register's worth —
the AAPCS rule that makes
va_listandprintfpossible in the first place
Not yet: multi-dimensional array declarators (int m[2][2]),
sizeof(expr), designated initializers and §6.7.9p20 brace elision,
and FILE * data imports. See "Project status" below.
Three decisions shape everything:
1. Multi-pass, with simple data structures between passes. Each stage
consumes and produces an inspectable value. You can print any of them
(--emit-tokens, --emit-ast, --emit-ir). Bugs are easy to localize:
wrong IR means the bug is above the IR; wrong code from correct IR means
it's below.
2. A typed IR as the firewall between frontend and backend. Everything that is C semantics — integer promotion, usual arithmetic conversions, array decay, short-circuit evaluation — is fully resolved above the IR. Below the IR there are only widths (1, 4, or 8 bytes), basic blocks, and machine-ish operations. This keeps the IR independent of C knowledge and preserves a clean backend boundary if another backend is ever revisited.
3. Arena-free, allocation-free simplicity — deliberately. The codegen uses an "everything spills to the stack frame" strategy: every temporary gets a memory slot — 8 bytes each, holding an integer/pointer or a double (floats promote to double before any arithmetic, so the spill area never sees a 4-byte value). The generated code is slow and wonderfully readable in a disassembler, and correctness comes almost for free. Register allocation is a later chapter that only changes the temp→location map.
These were the real surprises — each is documented in code comments where it lives.
Code signing is mandatory on arm64 macOS, and it is picky. The kernel
kills unsigned binaries outright. Getting our hand-built ad-hoc signature
accepted took several distinct fixes, each with its own lesson:
cpusubtype must be plain ARM64_ALL (set arm64e and the kernel demands
a CMS signature blob no ad-hoc signature can provide); the code directory
needs the CS_LINKER_SIGNED flag; and codeLimit must be the real byte
count, or the file parses as "not signed at all" — a spectacularly
misleading error message for a one-word bug.
Mach-O layout is a contract, not a suggestion. Segment file sizes must
be page-aligned; load command sizes must match the declared totals to the
byte (one 18-byte UUID constant cost a debugging session); __DATA_CONST
needs the SG_READ_ONLY flag or dyld aborts with exactly one line of
explanation.
dyld bind opcodes have a canonical order. Our first lazy-binding
attempt failed with bad lazy bind opcode until we matched the real
linker's exact byte sequence — found by xxd-ing a real binary's bind
stream. The OS is the specification; its error messages are terse; your
best debugging tool is diffing against something that works.
ARM's W registers zero the top 32 bits. A sxtb w9, w9 on the byte 200
yields +200, not −56, when later compared as a 64-bit value — because
the 32-bit write erased the sign extension. The fix became a clean
invariant: values are kept extended to 64 bits, signed values
sign-extended and unsigned values zero-extended (which the W-write gives
for free), and truncation happens only at frame stores — which is precisely
where C truncates too (char c = 300; is 44 because the store writes one
byte). The unsigned half is one signed bit, decided by
the C type above the IR, chooses sdiv/udiv, sxtw/uxtw, ldrsb/ldrb,
and the signed-vs-unsigned branch condition everywhere below it.
Apple's arm64 variadic calling convention diverges from the standard
AAPCS. Variadic arguments (everything after printf's format string) go
on the stack, never in registers. We discovered this by compiling a
test program with clang and reading its output — str x8, [sp] — which is
a generally useful technique: when the ABI documentation disagrees with
reality, reality wins.
A shell printf ate our test file. While generating a C test with
printf 'printf("%d\n")', the shell interpreted the %d itself and we
spent a while debugging "wrong output" from a perfectly correct compiler.
The binary was innocent; the test was mangled. Moral: heredocs (<<'EOF')
for test files, always.
AMFI has opinions about segment geometry that cost an afternoon. Adding
globals (__data/__bss sections) produced binaries the kernel killed
with the fully misleading AMFI: '...' has no CMS blob?. The actual
rules, found by diffing against clang's own output: a __DATA file extent
may not end mid-page (pad to a full page), and no two segments may share a
virtual page — a pure zero-fill __DATA (filesize 0) must move one page
past __LINKEDIT in vmaddr space. The signature was fine the whole time;
the error message just names the thing AMFI was checking when it gave up.
By-value structs exposed a sign error the frame layout had hidden all
along. Frame offsets grow downward from fp, and every variable
was a single slot until structs arrived, so "the variable's address" and
"the slot's address" were the same thing. A struct is the first variable
spanning multiple slots: its address is fp - offset (its first
field), and later fields sit at higher addresses — smaller offsets.
The AAPCS chunk-spill wrote a struct parameter's second register to
offset + 8 (into the temp area) instead of offset - 8 (the struct's
own second word), so p.b read garbage that happened to look like small
integers. The telltale was {long a; long b;} returning p.b == 1
regardless of the value stored: 42 became 1, 256 became 1, 65536 became 1
— not a value at all, but the presence of data in the clobbered slot.
Two more rules fell out of the same feature: struct returns must not let
the ret terminator reload x0 with the dummy operand (the result is
already in x0/x1), and struct copies must merge the final chunk with
the destination's existing bytes — an 8-byte blind store would clobber
the neighboring slot's low bytes.
The frame record fought back when the frame grew past 256 bytes. A
perfectly ordinary test (a dozen locals exercising ++/--) crashed by
jumping to address 0x3b — 59, its own return value: the function had
"returned" into its result. Two immediate-range limits, invisible while
every test stayed small, were silently wrapping. ldur/stur take a
signed 9-bit displacement (±256 B), so once locals pushed the temp
slots past 256 bytes, every temp store aliased onto the saved fp/lr
at [fp, #-8]; the epilogue's ldp then restored garbage and ret
jumped to it. And sub sp, sp, #imm takes a 12-bit immediate — our
wide-subtract helper re-aimed the shift bit at the whole remainder,
multiplying the low chunk by 4096 (a 4144-byte frame subtracted ≈786 KB).
The fixes: any frame offset beyond 255 bytes now goes through X16 (IP0,
the ABI's scratch register) computed with a wide subtract, and the wide
add/sub emit one instruction per 12-bit chunk, oracle-tested against the
system assembler. The lesson generalizes: an encoder that silently
masks an out-of-range immediate is a bug waiting for a bigger test;
assert the range, or handle the whole range.
Float promotion is a single rule that pays for the whole backend. The
first float cut typed g * 28 (float × int) as an f32 multiply: my
common() had a float case, so a float operand could survive the usual
arithmetic conversions — and the f32 multiply then read an 8-byte temp
whose upper half was stale bytes. 1.5f * 28 ≠ 42.0. C's actual rule is
simpler and stronger (C11 §6.3.1.8): a float promotes to double in every
expression, so after promotion the usual conversions never see float at
all. Moving that one line into promote() collapsed the IR's arithmetic
to a single F8 path, with F4 existing purely at memory boundaries — the
floats' version of "chars load and widen, stores narrow."
A compiled program is not a uniform blob of bytes. Different kinds of content have different lifetimes and permissions: machine code must be executable but never writable; string constants must be readable but not writable or executable; writable variables must not be executable (a buffer you can write to should not become code you can run — that's how exploits work). An executable file therefore describes itself as a set of segments, each a contiguous range of virtual memory with its own permissions, laid out by the loader when the program starts.
On macOS (Mach-O), the segments of a charlic binary are:
| Segment | Permissions | What's in it |
|---|---|---|
__PAGEZERO |
none | The first 4 GiB of address space, deliberately unmapped so that dereferencing a null (or small) pointer crashes immediately instead of reading garbage. Occupies no file bytes. |
__TEXT |
read + execute | Everything the CPU runs or reads but never writes: __text (our machine code), __stubs (trampolines into dyld), __cstring (string literals). |
__DATA_CONST |
read + write | Pointers the dynamic linker fills in but the program shouldn't casually overwrite (the __got). Exists only when we call external functions like printf. |
__DATA |
read + write | Writable program data: __la_symbol_ptr (lazy-binding cells), __data (initialized globals: int g = 5;), __bss (uninitialized globals: int z;). |
__LINKEDIT |
read-only | Metadata for the linker and loader: symbol and string tables, dyld bind opcodes, the export trie, and the embedded code signature. The program itself never touches it. |
Inside a segment, sections name the individual regions (__text,
__data, __bss, …) so tools and the loader can find them.
Two sections deserve a closer look, because they explain a classic C interview question:
__data vs __bss — why does C distinguish them? A global with an
initializer needs its value stored in the file: int g = 5; costs 4
real bytes in __data, mapped into memory at startup. But an
uninitialized global is zero by definition (C11 §6.7.9: static storage is
zero-initialized), and storing a million zeros in the file would be waste.
So int z; goes to __bss (block started by symbol — the
name is historical), a section with a virtual address and size but no
file bytes at all. The loader simply hands the process zero-filled pages
for that range. The file stays small; the zeros appear for free. You can
see both in our output:
$ otool -l a.out | grep -A3 'sectname __data'
sectname __data
addr 0x0000000100004000
size 0x0000000000000004 # g's 4 bytes, present in the file
$ otool -l a.out | grep -A3 'sectname __bss'
sectname __bss
addr 0x0000000100008000
size 0x0000000000000004 # z's 4 bytes, zero pages at runtimeThe same split exists in memory even without globals. Local variables
don't come from any segment — they live on the call stack, a region
the kernel creates for the thread, or in registers. Segments are only for
data that outlives a function call: code, constants, and static/global
storage. That's why taking the address of a local (&x) gives an address
far away from &g for a global: they live in entirely different worlds.
Why should a compiler writer care about any of this? Because the executable format is the contract with the operating system, and the OS enforces it: on arm64 macOS, AMFI (the code-signing enforcer) validates segment geometry before a single instruction runs — page-aligned file extents, non-overlapping virtual ranges — and kills binaries that break the rules — discovered the hard way (see "Hard problems").
No compiler background assumed. Terms as used in this codebase:
AST (abstract syntax tree) — the "shape" of the program as a tree:
2 + 3 * 4 becomes add(2, mul(3, 4)). Abstract because parentheses and
other surface details are gone; only structure remains.
AAPCS — the ARM64 calling convention: the rules for which registers
hold arguments (integers in x0–x7, floats in d0–d7 — two separate
budgets), where return values go (x0, or d0 for floats), and how the
stack must be aligned (16 bytes) at function calls. Composite (struct)
arguments occupy one register per 8 bytes; structs ≤16 bytes return in
x0/x1, larger ones through a hidden result pointer in x8. Apple
deviates for variadic functions (see above).
Ad-hoc code signing — a code signature with no identity ("signed to run locally"). arm64 macOS requires at least this on every executable. We build the signature blob ourselves, including SHA-256 hashes of every page.
Basic block — a straight run of instructions with one entry and one
exit. Control flow (if, while) is a graph of basic blocks connected by
branches. In --emit-ir output, blocks are b0:, b1:, …
Binding / lazy binding — how a call to printf (which lives in
libSystem, loaded at runtime) gets connected. The first call jumps to a
stub that asks dyld (the dynamic linker) to find the real address and
write it into a pointer cell; later calls go straight there. "Lazy" because
it happens on first use, not at startup.
Calling convention — see AAPCS.
Codegen (code generation) — turning the IR into actual machine instructions.
Decay (array-to-pointer decay) — C's rule that an array used in an
expression becomes a pointer to its first element. a in a[i] or
printf(a) is really &a[0]. In charlic this rule is visible: the
resolved AST uses AddrOfVar for decayed arrays and Var for plain reads,
so you can watch it happen in the code.
dyld — macOS's dynamic linker: the program that loads libSystem and
connects our printf calls at runtime.
Fixup (relocation) — branches and calls encode the distance to their target, but we don't know distances until all code is laid out. So codegen emits placeholder offsets and patches them afterward — that patching is a "fixup".
Frontend / backend — the frontend is everything about C (lexer, parser, type checker); the backend is everything about the target machine (instruction selection, encodings, executable format). The IR sits between.
Frame (stack frame) — the memory a function owns on the stack: its
locals and spilled temporaries, addressed relative to the frame pointer
register (fp/x29) as [fp, #-offset].
GOT (global offset table) — a table of pointers to external things,
filled in by dyld. Calls to printf go through it.
IR (intermediate representation) — a small, fake, idealized instruction
set between C and AArch64. Ours is three-address code (t3 = t1 + t2)
with basic blocks. It exists so C knowledge stays in the frontend and
machine knowledge stays in the backend.
Linker — traditionally a separate program; ours is ~100 lines in
main.rs. It lays functions out in memory and patches call/address
instructions with final locations.
Lowering — turning a higher-level construct into simpler, more
machine-like pieces. while lowers to blocks and branches; a[i] lowers
to *(a + i); short-circuit && lowers to conditional jumps.
Mach-O — the macOS executable file format: a header, a list of "load
commands" describing segments (__TEXT for code, __DATA for writable
data, __LINKEDIT for linking metadata), then the data itself. See the
segments-and-sections appendix for the full picture.
Promotion — C's habit of widening small types before use. Integer
promotion: anything smaller than int (e.g. char) becomes int
before arithmetic — char + char is computed as int + int. Float
promotion: float becomes double in every expression (and in variadic
calls, which is why printf("%f", f) receives a double). In charlic both
live in one function, CType::promote, and the float half is what keeps
the IR's arithmetic single-width (F8 only).
Recursive descent — a parsing style where each grammar rule is a function that may call the others. Readable and hackable; our entire parser is ~450 lines.
Section — a named region inside a segment (__text, __data,
__bss), so the loader and tools can find specific content. See the
segments-and-sections appendix.
Segment — a contiguous range of virtual memory with its own
permissions (read/write/execute), mapped from the executable file at
startup. __TEXT holds code, __DATA holds writable data. See the
segments-and-sections appendix.
Register allocation — deciding which values live in which registers (vs. memory). We don't do it yet: every temporary lives in the stack frame. Slow, obviously correct, and the upgrade path is clear.
Sema (semantic analysis) — the pass that checks meaning, not shape: "is this variable declared?", "what type is this expression?", "may I dereference this?". Where most user-facing errors come from.
Short-circuit evaluation — A && B must not evaluate B when A is
false. So && and || compile to branches, not boolean instructions —
which is why x != 0 && 10/x > 1 is safe.
Slot (frame slot) — the fixed [fp, #-offset] address where a local
variable or temporary lives.
Spilling — keeping a value in memory instead of a register. Our codegen spills everything, on purpose.
Symbol table — the map from names (x, fib) to what they mean (a
frame slot and a type, or a function signature). Ours is a stack of scopes
so shadowing works naturally.
Three-address code — IR instructions with at most three operands:
dst = a op b. Complex expressions become chains of temporaries
(t3 = t1 + t2).
Tokenizer / lexer — the first pass: turns raw text into tokens
(int, main, (, 42, ;), discarding whitespace and comments.
Usual arithmetic conversions — C's rules for mixing types in one
operation: int + long converts the int to long first; anything
mixed with a double becomes double. In charlic these conversions are
inserted as explicit Convert nodes in sema, visible in --emit-ir as
conv.i32→.i64 or conv.i32→.f64.
The compiler is feature-complete for its target: a single C file in, a
signed, running Mach-O executable out. The full test suite is green
(167 unit + 97 end-to-end tests), and the benchmark suite
(benchmarks/) tracks generated-code quality against clang at
-O0/-Oz/-O2/-O3.
Implemented:
- The language: the integer types (
char/short/int/long, signed and unsigned), IEEE floats, pointers, arrays, strings, structs and unions (nested, by value, with brace initializers), enums,typedef,void/void*,const(checked in sema, erased before the IR),static(linkage and block-scope), function prototypes andextern, variadic functions, function pointers, casts, the full statement and expression language, and flexible array members. - The preprocessor: object-like and function-like macros (
#/##, variadics,__VA_ARGS__),#includewith guards and#pragma once, conditional compilation (#if/#ifdef/#elif/#else/#endif),#error/#warning,__LINE__/__FILE__, adjacent string-literal concatenation. Feature-complete for real-world headers. - The hosted stdlib:
stdio,stdlib,string,unistd,fcntl,errno,time,poll,sys/socket,netinet/in— all bound from libSystem by dyld at load time. - The optimizer: inlining, constant folding, copy propagation, instruction combining, dead-code elimination, jump threading, loop-invariant code motion, address-mode fusion — and an SSA window (mem2reg, φ-nodes, GVN, sparse conditional constant propagation, aggressive DCE) with destruction back to copies before register allocation. Linear-scan register allocation; tail calls.
- Instruction selection: oracle-tested AArch64 encoders, immediate
selection (
movn/movz/movkchains), extension-provenance tracking to elide redundant sign extensions, fused multiply-add. - The NEON vectorizer: dependence and stride analysis, a 4-wide widening transform (map, reduction, and map-accumulate shapes) with a scalar epilogue, loop interchange for the matrix-multiply nest, and a runtime alias guard with a preserved scalar fallback for pointer-parameter loops.
- The toolchain end: a Mach-O writer with dyld binding (GOT/stubs, rebase records), and ad-hoc code signing with a hand-rolled SHA-256 — the binaries run on Apple silicon with no external tools.
Deferred, deliberately:
- Multiple input files — wants a real relocatable
.owriter plus a small in-house linker; the compiler links in memory with absolute addresses today. Concatenating sources is a parlor trick, not multi-file. - A freestanding mode (a tiny libc over raw
svc #0syscalls) — gated on the linker work above. - General nested declarators (
int (*a)[4]) and multi-character literals — real C, low teaching value per line. sizeof(expr)and multi-dimensional array declarators (int m[2][2]) — two known parser gaps.- Designated initializers (
.x = 1,[i] = 1) and brace elision — optional conveniences. FILE */ data imports (stdin/stdout/stderrare libSystem globals; we bind functions, not data — a new relocation kind in the Mach-O writer).- Float vectors (
V4F32/V2F64) and a vectorizer cost model — the one remaining NEON slice. - An x86-64 backend — indefinitely deferred; the active target is macOS/AArch64.
See book/ for a 15-chapter tutorial on how the compiler works, stage
by stage.
MIT — see LICENSE.