Write a Compiler from Scratch in C: Build a Working Toy Compiler

Learn how to write a compiler from scratch in C. Build a complete toy compiler with a hand-written lexer, recursive descent parser, and code generator targeting x86-64 and ARM64 assembly.

Last Updated on April 28, 2026 by Vivekanand

Throughout this Compiler Internals series, we have methodically disassembled every stage of the compilation pipeline — from lexing and parsing through intermediate representation, optimization passes, code generation, register allocation, and linking. We have examined how industrial compilers like GCC and Clang transform C source into machine code. Now it is time to write a compiler from scratch ourselves.

In this capstone post, we will build minicc — a complete, working compiler written in C that takes a small language called MiniC and produces real x86-64 and ARM64 assembly. The compiler is a single file of roughly 850 lines. Every stage mirrors what we studied in Parts 1 through 7: a hand-written lexer tokenizes the source, a recursive descent parser builds an AST, a semantic pass resolves variables, and a code generator emits assembly that gcc can assemble and link into a running executable. When you write a compiler from scratch, the theory becomes viscerally concrete.

1. What We Are Building — The MiniC Language

Before we write a compiler from scratch, we need to define the language it will compile. MiniC is a minimal subset of C with just enough features to demonstrate every compiler stage while keeping the implementation under 850 lines:

  • One type: int (32-bit signed integers)
  • Arithmetic: +, -, *, /
  • Comparisons: <, >, ==, !=
  • Variables: local declarations with int x = expr;
  • Control flow: if/else blocks
  • Functions: parameters, return values, and recursion

Here is a complete MiniC program — recursive Fibonacci, the classic compiler test case:

// fibonacci.mc — Recursive Fibonacci in MiniC
int fibonacci(int n) {
    if (n < 2) {
        return n;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}

int main() {
    // Return the 10th Fibonacci number as the exit code
    // (In real use, link with printf to print output)
    return fibonacci(10);
}

Our compiler, minicc, will transform this source file into x86-64 or ARM64 assembly. We then use gcc as our assembler and linker — exactly as we described in Part 1: The Compilation Pipeline. The pipeline is: .mcminicc.sgcc → executable.

The compiler has four stages, each mirroring a part of this series:

StageWhat It DoesSeries Reference
LexerTurns source text into tokensPart 2: Lexing & Parsing
ParserBuilds an Abstract Syntax Tree from tokensPart 2: Lexing & Parsing
Semantic AnalysisResolves variable names and scopesPart 3: LLVM IR (symbol tables)
Code GenerationEmits x86-64 or ARM64 assemblyPart 5: Code Generation

2. Step 1 — The Lexer: Turning Text into Tokens

The lexer is the first stage of any compiler. As we explored in Part 2 of this series, lexical analysis transforms a stream of characters into a stream of tokens — the smallest meaningful units of the language. Our hand-written lexer (no flex or lex) recognizes 22 token types:

C
typedef enum {
    TOK_INT, TOK_IF, TOK_ELSE, TOK_RETURN,     /* keywords */
    TOK_IDENT, TOK_NUM,                          /* literals */
    TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH,   /* arithmetic */
    TOK_LT, TOK_GT, TOK_EQ, TOK_NEQ,            /* comparison */
    TOK_ASSIGN,                                   /* = */
    TOK_LPAREN, TOK_RPAREN,                       /* ( ) */
    TOK_LBRACE, TOK_RBRACE,                       /* { } */
    TOK_SEMI, TOK_COMMA,                          /* ; , */
    TOK_EOF
} TokenType;

The core of the lexer is the next_token() function. It skips whitespace and comments, then classifies the next character. Numbers are accumulated digit by digit. Identifiers are read until a non-alphanumeric character, then checked against the keyword list. Two-character operators like == and != require a one-character lookahead:

C
static void next_token(void) {
    skip_ws();
    tok.line = cur_line;

    if (!src[pos]) { tok.type = TOK_EOF; return; }

    /* Numbers: accumulate digits */
    if (isdigit(src[pos])) {
        tok.type = TOK_NUM;
        tok.value = 0;
        while (isdigit(src[pos]))
            tok.value = tok.value * 10 + (src[pos++] - '0');
        return;
    }

    /* Identifiers & keywords */
    if (isalpha(src[pos]) || src[pos] == '_') {
        int start = pos;
        while (isalnum(src[pos]) || src[pos] == '_') pos++;
        // ... copy name, check against keyword list ...
        return;
    }

    /* Two-char operators: == and != */
    if (src[pos] == '=' && src[pos + 1] == '=') {
        tok.type = TOK_EQ; pos += 2; return;
    }
    // ... single-char tokens via switch ...
}

Compare this to what Clang does internally. When you run clang -Xclang -dump-tokens, you see the same classification happening — identifiers, numeric literals, punctuation — just with far more token types for the full C language. Our 22-token lexer is a simplified version of the same finite automaton that drives every production compiler’s front end.

3. Step 2 — The Parser: Building the Abstract Syntax Tree

The parser consumes tokens and constructs an Abstract Syntax Tree (AST) — a hierarchical representation of the program’s structure. As we discussed in Part 2, there are many parsing strategies. We use recursive descent, the simplest approach that works well for C-like languages. Each grammar rule becomes a function that calls other rule functions, naturally building the tree from the bottom up.

Our AST has 12 node types, each representing a different syntactic construct:

C
typedef enum {
    NODE_PROGRAM, NODE_FUNC, NODE_BLOCK, NODE_RETURN,
    NODE_IF, NODE_VAR_DECL, NODE_ASSIGN, NODE_BINARY,
    NODE_CALL, NODE_NUM, NODE_IDENT, NODE_EXPR_STMT
} NodeType;

The trickiest part of expression parsing is operator precedence. The expression a + b * c must parse as a + (b * c), not (a + b) * c. We handle this by splitting expression parsing into three levels — each level handles operators of the same precedence and calls the next level for tighter-binding operators:

C
/* Lowest precedence in MiniC: comparisons */
static ASTNode *parse_expr(void) {
    ASTNode *left = parse_add();          // try addition first
    while (tok.type == TOK_LT || tok.type == TOK_GT ||
           tok.type == TOK_EQ || tok.type == TOK_NEQ) {
        ASTNode *n = new_node(NODE_BINARY);
        n->binary.op = tok.type;
        next_token();
        n->binary.left = left;
        n->binary.right = parse_add();    // right side is also additive
        left = n;
    }
    return left;
}

/* Middle precedence: addition and subtraction */
static ASTNode *parse_add(void) {
    ASTNode *left = parse_mul();          // try multiplication first
    while (tok.type == TOK_PLUS || tok.type == TOK_MINUS) {
        // ... same pattern, calls parse_mul() for right side
    }
    return left;
}

/* Highest precedence: multiplication and division */
static ASTNode *parse_mul(void) {
    ASTNode *left = parse_unary();        // atoms are tightest
    while (tok.type == TOK_STAR || tok.type == TOK_SLASH) {
        // ... same pattern, calls parse_unary() for right side
    }
    return left;
}

This cascading design ensures that multiplication binds tighter than addition, which binds tighter than comparison — matching C’s operator precedence rules. The bottom of the chain, parse_primary(), handles numbers, variable references, function calls, and parenthesized expressions.

Statement parsing uses simple keyword-driven dispatch. If the current token is return, we parse a return statement. If it is if, we parse an if/else. If it is int, we parse a variable declaration. For assignment versus expression statements, we need a one-token lookahead: if an identifier is followed by =, it is an assignment; otherwise we backtrack and parse it as an expression.

4. Step 3 — Semantic Analysis: Resolving Names and Scopes

Before we can generate assembly, we need to know where each variable lives. In a real compiler like Clang, this is where the symbol table is built — mapping names to memory locations, checking types, and resolving scopes. Our semantic analysis is lightweight because MiniC has only one type (int), but it still performs two critical jobs.

First, it registers all functions so the code generator knows valid call targets. Second, it manages a local variable table that maps each variable name to a stack frame offset. Every time we see int x = ..., we assign the next 4-byte slot on the stack:

C
typedef struct { char name[64]; int offset; } Local;
static Local locals[128];
static int local_count;
static int frame_size;

static int add_local(const char *name) {
    frame_size += 4;                  // each int is 4 bytes
    strcpy(locals[local_count].name, name);
    locals[local_count].offset = frame_size;
    local_count++;
    return frame_size;                // offset from base pointer
}

These offsets map directly to the stack frame layout we studied in Assembly Part 3: Stack Frames. When we later generate mov eax, DWORD PTR [rbp-4], that -4 came from this table. Function parameters are treated as the first locals — they arrive in registers (per the calling conventions we covered in Assembly Part 2) and are immediately stored to their assigned stack slots.

5. Step 4 — Write a Compiler from Scratch: AST to x86-64 and ARM64 Assembly

This is where theory meets machine. The code generator walks the AST and emits assembly instructions. As we explored in Part 5: Code Generation, real compilers use sophisticated instruction selection with SelectionDAG or GlobalISel. Our approach is deliberately simpler: a stack-based evaluator where every expression leaves its result in eax (x86-64) or w0 (ARM64).

Number literals are the simplest — just load the value into the result register:

C
case NODE_NUM:
    emit("    mov     eax, %d", n->num.value);
    break;

Binary operations use a push/pop pattern. Evaluate the left side (result in eax), push it, evaluate the right side (result in eax), move right to ecx, pop left back into eax, then compute:

C
case NODE_BINARY:
    gen_x86_expr(n->binary.left);       // left result in eax
    emit("    push    rax");             // save left on stack
    gen_x86_expr(n->binary.right);      // right result in eax
    emit("    mov     ecx, eax");        // move right to ecx
    emit("    pop     rax");             // restore left to eax
    // Now: eax = left, ecx = right
    switch (n->binary.op) {
    case TOK_PLUS:  emit("    add     eax, ecx"); break;
    case TOK_MINUS: emit("    sub     eax, ecx"); break;
    case TOK_STAR:  emit("    imul    eax, ecx"); break;
    case TOK_SLASH: emit("    cdq"); emit("    idiv    ecx"); break;
    case TOK_LT:
        emit("    cmp     eax, ecx");
        emit("    setl    al");          // set al=1 if less
        emit("    movzx   eax, al");     // zero-extend to 32 bits
        break;
    // ... other comparisons follow the same cmp/setCC pattern
    }

This is intentionally unoptimized — notice the redundant push/pop when evaluating simple expressions. In Part 6: Register Allocation, we saw how real compilers use graph coloring to keep values in registers, eliminating these stack spills entirely. Our toy compiler trades performance for simplicity.

Function calls follow the System V ABI we studied in Assembly Part 2: Calling Conventions. Arguments go into rdi, rsi, rdx, rcx, r8, r9 (the first six integer parameters). We evaluate each argument, push it, then pop all values into the correct ABI registers before the call:

C
case NODE_CALL:
    // Evaluate args in reverse, push each
    for (int i = n->call.arg_count - 1; i >= 0; i--) {
        gen_x86_expr(n->call.args[i]);
        emit("    push    rax");
    }
    // Pop into ABI registers: rdi, rsi, rdx, rcx, r8, r9
    for (int i = 0; i < n->call.arg_count && i < 6; i++)
        emit("    pop     %s", abi_regs[i]);
    emit("    call    %s", sym(n->call.name));
    break;

Function prologues and epilogues follow the exact pattern from Assembly Part 3: Stack Frames. Each function saves the base pointer, allocates stack space for locals, stores incoming register arguments to their stack slots, then reverses everything on return:

C
; x86-64 function prologue
factorial:
    push    rbp              ; save caller's base pointer
    mov     rbp, rsp         ; establish our frame
    sub     rsp, 16          ; allocate space for locals
    mov     DWORD PTR [rbp-4], edi  ; store param 'n' from register

; ... function body ...

; x86-64 function epilogue (via return statement)
    leave                    ; mov rsp, rbp; pop rbp
    ret                      ; return to caller

ARM64 Code Generation

Our compiler also targets ARM64 using the AAPCS64 calling convention. The same AST is walked by a parallel set of codegen functions that emit ARM64 instructions. The key differences from x86-64 are architectural:

ASM
; ARM64 function prologue
_factorial:
    stp     x29, x30, [sp, #-16]!   ; save frame pointer + link register
    mov     x29, sp                  ; establish frame
    sub     sp, sp, #16              ; allocate locals
    str     w0, [x29, #-4]           ; store param 'n' (arrives in w0)

; ARM64 binary addition
    add     w0, w0, w1               ; three-operand form (no destructive ops)

; ARM64 comparison
    cmp     w0, w1
    cset    w0, lt                   ; conditional set (no setCC + movzx dance)

; ARM64 function call
    bl      _fibonacci               ; branch-and-link (stores return addr in x30)

; ARM64 epilogue
    mov     sp, x29
    ldp     x29, x30, [sp], #16     ; restore frame pointer + link register
    ret

Notice how ARM64’s three-operand instructions (add w0, w0, w1 vs x86’s two-operand add eax, ecx) and its cset instruction (vs x86’s setl + movzx pair) produce slightly cleaner assembly. The stp/ldp paired store/load instructions save the frame pointer and link register in a single operation — a pattern we examined in Assembly Part 3.

6. Putting It All Together — Compiling Fibonacci

Let us see the complete pipeline in action. We take our Fibonacci program and run it through every stage, just as we traced the compilation pipeline in Part 1:

Bash
# Step 1: Compile MiniC → x86-64 assembly
$ ./minicc -arch x86_64 fibonacci.mc > fibonacci.s

# Step 2: Assemble and link with gcc
$ gcc -o fibonacci fibonacci.s

# Step 3: Run and check the exit code
$ ./fibonacci
$ echo $?
55

fibonacci(10) = 55 — the compiler produces correct, working machine code. For ARM64 on macOS, we add the -macos flag to prefix symbols with underscores (as required by the Mach-O format we studied in Assembly Part 4: Executable Formats):

Bash
# ARM64 on macOS
$ ./minicc -arch arm64 -macos fibonacci.mc > fibonacci.s
$ gcc -o fibonacci fibonacci.s
$ ./fibonacci; echo $?
55

We can test other programs too. Recursive factorial produces factorial(5) = 120, and an arithmetic test combining compute(x) = x*x + 2*x + 1 with a max() function yields the correct result of 36. The compiler handles recursion, arithmetic with correct operator precedence, local variables, and if/else control flow — all the core constructs needed to write a compiler from scratch.

Here is a sample of what the generated x86-64 assembly looks like for the factorial function. Notice the prologue, parameter storage, comparison, recursive call, and epilogue — every pattern we studied across this series in real compiler output:

ASM
    .intel_syntax noprefix
factorial:
    push    rbp
    mov     rbp, rsp
    sub     rsp, 16
    mov     DWORD PTR [rbp-4], edi     ; store param n
    mov     eax, DWORD PTR [rbp-4]     ; load n
    push    rax
    mov     eax, 2                      ; load 2
    mov     ecx, eax
    pop     rax
    cmp     eax, ecx                    ; n < 2?
    setl    al
    movzx   eax, al
    test    eax, eax
    je      .L1                         ; if false, skip to .L1
    mov     eax, 1                      ; return 1
    leave
    ret
.L1:
    mov     eax, DWORD PTR [rbp-4]     ; load n
    push    rax                         ; save n
    mov     eax, DWORD PTR [rbp-4]     ; load n again
    push    rax
    mov     eax, 1
    mov     ecx, eax
    pop     rax
    sub     eax, ecx                    ; n - 1
    push    rax
    pop     rdi                         ; arg in rdi
    xor     eax, eax
    call    factorial                    ; factorial(n-1)
    mov     ecx, eax                    ; result in ecx
    pop     rax                         ; restore saved n
    imul    eax, ecx                    ; n * factorial(n-1)
    leave
    ret

7. What Real Compilers Do Differently

Our toy compiler works, but it takes many shortcuts that production compilers cannot afford. Understanding these gaps is the whole point — it ties together every concept from this series:

No Intermediate Representation. Real compilers like Clang lower the AST to LLVM IR before touching any target-specific instructions. This enables optimization passes to work on a single representation regardless of whether the target is x86-64, ARM64, or RISC-V. Our compiler goes directly from AST to assembly, which means we cannot share any optimization logic between our two backends.

No Optimization Passes. When you run gcc -O2, the compiler applies dozens of optimization passes — constant folding, dead code elimination, loop unrolling, vectorization. Our compiler emits exactly what the AST says, including redundant loads and unnecessary push/pop sequences. A real compiler would optimize factorial(5) down to a single mov eax, 120 with constant propagation.

No Register Allocation. We use a stack-based evaluation model where intermediate results are pushed and popped via memory. As we covered in Part 6: Register Allocation, real compilers use graph coloring or linear scan algorithms to keep values in registers, dramatically reducing memory traffic. Our generated code is roughly 3-5x more instructions than what clang -O2 produces.

Strict ABI Compliance. The System V AMD64 ABI requires the stack pointer to be 16-byte aligned before any call instruction. Our simple stack-based evaluator pushes intermediate values (shifting rsp by 8 bytes), which means a function call inside a complex expression can occur with a misaligned stack. This happens to work in minicc because we only call our own code (which doesn’t use SIMD instructions), but a real compiler meticulously tracks stack alignment to ensure safe integration with external libraries like libc.

Simplified Linking. We rely on gcc to handle assembly and linking. A production compiler toolchain includes its own assembler and interacts with the linker for symbol resolution, relocation, and potentially Link-Time Optimization. Our .s output does not even contain object file metadata — gcc handles all of that.

If you want to take this further, the next steps would be: add an IR layer (even a simple three-address code), implement basic constant folding, or try a simple register allocator. Robert Nystrom’s Crafting Interpreters is an excellent resource for building a more complete language implementation.

8. Conclusion — The Complete Journey

Over eight posts, we have traced the complete journey from C source code to executable binary. In Part 1, we mapped the pipeline. In Part 2, we saw how compilers read source. In Part 3, we explored the IR that bridges front and back ends. In Part 4, we uncovered what -O2 actually does. In Part 5, we watched IR transform into machine instructions. In Part 6, we solved the register allocation problem. In Part 7, we stitched object files into executables.

And now, in this capstone post, we have put it all together by building a compiler from scratch — a real, working program that reads source code and produces executable assembly for two architectures. The compiler is deliberately simple. It has no IR, no optimizer, and no register allocator. But it has something more valuable for learning: clarity. Every line of code maps directly to a concept we studied in this series.

The full source code for minicc (approximately 850 lines of C) is included below, along with the test programs. You can compile it on any system with a C compiler, then use it to compile MiniC programs into working executables. We encourage you to extend it — add while loops, arrays, or even a simple optimizer. Every enhancement you make will deepen your understanding of what your production compiler does thousands of times a day.

C
/*
 * minicc — A Minimal C Compiler
 *
 * Compiles "MiniC" (a subset of C) to x86-64 or ARM64 assembly.
 * Capstone project for the CoderMusings "Compiler Internals" series.
 *
 * Language: int type, arithmetic (+,-,*,/), comparisons (<,>,==,!=),
 *           local variables, if/else, functions, recursion.
 *
 * Usage: ./minicc [-arch x86_64|arm64] input.mc
 * Output: assembly written to stdout
 *
 * License: MIT
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <stdarg.h>

/* ================================================================
 * SECTION 1: TOKEN TYPES
 * ================================================================ */

typedef enum {
    TOK_INT, TOK_IF, TOK_ELSE, TOK_RETURN,     /* keywords */
    TOK_IDENT, TOK_NUM,                          /* literals */
    TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH,   /* arithmetic */
    TOK_LT, TOK_GT, TOK_EQ, TOK_NEQ,            /* comparison */
    TOK_ASSIGN,                                   /* = */
    TOK_LPAREN, TOK_RPAREN,                       /* ( ) */
    TOK_LBRACE, TOK_RBRACE,                       /* { } */
    TOK_SEMI, TOK_COMMA,                          /* ; , */
    TOK_EOF
} TokenType;

typedef struct {
    TokenType type;
    char name[64];
    int value;
    int line;
} Token;

/* ================================================================
 * SECTION 2: AST NODE TYPES
 * ================================================================ */

typedef enum {
    NODE_PROGRAM, NODE_FUNC, NODE_BLOCK, NODE_RETURN,
    NODE_IF, NODE_VAR_DECL, NODE_ASSIGN, NODE_BINARY,
    NODE_CALL, NODE_NUM, NODE_IDENT, NODE_EXPR_STMT
} NodeType;

typedef struct ASTNode ASTNode;

struct ASTNode {
    NodeType type;
    int line;
    union {
        struct { ASTNode **funcs; int count; } program;
        struct { char name[64]; char params[8][64]; int param_count;
                 ASTNode *body; } func;
        struct { ASTNode **stmts; int count; } block;
        struct { ASTNode *expr; } ret;
        struct { ASTNode *cond; ASTNode *then_body;
                 ASTNode *else_body; } if_stmt;
        struct { char name[64]; ASTNode *init; } var_decl;
        struct { char name[64]; ASTNode *expr; } assign;
        struct { TokenType op; ASTNode *left; ASTNode *right; } binary;
        struct { char name[64]; ASTNode **args; int arg_count; } call;
        struct { int value; } num;
        struct { char name[64]; } ident;
        struct { ASTNode *expr; } expr_stmt;
    };
};

/* ================================================================
 * SECTION 3: GLOBAL STATE
 * ================================================================ */

typedef enum { ARCH_X86_64, ARCH_ARM64 } Arch;

static const char *src;
static int pos;
static int cur_line = 1;
static Token tok;
static int label_id = 0;
static Arch arch = ARCH_X86_64;
static int is_macos = 0;
static FILE *out;

/* Symbol table for locals */
typedef struct { char name[64]; int offset; } Local;
static Local locals[128];
static int local_count;
static int frame_size;

/* Function registry */
typedef struct { char name[64]; int params; } FuncReg;
static FuncReg funcs[64];
static int func_reg_count;

/* ================================================================
 * SECTION 4: ERROR HANDLING
 * ================================================================ */

static void error(const char *fmt, ...) {
    va_list ap;
    fprintf(stderr, "minicc:%d: error: ", cur_line);
    va_start(ap, fmt);
    vfprintf(stderr, fmt, ap);
    va_end(ap);
    fprintf(stderr, "\n");
    exit(1);
}

/* ================================================================
 * SECTION 5: LEXER
 * ================================================================ */

static void skip_ws(void) {
    while (src[pos]) {
        if (src[pos] == '\n') { cur_line++; pos++; }
        else if (isspace((unsigned char)src[pos])) { pos++; }
        else if (src[pos] == '/' && src[pos + 1] == '/') {
            while (src[pos] && src[pos] != '\n') pos++;
        } else break;
    }
}

static void next_token(void) {
    skip_ws();
    tok.line = cur_line;

    if (!src[pos]) { tok.type = TOK_EOF; strcpy(tok.name, "EOF"); return; }

    /* Numbers */
    if (isdigit((unsigned char)src[pos])) {
        tok.type = TOK_NUM;
        tok.value = 0;
        while (isdigit((unsigned char)src[pos]))
            tok.value = tok.value * 10 + (src[pos++] - '0');
        snprintf(tok.name, 64, "%d", tok.value);
        return;
    }

    /* Identifiers & keywords */
    if (isalpha((unsigned char)src[pos]) || src[pos] == '_') {
        int s = pos;
        while (isalnum((unsigned char)src[pos]) || src[pos] == '_') pos++;
        int len = pos - s;
        if (len > 63) len = 63;
        memcpy(tok.name, src + s, len);
        tok.name[len] = '\0';
        if (!strcmp(tok.name, "int"))    tok.type = TOK_INT;
        else if (!strcmp(tok.name, "if"))     tok.type = TOK_IF;
        else if (!strcmp(tok.name, "else"))   tok.type = TOK_ELSE;
        else if (!strcmp(tok.name, "return")) tok.type = TOK_RETURN;
        else tok.type = TOK_IDENT;
        return;
    }

    /* Two-char operators */
    if (src[pos] == '=' && src[pos + 1] == '=') {
        tok.type = TOK_EQ; strcpy(tok.name, "=="); pos += 2; return;
    }
    if (src[pos] == '!' && src[pos + 1] == '=') {
        tok.type = TOK_NEQ; strcpy(tok.name, "!="); pos += 2; return;
    }

    /* Single-char tokens */
    char c = src[pos++];
    tok.name[0] = c; tok.name[1] = '\0';
    switch (c) {
        case '+': tok.type = TOK_PLUS;   return;
        case '-': tok.type = TOK_MINUS;  return;
        case '*': tok.type = TOK_STAR;   return;
        case '/': tok.type = TOK_SLASH;  return;
        case '<': tok.type = TOK_LT;     return;
        case '>': tok.type = TOK_GT;     return;
        case '=': tok.type = TOK_ASSIGN; return;
        case '(': tok.type = TOK_LPAREN; return;
        case ')': tok.type = TOK_RPAREN; return;
        case '{': tok.type = TOK_LBRACE; return;
        case '}': tok.type = TOK_RBRACE; return;
        case ';': tok.type = TOK_SEMI;   return;
        case ',': tok.type = TOK_COMMA;  return;
        default: error("unexpected character '%c'", c);
    }
}

static void expect(TokenType t) {
    if (tok.type != t) error("expected token type %d, got '%s'", t, tok.name);
    next_token();
}

static ASTNode *new_node(NodeType t) {
    ASTNode *n = calloc(1, sizeof(ASTNode));
    if (!n) { fprintf(stderr, "out of memory\n"); exit(1); }
    n->type = t;
    n->line = tok.line;
    return n;
}

/* ================================================================
 * SECTION 6: PARSER (Recursive Descent)
 * ================================================================ */

static ASTNode *parse_expr(void);
static ASTNode *parse_stmt(void);

static ASTNode *parse_primary(void) {
    if (tok.type == TOK_NUM) {
        ASTNode *n = new_node(NODE_NUM);
        n->num.value = tok.value;
        next_token();
        return n;
    }
    if (tok.type == TOK_IDENT) {
        char name[64];
        strcpy(name, tok.name);
        next_token();
        if (tok.type == TOK_LPAREN) {       /* function call */
            next_token();
            ASTNode *n = new_node(NODE_CALL);
            strcpy(n->call.name, name);
            n->call.args = malloc(8 * sizeof(ASTNode *));
            n->call.arg_count = 0;
            if (tok.type != TOK_RPAREN) {
                n->call.args[n->call.arg_count++] = parse_expr();
                while (tok.type == TOK_COMMA) {
                    next_token();
                    if (n->call.arg_count >= 8) error("too many arguments");
                    n->call.args[n->call.arg_count++] = parse_expr();
                }
            }
            expect(TOK_RPAREN);
            return n;
        }
        ASTNode *n = new_node(NODE_IDENT);  /* variable */
        strcpy(n->ident.name, name);
        return n;
    }
    if (tok.type == TOK_LPAREN) {
        next_token();
        ASTNode *n = parse_expr();
        expect(TOK_RPAREN);
        return n;
    }
    error("unexpected '%s' in expression", tok.name);
    return NULL;
}

static ASTNode *parse_unary(void) {
    if (tok.type == TOK_MINUS) {
        next_token();
        ASTNode *n = new_node(NODE_BINARY);
        n->binary.op = TOK_MINUS;
        n->binary.left = new_node(NODE_NUM);
        n->binary.left->num.value = 0;
        n->binary.right = parse_primary();
        return n;
    }
    return parse_primary();
}

static ASTNode *parse_mul(void) {
    ASTNode *left = parse_unary();
    while (tok.type == TOK_STAR || tok.type == TOK_SLASH) {
        ASTNode *n = new_node(NODE_BINARY);
        n->binary.op = tok.type;
        next_token();
        n->binary.left = left;
        n->binary.right = parse_unary();
        left = n;
    }
    return left;
}

static ASTNode *parse_add(void) {
    ASTNode *left = parse_mul();
    while (tok.type == TOK_PLUS || tok.type == TOK_MINUS) {
        ASTNode *n = new_node(NODE_BINARY);
        n->binary.op = tok.type;
        next_token();
        n->binary.left = left;
        n->binary.right = parse_mul();
        left = n;
    }
    return left;
}

/* Lowest precedence in MiniC: comparisons */
static ASTNode *parse_expr(void) {
    ASTNode *left = parse_add();
    while (tok.type == TOK_LT || tok.type == TOK_GT ||
           tok.type == TOK_EQ || tok.type == TOK_NEQ) {
        ASTNode *n = new_node(NODE_BINARY);
        n->binary.op = tok.type;
        next_token();
        n->binary.left = left;
        n->binary.right = parse_add();
        left = n;
    }
    return left;
}

static ASTNode *parse_block_body(void) {
    ASTNode *b = new_node(NODE_BLOCK);
    b->block.stmts = malloc(128 * sizeof(ASTNode *));
    b->block.count = 0;
    while (tok.type != TOK_RBRACE && tok.type != TOK_EOF) {
        if (b->block.count >= 128) error("too many statements");
        b->block.stmts[b->block.count++] = parse_stmt();
    }
    return b;
}

static ASTNode *parse_stmt(void) {
    if (tok.type == TOK_RETURN) {
        next_token();
        ASTNode *n = new_node(NODE_RETURN);
        n->ret.expr = parse_expr();
        expect(TOK_SEMI);
        return n;
    }
    if (tok.type == TOK_IF) {
        next_token();
        ASTNode *n = new_node(NODE_IF);
        expect(TOK_LPAREN);
        n->if_stmt.cond = parse_expr();
        expect(TOK_RPAREN);
        expect(TOK_LBRACE);
        n->if_stmt.then_body = parse_block_body();
        expect(TOK_RBRACE);
        n->if_stmt.else_body = NULL;
        if (tok.type == TOK_ELSE) {
            next_token();
            expect(TOK_LBRACE);
            n->if_stmt.else_body = parse_block_body();
            expect(TOK_RBRACE);
        }
        return n;
    }
    if (tok.type == TOK_INT) {
        next_token();
        ASTNode *n = new_node(NODE_VAR_DECL);
        if (tok.type != TOK_IDENT) error("expected variable name");
        strcpy(n->var_decl.name, tok.name);
        next_token();
        n->var_decl.init = NULL;
        if (tok.type == TOK_ASSIGN) {
            next_token();
            n->var_decl.init = parse_expr();
        }
        expect(TOK_SEMI);
        return n;
    }
    if (tok.type == TOK_IDENT) {
        char name[64];
        strcpy(name, tok.name);
        int sp = pos, sl = cur_line;
        Token st = tok;
        next_token();
        if (tok.type == TOK_ASSIGN) {
            next_token();
            ASTNode *n = new_node(NODE_ASSIGN);
            strcpy(n->assign.name, name);
            n->assign.expr = parse_expr();
            expect(TOK_SEMI);
            return n;
        }
        pos = sp; cur_line = sl; tok = st;  /* backtrack */
    }
    ASTNode *n = new_node(NODE_EXPR_STMT);
    n->expr_stmt.expr = parse_expr();
    expect(TOK_SEMI);
    return n;
}

static ASTNode *parse_function(void) {
    expect(TOK_INT);
    ASTNode *n = new_node(NODE_FUNC);
    if (tok.type != TOK_IDENT) error("expected function name");
    strcpy(n->func.name, tok.name);
    next_token();
    expect(TOK_LPAREN);
    n->func.param_count = 0;
    if (tok.type != TOK_RPAREN) {
        expect(TOK_INT);
        if (tok.type != TOK_IDENT) error("expected parameter name");
        strcpy(n->func.params[n->func.param_count++], tok.name);
        next_token();
        while (tok.type == TOK_COMMA) {
            next_token();
            expect(TOK_INT);
            if (n->func.param_count >= 8) error("too many parameters");
            if (tok.type != TOK_IDENT) error("expected parameter name");
            strcpy(n->func.params[n->func.param_count++], tok.name);
            next_token();
        }
    }
    expect(TOK_RPAREN);
    expect(TOK_LBRACE);
    n->func.body = parse_block_body();
    expect(TOK_RBRACE);
    return n;
}

static ASTNode *parse_program(void) {
    ASTNode *n = new_node(NODE_PROGRAM);
    n->program.funcs = malloc(64 * sizeof(ASTNode *));
    n->program.count = 0;
    while (tok.type != TOK_EOF) {
        if (n->program.count >= 64) error("too many functions");
        n->program.funcs[n->program.count++] = parse_function();
    }
    return n;
}

/* ================================================================
 * SECTION 7: SEMANTIC HELPERS
 * ================================================================ */

static void emit(const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);
    vfprintf(out, fmt, ap);
    va_end(ap);
    fprintf(out, "\n");
}

static int new_label(void) { return label_id++; }

/* Platform-aware symbol name: macOS prefixes with underscore */
static char _sym_buf[128];
static const char *sym(const char *name) {
    if (is_macos) { snprintf(_sym_buf, sizeof(_sym_buf), "_%s", name); return _sym_buf; }
    return name;
}

static void register_funcs(ASTNode *prog) {
    func_reg_count = 0;
    for (int i = 0; i < prog->program.count; i++) {
        strcpy(funcs[func_reg_count].name, prog->program.funcs[i]->func.name);
        funcs[func_reg_count].params = prog->program.funcs[i]->func.param_count;
        func_reg_count++;
    }
}

static int find_local(const char *name) {
    for (int i = 0; i < local_count; i++)
        if (!strcmp(locals[i].name, name)) return locals[i].offset;
    return 0;
}

static int add_local(const char *name) {
    if (local_count >= 128) error("too many locals");
    frame_size += 4;
    strcpy(locals[local_count].name, name);
    locals[local_count].offset = frame_size;
    local_count++;
    return frame_size;
}

static void scan_locals(ASTNode *n) {
    if (!n) return;
    if (n->type == NODE_VAR_DECL) {
        add_local(n->var_decl.name);
    } else if (n->type == NODE_IF) {
        scan_locals(n->if_stmt.then_body);
        scan_locals(n->if_stmt.else_body);
    } else if (n->type == NODE_BLOCK) {
        for (int i = 0; i < n->block.count; i++)
            scan_locals(n->block.stmts[i]);
    }
}

/* ================================================================
 * SECTION 8: x86-64 CODE GENERATOR
 * ================================================================ */

static void gen_x86_expr(ASTNode *n);
static void gen_x86_stmt(ASTNode *n);

static void gen_x86_expr(ASTNode *n) {
    switch (n->type) {
    case NODE_NUM:
        emit("    mov     eax, %d", n->num.value);
        break;
    case NODE_IDENT: {
        int off = find_local(n->ident.name);
        if (!off) error("undefined variable '%s'", n->ident.name);
        emit("    mov     eax, DWORD PTR [rbp-%d]", off);
        break;
    }
    case NODE_BINARY:
        gen_x86_expr(n->binary.left);
        emit("    push    rax");
        gen_x86_expr(n->binary.right);
        emit("    mov     ecx, eax");
        emit("    pop     rax");
        switch (n->binary.op) {
        case TOK_PLUS:  emit("    add     eax, ecx"); break;
        case TOK_MINUS: emit("    sub     eax, ecx"); break;
        case TOK_STAR:  emit("    imul    eax, ecx"); break;
        case TOK_SLASH: emit("    cdq"); emit("    idiv    ecx"); break;
        case TOK_LT:
            emit("    cmp     eax, ecx");
            emit("    setl    al");
            emit("    movzx   eax, al");
            break;
        case TOK_GT:
            emit("    cmp     eax, ecx");
            emit("    setg    al");
            emit("    movzx   eax, al");
            break;
        case TOK_EQ:
            emit("    cmp     eax, ecx");
            emit("    sete    al");
            emit("    movzx   eax, al");
            break;
        case TOK_NEQ:
            emit("    cmp     eax, ecx");
            emit("    setne   al");
            emit("    movzx   eax, al");
            break;
        default: error("unknown binary op");
        }
        break;
    case NODE_CALL: {
        const char *regs[] = {"edi","esi","edx","ecx","r8d","r9d"};
        /* Push args in reverse to save on stack */
        for (int i = n->call.arg_count - 1; i >= 0; i--) {
            gen_x86_expr(n->call.args[i]);
            emit("    push    rax");
        }
        /* Pop into ABI registers */
        for (int i = 0; i < n->call.arg_count && i < 6; i++)
            emit("    pop     %s", (i < 4) ? (const char*[]){"rdi","rsi","rdx","rcx"}[i] : (i==4?"r8":"r9"));
        /* Note: System V ABI requires 16-byte stack alignment at calls. 
           Pushing intermediate expr results violates this. Works in minicc but unsafe for external libs. */
        emit("    xor     eax, eax");  /* zero AL for variadic */
        emit("    call    %s", sym(n->call.name));
        break;
    }
    default: error("unexpected node in expression");
    }
}

static void gen_x86_stmt(ASTNode *n) {
    switch (n->type) {
    case NODE_RETURN:
        gen_x86_expr(n->ret.expr);
        emit("    leave");
        emit("    ret");
        break;
    case NODE_VAR_DECL: {
        int off = add_local(n->var_decl.name);
        if (n->var_decl.init) {
            gen_x86_expr(n->var_decl.init);
            emit("    mov     DWORD PTR [rbp-%d], eax", off);
        }
        break;
    }
    case NODE_ASSIGN: {
        int off = find_local(n->assign.name);
        if (!off) error("undefined variable '%s'", n->assign.name);
        gen_x86_expr(n->assign.expr);
        emit("    mov     DWORD PTR [rbp-%d], eax", off);
        break;
    }
    case NODE_IF: {
        int lelse = new_label(), lend = new_label();
        gen_x86_expr(n->if_stmt.cond);
        emit("    test    eax, eax");
        emit("    je      .L%d", n->if_stmt.else_body ? lelse : lend);
        for (int i = 0; i < n->if_stmt.then_body->block.count; i++)
            gen_x86_stmt(n->if_stmt.then_body->block.stmts[i]);
        if (n->if_stmt.else_body) {
            emit("    jmp     .L%d", lend);
            emit(".L%d:", lelse);
            for (int i = 0; i < n->if_stmt.else_body->block.count; i++)
                gen_x86_stmt(n->if_stmt.else_body->block.stmts[i]);
        }
        emit(".L%d:", lend);
        break;
    }
    case NODE_EXPR_STMT:
        gen_x86_expr(n->expr_stmt.expr);
        break;
    case NODE_BLOCK:
        for (int i = 0; i < n->block.count; i++)
            gen_x86_stmt(n->block.stmts[i]);
        break;
    default: error("unexpected statement type");
    }
}

static void gen_x86_func(ASTNode *fn) {
    const char *regs[] = {"edi","esi","edx","ecx","r8d","r9d"};
    local_count = 0;
    frame_size = 0;

    /* Register params as locals first (pre-scan for stack size) */
    for (int i = 0; i < fn->func.param_count; i++)
        add_local(fn->func.params[i]);

    /* Pre-scan body for variable declarations to calculate frame size */
    scan_locals(fn->func.body);
    int total_frame = (frame_size + 15) & ~15; /* align to 16 */
    /* Reset locals — they'll be re-added during codegen */
    local_count = 0; frame_size = 0;
    for (int i = 0; i < fn->func.param_count; i++)
        add_local(fn->func.params[i]);

    emit("");
    emit("    .globl  %s", sym(fn->func.name));
    if (!is_macos) emit("    .type   %s, @function", sym(fn->func.name));
    emit("%s:", sym(fn->func.name));
    emit("    push    rbp");
    emit("    mov     rbp, rsp");
    if (total_frame > 0)
        emit("    sub     rsp, %d", total_frame);

    /* Store params from registers to stack */
    for (int i = 0; i < fn->func.param_count && i < 6; i++)
        emit("    mov     DWORD PTR [rbp-%d], %s", locals[i].offset, regs[i]);

    /* Generate body */
    for (int i = 0; i < fn->func.body->block.count; i++)
        gen_x86_stmt(fn->func.body->block.stmts[i]);

    /* Implicit return 0 */
    emit("    xor     eax, eax");
    emit("    leave");
    emit("    ret");
}

static void gen_x86(ASTNode *prog) {
    emit("    .intel_syntax noprefix");
    emit("    .text");
    for (int i = 0; i < prog->program.count; i++)
        gen_x86_func(prog->program.funcs[i]);
    if (!is_macos) {
        emit("");
        emit("    .section .note.GNU-stack,\"\",@progbits");
    }
}

/* ================================================================
 * SECTION 9: ARM64 CODE GENERATOR (AAPCS64)
 * ================================================================ */

static void gen_arm_expr(ASTNode *n);
static void gen_arm_stmt(ASTNode *n);

static void gen_arm_expr(ASTNode *n) {
    switch (n->type) {
    case NODE_NUM:
        if (n->num.value >= 0 && n->num.value <= 65535)
            emit("    mov     w0, #%d", n->num.value);
        else {
            emit("    mov     w0, #%d", n->num.value & 0xFFFF);
            if (n->num.value >> 16)
                emit("    movk    w0, #%d, lsl #16", (n->num.value >> 16) & 0xFFFF);
        }
        break;
    case NODE_IDENT: {
        int off = find_local(n->ident.name);
        if (!off) error("undefined variable '%s'", n->ident.name);
        emit("    ldr     w0, [x29, #-%d]", off);
        break;
    }
    case NODE_BINARY:
        gen_arm_expr(n->binary.left);
        emit("    str     w0, [sp, #-16]!");   /* push left */
        gen_arm_expr(n->binary.right);
        emit("    mov     w1, w0");             /* right in w1 */
        emit("    ldr     w0, [sp], #16");      /* pop left into w0 */
        switch (n->binary.op) {
        case TOK_PLUS:  emit("    add     w0, w0, w1"); break;
        case TOK_MINUS: emit("    sub     w0, w0, w1"); break;
        case TOK_STAR:  emit("    mul     w0, w0, w1"); break;
        case TOK_SLASH: emit("    sdiv    w0, w0, w1"); break;
        case TOK_LT:
            emit("    cmp     w0, w1");
            emit("    cset    w0, lt");
            break;
        case TOK_GT:
            emit("    cmp     w0, w1");
            emit("    cset    w0, gt");
            break;
        case TOK_EQ:
            emit("    cmp     w0, w1");
            emit("    cset    w0, eq");
            break;
        case TOK_NEQ:
            emit("    cmp     w0, w1");
            emit("    cset    w0, ne");
            break;
        default: error("unknown binary op");
        }
        break;
    case NODE_CALL: {
        /* Push args in reverse, then pop into w0-w7 */
        for (int i = n->call.arg_count - 1; i >= 0; i--) {
            gen_arm_expr(n->call.args[i]);
            emit("    str     w0, [sp, #-16]!");
        }
        for (int i = 0; i < n->call.arg_count && i < 8; i++)
            emit("    ldr     w%d, [sp], #16", i);
        emit("    bl      %s", sym(n->call.name));
        break;
    }
    default: error("unexpected node in expression");
    }
}

static void gen_arm_stmt(ASTNode *n) {
    switch (n->type) {
    case NODE_RETURN:
        gen_arm_expr(n->ret.expr);
        emit("    mov     sp, x29");
        emit("    ldp     x29, x30, [sp], #16");
        emit("    ret");
        break;
    case NODE_VAR_DECL: {
        int off = add_local(n->var_decl.name);
        if (n->var_decl.init) {
            gen_arm_expr(n->var_decl.init);
            emit("    str     w0, [x29, #-%d]", off);
        }
        break;
    }
    case NODE_ASSIGN: {
        int off = find_local(n->assign.name);
        if (!off) error("undefined variable '%s'", n->assign.name);
        gen_arm_expr(n->assign.expr);
        emit("    str     w0, [x29, #-%d]", off);
        break;
    }
    case NODE_IF: {
        int lelse = new_label(), lend = new_label();
        gen_arm_expr(n->if_stmt.cond);
        emit("    cmp     w0, #0");
        emit("    b.eq    .L%d", n->if_stmt.else_body ? lelse : lend);
        for (int i = 0; i < n->if_stmt.then_body->block.count; i++)
            gen_arm_stmt(n->if_stmt.then_body->block.stmts[i]);
        if (n->if_stmt.else_body) {
            emit("    b       .L%d", lend);
            emit(".L%d:", lelse);
            for (int i = 0; i < n->if_stmt.else_body->block.count; i++)
                gen_arm_stmt(n->if_stmt.else_body->block.stmts[i]);
        }
        emit(".L%d:", lend);
        break;
    }
    case NODE_EXPR_STMT:
        gen_arm_expr(n->expr_stmt.expr);
        break;
    case NODE_BLOCK:
        for (int i = 0; i < n->block.count; i++)
            gen_arm_stmt(n->block.stmts[i]);
        break;
    default: error("unexpected statement type");
    }
}

static void gen_arm_func(ASTNode *fn) {
    local_count = 0;
    frame_size = 0;

    /* Register params as locals */
    for (int i = 0; i < fn->func.param_count; i++)
        add_local(fn->func.params[i]);

    /* Pre-scan for var declarations */
    scan_locals(fn->func.body);
    int total_frame = (frame_size + 15) & ~15;
    local_count = 0; frame_size = 0;
    for (int i = 0; i < fn->func.param_count; i++)
        add_local(fn->func.params[i]);

    emit("");
    emit("    .globl  %s", sym(fn->func.name));
    emit("%s:", sym(fn->func.name));
    emit("    stp     x29, x30, [sp, #-16]!");
    emit("    mov     x29, sp");
    if (total_frame > 0)
        emit("    sub     sp, sp, #%d", total_frame);

    /* Store params from registers to stack */
    for (int i = 0; i < fn->func.param_count && i < 8; i++)
        emit("    str     w%d, [x29, #-%d]", i, locals[i].offset);

    /* Generate body */
    for (int i = 0; i < fn->func.body->block.count; i++)
        gen_arm_stmt(fn->func.body->block.stmts[i]);

    /* Implicit return 0 */
    emit("    mov     w0, #0");
    emit("    mov     sp, x29");
    emit("    ldp     x29, x30, [sp], #16");
    emit("    ret");
}

static void gen_arm(ASTNode *prog) {
    emit("    .text");
    for (int i = 0; i < prog->program.count; i++)
        gen_arm_func(prog->program.funcs[i]);
    if (!is_macos) {
        emit("");
        emit("    .section .note.GNU-stack,\"\",@progbits");
    }
}

/* ================================================================
 * SECTION 10: MAIN
 * ================================================================ */

int main(int argc, char **argv) {
    const char *infile = NULL;
    out = stdout;

    for (int i = 1; i < argc; i++) {
        if (!strcmp(argv[i], "-arch") && i + 1 < argc) {
            i++;
            if (!strcmp(argv[i], "x86_64")) arch = ARCH_X86_64;
            else if (!strcmp(argv[i], "arm64")) arch = ARCH_ARM64;
            else { fprintf(stderr, "unknown arch: %s\n", argv[i]); return 1; }
        } else if (!strcmp(argv[i], "-macos")) {
            is_macos = 1;
        } else {
            infile = argv[i];
        }
    }

    if (!infile) {
        fprintf(stderr, "Usage: minicc [-arch x86_64|arm64] input.mc\n");
        return 1;
    }

    /* Read input file */
    FILE *f = fopen(infile, "r");
    if (!f) { perror(infile); return 1; }
    fseek(f, 0, SEEK_END);
    long len = ftell(f);
    fseek(f, 0, SEEK_SET);
    char *buf = malloc(len + 1);
    if (!buf) { fprintf(stderr, "out of memory\n"); return 1; }
    fread(buf, 1, len, f);
    buf[len] = '\0';
    fclose(f);

    /* Compile */
    src = buf;
    pos = 0;
    next_token();
    ASTNode *prog = parse_program();
    register_funcs(prog);

    if (arch == ARCH_X86_64)
        gen_x86(prog);
    else
        gen_arm(prog);

    free(buf);
    return 0;
}
// arithmetic.mc — Tests arithmetic and if/else in MiniC

int max(int a, int b) {
    if (a > b) {
        return a;
    } else {
        return b;
    }
}

int compute(int x) {
    int result = x * x + 2 * x + 1;
    return result;
}

int main() {
    int a = compute(5);
    int b = compute(3);
    return max(a, b);
}
// factorial.mc — Recursive factorial in MiniC

int factorial(int n) {
    if (n < 2) {
        return 1;
    }
    return n * factorial(n - 1);
}

int main() {
    return factorial(5);
}
// fibonacci.mc — Recursive Fibonacci in MiniC
// Compile: ./minicc fibonacci.mc > fibonacci.s
// Assemble & link: gcc -o fibonacci fibonacci.s
// Run: ./fibonacci

int fibonacci(int n) {
    if (n < 2) {
        return n;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}

int main() {
    // Return the 10th Fibonacci number as the exit code
    // (In real use, link with printf to print output)
    return fibonacci(10);
}

The compiler is no longer a black box. You have seen every gear, every lever, every mechanism. Now go build something with that knowledge. If you are coming from our Assembly series, you can even debug your compiled programs with the debugger we built there — closing the loop between writing code, compiling it, and understanding exactly how it runs on the machine.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top