A (Not Completely) C compiler written in Rust, inspired by Sandler's "Writing a C Compiler".
NCC is a full pipeline compiler, going from lexing all the way down to x86-64 machine code emission and linking.
Machine code is encoded directly using iced-x86 and emitted to ELF/Mach-O
object files via the object crate—no external assembler required.
A substantial subset of C is supported, including int, long, unsigned int, unsigned long, and double types, functions, static variables, all control
flow statements, and bitwise operations. Additionally, NCC supports developer-friendly warnings and pretty-printing
of each compiler pass.
Runs on Linux and macOS.
This Collatz Conjecture program compiles and runs with NCC, demonstrating static variables, functions, loops, bitwise operations, and conditionals:
// Collatz Conjecture Explorer
//
// For any positive integer n:
// - If n is even: n = n / 2
// - If n is odd: n = 3n + 1
// The conjecture states this always reaches 1.
//
// Which number under 100 takes the most steps?
static int total_steps = 0;
int collatz(int n) {
int steps = 0;
while (n != 1) {
if (n & 1) {
n = 3 * n + 1; // odd
} else {
n = n >> 1; // even: divide by 2
}
steps++;
}
total_steps += steps;
return steps;
}
int main(void) {
int champion = 1;
int max_steps = 0;
for (int i = 1; i < 100; i++) {
int steps = collatz(i);
if (steps > max_steps) {
max_steps = steps;
champion = i;
}
}
// Returns 97: takes 118 steps to reach 1!
// 97 -> 292 -> 146 -> 73 -> 220 -> 110 -> 55 -> ... -> 1
return champion;
}C code alongside the x86-64 assembly NCC generates (ncc -S). Labels are pretty-printed using
the original function and variable names from the source. Redundant moves will be eliminated
once copy propagation is implemented:
| C Code | Generated Assembly |
|---|---|
static int counter = 0;
int next(void) {
counter++;
return counter;
}
int main(void) {
return next() + next();
} |
next:
push %rbp
mov %rsp,%rbp
sub $16,%rsp
mov counter(%rip),%r10d
mov %r10d,-4(%rbp)
mov counter(%rip),%r10d
mov %r10d,-8(%rbp)
addl $1,-8(%rbp)
mov -8(%rbp),%r10d
mov %r10d,counter(%rip)
mov counter(%rip),%eax
mov %rbp,%rsp
pop %rbp
ret
main:
push %rbp
mov %rsp,%rbp
sub $16,%rsp
call next
mov %eax,-4(%rbp)
mov -4(%rbp),%r10d
mov %r10d,-8(%rbp)
call next
mov %eax,-12(%rbp)
mov -8(%rbp),%r10d
mov %r10d,-16(%rbp)
mov -12(%rbp),%r10d
add %r10d,-16(%rbp)
mov -16(%rbp),%eax
mov %rbp,%rsp
pop %rbp
ret
.bss
counter:
.zero 4 |
ncc [OPTIONS] <FILENAMES>...
<FILENAMES>... Input files (required). Supports multiple C (.c), assembly (.s), and
pre-built object (.o) files; objects are passed straight through to the linker.
| Option | Description |
|---|---|
--lex |
Run lexer |
--parse |
Run lexer and parser |
--validate |
Run lexer, parser, and validator |
--codegen |
Run lexer, parser, and code generator |
--tacky |
Emit TACKY IR |
-S |
Emit assembly |
--run |
Run compiled program and print result |
-c |
Emit object file only (no linking) |
--external-linker |
Use system linker (ld) instead of built-in libwild |
--static |
Link statically (no runtime dependencies) - Linux only |
-l <LIB> |
Link against a library, e.g. -lm (forwarded to linker) |
-o, --output <OUTPUT> |
Override output file location |
-h, --help |
Print help |
Note: --lex, --parse, --validate, --codegen, --tacky, -S, --run, -c are mutually exclusive options.
| Exit Code | Description |
|---|---|
| 0 | Success |
| 1 | General error (file I/O, compilation failure) |
| 10 | Lexer error (tokenization failed) |
| 20 | Parser error (syntax error) |
| 30 | Validation error (semantic error) |
NCC follows a classic multi-pass compiler pipeline:
flowchart LR
A[Source .c] --> B[Lexer]
B -->|Tokens| C[Parser]
C -->|Abstract AST| D[Validator]
D -->|Typechecked AST & Symbol Table| E[Tackifier]
E -->|TACKY IR| F[Codegen]
F -->|Assembly AST| G[Emitter]
G -->|Object File| H[Linker]
H --> I[Executable]
| Pass | Description |
|---|---|
| Lexer | Converts source text into tokens using regex patterns with maximal munch. Each token carries a span (file, line, column) for error reporting. |
| Parser | Recursive descent with precedence climbing. Builds an abstract syntax tree while handling operator precedence and associativity. |
| Validator | Two-pass semantic analysis: (1) resolves variables to unique names, labels loops/switches, validates gotos; (2) type checks, builds symbol table, evaluates constant expressions. |
| Tackifier | Lowers the AST to TACKY, a three-address code IR. Flattens nested expressions into sequences of simple operations and makes control flow explicit with jumps and labels. |
| Codegen | Converts TACKY to an x86-64 assembly AST. Assigns pseudo-registers to stack slots, fixes invalid instructions, and implements the System V AMD64 calling convention. |
| Emitter | Encodes instructions to machine code using iced-x86 and writes ELF/Mach-O object files via the object crate. |
| Linker | Resolves external symbols and produces the final executable. Uses wild on Linux; shells out to ld on macOS. |
The compiler currently implements a subset of C with the following grammar:
<program> ::= { <declaration> }
<declaration> ::= <variable-declaration> | <function-declaration>
<variable-declaration> ::= { <specifier> }+ <identifier> [ "=" <exp> ] ";"
<function-declaration> ::= { <specifier> }+ <identifier> "(" <param-list> ")" ( <block> | ";" )
<param-list> ::= "void" | <type> <identifier> { "," <type> <identifier> }
<type> ::= { "int" | "long" | "signed" | "unsigned" }+ | "double"
<specifier> ::= <type> | "static" | "extern"
<block> ::= "{" { <block-item> } "}"
<block-item> ::= <statement> | <declaration>
<for-init> ::= <variable-declaration> | [ <exp> ] ";"
<statement> ::= "return" <exp> ";"
| <exp> ";"
| "if" "(" <exp> ")" <statement> [ "else" <statement> ]
| "goto" <identifier> ";"
| <identifier> ":" <statement>
| <block>
| "break" ";"
| "continue" ";"
| "while" "(" <exp> ")" <statement>
| "do" <statement> "while" "(" <exp> ")" ";"
| "for" "(" <for-init> [ <exp> ] ";" [ <exp> ] ")" <statement>
| "switch" "(" <exp> ")" <statement>
| "case" <exp> ":" <statement>
| "default" ":" <statement>
| ";"
<exp> ::= <factor> | <exp> <binop> <exp> | <exp> <assign-op> <exp>
| <exp> "?" <exp> ":" <exp> | <exp> "++" | <exp> "--"
<factor> ::= <int> | <long> | <uint> | <ulong> | <double> | <identifier> | <unop> <factor> | "++" <factor> | "--" <factor>
| "(" <type> ")" <factor> | "(" <exp> ")"
| <identifier> "(" [ <argument-list> ] ")"
<argument-list> ::= <exp> { "," <exp> }
<unop> ::= "-" | "~" | "!"
<binop> ::= "-" | "+" | "*" | "/" | "%" | "&" | "|" | "^" | "<<" | ">>" | "&&" | "||"
| "==" | "!=" | "<" | "<=" | ">" | ">="
<assign-op> ::= "=" | "+=" | "-=" | "*=" | "/=" | "%=" | "&=" | "|=" | "^=" | "<<=" | ">>="
<identifier> ::= ? An identifier token ?
<int> ::= ? An integer constant token ?
<long> ::= ? A long integer constant token (suffix 'l' or 'L') ?
<uint> ::= ? An unsigned int constant token (suffix 'u' or 'U') ?
<ulong> ::= ? An unsigned long constant token (suffix combining 'u'/'U' and 'l'/'L') ?
<double> ::= ? A floating-point constant token (decimal point and/or exponent) ?| Type | Size | Representation | Notes |
|---|---|---|---|
int |
32-bit | two's complement signed | |
unsigned int |
32-bit | unsigned | wraps mod 2³² |
long |
64-bit | two's complement signed | LP64 — 64-bit, per System V AMD64 |
unsigned long |
64-bit | unsigned | LP64; wraps mod 2⁶⁴ |
double |
64-bit | IEEE-754 binary64 |
Not yet supported: char, short, float, pointers, arrays, structs. See Safer C for
arithmetic, conversion, and overflow semantics.
The compiler supports:
- Multiple functions: Function definitions and forward declarations
- Function calls: Call functions with arguments using the x86-64 System V ABI (first 6 integer arguments in registers RDI, RSI, RDX, RCX, R8, R9; additional arguments on the stack)
- Local variable declarations with optional initialization
- File-scope (global) variables: Defined at file scope with optional initializers (must be constant expressions)
- Storage-class specifiers:
static(internal linkage) andextern(external linkage) for both variables and functions - Compound statements (blocks):
{ ... }with proper scoping - Variable scoping: Block-local variables with shadowing support
- Type system: the integer and floating-point types above (see Data Types), with the usual arithmetic conversions, implicit conversions, and explicit casts
- Integer arithmetic: addition, subtraction, multiplication, division, modulo
- Floating-point arithmetic:
doubleaddition, subtraction, multiplication, division, negation, and comparisons (SSE2), with conversions to and from every integer type; comparisons follow IEEE-754 ordering, so aNaNoperand compares unordered (every relational and==is false,!=is true, andNaNis truthy in a condition) - Bitwise operations: AND (
&), OR (|), XOR (^), complement (~), left/right shift (<<,>>) - Logical operations: AND (
&&), OR (||), NOT (!) with short-circuit evaluation - Comparison operators:
==,!=,<,>,<=,>= - Assignment operators: simple (
=) and compound (+=,-=,*=,/=,%=,&=,|=,^=,<<=,>>=) - Increment/decrement: prefix (
++x,--x) and postfix (x++,x--) - Conditional (ternary) operator:
condition ? true_expr : false_expr - Control flow:
if/elsestatementsswitchstatements withcaseanddefaultlabelswhileloopsdo-whileloopsforloops with all three components (init, condition, update)breakandcontinuestatements- Compound statements/blocks
gotoand labeled statementsreturnstatements
- Expression statements and null statements
NCC provides several safety features and guarantees to help developers write more reliable code:
- Deterministic integer overflow: Signed integer arithmetic uses two's complement wrapping (
int: 32-bit,long: 64-bit) instead of being undefined — e.g.INT_MAX + 1reliably wraps toINT_MIN. Unsigned arithmetic (unsigned int,unsigned long) already wraps modulo 2^N per the C standard. - Left-to-right evaluation: Binary operations are evaluated left to right, eliminating undefined behavior from evaluation order.
- Type conversions: Narrowing (e.g.
longtoint) truncates to the lower 32 bits using two's complement representation, equivalent to repeatedly subtracting 2^32 until the value fits in anintrange. For example,2147483650L(INT_MAX + 3) converts to-2147483646. Widening is value-preserving — signed sources sign-extend, unsigned sources zero-extend — and same-width signed/unsigned conversions reinterpret the bits (e.g.(unsigned)-1isUINT_MAX). - Shift masking: Left and right shifts mask the shift amount to prevent undefined behavior. For
inttypes, the shift amount is masked with& 31(modulo 32); forlongtypes, masked with& 63(modulo 64). For example,1 << 32evaluates to1 << 0 = 1, matching x86 hardware behavior. - Deterministic floating-point edges: Cases C leaves undefined are given defined results. A floating-point
constant too large for
doublerounds to ±infinity and one too small rounds to zero (both flagged by-Woverflow), where C §6.4.4.2 leaves an out-of-range floating constant undefined. Converting adoubleto an integer truncates toward zero; out-of-range or NaN values produce the x86cvttsd2si"integer indefinite" result (the target type's minimum, e.g.INT_MIN), where C §6.3.1.4 leaves the conversion undefined. - Consistent compile-time and runtime behavior: Constant expressions (static initializers, case labels) are
folded with the same arithmetic and type-conversion rules as runtime expressions — including all of the
deterministic resolutions above. Where standard C would make an overflowing or out-of-range constant expression a
constraint violation (a required diagnostic), NCC instead folds it to the value equivalent runtime code would
produce:
static int x = 2147483647 + 1;wraps toINT_MIN, and a constantdouble→intcast yields the samecvttsd2siresult the runtime conversion would.
- Variable shadowing (
-Wshadow): Warns when a variable declaration shadows a previous declaration in an outer scope - Unreachable switch code (
-Wswitch-unreachable): Warns about statements before the firstcaselabel in a switch, which can never be executed - Unused parameters (
-Wunused-parameter): Warns when a function parameter is declared but never used in the function body - Division by zero (
-Wdiv-by-zero): Warns when/or%, including the compound forms/=and%=, has a constant zero divisor (e.g.x / 0,x % 0,x /= 0,x %= 0). A constant zero divisor in a context that requires a constant expression (such as a static initializer) is a hard error instead - Out-of-range shift count (
-Wshift-count-overflow,-Wshift-count-negative): Warns when a<<or>>(including<<=/>>=) has a constant shift count that is negative or>=the width of the left operand's type (32 for 32-bit types, 64 for 64-bit types), e.g.1 << 32or1 << -1 - Integer overflow in a constant expression (
-Woverflow): Warns when folding a signed constant expression in a static initializer or case label overflows the result type (e.g.int x = 2147483647 + 1;). NCC wraps deterministically (two's complement) rather than treating it as undefined, so this flags non-portable code instead of erroring. Unsigned wraparound is well-defined and is not warned (matching gcc/clang) - Constant changed by an implicit conversion (
-Wconstant-conversion): Warns when a constant initializer is implicitly narrowed to a type that can't hold it (e.g.int x = 2147483648;, which truncates to-2147483648). An explicit cast (int x = (int)2147483648;) silences it - Unsequenced modification (
-Wsequence-point): Warns when the same object is modified more than once between sequence points (e.g.i = i++,i++ + i++,f(i++, i++)) — undefined in standard C. NCC evaluates left-to-right so the result is well-defined, but the code is non-portable
- Precise error locations: All errors and warnings include exact line and column numbers with file:line:column format, making it easy to locate problematic code
- Contextual error messages: Semantic errors reference related code locations (e.g., showing both the shadowing variable and the original declaration)
- Pretty-printed ASTs: Visual tree representations of parsed code (
--parse), intermediate representations (--tacky), and generated code (--codegen) for debugging and understanding compilation stages
These features help catch common bugs at compile time while providing predictable runtime behavior.
- Rust (latest stable)
- A C toolchain to provide and locate system libraries (CRT & libc) -
ccis not used for compilation
sudo apt install build-essential
# For Makefile (optional - linting/formatting)
sudo apt install pkg-config libssl-dev makexcode-select --install# Clone the repository
gh repo clone johnhringiv/NCC-Rust
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Initialize submodules
git submodule update --initcargo build --releasecargo testUse the Makefile to check code quality (make quality) and fix formatting (make fix).