Skip to content

Latest commit

 

History

44 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

bytecode_substrate

A simple extensible stack-based bytecode virtual machine and assembler written in Rust.

Table of contents

Implementation notes

Endianness

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.

Registers

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.

Opcodes

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.

Opcode descriptions

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.

Assembler

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 loop

Comments begin with ; and continue to the end of the line:

PUSH 10      ; push ten
ADD          ; add the top two values

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

Assembler commands

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.

About

An assembler and bytecode interpreter. The intention is that higher-level languages can be built on top.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages