Bytecode & VM: Making It Run

Work through examples, try the reasoning yourself, then put it into practice.

View Slides

Before you start

  • Finish v2 parsing and name analysis.
  • Trace expression postfix instructions.

What you will practice

  • Connect each source statement to stack and scope effects.
  • Patch absolute jumps and preserve caller operands across function execution.
  • Distinguish a VM stack result from discarded source expression statements.
In this lesson

Lesson

3 min read

Bytecode & VM: Making It Run

This module contains two exercises: compile a valid v2 Program, then execute its Bytecode. Keep the boundary visible: the compiler consumes an AST and emits instructions; the VM consumes only instructions and values. Calls are still CALL name arity, with a name-based function table.

Worked reconstruction

Compile let x=3; while (x>1) { x=x-1; }:

0 PUSH_NUM 3       1 DEFINE_VAR x
2 LOAD x           3 PUSH_NUM 1       4 GT
5 JUMP_IF_FALSE 13
6 ENTER_SCOPE      7 LOAD x           8 PUSH_NUM 1
9 SUB             10 STORE x        11 EXIT_SCOPE
12 JUMP 2

A target of 13 is the legal one-past-end exit in this code slice. At x=3 and x=2 the test consumes two numeric operands and produces true; the conditional jump consumes that boolean. Each body returns to the same stack height and exits its scope. At x=1 the jump exits. The final operand stack is empty, so Run returns nil. Changing the back edge from 2 to 0 would reset x forever.

Stack, scopes, and calls

For binary operations pop the right operand first, then the left. STORE consumes the value and updates the nearest existing mutable binding; it does not create a new binding. ENTER_SCOPE/EXIT_SCOPE bracket blocks. JUMP_IF_FALSE pops a condition and branches only for false or nil. Zero and empty string are truthy here.

For CALL difference 2, arguments were pushed in source order. Popping them must restore that order when binding parameters. The callee uses a local scope with access to globals, not the caller's arbitrary block locals; v2 has no closures. A call leaves one result for the caller. A compiler-generated expression statement then emits POP, discarding that result. Thus a source program ending in difference(9,2); normally returns nil, while hand-authored Main bytecode leaving a 7 on its stack returns 7.

Implement in observable slices

First support literals and arithmetic, then binding/scopes, then jumps, then calls and returns. For each step trace instruction index, stack, and binding changes. Underflow, undefined loads, const writes, bad calls, type mismatches, and division by zero should fail clearly. The API does not require a public trace switch: a temporary local trace is a debugging tool, and must not change execution behavior.

Debugging lab

A while loop leaves one condition value on the stack after every test, and a program ending in f(); unexpectedly returns f’s result. Identify the two missing stack effects.

Compare your diagnosis after attempting the lab

JUMP_IF_FALSE must consume the condition. The compiler must emit POP for an expression statement. Both branches and loop back edges should preserve the expected stack height; the final source expression is not an implicit program return.

Transfer task

Replace the worked loop body with a nested block containing a shadowing declaration. Trace which x each LOAD/STORE sees and verify the outer loop still progresses. Independently hand-author bytecode computing 17-5 to test right-then-left pops.

Carry forward

Carry an executable Bytecode object into BC1 serialization. Preserve the instruction-index trace as a comparison point after decoding.

Build compile-bytecode, then bytecode-vm. Keep a before/after trace of one failing test. Use the checkpoint to diagnose specific skills; passing it does not replace implementing and testing the artifact.


Practice sequence

Use the order below for a guided path, or choose an item to revisit. A pass records a successful attempt; try a fresh example later to check what you can reconstruct.

Join Discord