A bytecode virtual machine with two matching execution engines:
- an AArch64 assembly engine that implements the VM execution core;
- a C reference engine used for differential verification and behavioral comparison.
The project also includes a static bytecode verifier, a versioned bytecode container format, an assembler, a disassembler, instruction tracing, configurable output, function calls, frame-local variables, execution limits, and a QEMU-based test workflow.
The AArch64 assembly engine is the low-level implementation of the VM. The C engine is intentionally maintained as an independent behavioral reference so that every execution result can be compared across both engines.
The project is designed around the following technical areas:
- AArch64 assembly programming;
- AAPCS64 calling conventions;
- C and assembly interoperability;
- bytecode interpreter design;
- virtual machine execution models;
- stack-based instruction sets;
- static control-flow validation;
- binary file-format design;
- cross-compilation and QEMU-based testing;
- differential testing between independent implementations.
The result is not only a minimal interpreter. It is a small execution platform with a defined instruction set, binary format, verifier, development tools, and automated tests.
The same bytecode can be executed by:
- the C reference engine;
- the AArch64 assembly engine;
- both engines in differential-comparison mode.
When both engines are selected, the runner compares:
- return and context status;
- halted state and program counter;
- operand stack;
- return-address stack;
- frame-local storage;
- emitted output;
- instruction count;
- instruction trace.
A mismatch causes the runner to return a dedicated error code.
The VM currently provides:
- a 256-element signed 32-bit operand stack;
- a dedicated 64-entry return-address stack;
- one top-level local frame;
- one local frame per call depth;
- 16 signed 32-bit local slots per frame.
Operand values and return addresses are intentionally stored in separate stacks. Arithmetic instructions cannot accidentally corrupt function return addresses.
The assembly engine implements the VM instruction loop directly in AArch64 assembly, including:
- opcode fetch and dispatch;
- little-endian immediate decoding;
- signed arithmetic;
- division and remainder checks;
- relative branches;
- function calls and returns;
- frame-local loads and stores;
- output and trace callbacks;
- instruction-budget enforcement;
- VM status propagation.
The implementation follows AAPCS64 rules when calling C helper functions.
Bytecode can be validated before execution. The verifier checks:
- unknown opcodes and truncated operands;
- invalid jump targets;
- jumps into instruction operands;
- invalid entry points;
- operand-stack underflow and possible overflow;
- inconsistent stack depth at control-flow merges;
- fallthrough past the end of the program;
- invalid local-variable indices;
- top-level
RET; - possible return-stack overflow;
- malformed recursive call paths.
It also reports total, reachable, and unreachable instruction counts, maximum operand-stack depth, maximum return-stack depth, and the number of referenced local slots.
The PRINT instruction does not depend directly on terminal output. Output is routed through a callback:
typedef void (*VMOutputCallback)(int32_t value, void *user_data);This allows the same instruction to write to a terminal, test buffer, GUI, embedded transport, or another application-defined sink. PRINT reads the current stack top without consuming it.
An optional trace callback records the VM state immediately before each instruction executes. Trace events include the instruction address, opcode, mnemonic, operand-stack state, return-stack state, and active local-frame values.
Example:
Instruction trace:
#0000 pc=0000 opcode=0x01 PUSH_I32 sp=0 top=<empty> rsp=0 ret=<empty>
#0001 pc=0005 opcode=0x33 CALL_REL_I16 sp=1 top=21 rsp=0 ret=<empty>
#0002 pc=000a opcode=0x41 STORE_LOCAL sp=1 top=21 rsp=1 ret=8
#0003 pc=000c opcode=0x40 LOAD_LOCAL sp=0 top=<empty> rsp=1 ret=8
When both engines are used, their traces are captured and compared event by event.
The runner can enforce a maximum number of executed instructions:
--max-steps 1000This prevents malformed or intentionally infinite bytecode from blocking tests or tools indefinitely.
The project supports two input formats:
.a64bc— legacy raw bytecode;.a64vm— versioned A64B container.
The version 1 container includes magic bytes, format version, header size, flags, entry point, code size, IEEE CRC-32 checksum, reserved fields, and the code section.
┌─────────────────────┐
│ Assembly source │
│ .a64asm │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Python assembler │
└──────────┬──────────┘
│
┌──────────────┴──────────────┐
▼ ▼
Raw bytecode A64B container
.a64bc .a64vm
│
▼
┌────────────────────────┐
│ Header and CRC checks │
└────────────┬───────────┘
│
┌─────────────────────────────┘
▼
┌────────────────────────┐
│ Static bytecode │
│ verifier │
└────────────┬───────────┘
│
▼
┌────────────────────────┐
│ Bytecode runner │
└────────────┬───────────┘
│
┌─────────┴─────────┐
▼ ▼
┌─────────────────┐ ┌────────────────────┐
│ C reference │ │ AArch64 assembly │
│ engine │ │ engine │
└────────┬────────┘ └─────────┬──────────┘
│ │
└──────────┬──────────┘
▼
┌─────────────────────┐
│ Differential │
│ state comparison │
└─────────────────────┘
See docs/ARCHITECTURE.md for the component boundaries, execution pipeline, C/Assembly ABI contract, verifier design, runner flow, and testing architecture.
| Document | Scope |
|---|---|
docs/INSTRUCTION_SET.md |
Opcode encodings, operands, stack effects, control flow, calls, locals, errors, tracing, and instruction-budget semantics. |
docs/BYTECODE_FORMAT.md |
A64B container layout, field encodings, CRC-32, entry-point rules, validation requirements, and compatibility behavior. |
docs/ARCHITECTURE.md |
System layers, VM context, C/Assembly ABI, execution engines, verifier, runner, memory model, and test architecture. |
| Opcode | Instruction | Operand | Stack effect | Description |
|---|---|---|---|---|
0x01 |
PUSH_I32 |
signed little-endian i32 |
+1 |
Pushes a signed 32-bit immediate. |
0x02 |
POP |
none | -1 |
Removes the stack top. |
0x03 |
DUP |
none | +1 |
Duplicates the stack top. |
0x10 |
ADD |
none | -1 |
Replaces two operands with their wrapped 32-bit sum. |
0x11 |
SUB |
none | -1 |
Calculates lhs - rhs. |
0x12 |
MUL |
none | -1 |
Keeps the lower 32 bits of lhs * rhs. |
0x13 |
DIV |
none | -1 |
Performs signed division rounded toward zero. |
0x14 |
MOD |
none | -1 |
Calculates signed remainder. |
0x20 |
CMP |
none | -1 |
Produces -1, 0, or 1. |
0x30 |
JMP_REL_I16 |
signed little-endian i16 |
0 |
Performs an unconditional relative jump. |
0x31 |
JZ_REL_I16 |
signed little-endian i16 |
-1 |
Consumes the condition and jumps when it is zero. |
0x32 |
JNZ_REL_I16 |
signed little-endian i16 |
-1 |
Consumes the condition and jumps when it is non-zero. |
0x33 |
CALL_REL_I16 |
signed little-endian i16 |
0 |
Pushes a return address and enters a function. |
0x34 |
RET |
none | 0 |
Returns to the most recent call site. |
0x40 |
LOAD_LOCAL_U8 |
unsigned u8 index |
+1 |
Pushes a value from the active local frame. |
0x41 |
STORE_LOCAL_U8 |
unsigned u8 index |
-1 |
Stores and consumes the stack top. |
0xF0 |
PRINT |
none | 0 |
Emits the stack top without consuming it. |
0xFF |
HALT |
none | 0 |
Stops execution successfully. |
See docs/INSTRUCTION_SET.md for complete instruction encodings, execution semantics, stack effects, and error conditions.
The A64B version 1 header is 32 bytes:
| Offset | Size | Field |
|---|---|---|
0x00 |
4 | Magic bytes: A64B |
0x04 |
2 | Format version |
0x06 |
2 | Header size |
0x08 |
4 | Flags |
0x0C |
4 | Entry point |
0x10 |
4 | Code size |
0x14 |
4 | Code CRC-32 |
0x18 |
4 | Reserved field 0 |
0x1C |
4 | Reserved field 1 |
0x20 |
N | Code section |
All multi-byte fields are encoded in little-endian order. The checksum uses IEEE CRC-32:
CRC32("123456789") = 0xCBF43926
See docs/BYTECODE_FORMAT.md for the complete specification.
; Doubles a value inside a function-local frame.
.entry start
unused:
HALT
start:
PUSH_I32 21
CALL double_with_local
PRINT
HALT
double_with_local:
STORE_LOCAL_U8 0
LOAD_LOCAL_U8 0
LOAD_LOCAL_U8 0
ADD
RETExpected output:
42
The function receives the value through the operand stack, stores it in local[0], loads it twice, adds the copies, and returns with 42 on the operand stack.
The project is developed and tested on an x86-64 Linux host using an AArch64 cross-toolchain and QEMU user-mode emulation.
Required tools:
- GNU Make;
- Python 3.12 or later;
- AArch64 GNU cross-compiler;
- AArch64 binutils;
- QEMU user-mode emulator;
gdb-multiarchfor debugging.
On Ubuntu:
sudo apt-get update
sudo apt-get install --yes \
binutils-aarch64-linux-gnu \
gcc-aarch64-linux-gnu \
gdb-multiarch \
libc6-dev-arm64-cross \
make \
python3 \
qemu-userBuild the project and test binaries:
makeBuild in parallel:
make -j"$(nproc)"Remove generated files:
make cleanRun all tests:
make testThe suite covers the C reference engine, AArch64 stack helpers, C/AArch64 differential execution, arithmetic edge cases, branches, output callbacks, tracing, instruction limits, static verification, CALL/RET, frame-local variables, container parsing, CRC-32, entry points, Python tools, and runner integration.
Successful differential tests report:
State comparison: MATCH
Output comparison: MATCH
Trace comparison: MATCH
python3 -m tools.assembler \
examples/countdown.a64asm \
-o build/countdown.a64vm \
--format container \
--listingpython3 -m tools.assembler \
examples/countdown.a64asm \
-o build/countdown.a64bc \
--format raw \
--listingAn entry point may be declared inside the source:
.entry startor supplied through the command line:
python3 -m tools.assembler \
examples/program.a64asm \
-o build/program.a64vm \
--format container \
--entry startEntry-point metadata requires the container format.
Container input:
python3 -m tools.disassembler \
build/countdown.a64vm \
--input-format container \
--annotateRaw input:
python3 -m tools.disassembler \
build/countdown.a64bc \
--input-format raw \
--annotateAutomatic detection:
python3 -m tools.disassembler \
build/countdown.a64vm \
--input-format auto \
--annotateqemu-aarch64 ./build/a64vm-runner \
--engine aarch64 \
build/countdown.a64vmqemu-aarch64 ./build/a64vm-runner \
--engine reference \
build/countdown.a64vmqemu-aarch64 ./build/a64vm-runner \
--engine both \
--dump-stack \
build/countdown.a64vmqemu-aarch64 ./build/a64vm-runner \
--engine both \
--trace \
--dump-stack \
build/countdown.a64vmqemu-aarch64 ./build/a64vm-runner \
--engine both \
--max-steps 1000 \
build/countdown.a64vmqemu-aarch64 ./build/a64vm-runner \
--verify-only \
build/countdown.a64vmqemu-aarch64 ./build/a64vm-runner \
--input-format container \
build/countdown.a64vmSupported values are auto, raw, and container.
| Target | Purpose |
|---|---|
make test |
Runs the complete test suite. |
make test-reference |
Runs C reference-engine tests. |
make test-asm-stack |
Runs AArch64 stack-helper tests. |
make test-asm-vm |
Runs C/AArch64 execution differential tests. |
make test-verifier |
Runs static-verifier tests. |
make test-container |
Runs container-format and CRC tests. |
make test-call-vm |
Runs CALL/RET runtime tests. |
make test-call-verifier |
Runs call-verifier tests. |
make test-locals-vm |
Runs local-variable runtime tests. |
make test-locals-verifier |
Runs local-variable verifier tests. |
make verify-bytecode |
Verifies an example without executing it. |
make run-bytecode |
Runs the container example. |
make run-bytecode-raw |
Runs the legacy raw example. |
make run-bytecode-trace |
Runs both engines with tracing. |
make run-bytecode-limited |
Runs with an instruction budget. |
make run-print-demo-both |
Runs the output-callback example. |
make run-call-demo-both |
Runs the function-call example. |
make run-locals-demo-both |
Runs the frame-local example. |
make run-entry-demo-both |
Runs the non-zero-entry example. |
make roundtrip-example |
Verifies assembler/disassembler round-trip behavior. |
Exact target availability is defined by the current Makefile.
.
├── .github/
│ └── workflows/
│ └── ci.yml
├── docs/
│ ├── ARCHITECTURE.md
│ ├── BYTECODE_FORMAT.md
│ └── INSTRUCTION_SET.md
├── examples/
│ ├── countdown.a64asm
│ ├── print_demo.a64asm
│ ├── call_demo.a64asm
│ ├── locals_demo.a64asm
│ └── entry_demo.a64asm
├── include/
│ ├── bytecode.h
│ ├── bytecode_abi.h
│ ├── bytecode_container.h
│ ├── bytecode_verify.h
│ ├── vm.h
│ └── vm_asm_layout.h
├── src/
│ ├── bytecode_container.c
│ ├── bytecode_verify.c
│ ├── runner.c
│ ├── vm.c
│ ├── vm_reference.c
│ ├── vm_execute_aarch64.S
│ ├── vm_stack_aarch64.S
│ └── vm_layout_checks.c
├── tests/
│ ├── test_tools.py
│ ├── test_runner.sh
│ └── test_*.c
├── tools/
│ ├── assembler.py
│ ├── bytecode_container.py
│ ├── bytecode_spec.py
│ └── disassembler.py
├── Makefile
└── README.md
The C implementation is not a replacement for the AArch64 engine. It acts as an executable specification. Each feature is implemented independently in both engines and tested against identical bytecode, making subtle assembly defects easier to detect.
Hard-coded terminal I/O would make the VM difficult to test and reuse. Callbacks keep execution independent from presentation and allow tests to capture output and traces without parsing terminal text.
Operand stacks, return stacks, and local frames use fixed capacities. This provides deterministic memory use, no allocation failures during execution, auditable bounds checks, embedded-oriented behavior, and straightforward C/assembly equivalence.
Runtime checks are still required, but static verification rejects malformed programs earlier and provides more precise diagnostics. It also prevents invalid control-flow targets from being interpreted as opcodes.
Raw .a64bc files are useful for testing and minimal examples. The .a64vm container is recommended for normal use because it preserves version, entry-point, length, and checksum metadata.
The VM reports explicit status values for invalid arguments, unknown opcodes, truncated instructions, operand-stack errors, program-counter errors, division errors, invalid jump targets, instruction-budget exhaustion, return-stack errors, invalid return addresses, and invalid local indices.
Container, verifier, runner-usage, VM-execution, and engine-mismatch failures use separate runner exit codes.
Builds use debug-friendly options:
-O0
-g
-fno-inline
-fno-omit-frame-pointer
Start a QEMU GDB server:
make debug-asm-vmConnect from another terminal:
gdb-multiarch ./build/test_vm_execute_aarch64Inside GDB:
target remote :1234
break vm_execute_aarch64
continueImportant persistent registers in the assembly execution loop:
x19 VMContext pointer
x20 bytecode base
x21 bytecode size
x22 VM program counter
The GitHub Actions workflow performs AArch64 cross-compilation, QEMU execution, complete C and assembly test suites, Python tool tests, runner integration tests, assembler/disassembler round-trip checks, and tracked-file cleanliness checks.
The workflow runs on pushes, pull requests, and manual dispatches.
v0.1.0 defines the first stable A64VM release.
Implemented subsystems include:
- the complete current instruction set;
- C and AArch64 execution engines;
- differential verification;
- static bytecode verification;
- assembler and disassembler;
- raw and container file formats;
- function calls and local frames;
- configurable output;
- execution tracing;
- instruction budgets;
- automated CI.
Possible post-v0.1.0 improvements:
- differential fuzz testing;
- interactive debugger commands;
- linear memory and load/store instructions;
- constant pools;
- debug symbols and source maps;
- benchmark infrastructure;
- optimized assembly dispatch;
- additional bytecode sections;
- sanitizer-enabled host-side tests.
These are intentionally outside the first stable release scope.
This project is distributed under the MIT License.
Focused bug reports, implementation improvements, and technical suggestions are welcome.
When reporting an execution mismatch, include the bytecode or assembly source, selected engine mode, runner command, return status, final VM state, trace output when available, and host toolchain versions.
Before submitting changes, run:
make clean
make -j"$(nproc)"
make test
git diff --check