TAC IR
Format
TAC uses virtual registers (%r0, %r1, %r2, …) with explicit assignments:
%r0 = const_int 10
%r1 = const_int 20
%r2 = add %r0, %r1
arg %r2, 0
%r3 = call @print_i64, argc=1Every instruction produces at most one result in a named register. Instructions without a result (like arg, store, jump) have dest = TAC_REG_NONE.
Instruction encoding
Each instruction is a fixed-size struct:
| Field | Type | Purpose |
|---|---|---|
op | tac_opcode | Operation kind |
dest | uint32_t | Destination register (or TAC_REG_NONE) |
a | tac_operand | First operand (register or immediate) |
b | tac_operand | Second operand (register or immediate) |
extra | uint32_t | Opcode-specific data (arg count, else-block, field offset, etc.) |
loc | tape_source_loc | Source location for debug info |
Operands are either a register reference (TAC_OP_REG), an immediate value (TAC_OP_IMM), or absent (TAC_OP_NONE).
Basic blocks
Instructions are grouped into basic blocks. Each block has a linear array of instructions and ends with a terminator (jump, branch, or ret):
fn example(x: i64) -> void:
bb0:
%r0 = load local[0]
%r1 = const_int 0
%r2 = cmp_gt %r0, %r1
branch %r2, bb1, bb2
bb1:
%r3 = const_str "positive"
arg %r3, 0
call @println, argc=1
jump -> bb3
bb2:
%r4 = const_str "non-positive"
arg %r4, 0
call @println, argc=1
jump -> bb3
bb3:
retOpcodes
Constants
| Opcode | Semantics |
|---|---|
const_int | dest = imm (64-bit integer literal) |
const_str | dest = strings[imm] (index into module string table) |
Arithmetic
| Opcode | Semantics |
|---|---|
add | dest = a + b |
sub | dest = a - b |
mul | dest = a * b |
div | dest = a / b |
mod | dest = a % b |
Opcodes are type-agnostic — the register’s tape_rtype_kind determines whether the backend emits integer or float instructions.
Bitwise
| Opcode | Semantics |
|---|---|
shl | dest = a << b |
shr | dest = a >> b |
and | dest = a & b |
or | dest = a | b |
xor | dest = a ^ b |
Comparison
| Opcode | Semantics |
|---|---|
cmp_eq | dest = (a == b) |
cmp_ne | dest = (a != b) |
cmp_lt | dest = (a < b) |
cmp_le | dest = (a <= b) |
cmp_gt | dest = (a > b) |
cmp_ge | dest = (a >= b) |
Memory
| Opcode | Semantics |
|---|---|
load | dest = locals[imm] (load from local slot by index) |
store | locals[imm] = b (store to local slot) |
alloca | dest = stack_alloc(imm bytes) |
local_addr | dest = &locals[imm] (address of local slot) |
load_field | dest = *(a + b) (load from base + byte offset b; extra = access size, | 0x100 to sign-extend) |
store_field | *(a + offset) = b (store to base + byte offset; extra = offset | size<<28) |
load_ptr | dest = *a (dereference pointer; extra = element size) |
store_ptr | *a = b (write through pointer; extra = element size) |
load_volatile | dest = *a, a volatile access (extra = size): exactly once, in order, never removed, moved or merged by any pass |
store_volatile | *a = b, a volatile access (extra = size) |
atomic_load | dest = *a, one atomic, sequentially consistent load (extra = size, 4 or 8): @atomic_load |
atomic_store | *a = b, an atomic store (extra = size): @atomic_store |
atomic_add | dest = *a; *a = dest + b as one atomic operation, wrapping; dest the value before (@atomic_add) |
atomic_cas | dest = (*a == b), and then *a = c when equal, as one atomic operation (c, the new value, is the instruction’s third register; dest a bool): @atomic_cas |
Every pass treats the four atomic opcodes as it treats the volatile ones:
each runs exactly once where it stands, its value used or not, and no other
memory access or call moves across one. Natively each is a fixed
instruction sequence (mov, xchg, lock xadd, lock cmpxchg on x86-64;
ldar, stlr and ldaxr / stlxr loops on arm64), the same at -O0; the
VM performs the plain operation.
| move_bytes | memmove(dest, a, b): dest the destination’s address (read, not written), a the source, b the length (@move_bytes) |
| fill_bytes | memset(dest, a, b): a the byte (its low 8 bits), b the length (@fill_bytes) |
| heap_alloc | dest = heap_alloc(extra bytes) (zeroed allocation; with a register operand, the size comes from a) |
| heap_free | heap_free(a) (give back a block heap_alloc returned) |
| load_global | dest = globals[a.imm] (module-level variable) |
| store_global | globals[a.imm] = b |
| global_addr | dest = &globals[a.imm] (address of a module-level variable, for arrays and structs) |
Control flow
| Opcode | Semantics |
|---|---|
jump | Unconditional branch to a.imm (block id) |
branch | If a then b.imm else extra (conditional branch to two blocks) |
call | dest = fn[a.imm](...) (extra = argument count) |
call_ind | dest = a(...) (indirect call through function pointer; extra = arg count) |
fn_addr | dest = &fn[a.imm] (take address of function) |
arg | Push a as argument b.imm for next call |
ret | Return a (or void if a is none) |
trap | Runtime trap: write the message strings[a.imm] (trap: <what> at file:line:col) to stderr and exit with 101. The VM executes it; native code generation first rewrites it into a call to a per-target __tape_trap |
String operations
| Opcode | Semantics |
|---|---|
string_copy | dest = deep_copy(a) |
string_drop | free(a) if dynamic (cap > 0) |
string_concat | dest = a + b (always allocates; extra bytes of room reserved past the two, which an interpolation’s later pieces append into) |
string_append | dest = append(a, b) (reallocs a’s buffer) |
string_eq | dest = (a == b) (byte comparison) |
int_to_str | dest = decimal_repr(a) (extra 1: a is a u64; 2: the digits in the instruction’s own frame storage, a borrowed descriptor, cap 0: an interpolation’s {value}) |
mulhi | dest = the high 64 bits of the 128-bit a * b (extra 1 unsigned, 0 signed: @mul_hi) |
addc | dest = the low 64 bits of a + b + (c != 0), or a - b - (c != 0) with extra bit 0; with extra bit 1 the carry (borrow) out instead, 0 or 1. c is a third register operand (@add_carry, @sub_borrow) |
Type operations
| Opcode | Semantics |
|---|---|
cast | dest = cast(a) (extra = src_type | dst_type<<8) |
Vectors and processor features
| Opcode | Semantics |
|---|---|
vec | *dest = op(*a, *b) on 128-bit vectors (extra = vector op | lane type<<8): lane arithmetic, compares, shifts, splat, widen/narrow, AES and carry-less multiply |
vec_get | dest = a scalar of the vector at a: one lane, any/all lanes set, smallest/largest lane |
cpu_features | dest = the running processor’s feature mask (@cpu_has) |
cpuid | x86-64 only: cpuid leaf query, produced when cpu_features is lowered |
asm | A #bytecode function’s whole body: its machine code bytes |
Functions
Each TAC function contains:
| Field | Purpose |
|---|---|
name / name_len | Function name |
param_count | Number of parameters (registers 0..n-1) |
local_count | Number of local variable slots |
next_reg | Next available virtual register |
blocks | Array of basic blocks |
reg_types | Type of each virtual register |
local_types | Type of each local slot |
return_type | Function return type |
link_lib | DLL/library name for extern functions |
symbol_name | Explicit linker symbol (for @export) |
is_export | Whether this function appears in the export table |
Parameters occupy registers %r0 through %r(param_count-1). Local variables are accessed by slot index via load/store.
Module
A tac_module contains:
functions[]— all compiled functions (own module + dependencies)strings[]— deduplicated string literal table (and const tables)globals[]— module-level variablesroot_fn_offset/root_fn_count— which functions belong to the root module (vs dependencies)
Register typing
Every virtual register and local slot has an associated tape_rtype_kind:
| Kind | Size | Description |
|---|---|---|
void | 0 | No value |
bool | 1 | Boolean |
i8/u8 | 1 | 8-bit integer |
i16/u16 | 2 | 16-bit integer |
i32/u32 | 4 | 32-bit integer |
i64/u64 | 8 | 64-bit integer |
f32 | 4 | 32-bit float |
f64 | 8 | 64-bit float |
ptr | 8 | Pointer |
fn_ptr | 8 | Function pointer |
string | 24 | String (ptr + len + cap) |
slice | 16 | Slice (ptr + len) |
struct | varies | Struct (passed by pointer in registers) |
optional | varies | Optional (8-byte tag + value; 16 for a scalar) |
fallible | varies | Result type (tag + max(ok, err)) |
tagged | varies | Tagged union |
enum | varies | Enum (its backing integer) |
array | varies | Fixed array [N]T |
vec | 16 | 128-bit vector (u8x16 .. i64x2) |
color | 8 | Color value (packed RGBA) |
Wide values
Types larger than 64 bits (structs, strings, slices) are stack-allocated via alloca. The register holds a pointer to the stack slot:
%r0 = alloca 24 // allocate 24 bytes for Vec3
store_field %r0, %r1 // off=0: x
store_field %r0, %r2 // off=8: y
store_field %r0, %r3 // off=16: zWhy not stack-based bytecode?
Tape 1.0 used stack-machine bytecode. Problems:
- Reverse-engineering value flow for native codegen
- Phantom operand bugs
- Undebuggable calling conventions
TAC eliminates all of these by making data flow explicit. Both the VM interpreter and native backends read the same representation without needing a decompilation step.
Last modified: