Tiny JVM

A compact virtual machine that runs the same program two ways: as WebAssembly in this page, and as firmware on a microcontroller. The program is written in a small language of its own and turned into bytecode by a toolchain written in Java.

The demo below runs a real WebAssembly build of the C interpreter (compiled from the exact same vm.c a microcontroller build would use). Not yet wired up: the Wokwi firmware view, and free-form editing (the three samples are precompiled -- see docs/build-spec-tiny-jvm.md).

The idea

"Java" and "embedded" usually sit in two different projects. This one fuses them into a single artifact. A tiny stack-based interpreter (a few hundred lines of portable C) executes a fixed bytecode. Because the interpreter is just C with no OS calls, the exact same source compiles to two targets: a WebAssembly module that runs in the browser, and a firmware image that runs on an ESP32 in the Wokwi simulator.

The bytecode is produced by a small compiler written in Java: a lexer, a recursive-descent parser, and a code generator. Writing that toolchain is the Java half of the project; keeping the VM small enough to fit a microcontroller is the embedded half.

How it works

Five stages, from source text to a running program:

1
Source: a small C/BASIC-like language
2
Java toolchain: lex, parse, generate
3
Bytecode: one flat array of bytes
4
C VM: fetch / decode / execute loop
5
Two targets: WASM in the page, firmware on Wokwi

The same bytecode file is byte-for-byte identical on both targets. The over-temp-alarm sample below reads a sensor and drives an output pin; run it in the browser and the pin writes go to a fake console, flash it to the Wokwi board and they light a real LED in the simulation.

Instruction set

One-byte opcodes, a value stack of 32-bit integers, up to 256 local-variable slots. This table is the contract the C VM and the Java code generator both target.

OpcodeNameOperandEffect
0x01 PUSH int32 (LE) push a constant onto the stack
0x02 POP -- discard the top of stack
0x03 DUP -- duplicate the top of stack
0x10 ADD -- a, b -> a + b
0x11 SUB -- a, b -> a - b
0x12 MUL -- a, b -> a * b
0x13 DIV -- a, b -> a / b (trap on 0)
0x14 LT -- a, b -> (a < b) ? 1 : 0
0x20 LOAD uint8 slot push local variable #slot
0x21 STORE uint8 slot pop into local variable #slot
0x30 JMP int16 offset unconditional branch
0x31 JZ int16 offset branch if top of stack == 0
0x32 JNZ int16 offset branch if top of stack != 0
0x40 CALL uint16 addr call function at bytecode addr
0x41 RET -- return from function
0x50 PRINT -- pop and emit to the output console
0x51 PINMODE -- pin, mode -> configure a GPIO pin
0x52 DWRITE -- pin, value -> drive a GPIO pin
0x53 DREAD -- pin -> push the pin's current level
0xFF HALT -- stop the VM

Try it

Pick a sample (already compiled to bytecode -- tlc runs server-side during the build, not in your browser) and step the VM one instruction at a time, watching the value stack, locals, and console update live.

-
Status
-
Instruction pointer

Value stack

-

Locals (slots 0-7)

-

Console (PRINT output)

-

GPIO -- relevant to the over-temp alarm sample

-

Sample source

Written in the project's own language -- what actually got compiled into the bytecode the demo above is running.

Fibonacci

Shows: loops, locals, arithmetic

let a = 0;
let b = 1;
let i = 0;
while (i < 15) {
    let t = a + b;
    a = b;
    b = t;
    i = i + 1;
}
print a;

Factorial (recursive)

Shows: function calls, the call stack, RET

fn fact(n) {
    if (n == 0) { return 1; }
    return n * fact(n - 1);
}
print fact(6);

Over-temp alarm

Shows: the same bytecode driving a GPIO on the Wokwi board

# reads a simulated sensor on pin 34, lights pin 2 when it's hot
pinMode(2, OUTPUT);
while (1) {
    let c = digitalRead(34);
    digitalWrite(2, c > 28);
}

What's built so far

  • The C VM: interpreter loop, stack, locals, calls, GPIO hooks, and a full set of traps (divide-by-zero, stack overflow, bad bytecode) instead of undefined behavior -- 8/8 hand-assembled test cases pass, including the adversarial ones.
  • The Java toolchain: lexer, recursive-descent parser with panic-mode error recovery, a scope-resolving pass, and a code generator with full back-patching for control flow and function calls -- 57 JUnit tests pass.
  • The WebAssembly build and the interactive stepper above, running the exact samples the Java compiler produced.

Still to come: the Wokwi firmware view running the same bytecode on a simulated ESP32, and a GitHub repo/README for the standalone VM and toolchain.