comfy-lang: A Compiler Pipeline for ARM32
comfy-lang is a small compiled language targeting ARM32 Linux. The front-end produces an AST, the middle-end lowers it through a series of tree rewrites, and the back-end emits ARM32 machine code directly — no assembler, no linker. The compiler is about 2000 lines of C and the interesting parts that actually work are the ARM32 code generator and the syscall wrappers.
Front-End
The lexer and parser are a single-pass recursive descent parser. The
grammar is expression-oriented — everything returns a value, including
blocks and conditionals. Types are inferred bottom-up; there is no type
checker pass, just a typeof() that walks the AST at parse time.
typedef enum {
NODE_INT, NODE_IDENT, NODE_BINOP,
NODE_BLOCK, NODE_CALL, NODE_IF,
} node_kind;
typedef struct node {
node_kind kind;
struct node *kids[3];
int ival;
char *sval;
} node;
Middle-End: Rewrites
The middle-end applies a fixed set of tree rewrites before codegen: constant folding, dead-branch elimination, and tail-call identification. Each rewrite is a recursive walk that returns a (possibly new) node. The passes are cheap enough that we run them to fixpoint — typically two iterations exhaust all opportunities.
ARM32 Code Generation
The back-end walks the AST and emits instructions into a fixed-size buffer.
Registers are allocated on the fly with a simple linear-scan scheme: the
first four available registers (r0–r3) serve as the working set, and
anything beyond that spills to the stack.
static void emit(node *n) {
switch (n->kind) {
case NODE_INT:
emit_mov_imm(n->ival);
break;
case NODE_BINOP:
emit(n->kids[0]);
push();
emit(n->kids[1]);
pop_into_r1();
switch (n->ival) {
case '+': emit_add(); break;
case '-': emit_sub(); break;
case '*': emit_mul(); break;
}
break;
case NODE_CALL:
for (int i = 0; n->kids[i]; i++)
emit(n->kids[i]);
emit_bl(n->sval);
break;
}
}
Each emit_* function writes 2–4 bytes into the buffer and advances the
cursor. The final buffer is a valid .text segment that can be mprotected
to PROT_EXEC and called directly.
uint8_t *buf = mmap(NULL, 4096, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
// ... emit into buf ...
mprotect(buf, 4096, PROT_EXEC);
int (*fn)(int) = (int (*)(int))buf;
fn(42);
Syscall Wrappers
The runtime library replaces the C standard library with direct Linux
syscalls via SVC instructions. Each wrapper follows the AAPCS calling
convention — arguments in r0–r3, syscall number in r7, SVC #0
to trap, return value in r0.
// _exit(0) → mov r0, #0; mov r7, #1; svc #0
static void emit_syscall(int n) {
emit_mov_imm(7, n); // r7 = syscall number
emit_svc(0); // svc #0
}
The linker stub resolves symbolic calls like print_int to the
corresponding syscall (write to stdout), so user code never needs to
know the syscall table.
Status
The front-end handles the core language. The ARM32 code generator produces
correct output for integer arithmetic, conditionals, and function calls.
The syscall wrappers cover exit, write, read, and mmap. The missing
pieces — structs, floats, a proper register allocator — are standard
compiler engineering that builds on the pipeline already in place.
Have a comment on this article? Send me an email.