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 (r0r3) 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 r0r3, 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.

The Segfault Garden

Lu

frgmntedflower@linux.com