A tour of the Io VM source tree — object representation, the message tree, the evaluator, the standard library loader, and the garbage collector.
The VM lives under libs/iovm/ and is about 25k lines of portable C, with no threading, no assembly, and no platform-specific syscalls beyond what WASI provides.
libs/iovm/source/ Core VM (C)
libs/iovm/io/ Standard library (.io)
libs/iovm/tests/ C test files
tools/source/main.c REPL / CLI entry pointThe public C API is in IoState.h, IoObject.h, IoMessage.h, and the per-primitive headers (IoNumber.h, IoSeq.h, IoList.h, IoMap.h, IoBlock.h, IoCoroutine.h, etc.). Every file is prefixed Io so the C namespace stays tidy when embedded.
Every Io value is a CollectorMarker — the header that the collector uses to link live objects on its gray/black/white lists. The marker's object field points to an IoObjectData:
struct IoObjectData {
unsigned int markerCount;
union { void *ptr; double d; uint32_t ui32; } data; // primitive payload
IoTag *tag; // vtable
PHash *slots; // cuckoo hash
List *listeners;
IoObject **protos; // parent list
/* ... flag bits: isActivatable, isLocals, isSymbol, ... */
};A few things to notice:
tag names the primitive kind (Number, Sequence, List, …) and carries function pointers for clone, mark, free, and compare. Behavior lives in slots, not in the tag.hasDoneLookup bit. Multiple inheritance falls out naturally.data union holds the primitive payload. A Number stores its double inline; a List's ptr points at a basekit List; a Block's ptr points at the compiled message tree. Everything else — identity, identity-compare, GC — goes through the same header.Assignment operators desugar to slot sends: := → setSlot, = → updateSlot, ::= → newSlot. Slot creation is a write; slot lookup is the proto walk.
Parsing produces a tree of IoMessage nodes, each holding a name symbol, an argument list of messages, and a next pointer to the next message in the chain. Operators are ordinary messages at this stage.
Then IoMessage_opShuffle.c rewrites the tree by operator precedence using the table in libs/iovm/io/OperatorTable.io. After shuffling, an expression like 1 + 2 × 3 has become 1 +(2 ×(3)) — the same tree shape you'd get from an explicit method call.
Because messages are first-class objects, the shuffle is just tree rewriting on live data. Programs can read, rewrite, and evaluate messages the same way. This is what makes if, while, for, and method implementable in Io itself rather than as compiler built-ins.
IoState_iterative.c implements a single while(1) eval loop. Each iteration pulls the current heap-allocated IoEvalFrame off the state's currentFrame, steps its state machine one click, and loops. There is no C recursion for message evaluation; argument evaluation, block activation, control flow, and coroutine switching all happen by pushing frames and transitioning states.
A frame carries everything a recursive evaluator would keep on the C stack:
target the receiver (self)
locals the enclosing scope
message the current message being evaluated
argN pre-evaluated arguments
result accumulated chain result
state state-machine enum
parent previous frameThis layout buys four properties:
callcc captures the current frame chain as an ordinary Io object. Invoking it later just swaps currentFrame. (Disabled by default behind -DIO_CALLCC.)IoState_error_ sets state->errorRaised and the loop pops frames until a handler is found. No longjmp hopping over C code that expected to run cleanup.IoMessage_locals_performOn_ (the old recursive evaluator) still exists for bootstrap. Once the first eval loop starts, it redirects to the iterative path.
Most messages pre-evaluate their arguments before the receiver's method runs. A handful must not, because they implement control flow or introspect the unevaluated message itself:
if while loop for callcc method block
foreach reverseForeach foreachLineThe iterative evaluator checks for these by name in two places in IoState_iterative.c and skips pre-evaluation. The method then evaluates its argument messages explicitly with the evaluator APIs.
The files in libs/iovm/io/ are loaded in the explicit order listed by the IO_FILES variable in the root Makefile:
List_bootstrap.io, Object_bootstrap.io, OperatorTable.io,
Object.io, List.io, Exception.io,
<alphabetical core>,
CLI.io, Importer.ioOrder matters — the bootstrap files install the minimum slots needed to load everything else. make regenerate runs io2c to compile these files into string literals inside libs/iovm/source/IoVMInit.c, which the VM evaluates at startup. That means the shipped binary carries its own standard library; there is no filesystem lookup at init.
The collector is an incremental, tri-color, mark-and-sweep design living under libs/garbagecollector/. Roots are the lobby, the active coroutine's frame chain, and any object the VM has pinned with IoState_retain_. Write barriers (IoObject_shouldMark, invoked from IOREF) keep the gray set consistent when mutators store new references into already-scanned objects.
Because frames are ordinary heap objects, the live stack is itself reachable through state->currentFrame. Stack traces, debuggers, and serializers all walk the same graph the collector marks.
A coroutine is just a saved currentFrame plus a small bit of bookkeeping. Coroutine resume sets the VM's currentFrame to the coroutine's saved pointer; the eval loop picks up where it left off. yield and pause cooperate with Scheduler to pick the next coroutine.
See Stackless for a deeper treatment of the evaluator, the scheduler, the actor pattern, and the performance characteristics of the frame machine.