Skip to main content

Your First Compilation

Let’s compile a simple C program with MCC and explore the compilation pipeline.
1

Create a C source file

Create a file named hello.c:
hello.c
int main(void) {
    return 0;
}
2

Compile with MCC

Run the MCC compiler:
mcc hello.c -o hello
This will:
  • Preprocess with the system C compiler
  • Parse using tree-sitter
  • Typecheck and build HIR
  • Lower to Three Address Code (TAC)
  • Generate assembly IR
  • Render to x86_64 assembly text
  • Assemble and link to create an executable
3

Run your program

Execute the compiled binary:
./hello
echo $?  # Prints: 0
Congratulations! You’ve successfully compiled and run your first program with MCC.

Exploring Compilation Stages

MCC allows you to stop at any compilation stage to inspect intermediate representations.

Stop After Parsing

Inspect the AST without generating code:
mcc --parse hello.c
This runs preprocessing and parsing, then exits. Useful for debugging parse errors.

Stop After Lowering to TAC

See the Three Address Code intermediate representation:
mcc --tacky hello.c
TAC is a simplified IR where each instruction performs at most one operation. This stage happens after typechecking and HIR construction.

Stop After Code Generation

Generate assembly IR but don’t render to text:
mcc --codegen hello.c
This produces the assembly IR (target-agnostic representation) without rendering to textual assembly.

Keep Assembly Output

Generate assembly text and save it to a file:
mcc -S hello.c -o hello
This creates hello.s with the generated x86_64 assembly. On macOS, you’ll see symbol names with leading underscores (_main). On Linux, you’ll see a .note.GNU-stack section.
    .globl _main
_main:
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $0, %eax
    popq    %rbp
    retq

Command-Line Options

MCC supports these options:
OptionDescription
-o <file>Specify output file
-SKeep assembly file (creates .s file)
--parseStop after parsing
--tackyStop after lowering to TAC
--codegenStop after code generation
--lexStop after lexing (rarely used)
--target <triple>Specify target triple (default: x86_64-unknown-linux-gnu on Linux, x86_64-apple-darwin on macOS)
--color <when>Control colored output (auto/always/never)
The CC environment variable controls which C compiler is used for preprocessing and linking. Default is cc.

Working with Real Programs

Compile a Program with Functions

Create factorial.c:
factorial.c
int factorial(int n) {
    if (n <= 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

int main(void) {
    return factorial(5);  // Returns 120
}
Compile and run:
mcc factorial.c -o factorial
./factorial
echo $?  # Prints: 120

View Generated Assembly

See how MCC compiles functions:
mcc -S factorial.c -o factorial
cat factorial.s
You’ll see the calling convention, stack frame setup, and recursive call handling.

Handling Errors

MCC provides detailed diagnostics when compilation fails.

Parse Error Example

Create broken.c:
broken.c
int main(void) {
    return 0
}  // Missing semicolon
Compile:
mcc broken.c
You’ll see an error message with:
  • The exact file and line number
  • A snippet of the problematic code
  • A helpful error message
MCC accumulates all errors in a single pass, so you may see multiple errors at once. Fix them one at a time, starting from the top.

Type Error Example

Create type_error.c:
type_error.c
int main(void) {
    int x;
    return x + "hello";  // Can't add int and string
}
MCC’s typechecker will catch this during semantic analysis and show which types are incompatible.

Using MCC as a Library

You can also use MCC programmatically from Rust code:
use mcc::{Database, SourceFile, Text};

let db = Database::default();
let src = "int main(void) { return 0; }";
let file = SourceFile::new(&db, Text::from("main.c"), Text::from(src));

// Parse the source
let ast = mcc::parse(&db, file);

// Lower to TAC
let tacky = mcc::lowering::lower_program(&db, file);

// Generate assembly IR
let asm_ir = mcc::codegen::generate_assembly(&db, tacky);

// Render to assembly text
let asm_text = mcc::render_program(&db, asm_ir, mcc::default_target()).unwrap();

assert!(asm_text.as_str().contains("main"));

Capturing Diagnostics

Use Salsa accumulators to collect diagnostics:
use mcc::{Database, SourceFile, Text, diagnostics::Diagnostics};

let db = Database::default();
let file = SourceFile::new(&db, "test.c".into(), "int main(void) {}".into());

let _ = mcc::parse(&db, file);
let diags: Vec<&Diagnostics> = mcc::parse::accumulated::<Diagnostics>(&db, file);

for diag in diags {
    println!("Diagnostic: {:?}", diag);
}

Using Callbacks

The mcc-driver crate provides a callback API for observing compilation stages:
use std::ops::ControlFlow;
use mcc_driver::{Callbacks, Config, Outcome};
use mcc::{Ast, Text, diagnostics::Diagnostics, codegen::asm, lowering::tacky};

struct MyCallbacks;

impl Callbacks for MyCallbacks {
    type Output = ();

    fn after_parse<'db>(
        &mut self,
        _db: &'db dyn mcc::Db,
        _source_file: mcc::SourceFile,
        _ast: Ast<'db>,
        diags: Vec<&Diagnostics>,
    ) -> ControlFlow<Self::Output> {
        println!("Parse stage produced {} diagnostics", diags.len());
        ControlFlow::Continue(())
    }

    fn after_lower<'db>(
        &mut self,
        _db: &'db dyn mcc::Db,
        tacky: tacky::Program<'db>,
        _diags: Vec<&Diagnostics>,
    ) -> ControlFlow<Self::Output> {
        println!("Lowering produced TAC: {:?}", tacky);
        ControlFlow::Continue(())
    }

    fn after_codegen<'db>(
        &mut self,
        _db: &'db dyn mcc::Db,
        _asm: asm::Program<'db>,
        _diags: Vec<&Diagnostics>,
    ) -> ControlFlow<Self::Output> {
        ControlFlow::Continue(())
    }

    fn after_render_assembly(
        &mut self,
        _db: &dyn mcc::Db,
        asm: Text,
        _diags: Vec<&Diagnostics>,
    ) -> ControlFlow<Self::Output> {
        println!("Generated {} bytes of assembly", asm.len());
        ControlFlow::Continue(())
    }
}
This allows you to hook into each compilation stage for testing, analysis, or debugging.

Debugging Compilation

Enable Logging

MCC uses tracing for detailed logging:
# Debug logging for mcc modules
RUST_LOG=mcc=debug,mcc-syntax=debug mcc hello.c

# Trace everything
RUST_LOG=trace mcc hello.c
You’ll see detailed information about each compilation stage, including:
  • Preprocessing commands
  • Parse tree construction
  • Typecheck passes
  • IR transformations
  • Assembly generation

Use Different C Compilers

Switch between C compilers for preprocessing:
CC=clang mcc hello.c

Cross-Compilation

Specify a target triple:
mcc --target x86_64-unknown-linux-gnu hello.c
mcc --target x86_64-apple-darwin hello.c
Cross-compilation only changes assembly rendering (symbol prefixes, stack notes). You still need the appropriate linker and system libraries for the target platform.

Running the Test Suite

MCC includes a comprehensive test suite based on the writing-a-c-compiler-tests framework:
# Run integration tests
cargo test -p integration-tests --test integration

# Run tests for specific chapters
cargo test -p integration-tests --test integration -- chapter_1

# Run all tests including ignored ones
cargo test -p integration-tests --test integration -- --ignored
The test suite is organized into 20 chapters representing progressive language features:
  • Chapters 1-3: Basic lexing, parsing, and unary operators
  • Chapters 4-6: Binary operators, variables, control flow
  • Chapters 7-10: Functions, pointers, arrays
  • Chapters 11-18: Advanced features (structs, unions, etc.)
  • Chapter 19: Optimizations
  • Chapter 20: Register allocation

Next Steps

Now that you’ve compiled your first program, explore:

Architecture

Understand MCC’s compilation pipeline and IR design

API Reference

Browse the complete Rust API documentation

Testing

Learn about MCC’s comprehensive test framework

Contributing

Contribute to MCC development

Build docs developers (and LLMs) love