Skip to content

Latest commit

 

History

24 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

A64 Bytecode VM

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.


Technical Scope

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.


Main Features

Dual execution engines

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.

Stack-based virtual machine

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.

AArch64 assembly execution core

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.

Static bytecode verifier

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.

Configurable output

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.

Instruction-level tracing

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.

Instruction budget

The runner can enforce a maximum number of executed instructions:

--max-steps 1000

This prevents malformed or intentionally infinite bytecode from blocking tests or tools indefinitely.

Versioned bytecode container

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.


Architecture

                         ┌─────────────────────┐
                         │  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.


Technical Documentation

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.

Instruction Set

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.


Bytecode Container Format

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.


Example Program

; 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
    RET

Expected 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.


Requirements

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-multiarch for 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-user

Build

Build the project and test binaries:

make

Build in parallel:

make -j"$(nproc)"

Remove generated files:

make clean

Run the Test Suite

Run all tests:

make test

The 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

Assemble Programs

Produce an A64B container

python3 -m tools.assembler \
    examples/countdown.a64asm \
    -o build/countdown.a64vm \
    --format container \
    --listing

Produce legacy raw bytecode

python3 -m tools.assembler \
    examples/countdown.a64asm \
    -o build/countdown.a64bc \
    --format raw \
    --listing

An entry point may be declared inside the source:

.entry start

or supplied through the command line:

python3 -m tools.assembler \
    examples/program.a64asm \
    -o build/program.a64vm \
    --format container \
    --entry start

Entry-point metadata requires the container format.


Disassemble Programs

Container input:

python3 -m tools.disassembler \
    build/countdown.a64vm \
    --input-format container \
    --annotate

Raw input:

python3 -m tools.disassembler \
    build/countdown.a64bc \
    --input-format raw \
    --annotate

Automatic detection:

python3 -m tools.disassembler \
    build/countdown.a64vm \
    --input-format auto \
    --annotate

Run Bytecode

AArch64 engine

qemu-aarch64 ./build/a64vm-runner \
    --engine aarch64 \
    build/countdown.a64vm

C reference engine

qemu-aarch64 ./build/a64vm-runner \
    --engine reference \
    build/countdown.a64vm

Compare both engines

qemu-aarch64 ./build/a64vm-runner \
    --engine both \
    --dump-stack \
    build/countdown.a64vm

Enable tracing

qemu-aarch64 ./build/a64vm-runner \
    --engine both \
    --trace \
    --dump-stack \
    build/countdown.a64vm

Enforce an instruction limit

qemu-aarch64 ./build/a64vm-runner \
    --engine both \
    --max-steps 1000 \
    build/countdown.a64vm

Verify without executing

qemu-aarch64 ./build/a64vm-runner \
    --verify-only \
    build/countdown.a64vm

Force an input format

qemu-aarch64 ./build/a64vm-runner \
    --input-format container \
    build/countdown.a64vm

Supported values are auto, raw, and container.


Useful Make Targets

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.


Project Structure

.
├── .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

Design Decisions

Why maintain both C and assembly engines?

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.

Why use callbacks for output and tracing?

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.

Why use fixed-size VM storage?

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.

Why verify bytecode before execution?

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.

Why keep raw bytecode support?

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.


Error Handling

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.


Debugging

Builds use debug-friendly options:

-O0
-g
-fno-inline
-fno-omit-frame-pointer

Start a QEMU GDB server:

make debug-asm-vm

Connect from another terminal:

gdb-multiarch ./build/test_vm_execute_aarch64

Inside GDB:

target remote :1234
break vm_execute_aarch64
continue

Important persistent registers in the assembly execution loop:

x19  VMContext pointer
x20  bytecode base
x21  bytecode size
x22  VM program counter

Continuous Integration

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.


Current Status

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.

Future Work

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.


License

This project is distributed under the MIT License.


Contributing

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

About

A bytecode virtual machine with matching C reference and AArch64 assembly execution engines, static verification, tracing, assembler, disassembler, and differential tests.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages