Skip to content

Bytecode VM

This page is for people working on the interpreter itself. Kiwi source is compiled to bytecode and executed by a stack-based virtual machine. The code lives in src/VM/.

source ──▶ Lexer ──▶ tokens ──▶ Parser ──▶ AST ──▶ Compiler ──▶ Chunk ──▶ KiwiVM
StageSourceOutput
Lexingsrc/Parsing/Lexer.csA token stream
Parsingsrc/Parsing/Parser.cs, ParserCore.csAn abstract syntax tree (src/Parsing/AST/)
Compilingsrc/VM/Compiler.csA Chunk of bytecode
Executionsrc/VM/KiwiVM.csProgram effects

The standard library is parsed and compiled together with the script into a single top-level chunk, so it is loaded before the first line of user code runs.

A Chunk (src/VM/Chunk.cs) is the compiled form of one unit of code: the program, a function, or a lambda. Functions and lambdas get their own chunks, stored in their parent’s SubChunks.

FieldContents
CodeThe instructions.
ConstantsLiteral values referenced by index.
NamesInterned identifier strings referenced by index.
SubChunksNested function and lambda chunks.
UpvaluesDescriptors for variables captured from enclosing functions.
Lines, Columns, FileIdsSource positions for each instruction, used in error messages.
LocalNames, LocalCountLocal variable slots, assigned at compile time.
ParamNames, Arity, MinArity, VariadicParamName, DefaultParamNamesParameter metadata, used to check a call’s argument count (lambdas, marked IsLambda, are exempt).
TypeChecksThe type hints that CheckType instructions check: parameters, the return value and var initializers.
ArgNameSetsArgument names for calls that use named arguments.
NodePoolAST nodes kept for instructions that need them at runtime, such as CallBuiltin.
IsGeneratorWhether the function contains yield.

Each instruction is a small record:

record struct Instruction(Opcode Op, int A = 0, int B = 0);

A and B are operands whose meaning depends on the opcode: a constant or name index, a slot number, a jump target, an argument count, or packed flags.

Inside a function, the compiler resolves each name at compile time:

  1. Local slot: a parameter or a variable assigned in the function body.
  2. Upvalue: a local of an enclosing function, captured by a closure.
  3. Global: everything else, looked up at runtime with LoadGlobal.

At the top level every name is global.

The VM uses one value stack of 8,192 slots shared by all frames, and a frame stack of 512 frames. Each frame (VMFrame) records its chunk, instruction pointer, the stack index where its locals begin (StackBase), its captured upvalues, the current instance (@) and struct (@@), and its active try handlers.

StackBase + 0 parameter 0
StackBase + 1 parameter 1
StackBase + 2 ... other locals
StackBase + LocalCount temporaries (grow upward)

RunLoop fetches and dispatches instructions for the top frame. A call to compiled Kiwi code pushes a new frame and keeps dispatching; native builtins run in place and push their result. Return pops the frame and resumes the caller.

A closure captures variables through upvalues. An upvalue starts open, pointing at the variable’s stack slot so that the function and the closure share it. When the enclosing frame returns, its open upvalues are closed: the value moves into the upvalue itself, so the closure keeps working after the frame is gone. Two closures that capture the same variable share one upvalue. A for loop closes the loop variable’s upvalue (CloseUpvalueAt) at the start of each iteration, so the closures created in one iteration keep that iteration’s value.

try blocks push a handler (PushTryHandler) recording the catch and finally targets and the stack height. When an error is thrown, the VM unwinds frames to the nearest handler, restores the stack height and jumps to the catch target, where LoadCatchError loads the error in the form the catch clause asks for.

A finally block is compiled inline on every path out of the try statement: after the try block, after the catch block, before a return, break or next that leaves it, and on an error path that keeps the error in a hidden local, runs the block and then throws the error again (Rethrow).

Set KIWI_DISASM=1 to print the compiled bytecode before running. Add -ns to leave out the standard library:

$ KIWI_DISASM=1 kiwi -ns greet.kiwi
=== <main> (arity=0, locals=0, upvalues=0) ===
0 [ 1] DefFunc sub[0] name="greet"
1 [ 4] LoadGlobal "greet"
2 [ 4] Const 0
3 [ 4] Call argc=1
4 [ 0] Pop
5 [ 0] Halt
=== greet (arity=1, locals=1, upvalues=0) ===
params: name
0 [ 2] Const 0
1 [ 2] LoadLocal slot=0
2 [ 2] Const 1
3 [ 2] Interpolate parts=3
4 [ 2] Print newline=True stderr=False
5 [ 0] ReturnNull

The bracketed number is the source line.

The tables below are generated from the doc comments in src/VM/Opcode.cs. A and B are the instruction operands.

OpcodeBehavior
Constpush constants[A]
Nullpush null
Truepush true
Falsepush false
OpcodeBehavior
LoadLocalpush stack[base + A]
StoreLocalstack[base + A] = pop()
OpcodeBehavior
LoadGlobalpush globals[names[A]]
StoreGlobalglobals[names[A]] = pop()
StoreConstglobals[names[A]] = pop(); also registers names[A] in context.Constants (immutable).
OpcodeBehavior
LoadUpvaluepush upvalues[A].Get()
StoreUpvalueupvalues[A].Set(pop())
CloseUpvalueClose all open upvalues whose absolute slot >= base + A
CloseUpvalueAtClose the open upvalue for slot base + A only, if there is one. Emitted at the top of each for iteration so closures created in the body keep that iteration's value.
OpcodeBehavior
PopDiscard the top value.
DupDuplicate the top value.
SwapSwap the top two stack values.
OpcodeBehavior
Addpop right, pop left → push left + right
Subpop right, pop left → push left - right
Mulpop right, pop left → push left * right
Divpop right, pop left → push left / right (float)
IntDivpop right, pop left → push left // right (floor)
Modpop right, pop left → push left % right
Powpop right, pop left → push left ** right
NegUnary negation
OpcodeBehavior
BAndpop right, pop left → push left & right
BOrpop right, pop left → push left | right
BXorpop right, pop left → push left ^ right
BNotpop value → push ~value
BLShpop right, pop left → push left << right
BRShpop right, pop left → push left >> right
BURShpop right, pop left → push left >>> right
OpcodeBehavior
Eqpop right, pop left → push left == right
NEqpop right, pop left → push left != right
Ltpop right, pop left → push left < right
LtEpop right, pop left → push left <= right
Gtpop right, pop left → push left > right
GtEpop right, pop left → push left >= right
Inpop right, pop left → push left in right (collection membership)
OpcodeBehavior
NotUnary not
JumpAndShort-circuit AND: peek top; if falsy → jump to A (leave value on stack); if truthy → pop and continue (right side will supply the value).
JumpOrShort-circuit OR: peek top; if truthy → jump to A (leave value on stack); if falsy → pop and continue.
OpcodeBehavior
NullCoalescepop right, pop left; push left ?? right
OpcodeBehavior
ConcatString ".." concat: pop right, pop left, push left..right
OpcodeBehavior
JumpIP = A (unconditional jump)
JumpFpop; if falsy: IP = A
JumpTpop; if truthy: IP = A
OpcodeBehavior
CallA = arg count. Stack layout before: [..., func_value, arg0, …, argN-1]. Calls the function, replaces func+args with the return value.
CallMethodA = arg count, B = name index (names[B] = method name). Stack before: [..., obj, arg0, …, argN-1]. Dispatches the method call; replaces obj+args with result.
CallSuperMethodA = arg count, B = name index (names[B] = method name). Stack before: [..., arg0, …, argN-1] (no obj — uses frame.Self and frame.OwnerStruct). Dispatches a super.method() call starting from the base of the defining struct.
ReturnReturn top-of-stack to caller.
ReturnNullReturn null to caller.
OpcodeBehavior
DefFuncA = sub-chunk index, B = name index (names[B] = function name). Register a compiled KFunction in the context. Does not push a value.
DefFuncAndPushA = sub-chunk index, B = name index (names[B] = raw function name). Like DefFunc but also wraps the function as an internal KLambda and pushes a lambda-ref onto the stack. Used by decorator compilation so the function can be passed as a value to the decorator callable without a separate LoadGlobal.
StoreDecoratedFuncA = name index (raw function name). Pop the top-of-stack (the final decorated value); compute the package-qualified function name; remove the original KFunction entry from Context.Functions; then register the decorated result (lambda mapping or global assignment). Used as the last step of decorator application.
MakeClosureA = sub-chunk index, B = number of upvalue descriptors that follow as inline (isLocal, index) pairs encoded in subsequent Nop-like slots. Pushes the resulting closure Value.
OpcodeBehavior
BuildListPop A values (bottom-first) and push a List.
BuildHashmapPop 2*A values (key, value pairs bottom-first) and push a Hashmap.
BuildRangePop start then stop → push inclusive range list.
OpcodeBehavior
IndexGetpop key, pop obj → push obj[key]
IndexSetpop value, pop key, pop obj → obj[key] = value (pushes value)
IndexOpAssignA = inner opcode (as int). pop rhs, pop key, pop obj → old = obj[key]; new = op(old, rhs); obj[key] = new (pushes new). Handles compound index assignment: lst[i] += v.
UnpackListA = count. Pop a value; if it's a list unpack A elements; else push value then A-1 nulls. Used for PackAssignment: a, b = func_returning_list().
SliceGetA = flags: bit0=hasStart, bit1=hasStop, bit2=hasStep. Pops (in order): obj, start?, stop?, step? → push slice.
SliceSetA = flags: bit0=hasStart, bit1=hasStop, bit2=hasStep. Pops (in order from stack top): rhs, step?, stop?, start?, obj. Applies rhs to the slice range of obj in-place; pushes rhs.
OpcodeBehavior
GetMemberA = name index; pop obj → push obj.names[A]
SetMemberA = name index; pop value, pop obj → obj.names[A] = value (pushes value)
OpcodeBehavior
LoadSelfPush @ (current object context)
LoadSelfAttrA = name index → push @.names[A]
StoreSelfAttrA = name index; pop → @.names[A] = value (pushes value)
LoadStaticAttrA = name index → push @@.names[A] (static variable)
StoreStaticAttrA = name index; pop → @@.names[A] = value (pushes value)
OpcodeBehavior
NewObjectA = arg count, B = name index; create new struct instance and push it.
OpcodeBehavior
InterpolatePop A string parts (bottom-first) and push the concatenated result.
OpcodeBehavior
PrintA: bit0 = newline, bit1 = stderr. Pop value and print it.
PrintXyPop y, pop x, pop text → print text at terminal position (x, y) using ANSI cursor-move.
OpcodeBehavior
ForIterInitPop the collection and push a ForIterState (held directly in the stack slot).
ForIterInitRangePop stop, pop start → push a ForIterState that counts from start to stop inclusive (downward when stop < start). Used for for x in [a to b] so the range is never materialized as a list.
ForIterNextA = jump-if-done target IP, B = number of loop vars to push (1 or 2). Peek at the ForIterState on top of stack: - If done: pop state, jump to A. - Else: push next value(s) (B values) without removing state.
OpcodeBehavior
BreakJumpSignal a break: VM pops loop state (A = extra items to pop) and jumps.
NextJumpSignal a continue/next.
OpcodeBehavior
YieldPop value and yield it to the generator consumer.
OpcodeBehavior
SplatResetPush 0 onto the splat-adjustment side-stack. Emitted before the function/object of any call that contains splat arguments, so nested splat calls each get their own adjustment slot.
SplatPushPop value. If it is a list, push each element and add (count-1) to the top of the splat-adjustment side-stack. Otherwise push the value unchanged (single-element, zero adjustment).
CallSplatLike Call but argc = A + splatAdjStack.Pop(). Used for function/lambda calls that contain one or more splat arguments.
MethodCallSplatLike CallMethod but argc = A + splatAdjStack.Pop(). Used for method calls that contain one or more splat arguments.
CallNamedLike Call but reorders args to match the callee's declared parameter order. A = argc, B = index into chunk.ArgNameSets (parallel string[] of arg names; empty string = positional, non-empty = named arg name).
CallMethodNamedLike CallMethod but reorders args before dispatch. A = argc, B = packed: lower 16 bits = method name index in chunk.Names, upper 16 bits = index into chunk.ArgNameSets.
OpcodeBehavior
ThrowPop value and throw it as a Kiwi runtime error.
PushTryHandlerA = catch IP, B = finally IP (0 = none). Pushes a try-handler frame: on KiwiError, restore _sp to current value, jump to catch IP, and store the caught error in _caughtError.
PopTryHandlerPop the top try-handler frame (try body completed normally).
LoadCatchErrorA = 0 → push error hashmap {error, message} (single-param catch) A = 1 → push error type string (first of two-param catch) A = 2 → push error message string (second of two-param catch) A = 3 → push the error itself, to rethrow after a finally block Reads from the VM's _caughtError field set when a handler fires.
CheckTypeA = type-check index. Throw a TypeError unless the value on top of the stack (left in place) matches the type hint chunk.TypeChecks[A]: a parameter, return value or var.
RethrowPop an error pushed by LoadCatchError (A = 3) and throw it again, so an error leaving a try with a finally block keeps propagating once the block has run.
OpcodeBehavior
TypeOfPop value → push typeof string (e.g. "integer", "string", "list", …).
OpcodeBehavior
EventOnA = priority. Pop callback, pop event-name → register 'on' handler.
EventOnceA = priority. Pop callback, pop event-name → register 'once' handler.
EventOffA: 0 = no callback (remove all), 1 = pop callback too. Pop [callback,] event-name → unregister handler(s).
EventEmitA = arg count. Pop A args (bottom-first), pop event-name → emit event, push result list.
OpcodeBehavior
StructBeginA = name index. B = packed: bit 0 = isAbstract, bits 1+ = baseNameIdx+1 (0 = no base). Create KStruct and push onto the pending-struct stack.
DefMethodA = sub-chunk index, B = nameIdx | (isAbstract ? 0x80000000 : 0) | (isStatic ? 0x40000000 : 0) | (isPrivate ? 0x20000000 : 0) | (isDecorated ? 0x10000000 : 0). Like DefFunc but registers the method into the current pending struct. A decorated method is also pushed as a value for its decorators.
StoreDecoratedMethodA = name index. Pop the decorated value of a method and install it in the pending struct: calling the method calls that value, with @ still the instance.
InitStructStaticA = name index. Pop value → store in pending struct's StaticVariables[names[A]].
StructEndValidate abstract compliance, register pending struct in Context.Structs.
OpcodeBehavior
PackageBeginA = name index, B = node-pool index (stores PackageNode AST for retry). Build qualified name, push onto PackageStack, register KPackage.
PackageEndPop PackageStack; register type builtins; mark package imported.
PackageAbortCatch path for a failed package activation: pop PackageStack without marking as imported. Allows ImportPackage to retry after dependencies are resolved.
OpcodeBehavior
EnumBeginA = name index. Create KStruct{IsEnum=true} and push onto the pending-struct stack.
DefEnumMemberA = name index. Pop value → store in pending enum's StaticVariables["@@" + names[A]].
EnumEndPop pending KStruct and register it as an enum in Context.Structs.
OpcodeBehavior
CallBuiltinA = node-pool index (FunctionCallNode, used for Token and Op). B = argc. Pop B args (in order), call _interp.ExecuteBuiltin, push result. Used for C# native builtin calls (KiwiBuiltin, SocketBuiltin, Math, etc.) that are not CoreBuiltin method calls.
OpcodeBehavior
ExportPop top-of-stack (package name string); call _interp.ImportPackage with it.
ImportPkgPop top-of-stack (package name string); call _interp.ImportPackage with it; push Value.CreatePackage(name) as the result of the import expression.
RequirePop top-of-stack (package name string); throw PackageUndefinedError if the package is not registered in the context. Used by the require keyword.
OpcodeBehavior
EvalPop top-of-stack (Kiwi source string); lex/parse/interpret it; push the result.
IncludePop top-of-stack (file path string); load and interpret the file (no push).
OpcodeBehavior
ExitPop exit-code value; call Environment.Exit(code). Condition (if any) is handled at compile time via a JumpF guard.
OpcodeBehavior
NopDo nothing.
HaltStop executing the chunk.

A new construct usually touches each stage in turn:

  1. Lexer: add a token in Lexer.cs if the syntax needs one.
  2. Parser: add an AST node in src/Parsing/AST/ and parse it in Parser.cs.
  3. Compiler: emit instructions for the node in Compiler.cs.
  4. VM: if no existing opcode fits, add one to Opcode.cs, with a <summary> comment so it appears on this page, and handle it in KiwiVM.cs.
  5. Tests: add tests under tests/lib/suite/, rebuild with ./build.sh, and run ./bin/kiwi tests/test.