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
    → walk blocks[block_idx].instrs[instr_idx]
    → switch (instr->op) { ... }
    → on TAC_RET: pop frame, return value

Call frame

Each function invocation gets its own vm_frame:

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

c
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

LimitValueWhat happens
Call depth64 frames”stack overflow” error
Registers512 per frame”register overflow” error
Locals256 per frameOut-of-bounds silently ignored
Alloca8192 bytes per frameReturns NULL pointer (0)
FFI args14 maxCalls with >14 args return 0

There is no instruction count limit or fuel budget in the VM.

Memory

  • Localsload/store access the locals[] array by index
  • AllocaTAC_ALLOCA bumps alloca_cursor in the frame’s 8KB buffer, returns zeroed memory
  • HeapTAC_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: 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 = true instead of aborting
  • Any error from a nested exec_fn 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 (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()
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: