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
→ loop: walk frame->blocks[block_idx].instrs[instr_idx]
→ switch (instr->op) { ... }
→ on TAC_CALL: push the callee's frame, continue the loop in it
→ on TAC_RET: pop the frame, deliver the value to the caller's call,
continue in the caller (return from exec_fn at its own frame)The interpreter does not recurse in C for tape calls: one exec_fn runs a
whole call tree in its loop, so call depth costs heap, not the C stack.
Call frame
Each function invocation gets its own vm_frame:
vm_frame {
regs // 64-bit register file, sized to the function (next_reg)
locals // 64-bit local variable slots, sized to the function (local_count)
alloca_buf // frame storage: alloca blocks and string descriptors
nregs, nlocals // what the running function uses
cost // bytes counted against VM_STACK_BYTES
callee_idx // the function the pending call runs
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.
The buffers belong to the call depth, not the function: a frame struct is
allocated the first time a depth is reached and kept; its buffers grow when a
bigger function runs at that depth. They never move while the frame is live,
so &local and alloca pointers stay valid for the call’s lifetime.
Call stack
tape_vm {
mod // the tac_module being interpreted
frames // call stack: one vm_frame pointer per depth, grown by doubling
frame_count // current depth
stack_used // sum of the live frames' cost
args[64] // staging area for ARG instructions
arg_count // args written for next CALL
error // non-NULL if a fatal error occurred
trap_buf // a trap line the VM builds itself (stack overflow)
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 to a tape function, the args go into the new frame’s first registers and the loop continues in that frame. On TAC_RET, the frame pops and the return value propagates to the caller’s destination register (a returned string, optional or fallible is first copied out of the dead frame). An extern call runs through FFI without a frame.
A frame’s cost is its registers, locals and frame storage (plus the struct).
Depth is limited only by VM_STACK_BYTES (64 MB) of live frames, so a small
recursive function goes hundreds of thousands of calls deep. A call that
would pass it traps: trap: stack overflow at file:line:col (the call’s
position) and exit code 101.
Limits
| Limit | Value | What happens |
|---|---|---|
| Call stack | 64 MB of live frames (VM_STACK_BYTES) | trap: stack overflow at file:line:col, exit 101 |
| Registers | As many as the function uses (grown on call) | “out of memory” error |
| Locals | As many as the function uses | — |
| Frame storage | 1 MB per function (VM_MAX_ALLOCA) | “frame storage limit exceeded” error when the function is called |
| FFI args | 64 on Win64; elsewhere what fits in registers plus 14 stack slots | “too many extern arguments” error |
Instruction fuel is off by default: tape run has no instruction limit unless it is given one with tape run --fuel <n>. With a budget the VM runs its checked interpreter loop, which counts every instruction and stops the run with trap: instruction limit exceeded at file:line:col (exit 101) when the budget is spent; without one it runs the plain loop, which counts and checks nothing, so a run without --fuel pays nothing for the feature. The embedded VM (vm module) sets its own budgets the same way.
Memory
- Locals —
load/storeaccess thelocals[]array by index - Frame storage — every instruction that needs frame memory (
alloca, and the 24-byte descriptors ofstring_copy,string_concat,int_to_stringand string-returning calls) owns a fixed offset inalloca_buf, assigned in block and instruction order on the function’s first call. This is how native codegen lays out its stack slots, so a loop reuses the same memory on every iteration in both backends.allocareturns that memory zeroed. - 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 in the platform ABI, exactly as native code calls it.
TAC_ARGrecords which arguments aref32/f64(from the caller’s register types, as native codegen does); the extern’s return type says whether the result comes back in RAX/X0 or XMM0/V0.- Win64: a small assembly thunk (
tape_ffi_call_win64) loads each of the first four argument slots into both its integer register and its XMM register, copies the rest to the stack after the 32-byte shadow space, and returns RAX and XMM0. Every callee finds its arguments where its prototype looks, variadic ones included. - SysV x86-64 and AArch64: integer and float arguments are counted separately, so they are sorted into integer registers, float registers and stack slots, and the function is called through a C prototype with all integer registers, all float registers and that many stack slots.
- Win64: a small assembly thunk (
Library search order is described under tape run: the source file’s directory (tape_vm_set_lib_dir), the current directory, the tape binary’s own directory (tape_vm_set_home_dir, on Linux and macOS — where tape’s runtime libraries are), then the system paths. On Linux and macOS libraries are opened by absolute path only: the notarized macOS tape (hardened runtime) may not load a relative path at all.
Special cases intercepted before FFI:
__tt_assert_fail_cmp/__tt_assert_fail/__tt_fail— print assertion diagnostics__tt_trap_begin/__tt_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
Runtime traps — an out-of-bounds index, a call through a null function pointer, an unwrap of null or an error, an integer division by zero, a duplicate view key — are TAC_TRAP instructions. The VM stops, prints trap: <what> at file:line:col to stderr and tape run exits with 101: the same line and exit code as a native build, where the trap is a call to a per-target __tape_trap function. The VM raises two traps of its own the same way: stack overflow, when a call does not fit in the call stack, and out of memory (with no location, as natively), when a string buffer or other implicit allocation gets no memory.
Integer TAC_DIV/TAC_MOD also check their divisor (a backstop: the lowerer already traps before every division by a non-literal), and compute x / -1 and x % -1 without C’s overflow (INT64_MIN / -1 is INT64_MIN, % -1 is 0), as x86-64 and arm64 native code do.
When trap_expected is true (between __tt_trap_begin and __tt_trap_end):
- A trap anywhere under the expression unwinds the frames above the
@assert_trapframe and resumes at its__tt_trap_end - Any other error from a call sets
trap_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 |
tape_vm_run_test(mod, setup, fn_idx, teardown) | One tape test test as its own program: globals reset, module initializers, setup, the test, teardown |
tape_vm_set_lib_dir(dir, len) | Set the program’s library search directory (the source file’s) |
tape_vm_set_home_dir(dir) | Set the tape binary’s directory (its runtime libraries) |
tape_vm_set_args(args, count) | Set program arguments for os.args() |
Last modified: