A small subset C compiler written in C++ from scratch. It accepts a restricted form of C source, walks it through a classic compiler pipeline, and emits ARM64 assembly for macOS on Apple Silicon. The compiler does not produce a binary on its own—it writes an output.s file, which you then assemble and link with clang to get a runnable executable.
This is not a production compiler. It covers enough of C to be useful for learning how lexing, parsing, semantic analysis, and code generation fit together.
Source input. You provide a single .c file (or pipe source on stdin). The compiler reads the full text and passes it to the next stage.
Lexer. The lexer scans the source character by character and groups it into tokens—keywords like int and if, identifiers, numeric and character literals, operators, and punctuation. Lines starting with # (such as #include <stdio.h>) are skipped entirely; they never become tokens.
Parser. The parser reads the token stream and builds a tree-shaped representation of the program (an abstract syntax tree, or AST). It recognizes function definitions, variable declarations, assignments, if/else, return, expressions, and function calls according to the supported grammar.
AST. The AST is the in-memory structure shared by later stages. Each custom node corresponds to something meaningful in the source — a function, an if statement, a binary operation, and so on.
Semantic analysis. A separate pass walks the AST and checks that the program makes sense: variables are declared before use, types match on assignment and in function calls, if conditions are boolean, and return types agree with the enclosing function. Built-in libc calls like printf are registered here without a real declaration in source.
Code generation. The code generator walks the validated AST and prints ARM64 assembly to output.s. It handles stack frames, local variables, control flow for if/else, arithmetic, comparisons, and calls—including variadic-style handling for printf.
Assembly and linking. clang takes output.s, assembles it, and links against the system C library so calls like printf resolve at link time. The result is a native macOS executable.
| File | Role |
|---|---|
lexer.hpp / lexer.cpp |
Defines token types and scans source text into a token list. |
parser.hpp / parser.cpp |
Builds the AST from tokens using recursive-descent parsing. |
ast.hpp |
Declares AST node types (functions, statements, expressions, etc.). |
semantic.hpp / semantic.cpp |
Type-checks the AST and maintains symbol scopes. |
codegen.hpp / codegen.cpp |
Emits ARM64 assembly from the AST. |
main.cpp |
Reads input, runs the pipeline, and writes output.s. |
Readable pseudocode for what the compiler accepts:
program ::= { top_level_item }
top_level_item ::= function_decl
| declaration
| assignment
function_decl ::= type IDENT '(' [ param_list ] ')' block
param_list ::= type IDENT { ',' type IDENT }
declaration ::= type IDENT [ '=' expression ] ';'
assignment ::= IDENT '=' expression ';'
block ::= '{' { statement } '}'
statement ::= declaration
| assignment
| if_stmt
| return_stmt
| function_call ';'
if_stmt ::= 'if' '(' expression ')' block [ 'else' block ]
return_stmt ::= 'return' [ expression ] ';'
function_call ::= IDENT '(' [ arg_list ] ')'
arg_list ::= expression { ',' expression }
expression ::= term { ( '+' | '-' | '==' | '!=' | '<' | '>' ) term }
term ::= factor { ( '*' | '/' ) factor }
factor ::= INT_LITERAL
| CHAR_LITERAL
| BOOL_LITERAL /* true, false */
| STRING_LITERAL /* only for printf format strings */
| IDENT
| function_call
| '(' expression ')'
type ::= 'int' | 'char' | 'bool' | 'void' /* void only as function return type */
/* Preprocessor: lines beginning with # are ignored by the lexer. */
/* Builtins: printf is treated as an external libc function. */
The input must look like normal C, but stricter than the real language:
- Every statement block uses
{and}—no one-lineiforelsewithout braces. - Semicolons are required after declarations, assignments, returns, and expression statements.
- Function parameters and locals use only
int,char, orbool.voidis allowed only as a function return type. - No pointers, arrays, string variables, or
structtypes. - No
forordoloops yet. - String literals may appear only as arguments to
printf(format strings).
Valid examples:
int add(int a, int b) {
return a + b;
}
int main() {
int x = 10;
int y = 20;
if (x < y) {
return add(x, y);
} else {
return 0;
}
}#include <stdio.h>
int main() {
printf("hello\n");
return 0;
}Invalid (will not compile):
if (x > 0) return 1; /* missing braces */
int arr[10]; /* no arrays */
char* s = "hello"; /* no pointers or string variables */- No loops (
foranddo). - No pointers or address-of/dereference operators.
- No arrays.
- No general string type—only string literals passed to
printf. - Single-file programs only; no linking multiple translation units.
- At most 8 function parameters (register/stack calling convention limit in the codegen).
#includeis skipped, not processed—onlyprintfis supported as a builtin.- No explicit type casts.
- No
++/--operators.
1. Build the compiler
clang++ -std=c++20 -Wall -Wextra main.cpp lexer.cpp parser.cpp semantic.cpp codegen.cpp -o c-compilerOr, using the included Makefile:
make2. Compile a .c file to assembly
./c-compiler program.cThis writes output.s in the current directory.
3. Assemble and link with clang
clang output.s -o program4. Run the executable and check the exit code
./program
echo $?On macOS, main's return value becomes the process exit code (0–255).
int max(int a, int b) {
if (a > b) {
return a;
} else {
return b;
}
}
int main() {
return max(3, 7);
}Build and run:
./c-compiler example.c
clang output.s -o example
./example
echo $? # prints 7