Building a Stack-Based Bytecode VM from Scratch
A few days ago I decided to build an interpreted programming language. Not a tree-walking evaluator over an AST, not a thin wrapper around an existing runtime — a proper compiler that emits binary bytecode, a virtual machine that executes it, and a toolchain around both. Four days later it runs on Linux and on a Raspberry Pi Pico over UART.
This post covers how it works, what the bytecode looks like, and what I learned along the way.
The Language
The language is line-oriented. One statement per line, no semicolons, no braces. Variables are typed at declaration by what you assign to them — an integer literal creates an integer variable, a string literal creates a string variable. There is no separate declaration keyword.
yearNow = 2026
userYear = 0
println 'Hello, world!'
print 'Enter your birth year: '
inputInt &userYear
age = yearNow - userYear
print 'Your age: '
println age
Arithmetic expressions use standard infix notation with correct operator precedence and parentheses. Power and modulo are supported alongside the usual four. String concatenation uses .. to keep it unambiguous from integer addition.
result = 'Hello, ' .. name .. '!'
area = 2 * (width + height) * wallHeight - subArea
Control flow uses if/else/endif with six comparison operators, label/jump for unconditional branching, and routine/endroutine/call for subroutines with a proper return address stack. By-reference function arguments use & — inputInt &userYear reads an integer from stdin and writes it directly into the variable at its index, not via the value stack.
Comments start with # and can appear inline or on their own line.
The Compiler
The compiler is a single-pass line tokenizer. There is no AST. Each line is tokenized, the first meaningful keyword determines the operation type, and bytecode is emitted directly as tokens are processed.
Arithmetic expressions go through a shunting-yard algorithm that produces reverse Polish notation, which the compiler then walks left to right to emit stack instructions. The string concatenation path is handled separately at compile time — if .. appears in an assignment expression, the compiler collects all string operands, emits a PUSH for each, pushes the count, and emits JOIN. If arithmetic operators appear instead, the expression goes through the shunting-yard path. Mixing both in the same expression is a compile error.
Forward jumps use backpatching — an unresolved jump emits a placeholder byte, and after the full bytecode is built the compiler patches all placeholders with correct offsets. Subroutine calls work the same way: subroutine bodies are compiled into separate buffers, appended after the main HLT instruction, and all call sites are patched with the byte offsets where each routine starts.
The compiler produces a binary file with a 0xFE 0xFA magic header, followed by bytecode size as a 4-byte int, the bytecode itself, then the string pool with length-prefixed entries, and finally the variable count. A separate bin2h tool converts this binary to a C header of uint32_t values for embedding directly into Pico flash.
Inside the Bytecode
The instruction set is variable-width. Each opcode implies its own operand count, so the VM advances the instruction pointer by the correct amount without a fixed-width frame.
Here is the disassembly of the age calculator above:
===== Main =====
0x00000000: 03 02 7ea | PUSH 2026
0x00000003: 02 00 | POP yearNow
0x00000005: 03 02 00 | PUSH 0
0x00000008: 02 01 | POP userYear
0x0000000a: 03 01 00 | PUSH 'Hello, world!'
0x0000000d: 04 01 | EXEC println
0x0000000f: 03 01 01 | PUSH 'Enter your birth year: '
0x00000012: 04 02 | EXEC print
0x00000014: 03 02 01 | PUSH 1
0x00000017: 04 03 | EXEC inputInt
0x00000019: 03 03 00 | PUSH yearNow
0x0000001c: 03 03 01 | PUSH userYear
0x0000001f: a1 | SUB
0x00000020: 02 02 | POP age
0x00000022: 03 01 02 | PUSH 'Your age: '
0x00000025: 04 02 | EXEC print
0x00000027: 03 03 02 | PUSH age
0x0000002a: 04 01 | EXEC println
0x0000002c: ff | HLT
PUSH carries a type tag as its first operand: 0x01 for a string pool index, 0x02 for an integer literal, 0x03 for a variable index. POP stores the top of stack into a variable slot by index. EXEC calls a built-in function by index. Arithmetic opcodes (0xA0–0xA5) take no operands — they pop two values, operate, and push the result. The conditional opcodes (0xB0–0xB5) pop two values, evaluate the comparison, and jump to the encoded false-branch offset if the condition is false. If true, execution falls through — so an if block costs no extra jump instruction for the true path.
Subroutine calls push the return address onto a separate PC stack before jumping. RET (0xFE) pops it and resumes. The full opcode table is in the README.
The VM
The VM is a fetch-decode-execute loop over a flat bytecode array. It maintains a value stack of Variant structs, a variable table indexed by slot number, and a PC stack for subroutine return addresses.
Variant is a tagged struct wrapping a std::variant<int64_t, double, std::string> alongside a TypeTag enum. Native functions are registered in an std::unordered_map<int, NativeFn> keyed by function index — adding a new built-in is one map entry. The value stack, variable table, and control flow are entirely separate concerns, which keeps each opcode handler short and self-contained.
The stack machine architecture emerged from a real constraint: the initial design encoded operands directly into arithmetic instructions. This broke immediately on chained expressions — c = a * b + d has no clean way to reference the result of a * b as an operand to + without a dedicated result register or a more complex encoding. A stack solves it naturally. a * b pushes its result. + d pops two values and pushes another. The JVM, CPython, and Lua all arrived at the same design for the same reason.
The Toolchain
The interpreter binary accepts several flags that together form a small development toolchain.
--compile compiles a .script file to a .bin binary without executing it. --run executes a precompiled .bin directly, skipping the compiler. --disassemble reads a .bin and prints annotated output with instruction addresses, raw bytes, mnemonic names, and — when a .dbg debug symbols file is present — variable names, string values, and subroutine labels in place of raw indices. --verbose prints the token stream and bytecode alongside execution for tracing compiler output.
The debugger, enabled with --debugger, runs the program interactively. Breakpoints are set by instruction address — when the PC reaches a breakpointed address, execution pauses and control returns to the debugger prompt. From there, stack prints the current value stack, variables prints all live variable values, and pc prints the current instruction pointer. pc [address] moves the instruction pointer to an arbitrary address, letting you jump around the bytecode manually. Execution resumes on continue. You can also restart execution with run or stop it altogether with stop.
The Pico Port
The VM runs on the Raspberry Pi Pico with the C++ standard library removed entirely. std::vector becomes fixed-size arrays with a manual stack pointer. std::variant becomes a plain C tagged union. The native function map becomes a function pointer array indexed directly by opcode. String concatenation uses a static buffer instead of heap allocation where possible.
The desktop toolchain compiles a script to a .bin, then bin2h converts it to a C header embedded in Pico flash as a const uint32_t[] array — one byte per element. On boot, loadFromFlash reads the header, reconstructs the bytecode array and string pool via malloc, and hands off to execute. GPIO functions are registered as native built-ins: gpioInit, gpioSetDir, gpioPut, gpioGet, gpioPullUp, gpioPullDown, sleepMs. A script can initialize a pin, read a button, branch on its state, and blink an LED — written in the high-level language, compiled on the desktop, and running on the microcontroller with no changes to the VM itself.
Four days in, the interpreter handles typed variables, arithmetic with correct precedence, string concatenation, by-reference function arguments, conditionals with six comparison operators, unconditional jumps, subroutines with a return address stack, a binary bytecode format, a disassembler with debug symbol support, an interactive debugger, and GPIO on the Raspberry Pi Pico.
The source is at github.com/spikest3r/Interpreter.