A simple extensible stack-based bytecode virtual machine and assembler written in Rust.
Multi-byte immediate values and multi-byte memory loads and stores use little-endian byte order.
Higher-level languages targeting the VM may use a different internal representation, but are responsible for converting byte order when necessary.
The VM provides 16 global registers, numbered 0 through 15. Each register stores one raw u64 word and is initialized to zero when the VM starts.
Registers have no predefined purpose or calling convention. CALL and RET do not automatically save, restore, or otherwise modify register values. Languages and programs targeting the VM are free to define their own conventions for register use.
The VM uses one-byte opcodes and a u64 stack word.
Some opcode ranges encode a small signed or unsigned value directly in the low nibble.
The assembler supports the full instruction set listed below.
| Byte / Range | Opcode | Stack effect |
|---|---|---|
0x00 |
Noop |
[] → [] |
0x01 |
Add |
[a, b] → [a + b] |
0x02 |
Sub |
[a, b] → [a - b] |
0x03 |
Mul |
[a, b] → [a * b] |
0x04 |
DivUnsigned |
[a, b] → [a / b] |
0x05 |
DivSigned |
[a, b] → [a / b] |
0x06 |
RemUnsigned |
[a, b] → [a % b] |
0x07 |
RemSigned |
[a, b] → [a % b] |
0x08 |
BitAnd |
[a, b] → [a & b] |
0x09 |
BitOr |
[a, b] → [a | b] |
0x0A |
BitXor |
[a, b] → [a ^ b] |
0x0B |
BitNot |
[a] → [~a] |
0x0C |
ShiftLeft |
[a, b] → [a << b] |
0x0D |
LogicShiftRight |
[a, b] → [a >> b] |
0x0E |
ArithShiftRight |
[a, b] → [a >> b] |
0x0F |
Dup |
[a] → [a, a] |
0x10 |
Pop |
[a] → [] |
0x11 |
Swap |
[a, b] → [b, a] |
0x12 |
PushUnsigned8 |
[] → [value] |
0x13 |
PushSigned8 |
[] → [value] |
0x14 |
PushUnsigned16 |
[] → [value] |
0x15 |
PushSigned16 |
[] → [value] |
0x16 |
PushUnsigned32 |
[] → [value] |
0x17 |
PushSigned32 |
[] → [value] |
0x18 |
Push64 |
[] → [value] |
0x19 |
CompEqual |
[a, b] → [a == b] |
0x1A |
CompNotEqual |
[a, b] → [a != b] |
0x1B |
CompLessThanUnsigned |
[a, b] → [a < b] |
0x1C |
CompLessThanSigned |
[a, b] → [a < b] |
0x1D |
CompLessThanOrEqualUnsigned |
[a, b] → [a <= b] |
0x1E |
CompLessThanOrEqualSigned |
[a, b] → [a <= b] |
0x1F |
CompGreaterThanUnsigned |
[a, b] → [a > b] |
0x20 |
CompGreaterThanSigned |
[a, b] → [a > b] |
0x21 |
CompGreaterThanOrEqualUnsigned |
[a, b] → [a >= b] |
0x22 |
CompGreaterThanOrEqualSigned |
[a, b] → [a >= b] |
0x23 |
JumpRelSigned8 |
[] → [] |
0x24 |
JumpRelSigned16 |
[] → [] |
0x25 |
JumpRelSigned32 |
[] → [] |
0x26 |
JumpRelSigned64 |
[] → [] |
0x27 |
JumpIfZeroRelSigned8 |
[condition] → [] |
0x28 |
JumpIfZeroRelSigned16 |
[condition] → [] |
0x29 |
JumpIfZeroRelSigned32 |
[condition] → [] |
0x2A |
JumpIfZeroRelSigned64 |
[condition] → [] |
0x2B |
CallRelSigned8 |
[] → [] |
0x2C |
CallRelSigned16 |
[] → [] |
0x2D |
CallRelSigned32 |
[] → [] |
0x2E |
CallRelSigned64 |
[] → [] |
0x2F |
JumpAbsIndirect |
[address] → [] |
0x30 |
CallAbsIndirect |
[address] → [] |
0x31 |
Return |
[] → [] |
0x32 |
MemLoad8 |
[address] → [value] |
0x33 |
MemLoad16 |
[address] → [value] |
0x34 |
MemLoad32 |
[address] → [value] |
0x35 |
MemLoad64 |
[address] → [value] |
0x36 |
MemStore8 |
[address, value] → [] |
0x37 |
MemStore16 |
[address, value] → [] |
0x38 |
MemStore32 |
[address, value] → [] |
0x39 |
MemStore64 |
[address, value] → [] |
0x3A |
MemSize |
[] → [size] |
0x3B |
MemGrow |
[amount] → [old_size] |
0x3C |
MemCopy8 |
[destination, source, length] → [] |
0x3D |
MemFill8 |
[address, value, length] → [] |
0x60–0x6F |
RegisterGet |
[] -> [value] |
0x70–0x7F |
RegisterSet |
[value] -> [] |
0x90–0x9F |
PushUnsigned4 |
[] → [0..15] |
0xA0–0xAF |
PushSigned4 |
[] → [-8..7] |
0xB0–0xBF |
CallRelSigned4 |
[] → [] |
0xC0–0xCF |
JumpIfZeroRelSigned4 |
[condition] → [] |
0xD0–0xDF |
JumpRelSigned4 |
[] → [] |
0xFF |
Halt |
[] → [] |
The opcode ranges 0x3E–0x5F, 0x80–0x8F, and 0xE0–0xFE are currently unused.
The table below provides a plain-English description of what each opcode does during execution:
| Opcode | Description |
|---|---|
Noop |
Does nothing and continues to the next instruction. |
Add |
Pops two values and pushes their wrapping sum. |
Sub |
Pops two values and pushes their wrapping difference. |
Mul |
Pops two values and pushes their wrapping product. |
DivUnsigned |
Divides two values as unsigned integers and pushes the quotient. |
DivSigned |
Divides two values as signed integers and pushes the quotient. |
RemUnsigned |
Divides two values as unsigned integers and pushes the remainder. |
RemSigned |
Divides two values as signed integers and pushes the remainder. |
BitAnd |
Performs a bitwise AND on two values. |
BitOr |
Performs a bitwise OR on two values. |
BitXor |
Performs a bitwise XOR on two values. |
BitNot |
Inverts every bit of a value. |
ShiftLeft |
Shifts a value left by the specified number of bits. |
LogicShiftRight |
Shifts a value right logically, shifting zero bits in from the left. |
ArithShiftRight |
Shifts a signed value right arithmetically, preserving its sign. |
Dup |
Duplicates the value at the top of the operand stack. |
Pop |
Removes the value at the top of the operand stack. |
Swap |
Swaps the top two values on the operand stack. |
PushUnsigned4 |
Pushes a 4-bit unsigned immediate value encoded directly in the opcode. |
PushSigned4 |
Pushes a 4-bit signed immediate value encoded directly in the opcode. |
PushUnsigned8 |
Pushes an 8-bit unsigned immediate value. |
PushSigned8 |
Pushes an 8-bit signed immediate value, sign-extended to a stack word. |
PushUnsigned16 |
Pushes a 16-bit unsigned immediate value. |
PushSigned16 |
Pushes a 16-bit signed immediate value, sign-extended to a stack word. |
PushUnsigned32 |
Pushes a 32-bit unsigned immediate value. |
PushSigned32 |
Pushes a 32-bit signed immediate value, sign-extended to a stack word. |
Push64 |
Pushes a raw 64-bit immediate value. |
CompEqual |
Pushes 1 if two values are equal, otherwise 0. |
CompNotEqual |
Pushes 1 if two values are not equal, otherwise 0. |
CompLessThanUnsigned |
Compares two values as unsigned integers and pushes whether the first is less than the second. |
CompLessThanSigned |
Compares two values as signed integers and pushes whether the first is less than the second. |
CompLessThanOrEqualUnsigned |
Performs an unsigned less-than-or-equal comparison. |
CompLessThanOrEqualSigned |
Performs a signed less-than-or-equal comparison. |
CompGreaterThanUnsigned |
Performs an unsigned greater-than comparison. |
CompGreaterThanSigned |
Performs a signed greater-than comparison. |
CompGreaterThanOrEqualUnsigned |
Performs an unsigned greater-than-or-equal comparison. |
CompGreaterThanOrEqualSigned |
Performs a signed greater-than-or-equal comparison. |
JumpRelSigned4 |
Jumps by a signed 4-bit byte offset encoded directly in the opcode. |
JumpRelSigned8 |
Jumps by a signed 8-bit byte offset. |
JumpRelSigned16 |
Jumps by a signed 16-bit byte offset. |
JumpRelSigned32 |
Jumps by a signed 32-bit byte offset. |
JumpRelSigned64 |
Jumps by a signed 64-bit byte offset. |
JumpIfZeroRelSigned4 |
Pops a condition and performs a 4-bit relative jump if it is zero. |
JumpIfZeroRelSigned8 |
Pops a condition and performs an 8-bit relative jump if it is zero. |
JumpIfZeroRelSigned16 |
Pops a condition and performs a 16-bit relative jump if it is zero. |
JumpIfZeroRelSigned32 |
Pops a condition and performs a 32-bit relative jump if it is zero. |
JumpIfZeroRelSigned64 |
Pops a condition and performs a 64-bit relative jump if it is zero. |
CallRelSigned4 |
Saves the return address and calls a target using a signed 4-bit relative offset. |
CallRelSigned8 |
Saves the return address and calls a target using a signed 8-bit relative offset. |
CallRelSigned16 |
Saves the return address and calls a target using a signed 16-bit relative offset. |
CallRelSigned32 |
Saves the return address and calls a target using a signed 32-bit relative offset. |
CallRelSigned64 |
Saves the return address and calls a target using a signed 64-bit relative offset. |
JumpAbsIndirect |
Pops an absolute byte address from the operand stack and jumps to it. |
CallAbsIndirect |
Pops an absolute byte address, saves the return address, and calls it. |
Return |
Returns to the most recently saved call address. |
RegisterGet |
Pushes the value of the register encoded in the low nibble of the opcode onto the operand stack. |
RegisterSet |
Pops a value from the operand stack and stores it in the register encoded in the low nibble of the opcode. |
MemLoad8 |
Loads 1 byte from linear memory and zero-extends it to a stack word. |
MemLoad16 |
Loads 2 bytes from linear memory and zero-extends them to a stack word. |
MemLoad32 |
Loads 4 bytes from linear memory and zero-extends them to a stack word. |
MemLoad64 |
Loads 8 bytes from linear memory. |
MemStore8 |
Stores the low 8 bits of a value to linear memory. |
MemStore16 |
Stores the low 16 bits of a value to linear memory. |
MemStore32 |
Stores the low 32 bits of a value to linear memory. |
MemStore64 |
Stores all 64 bits of a value to linear memory. |
MemSize |
Pushes the current size of linear memory in bytes. |
MemGrow |
Grows linear memory by the requested number of bytes and pushes its previous size. |
MemCopy8 |
Copies a byte range within linear memory, including safely overlapping ranges. |
MemFill8 |
Fills a byte range of linear memory with the low 8 bits of a value. |
Halt |
Stops execution of the program. |
The assembler provides a human-readable layer over the bytecode instruction set.
Mnemonic commands are case-insensitive, while label names are case-sensitive.
Labels are written with a trailing colon:
loop:
PUSH 1
JMP loopComments begin with ; and continue to the end of the line:
PUSH 10 ; push ten
ADD ; add the top two valuesFor instructions with multiple available encodings, the assembler automatically chooses an appropriate compact encoding. PUSH selects the smallest encoding that can represent the value, while JMP, JZ, and CALL select the smallest relative-offset encoding that can reach the target label.
PADDR is an assembler pseudo-instruction that resolves a label to its absolute byte address and emits it using the Push64 opcode.
Some assembly commands map directly to a single opcode, while others select between several opcode encodings or expand to a different low-level instruction.
| Command | Description |
|---|---|
NOOP |
Does nothing and continues to the next instruction. |
PUSH value |
Pushes an integer value. The assembler automatically chooses the smallest suitable Push* encoding. |
PADDR label |
Pushes the absolute byte address of a label using Push64. |
ADD |
Adds the top two stack values. |
SUB |
Subtracts the top stack value from the value beneath it. |
MUL |
Multiplies the top two stack values. |
DIVU |
Divides the second stack value by the top value as unsigned integers. |
DIVS |
Divides the second stack value by the top value as signed integers. |
REMU |
Pushes the unsigned remainder of dividing the second stack value by the top value. |
REMS |
Pushes the signed remainder of dividing the second stack value by the top value. |
AND |
Performs a bitwise AND on the top two stack values. |
OR |
Performs a bitwise OR on the top two stack values. |
XOR |
Performs a bitwise XOR on the top two stack values. |
NOT |
Inverts every bit of the value at the top of the stack. |
SL |
Shifts the second stack value left by the number of bits given by the top value. |
LSR |
Shifts the second stack value right logically by the number of bits given by the top value. |
ASR |
Shifts the second stack value right arithmetically by the number of bits given by the top value. |
CEQ |
Pushes 1 if the top two stack values are equal, otherwise 0. |
CNE |
Pushes 1 if the top two stack values are not equal, otherwise 0. |
CLTU |
Compares the top two values as unsigned integers and pushes whether the lower stack value is less than the top value. |
CLTS |
Compares the top two values as signed integers and pushes whether the lower stack value is less than the top value. |
CLEU |
Performs an unsigned less-than-or-equal comparison. |
CLES |
Performs a signed less-than-or-equal comparison. |
CGTU |
Performs an unsigned greater-than comparison. |
CGTS |
Performs a signed greater-than comparison. |
CGEU |
Performs an unsigned greater-than-or-equal comparison. |
CGES |
Performs a signed greater-than-or-equal comparison. |
REGGET index |
Pushes the value of register index onto the operand stack. index must be between 0 and 15. |
REGSET index |
Pops a value from the operand stack and stores it in register index. index must be between 0 and 15. |
MEMLD8 |
Loads 1 byte from linear memory at the address on top of the stack. |
MEMLD16 |
Loads 2 bytes from linear memory at the address on top of the stack. |
MEMLD32 |
Loads 4 bytes from linear memory at the address on top of the stack. |
MEMLD64 |
Loads 8 bytes from linear memory at the address on top of the stack. |
MEMST8 |
Stores the low 8 bits of a value to linear memory. |
MEMST16 |
Stores the low 16 bits of a value to linear memory. |
MEMST32 |
Stores the low 32 bits of a value to linear memory. |
MEMST64 |
Stores all 64 bits of a value to linear memory. |
MEMSIZE |
Pushes the current size of linear memory in bytes. |
MEMGROW |
Grows linear memory by the requested number of bytes and pushes its previous size. |
MEMCOPY8 |
Copies a byte range within linear memory, safely handling overlapping ranges. |
MEMFILL8 |
Fills a byte range of linear memory with the low 8 bits of a value. |
DUP |
Duplicates the value at the top of the stack. |
POP |
Removes the value at the top of the stack. |
SWAP |
Swaps the top two stack values. |
JMP label |
Jumps to a label using the smallest suitable relative jump encoding. |
JZ label |
Pops a condition and jumps to a label if it is zero, using the smallest suitable relative encoding. |
CALL label |
Calls a label using the smallest suitable relative call encoding. |
JMPIND |
Pops an absolute byte address from the stack and jumps to it. |
CALLIND |
Pops an absolute byte address from the stack and calls it. |
RET |
Returns to the most recently saved call address. |
HALT |
Stops execution. |