A stack-based virtual machine written from scratch in C. It executes densely packed binary opcodes through its own dispatch loop, the same shape of machine that sits under runtimes like the JVM or the CPython interpreter.
Step through it in your browser → The VM is compiled to WebAssembly and driven one instruction at a time, with the bytecode, the instruction pointer, and the stack drawn after every step.
Written to understand what a bytecode interpreter is actually doing between "here is a byte" and "here is a result".
- Bytecode chunks. A
Chunkis a growableuint8_tarray of instructions plus a parallel pool ofdoubleconstants. Instructions that need a literal store a one-byte index into that pool rather than the value itself, which keeps the instruction stream compact. - A stack machine, not a register machine. There are no registers. Operands are
pushed onto a fixed 256-slot stack, and each arithmetic opcode pops what it needs and
pushes the result back.
1.2 + 3.4 * 5.6becomes three pushes, a multiply, and an add. - The dispatch loop.
vmStep()insrc/vm.creads one instruction, switches on it, and returns.interpret()is a loop around it; the web demo calls it once per click. The instruction pointer is a rawuint8_t*walking the chunk directly. - Bytecode is untrusted input. Every step checks what it is about to touch: stack
overflow and underflow, a constant index past the pool, an
OP_CONSTANTwith its operand cut off, an unknown opcode, and running off the end of the chunk without anOP_RETURN. Each stops the VM with a runtime error rather than reading or writing memory it does not own. - Instruction set.
OP_CONSTANT,OP_ADD,OP_SUBTRACT,OP_MULTIPLY,OP_DIVIDE,OP_RETURN. That is the whole ISA.
git clone https://github.com/apollo-2006/neon_vm.git
cd neon_vm
make
./neon_vmsrc/main.c hand-assembles the expression 1.2 + 3.4 * 5.6 and runs it:
--- Neon VM Booting ---
[ 1.2 ]
[ 1.2 ][ 3.4 ]
[ 1.2 ][ 3.4 ][ 5.6 ]
[ 1.2 ][ 19.04 ]
[ 20.24 ]
Program Execution Finished. Result: 20.24
The stack dump before each instruction comes from DEBUG_TRACE_EXECUTION in
include/common.h. Build with -DNEON_NO_TRACE for a silent run.
make testtests/test_vm.c runs well-formed programs and every class of malformed one (underflow,
overflow at 257 pushes, a missing OP_RETURN, a truncated operand, a bad constant index,
an unknown opcode) and checks the VM stops in the expected state. It builds with
AddressSanitizer and UndefinedBehaviorSanitizer, so an out-of-bounds read fails the run
even if the result happens to look right.
web/ holds the browser build: neon_web.c exposes the Chunk and VM to JavaScript,
and web/build.sh compiles it with the unmodified src/ files using Emscripten. GitHub
Actions runs the tests, builds the demo, and publishes it to Pages on every push to
main, so the live page always runs the code in this repository.
web/build.sh # needs emcc on PATH
python3 -m http.server -d web/dist # then open http://localhost:8000The demo includes a small infix-to-bytecode compiler in web/app.js so you can type an
expression instead of assembling by hand. It is part of the page, not the VM.
include/chunk.h Chunk: bytecode array + constant pool
include/vm.h VM state, interpret(), vmLoad(), vmStep()
src/chunk.c growable arrays
src/vm.c checked single-step dispatch
src/main.c hand-assembled example
tests/test_vm.c well-formed and malformed programs
web/ WebAssembly bindings and the demo page
- There is no compiler. Bytecode is written by hand in
main.c; there is no lexer, parser, or source language in front of the VM. - One value type. Everything on the stack is a
double. No strings, booleans, objects, or type tags. - No control flow. No jumps, branches, loops, or calls, so the ISA can only express straight-line arithmetic.
- 256 constants per chunk. The
OP_CONSTANToperand is one byte.addConstantdoes not refuse a 257th constant, so a caller has to check the index it returns (the web assembler does).
MIT. See LICENSE.
Abir Deol · abirdeol.tech