comfy: A Low-Level Scripting Language for ARM32
comfy is a compiled scripting language that targets ARM32 Linux with direct syscall access. The compiler is written in Rust, the output is ARM32 GNU assembler, and the runtime model is bare-metal — no libc, no CRT. You write high-level code with variables and syscall wrappers, and the compiler emits a static ARM32 binary with no dependencies.
The Language
A comfy program looks like this:
fn main() {
let msg = "Hello, World!\n";
$write(1, msg);
}
The $ prefix marks a syscall wrapper. The compiler knows the syscall numbers for exit, write, read, and open, and emits the corresponding svc #0 instruction with the syscall number in r7.
Variables are either strings or integers. Buffers are declared with the buf keyword:
fn main() {
buf[64] input;
let fd = $open("data.txt", 0);
$read(fd, input);
$write(1, input);
}
The Compiler Pipeline
Tokenizer
The front-end is a hand-written recursive descent tokenizer that produces a flat Vec<Token>. Tokens include identifiers, string literals, integer literals, syscall markers ($name), and structural punctuation (fn, let, buf, braces, semicolons).
Parser
The parser takes the token stream and produces an AST. The grammar is simple enough that the parser fits in about 150 lines. Function definitions become AstNode::Function with a body of statements: variable declarations, buffer declarations, and syscall invocations.
pub enum AstNode {
Function { name: String, body: Vec<AstNode> },
Let { name: String, value: Literal },
Buf { name: String, size: usize },
Syscall { name: String, args: Vec<AstNode> },
}
There is no type system beyond the distinction between strings and integers inferred at parse time.
Code Generator
The back-end walks the AST and emits ARM32 assembly into a three-part output: .rodata (string literals), .bss (buffer allocations), and .text (instructions). Each emit_* function appends formatted assembly lines to a SectionWriter.
fn generate_function(&self, func: &AstNode, writer: &mut SectionWriter) {
match func {
AstNode::Syscall { name, args } => {
self.emit_syscall(name, args, writer);
}
AstNode::Let { name, value } => {
self.emit_let(name, value, writer);
}
// ...
}
}
Syscall invocations are translated to the ARM32 ABI: arguments are loaded into r0–r3, the syscall number into r7, followed by svc #0. The return value — typically the number of bytes read or written — is left in r0.
Output and Assembly
The compiler writes a .s file (default build/main.s) that can be assembled and linked with the standard ARM cross-toolchain:
arm-linux-gnueabihf-as build/main.s -o build/main.o
arm-linux-gnueabihf-ld -Ttext=0x10000 --no-dynamic-linker \
-nostdlib build/main.o -o build/main
The resulting binary is a static ELF with no PT_INTERP segment. It runs under qemu-arm or on an ARM board.
Project Structure
src/frontend/tokenizer.rs— Lexer, converts source text to tokens.src/frontend/parser.rs— Recursive descent parser, tokens to AST.src/backend/generator.rs— AST walker, emits assembly.src/backend/arm32/asm.rs— Instruction-level emission helpers.src/backend/arm32/syscall_mapper.rs— Syscall name to number mapping.tests/*.fy— Sample programs covering write, read, and open.
Status
The compiler handles string output, file I/O, and variable declarations. The missing pieces — control flow (if/while), arithmetic expressions, and a register allocator — are the obvious next layer. The pipeline from source to binary is complete and produces working ARM32 executables.
The full source is at github.com/comfy-lang/comfy.
Have a comment on this article? Send me an email.