VM Backend
Overview
The VM interprets TAC IR directly in memory — there is no bytecode encoding step. It walks tac_instr structs from basic blocks, dispatching via a switch on tac_opcode. This keeps the implementation simple: the same data structure the lowerer produces is what the interpreter reads.
Execution model
tape_vm_run(mod)
→ find "main" function
→ exec_fn(vm, main_idx, NULL, 0)
→ push frame
→ walk blocks[block_idx].instrs[instr_idx]
→ switch (instr->op) { ... }
→ on TAC_RET: pop frame, return valueCall frame
Each function invocation gets its own vm_frame:
vm_frame {
regs[512] // 64-bit register file (int64_t slots)
locals[256] // 64-bit local variable slots
alloca_buf[8192]// per-frame stack allocation buffer
alloca_cursor // next free byte in alloca_buf
fn_idx // which function this frame runs
block_idx // current basic block
instr_idx // instruction pointer within block
}Parameters are placed in registers regs[0..param_count-1] before execution begins. The TAC prologue stores them into local slots.
Call stack
The VM maintains a fixed array of 64 frames:
tape_vm {
mod // the tac_module being interpreted
frames[64] // call stack (VM_MAX_CALL_DEPTH = 64)
frame_count // current depth
args[64] // staging area for ARG instructions
arg_count // args written for next CALL
error // non-NULL if a fatal error occurred
trap_expected // @assert_trap mode
trap_caught // whether trap fired during expect
}Function calls: TAC_ARG instructions write values into vm.args[]. On TAC_CALL, the args are copied out and exec_fn is called recursively, pushing a new frame. On TAC_RET, the frame pops and the return value propagates to the caller’s destination register.
Limits
| Limit | Value | What happens |
|---|---|---|
| Call depth | 64 frames | ”stack overflow” error |
| Registers | 512 per frame | ”register overflow” error |
| Locals | 256 per frame | Out-of-bounds silently ignored |
| Alloca | 8192 bytes per frame | Returns NULL pointer (0) |
| FFI args | 14 max | Calls with >14 args return 0 |
There is no instruction count limit or fuel budget in the VM.
Memory
- Locals —
load/storeaccess thelocals[]array by index - Alloca —
TAC_ALLOCAbumpsalloca_cursorin the frame’s 8KB buffer, returns zeroed memory - Heap —
TAC_HEAP_ALLOCcallscalloc()directly (no GC, no arena) - Strings — 24-byte descriptors
[data_ptr, len, cap];cap=0means static (string table pointer),cap>0means owned (malloc’d)
String operations
The VM implements dedicated string opcodes:
| Op | Behavior |
|---|---|
string_copy | If cap=0 (static): shallow copy. If cap>0 (dynamic): malloc + memcpy |
string_drop | If cap>0: free the data buffer |
string_concat | Always allocates: malloc(a.len + b.len), copies both, returns new descriptor |
string_append | Reuses a’s buffer if capacity allows; realloc otherwise; promotes static→dynamic |
string_eq | Compare lengths, then memcmp data |
int_to_str | snprintf → malloc → new descriptor |
String descriptors for results are allocated in the frame’s alloca buffer.
Type dispatch
Arithmetic and comparison opcodes check register types at runtime via fn->reg_types[]:
- Integer types → normal
int64_tarithmetic - Float types → reinterpret
int64_tbits asdouble, operate, store bits back
TAC_CAST decodes source and destination types from instr->extra and handles: float→int, int→float, int→int (sign/zero extend or truncate).
FFI
Extern functions (those with block_count == 0) are dispatched through platform FFI:
- Load library:
LoadLibraryA(Windows) /dlopen(Unix) — cached in a global table (max 32 libraries) - Resolve symbol:
GetProcAddress/dlsym— usesfn->symbol_nameif set, otherwisefn->name - Call: cast to typed function pointer (
ffi_fn0..ffi_fn14) based on argument count
The VM searches for libraries in the source file’s directory first (set via tape_vm_set_lib_dir), then the system default paths.
Special cases intercepted before FFI:
__test_assert_fail_cmp/__test_assert_fail/__test_fail— print assertion diagnostics__test_trap_begin/__test_trap_end— manage trap-expectation state__tapert_args_count/__tapert_arg_get— program argument access (can’t go through FFI because the DLL would see the compiler’s args, not the user’s)
Trap handling
When trap_expected is true (between __test_trap_begin and __test_trap_end):
- Division by zero sets
trap_caught = trueinstead of aborting - Any error from a nested
exec_fncall setstrap_caughtand clears the error
This powers @assert_trap in tests.
Entry points
| Function | Purpose |
|---|---|
tape_vm_run(mod) | Find main, execute it, return its result |
tape_vm_run_fn(mod, fn_idx) | Execute a specific function by index (used by tape test) |
tape_vm_set_lib_dir(dir, len) | Set DLL search directory |
tape_vm_set_args(args, count) | Set program arguments for os.args() |
Last modified: