What a Virtual Machine Has to Hold
The complete state of a running VM is six things: an instruction pointer, an operand stack, a stack of frames, per-frame locals, globals and a heap. Everything a VM can do is a function of that tuple, and everything a VM must decide — pausing, resuming, tracing, giving up — is a decision about where in the tuple to put the answer.
What is the complete state of a running virtual machine, and what would I have to save to suspend a program and resume it later?
The program is the immutable bytecode array from [[bytecode]]; the *machine* is a mutable tuple laid over it — instruction pointer, operand stack, frame stack, per-frame slot array, globals, heap. Splitting the two is what this representation exists to do, and the question it answers is a practical one: what is the smallest thing you would have to copy to suspend this program here and resume it identically somewhere else. Code is shared and read-only; state is per-execution and is the entire answer.
The dispatch loop is entitled to assume the bytecode is well-formed — that every jump target is a valid instruction index, that every slot index is inside the frame's slot array, and that the operand stack holds what each instruction pops. Those are the code generator's obligations and, in a VM that loads untrusted code, a verifier's. It is entitled to assume nothing about termination: a well-formed program may loop forever, and a VM that must not hang has to enforce that in the loop, because there is nowhere else. Our VM stops at a step budget and reports steps-exhausted rather than returning a number it did not compute.
Key points
- A running VM is six things: instruction pointer, operand stack, frame stack, per-frame slots, globals and heap. The bytecode is not state — it is immutable, shared and read-only.
- The split between code and state is what makes suspension, serialization, debugging and deoptimization expressible at all.
- A frame exists to answer one question: what does the caller need to carry on as if the call had not happened? Function, resume point, slots, and stack base.
- The stack base is the field most often omitted and the one that keeps a misbehaving callee from corrupting its caller.
- Virtual registers from the IR become ordinary per-frame slots, which is why recursion needs no extra machinery in the VM.
- A VM may not assume termination. If it must not hang, the check belongs in the dispatch loop, because nothing else runs between instructions.
- What is recorded and what is executed are different limits with different purposes; conflating them either truncates the program or floods the reader.
- When a limit is hit, report a status — never a partial result dressed up as an answer.
Six things, and nothing else
Ask what a VM "is" and you get the instruction set. Ask what a *running* VM is and you get a much shorter answer, because the instruction set is code, and code does not change while the program runs. Everything that changes fits in six slots, and being able to write them down is exactly what [[bytecode]] bought over walking a tree, where the machine state is smeared through the traversal's own recursion.
The split matters beyond tidiness. Code is immutable, shareable between threads, and mappable straight from a file. State is per-execution and is what a debugger inspects, a serializer writes, a coroutine suspends and a [[deoptimization]] reconstructs. When a JIT deoptimizes, the thing it has to rebuild is precisely this tuple — which is why the next module cannot be understood without this one.
| Component | Holds | Lifetime | If you get it wrong |
|---|---|---|---|
| Instruction pointer | An index into the current function's code array | One per active frame; the top frame's is "where we are" | Every branch lands one instruction off and the program silently takes wrong paths |
| Operand stack | Intermediates of the expression currently being evaluated | Deep inside an expression, empty between statements | Depth drifts and later pops read values from a previous statement |
| Frame stack | One frame per active call: function, return address, stack base | Pushed on call, popped on return | A return resumes in the wrong place, or the caller sees the callee's leftovers |
| Locals (slots) | The named variables of one call, indexed by number | The whole call, including across nested calls | A function reads another variable, and a value "changes on its own" |
| Globals | Names visible to every frame, including function definitions | The whole program run | Name resolution differs between the compiler and the VM, and a call finds the wrong target |
| Heapsimplified | Objects whose lifetime is not the frame that created them | Until nothing references them | A live object is freed, or nothing is freed and memory climbs until the process dies |
A frame is what makes a call resumable
The frame is the interesting one, because it is the only part of the state whose contents are dictated by a question rather than by convenience. The question is: when this call returns, what does the caller need in order to carry on as if nothing had happened? The answer is the frame — which function, which instruction to resume at, which slots belonged to this invocation, and how far down the operand stack this call's territory started.
That last field is the one people forget. Our VM shares one operand stack across all frames and records stackBase per frame; RETURN truncates the stack back to that base before pushing the result, so a callee that left values behind cannot corrupt its caller. A VM without that truncation still passes every test in which callees are well-behaved, and leaks stack forever in the one where they are not. The same information under a different name is what [[stack-frame-layout]] describes for native code, and what [[stack-unwinding]] walks when an exception propagates.
The trace below is the real thing: fn twice(n: int): int { return n + n; } print(twice(5)); executed by src/compilers/sim/vm.ts. Watch the locals column change owner at row 1 and change back at row 11.
- 0main 0 PUSH 5
- 1main 1 CALL twice / 1
- 2twice 0 LOAD 0 ; n
- 3twice 1 STORE 1 ; %0
- 4twice 4 LOAD 0 ; n
- 5twice 5 STORE 2 ; %1
- 6twice 8 LOAD 2 ; %1
- 7twice 9 LOAD 3 ; %2
- 8twice 10 ADD
- 9twice 11 STORE 4 ; %3
- 10twice 12 LOAD 4 ; %3
- 11twice 13 RETURN
- 12main 2 STORE 0 ; %0
- 13main 3 LOAD 0 ; %0
- 14main 4 PRINT
Read it asThe listing is condensed — two instructions inside twice are omitted and ip indexes this listing rather than either function's code array — but every stack and slot value shown is what the VM produced. The locals column is the load-bearing one: it belongs to whichever frame is on top, and it changes owner at row 1 and changes back at row 11 without a single value being copied between frames. That is the whole mechanism of a call — not "the arguments are passed" but "a new slot array becomes the current one, and the old one is remembered". Note also what RETURN does in the outermost frame: with no resume point to return to, it ends the run, so the HALT at the end of main never executes.
The state our VM actually keeps
n instead of 0. A real VM indexes a flat array by number and keeps the names in a separate debug table, which is smaller, faster and the reason a stripped stack trace shows slot 3 rather than total — see [[debug-information]]. The JVM additionally computes each method's maximum stack depth and local count at compile time and has the verifier check them, so a frame can be a fixed-size allocation rather than a growable map.Reading the type is faster than reading a description of it. The frame below is the real declaration from src/compilers/sim/vm.ts, and the four fields are exactly the four questions of the previous section — who am I, where do I resume, what are my variables, and where does my operand-stack territory begin.
Two absences are worth naming. There is no return *value* field, because the value travels on the operand stack. And there is no exception state, because AtlasLang has no exceptions — a language with them needs a per-frame handler table and an unwinding rule, which is where [[exception-handling]] and [[stack-unwinding]] start.
1export interface Frame {2 fn: string3 ip: number4 slots: Record<string, VmValue>5 /** where to resume in the caller */6 returnTo: { fn: string; ip: number } | null7 /** stack depth to restore on return */8 stackBase: number9}10 11// and the machine, in run():12const stack: VmValue[] = []13const frames: Frame[] = [{ fn: main.name, ip: 0, slots: {}, returnTo: null, stackBase: 0 }]returnTo: null is how the outermost frame is identified — there is nowhere to return to, so RETURN there ends the program. That single nullable field is the base case of the entire call mechanism, and giving main a synthetic caller instead is the other common design.
A budget instead of a hang, and a trace limit instead of a flood
A VM has to decide what happens when a program does not stop, and the decision has exactly one place to live: the dispatch loop, because that is the only code that runs between instructions. Ours makes the loop condition executed < stepBudget, with a default budget of 20,000 instructions. When the budget runs out, the result carries the status steps-exhausted and an error saying so, and no output value is reported — because there is not one. Returning the last partial accumulator would be worse than returning nothing: it would look like an answer.
A second, completely separate limit governs the trace. traceLimit — 500 by default — bounds how many steps are recorded, not how many are executed. Conflating the two is the mistake this design exists to prevent: a loop that runs a hundred times must still finish and still print the right number, while producing a table a human can read. The numbers in the matrix below are real runs, and the middle row is the one that makes the distinction visible.
This is not a toy concern. Every VM that runs code it did not write faces the same question under a different name — gas in Ethereum's EVM, reduction budgets in the BEAM, statement timeouts in a database's stored-procedure engine, instruction limits in a sandboxed plugin host. The answer is always a counter in the dispatch loop, and the interesting part is always what the VM reports when the counter runs out.
- The middle row is the point: 2,819 instructions executed, 500 trace rows kept, and the answer still correct. Recording is an observability decision; executing is the program.
- The first row shows a smaller gap for a different reason — 21 executed against 20 recorded, because the final
RETURNin the outermost frame ends the loop before a trace row for it is appended. A real property of the loop's structure, not of the limit. - The bottom row reports a status and an error and no number. A VM that guessed here would be a VM whose output you could not trust anywhere.
- Both limits are per
run()call and neither is part of the language. A different embedding of the same VM can set them differently, which is what makes them a *policy* rather than a semantic. - The same reporting shape carries the other statuses worth having:
stack-underflow,divide-by-zeroandunknown-functionare each a status plus a message rather than an exception the host has to catch.
src/compilers/sim/vm.ts, with the default budget of 20,000 and trace limit of 500simplified| Program | Status | Executed | Recorded | Output |
|---|---|---|---|---|
| print(twice(5)); | halted | 21 | 20 | 10 |
| accumulate while n < 100 | halted | 2,819 | 500 | 4950 |
| accumulate while n < 1000 | steps-exhausted | 20,000 | 500 | none — and none is reported |
How it works
The steps, in the order the compiler takes them.
- Initialize: one frame for the entry function with
ip = 0, an empty slot map, a null resume point and a stack base of zero; an empty operand stack; an execution counter at zero. - Each iteration, take the top frame, fetch the instruction at its instruction pointer, increment that pointer before executing so that a jump inside the handler simply overwrites it, and increment the execution counter.
- Execute the opcode against the operand stack and the top frame's slots.
LOADandSTOREaddress the top frame only; a frame cannot see another frame's locals. CALLpops the arity's worth of arguments, resolves the callee, records the current stack depth as the new frame's base and the caller's already-incremented pointer as the resume point, and pushes a frame whose slots are the arguments in parameter order.RETURNpops the result, pops the frame, truncates the operand stack back to that frame's base, and pushes the result — or, if there is no resume point, ends the run.- After the instruction, check the loop's obligations: the step budget, and in a real VM also safepoints, signals and preemption.
- Append a trace row only while the recorded count is below the trace limit, so observability degrades before execution does.
- On exit, if the counter reached the budget, replace the status with
steps-exhaustedand discard any claim to a result.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- The frame's stack base is not restored on return, so values a callee left behind stay on the shared stack. The caller's next pop reads the callee's garbage — after a call that returned perfectly normally, which is the last place anyone looks.
- The resume point is recorded before the instruction pointer is advanced, so every return re-executes the call instruction and the program recurses until memory is exhausted.
LOADandSTOREaddress the caller's slots instead of the top frame's, and recursion produces correct results at depth one and wrong ones at depth two — so every simple test passes.- A slot is read before it is written; ours yields a default and continues, turning a code-generation bug into a wrong number instead of a fault. The engineer sees a plausible answer with no error anywhere.
- The step budget is enforced but the status is not propagated, so a truncated run returns whatever was computed so far and the caller treats it as a result. This is the failure that makes every other number the system produces untrustworthy.
- The trace limit is applied to execution rather than to recording, and a loop that should run ten thousand times stops at five hundred and prints a confidently wrong total.
- Frames are reused rather than freshly allocated as an optimization, and a stale slot from a previous call remains visible in the new one.
When it helps
- Implementing anything that has to stop and start: coroutines, generators, debuggers, checkpointing, live migration. All of them are "serialize this tuple and restore it later", and the design either permits that or does not.
- Reasoning about deoptimization. The state a JIT must reconstruct when a guard fails is exactly this tuple in interpreter terms, which is why
[[deoptimization]]constrains what the optimizer may do. - Running untrusted or learner-written code, where the loop is the only place a budget, a timeout or a preemption point can be enforced.
- Debugging a code generator. Almost every generator bug shows up as a state invariant broken at a specific instruction, and the invariants are all listed above.
When it hurts
- When the state model is treated as the language's semantics. What a VM keeps is an implementation choice; two VMs for the same language can hold different state and both be correct, and code that depends on the shape of a frame is depending on an accident.
- When per-frame state grows to make some feature cheap. A frame is allocated on every call, so a field added for a rare feature is paid by every call in the program.
- When the loop accumulates obligations. Each check — budget, safepoint, signal, profiling counter — is multiplied by every instruction the program will ever execute; see
[[interpreter-performance]]. - When a debugger wants state the VM optimized away. As soon as a VM keeps values in host registers or elides a frame for a tail call, the tuple stops being fully materialized and the debugger has to reconstruct it — the same problem as
[[debugging-optimized-code]], one level down.
What it costs
Every one of these is paid by something.
- A shared operand stack with a per-frame base buys cheap calls — no allocation, no copying — and pays in isolation: without the truncation on return, a buggy callee silently corrupts its caller, and the symptom appears after the call rather than inside it.
- Keeping locals in a named map buys a readable disassembly and a debugger that can show real variable names, and pays a hash lookup on every
LOADandSTOREwhere a flat indexed array would pay one add. - Materializing the full state on every instruction buys perfect introspection — you can stop anywhere and see everything — and pays by forbidding the register-holding and frame-eliding optimizations that make a fast VM fast.
- A step budget buys a guarantee of termination and pays by making the VM refuse to answer questions it could have answered given more instructions; raise the budget and you trade the guarantee back.
- A trace limit buys a bounded, readable record and pays by making the record incomplete precisely for the long-running programs whose behaviour is hardest to reason about.
What else you could do
What a different compiler or language does instead, and when that is better.
- Hold no explicit state at all and let the host language's call stack be the machine, which is what
[[tree-walk-interpreter]]does. Simpler, and it makes suspension and inspection nearly impossible because the state is spread through the traversal. - Give every frame its own operand stack rather than sharing one array. Stronger isolation and simpler reasoning, at the cost of an allocation per call — the JVM specifies per-frame stacks and recovers the cost by computing the maximum depth at compile time.
- Keep values in the registers of a register VM instead of in slots addressed by a stack machine, which merges two components of the tuple into one —
[[register-based-vm]]. - Make frames heap-allocated first-class objects rather than entries in a native stack. This is how generators, coroutines and full continuations become expressible, and it costs an allocation per call and a great deal of collector pressure — see
[[coroutine-lowering]]. - Preempt instead of aborting when the budget runs out: save the tuple, hand the scheduler another process, and resume later. That is what the BEAM does with reduction counting, and it needs the state to be genuinely serializable to work.
See it for yourself
The flag, dump or tool that shows you this directly.
- Watch all of it at once at
/compilers/vm: the instruction pointer, the operand stack, the top frame's slots and the call stack update on every step, and the step counter and status are reported at the end. - The types are the documentation:
Frame,VmStep,VmStatusandVmResultinsrc/compilers/sim/vm.tsare twenty lines that describe the whole model. - CPython:
sys._getframe()hands you a live frame object withf_lasti(the instruction pointer),f_localsandf_back— the tuple in this lesson, reachable from the language itself. - The JVM:
javap -vprints each method'sstack=andlocals=maxima, which are this state model's sizes computed at compile time and checked by the verifier. - A native debugger is the same idea one level down:
btin gdb or lldb walks the frame stack, andinfo frameprints the base pointer that corresponds to our stack base.
Plausible wrong readings
Stated the way a confident engineer states them.
- "The bytecode is part of the VM's state." It is not; it never changes during a run. That is exactly what lets it be shared between threads, memory-mapped from a file and cached across processes — and it is why a self-modifying VM, like a specializing interpreter, is a genuinely different and more complicated design.
- "The operand stack and the call stack are the same stack." They hold different things with different lifetimes even when they share an array. One is expression scratch space; the other is the record of who called whom.
- "If the program does not terminate, the VM hangs." Only if the VM chose to let it. Termination behaviour is a design decision made in the dispatch loop, and gas, reductions, timeouts and step budgets are four different answers to it.
- "
steps-exhaustedmeans the program is buggy." It means the VM stopped counting. A correct program that needs more than the budget produces the same status, which is why the budget is reported alongside it and why raising it is a legitimate response. - "Locals are variables and the stack is temporaries, so a good compiler would use only one of them." They have different addressing modes for a reason: a slot is readable any number of times at any later point, and a stack entry is consumed by the next instruction that pops it.
Misconceptions
The claim, and what is actually true.
steps-exhausted on a non-terminating program is the design working; the alternative is a hung tab or a fabricated number.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
A running program in a VM is: where we are in the instructions, a small scratch stack for the calculation in progress, a pile of frames for the calls that are still in flight, the variables belonging to each of those calls, and whatever is on the heap. That is the whole machine. The instructions themselves are not part of it, because they never change — which is why you can save the rest and start again later.
practical
When a VM misbehaves, check the invariants in order and you will usually find it in three minutes. Is the stack empty between statements? Does its depth match on both sides of a merge? Does RETURN truncate to the frame's base? Is the instruction pointer advanced before the handler runs, so a jump is not overwritten? Do LOAD and STORE address the *top* frame? Each of those has a characteristic symptom — a slow leak, a branch-dependent wrong value, caller corruption after a normal return, every branch off by one, and recursion that works at depth one — and knowing the mapping from symptom to invariant is most of debugging a code generator.
advanced
The deep version of this subject is that everything sophisticated a VM does is a fight over how much of this tuple has to be materialized, and when. An interpreter materializes all of it on every instruction, which is why it is inspectable and why it is slow. A JIT materializes as little as it can: values live in machine registers, frames for inlined callees do not exist, and the operand stack is gone entirely. It gets away with that only because it keeps a *map* — a description of how to rebuild the interpreter's tuple from the optimized frame at each point where it might need to. That map is the price of speculation, it constrains which optimizations are allowed, because you cannot destroy a value the map still needs to describe, and it is the direct subject of [[deoptimization]]. Read this lesson as the specification the JIT has to be able to satisfy on demand, and the whole next module stops being a list of tricks and becomes one obligation.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
RETURN really does truncate the shared stack to the base.If you were asked this in an interview
- List everything that changes while a VM executes. Now tell me which of those you would need to serialize to resume the program on another machine.
- What does a call frame have to contain, and what breaks if you leave out the operand-stack base?
- A VM must run untrusted code and must not hang. Where does that requirement go, and what should it report when it fires?
- Why is the bytecode not part of the machine state, and what does that buy you?
Connections
- Programming Languages & Runtime Internals — The heap component: object layout, allocation, and the collector that decides when a heap value diesOur sixth state component is a stub because AtlasLang allocates nothing, and drawing a heap box nothing writes to would teach a shape rather than a mechanism. The compiler-side obligation — emitting stack maps so the collector can find live references in exactly the frames described here — is ours; the collector that consumes them is theirs, and neither half means much alone.
- DevOps / Production Engineering — Execution budgets, timeouts and preemption as an operational contract for untrusted workloadsThe step budget in our dispatch loop is the same decision an operator makes when setting a statement timeout, a serverless execution limit or a gas ceiling: bound the work, and decide what the system reports when the bound is hit. The mechanism is a counter in the VM loop; the policy and its consequences are an operations question owned there.