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

plaintext
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:

c
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

c
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

LimitValueWhat happens
Call stack64 MB of live frames (VM_STACK_BYTES)trap: stack overflow at file:line:col, exit 101
RegistersAs many as the function uses (grown on call)“out of memory” error
LocalsAs many as the function uses—
Frame storage1 MB per function (VM_MAX_ALLOCA)“frame storage limit exceeded” error when the function is called
FFI args64 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/store access the locals[] array by index
  • Frame storage — every instruction that needs frame memory (alloca, and the 24-byte descriptors of string_copy, string_concat, int_to_string and string-returning calls) owns a fixed offset in alloca_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. alloca returns that memory zeroed.
  • Heap — TAC_HEAP_ALLOC calls calloc() directly (no GC, no arena)
  • Strings — 24-byte descriptors [data_ptr, len, cap]; cap=0 means static (string table pointer), cap>0 means owned (malloc’d)

String operations

The VM implements dedicated string opcodes:

OpBehavior
string_copyIf cap=0 (static): shallow copy. If cap>0 (dynamic): malloc + memcpy
string_dropIf cap>0: free the data buffer
string_concatAlways allocates: malloc(a.len + b.len), copies both, returns new descriptor
string_appendReuses a’s buffer if capacity allows; realloc otherwise; promotes static→dynamic
string_eqCompare lengths, then memcmp data
int_to_strsnprintf → 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_t arithmetic
  • Float types → reinterpret int64_t bits as double, 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:

  1. Load library: LoadLibraryA (Windows) / dlopen (Unix) — cached in a global table (max 32 libraries)
  2. Resolve symbol: GetProcAddress / dlsym — uses fn->symbol_name if set, otherwise fn->name
  3. Call in the platform ABI, exactly as native code calls it. TAC_ARG records which arguments are f32/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.

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_trap frame and resumes at its __tt_trap_end
  • Any other error from a call sets trap_caught and clears the error

This powers @assert_trap in tests.

Entry points

FunctionPurpose
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()
Planned — A compact bytecode format for serializing TAC modules to disk. This would allow pre-compiled modules to be cached and loaded without re-parsing or re-lowering, enabling fast startup for game engines and other embedded scripting scenarios where compilation latency matters.

Last modified: