Skip to content

Repository files navigation

NCC - Not Completely C

Lint codecov

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.

Example

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;
}

Generated Assembly

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

Usage

ncc [OPTIONS] <FILENAMES>...

Arguments

<FILENAMES>... Input files (required). Supports multiple C (.c), assembly (.s), and pre-built object (.o) files; objects are passed straight through to the linker.

Options

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 Codes

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)

Architecture

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]
Loading
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.

Language Grammar

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) ?

Data Types

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.

Supported Features

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) and extern (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: double addition, subtraction, multiplication, division, negation, and comparisons (SSE2), with conversions to and from every integer type; comparisons follow IEEE-754 ordering, so a NaN operand compares unordered (every relational and == is false, != is true, and NaN is 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/else statements
    • switch statements with case and default labels
    • while loops
    • do-while loops
    • for loops with all three components (init, condition, update)
    • break and continue statements
    • Compound statements/blocks
    • goto and labeled statements
    • return statements
  • Expression statements and null statements

Safer C

NCC provides several safety features and guarantees to help developers write more reliable code:

Guaranteed Behaviors

  • 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 + 1 reliably wraps to INT_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. long to int) truncates to the lower 32 bits using two's complement representation, equivalent to repeatedly subtracting 2^32 until the value fits in an int range. 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)-1 is UINT_MAX).
  • Shift masking: Left and right shifts mask the shift amount to prevent undefined behavior. For int types, the shift amount is masked with & 31 (modulo 32); for long types, masked with & 63 (modulo 64). For example, 1 << 32 evaluates to 1 << 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 double rounds 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 a double to an integer truncates toward zero; out-of-range or NaN values produce the x86 cvttsd2si "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 to INT_MIN, and a constant doubleint cast yields the same cvttsd2si result the runtime conversion would.

Compile-Time Warnings

  • 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 first case label 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 << 32 or 1 << -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

Developer Experience

  • 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.

Requirements

  • Rust (latest stable)
  • A C toolchain to provide and locate system libraries (CRT & libc) - cc is not used for compilation

Linux

sudo apt install build-essential

# For Makefile (optional - linting/formatting)
sudo apt install pkg-config libssl-dev make

macOS

xcode-select --install

Setup

# 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 --init

Building

cargo build --release

Running Tests

cargo test

Contributing

Use the Makefile to check code quality (make quality) and fix formatting (make fix).

About

A almost C compiler in Rust Following Nora Sandlers Book

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages