Your First Compilation
Let’s compile a simple C program with MCC and explore the compilation pipeline.
Create a C source file
Create a file named hello.c: int main ( void ) {
return 0 ;
}
Compile with MCC
Run the MCC compiler: 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
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:
This runs preprocessing and parsing, then exits. Useful for debugging parse errors.
Stop After Lowering to TAC
See the Three Address Code intermediate representation:
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:
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:
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.
x86_64 Assembly (macOS)
x86_64 Assembly (Linux)
.globl _main
_main:
pushq % rbp
movq % rsp , % rbp
movl $0 , % eax
popq % rbp
retq
Command-Line Options
MCC supports these options:
Option Description -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:
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:
int main ( void ) {
return 0
} // Missing semicolon
Compile:
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:
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:
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