kov-lang: Building a Programming Language That Compiles to RISC-V
I built a programming language from scratch. Lexer, parser, type checker, IR lowering, RISC-V code generation, built-in emulator with a virtual filesystem. The compiler is written in Rust -- about 15,000 lines across 50 source files. It compiles kov source into bare-metal RISC-V binaries that run on real hardware or in the emulator. No operating system, no runtime, no libc.
This isn't a toy language that prints "hello world" and stops. It has a standard library (fs, io, str, mem, vec), imports, generics with monomorphization, arrays, pattern matching, and an x86-64 backend alongside the RISC-V one. I use it to generate this blog.
The pipeline
kov source goes through five stages before it becomes machine code. Each stage is a separate module in the compiler, with clear boundaries between them.
// Compiler pipeline:
//
// source.kov
// ↓ lexer/mod.rs — bytes → tokens
// ↓ parser/mod.rs — tokens → AST
// ↓ types/check.rs — AST → typed AST
// ↓ ir/lower.rs — typed AST → IR
// ↓ codegen/emit.rs — IR → RISC-V machine code
// ↓
// output.elf (bare-metal ELF binary)
// Or through the emulator:
// ↓ emu/cpu.rs — executes RISC-V in software
// ↓ emu/vfs.rs — maps filesystem via MMIO
// ↓
// program runs, reads/writes files through VFS
The lexer
The lexer is straightforward -- single-pass, character-by-character. It produces tokens with line and column information for error reporting. Nothing fancy. The interesting part is that I kept the token set small on purpose. kov has maybe 30 keywords, not 80.
// Sample kov code — the language looks like this:
import io;
import fs;
fn process_file(path: u32) {
let fd = fs_open(path);
if fd == 0 {
println("file not found" as u32);
return;
}
let buf = [0, 0, 0, 0, 0, 0, 0, 0];
let len = fs_read(fd, buf as u32, 8);
fs_close(fd);
}
Types are minimal: u32, i32, bool, arrays, and structs. No heap by default -- everything is stack-allocated or static. This is a bare-metal language. There's no allocator unless I write one in the stdlib.
Generics and monomorphization
kov supports generic functions and structs. When I write fn max<T>(a: T, b: T) -> T, the compiler generates a separate concrete function for each type it's called with. max<u32> becomes one function, max<i32> becomes another. No vtables, no runtime dispatch. Each specialization is optimized independently.
// Generic function in kov:
fn max<T>(a: T, b: T) -> T {
if a > b { a } else { b }
}
// Usage:
let x = max(10, 20); // generates max_u32
let y = max(-5, 3); // generates max_i32
// After monomorphization, the binary contains:
// max_u32: RISC-V bgeu/mv sequence
// max_i32: RISC-V bge/mv sequence
// Two separate functions. Zero abstraction cost.
The monomorphization pass is in parser/monomorph.rs. It walks the AST, finds every call site with concrete types, clones the generic definition with those types substituted, and adds the specialized version to the function table. The type checker then validates each specialization independently.
RISC-V code generation
The codegen backend translates IR (intermediate representation) into RV32IM instructions. I emit raw 32-bit instruction words into a byte buffer, then wrap them in an ELF binary. The emitter handles register allocation, stack frame layout, function calls, and branch offset fixups.
// IR instruction → RISC-V emission
match ir_op {
IrOp::Add(dst, lhs, rhs) => {
emit_add(buf, dst, lhs, rhs);
// ADD rd, rs1, rs2 → 0x00000033 | rd<<7 | rs1<<15 | rs2<<20
}
IrOp::Call(func, args) => {
save_caller_regs(buf);
for (i, arg) in args.iter().enumerate() {
emit_mv(buf, a0 + i, arg); // args in a0-a7
}
emit_jal(buf, ra, func.offset);
restore_caller_regs(buf);
}
IrOp::BranchIf(cond, target) => {
// offset resolved in fixup pass
emit_bne(buf, cond, zero, PLACEHOLDER);
fixups.push(Fixup { pos: buf.len() - 4, target });
}
// ... ~40 IR opcodes total ...
}
Branch offsets are a two-pass problem. First pass emits instructions with placeholder offsets. Second pass resolves labels and patches the immediates in-place. RISC-V's split-immediate encoding (the upper 20 bits and lower 12 bits are in different instruction fields) makes the fixup logic annoying but not hard.
The emulator and VFS
The built-in emulator in emu/cpu.rs executes RISC-V instructions in software. It's a simple fetch-decode-execute loop. But the interesting part is the VFS -- the virtual filesystem in emu/vfs.rs.
kov programs do I/O through MMIO (memory-mapped I/O). They write a command to a fixed memory address, and the emulator intercepts that write and performs the corresponding filesystem operation on the host. fs_open writes a path pointer to the MMIO region, the emulator reads the string from guest memory, opens the real file, and returns a file descriptor.
// VFS MMIO protocol:
//
// Address Operation
// 0x10000000 CONSOLE_OUT — write a byte to stdout
// 0x10000004 FS_OPEN — write path ptr, read back fd
// 0x10000008 FS_READ — write fd, read back byte
// 0x1000000C FS_WRITE — write fd+byte
// 0x10000010 FS_CLOSE — write fd
// 0x10000014 FS_CREATE — write path ptr, read back fd
// 0x10000018 FS_FLUSH — write fd
// In the emulator's store handler:
if addr >= 0x10000000 && addr < 0x10000100 {
match addr {
0x10000004 => {
// FS_OPEN: value = pointer to path string in guest memory
let path = read_guest_string(memory, value);
let fd = File::open(&path);
self.vfs.register(fd)
}
// ...
}
}
This is how the blog generator works. The kov program opens markdown files via VFS, reads them byte by byte, processes them, and writes HTML files via VFS. The emulator bridges kov's bare-metal I/O model to the host filesystem. The program thinks it's writing to memory-mapped hardware. The emulator translates those writes into std::fs calls.
Other backends and analysis passes
Beyond RISC-V, I have an x86-64 backend (codegen/x86_codegen.rs) that targets System V ABI. Same IR, different instruction emission. There are also analysis passes I built for embedded use cases:
- WCET analysis (
codegen/wcet.rs) -- worst-case execution time estimation. Walks the IR, assigns cycle costs per instruction, follows the longest path through every branch. For hard real-time systems where I need to guarantee a function completes within N microseconds. - Energy estimation (
codegen/energy.rs) -- estimates energy consumption per function based on instruction mix. Useful for battery-powered embedded devices. - Stack analysis (
codegen/stack.rs) -- computes maximum stack depth across all call paths. On a microcontroller with 8KB of SRAM, I need to know my program won't overflow the stack at runtime.
Flash to real hardware
The ELF output from the compiler can be flashed to real RISC-V hardware using probe-rs. I've tested on development boards with RV32IM cores. The same binary that runs in the emulator runs on silicon -- the only difference is that MMIO addresses map to real peripherals instead of emulated ones.
That's the point of building a bare-metal language. No OS layer to abstract away the hardware. The program talks directly to memory-mapped registers. On the emulator, those registers are VFS stubs. On real hardware, they're UART controllers, GPIO pins, SPI buses. Same code, different substrate.