Skip to content

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

Latest commit

 

History

84 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, embedded-data length fields 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 RT 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.

Registers can be accessed using RGET and RSET. When a register index is provided, the assembler emits a direct register instruction. When the index is omitted, the VM pops the register index from the operand stack.

Register indices must be between 0 and 15. An out-of-range index causes execution to fail.

Host calls do not modify registers.

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.

Stack effects are written from bottom to top, with the rightmost value representing the top of the operand stack.

Stack words are raw 64-bit values. Signed instructions interpret them as signed two's-complement integers, while unsigned instructions interpret them as unsigned integers.

Relative jump and call offsets are measured in bytes from the address immediately following the instruction.

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 Pop [a] → []
0x10 Swap [a, b] → [b, a]
0x11 PushUnsigned8 [] → [value]
0x12 PushSigned8 [] → [value]
0x13 PushUnsigned16 [] → [value]
0x14 PushSigned16 [] → [value]
0x15 PushUnsigned32 [] → [value]
0x16 PushSigned32 [] → [value]
0x17 Push64 [] → [value]
0x18 CompEqual [a, b] → [a == b]
0x19 CompNotEqual [a, b] → [a != b]
0x1A CompLessThanUnsigned [a, b] → [a < b]
0x1B CompLessThanSigned [a, b] → [a < b]
0x1C CompLessThanOrEqualUnsigned [a, b] → [a <= b]
0x1D CompLessThanOrEqualSigned [a, b] → [a <= b]
0x1E CompGreaterThanUnsigned [a, b] → [a > b]
0x1F CompGreaterThanSigned [a, b] → [a > b]
0x20 CompGreaterThanOrEqualUnsigned [a, b] → [a >= b]
0x21 CompGreaterThanOrEqualSigned [a, b] → [a >= b]
0x22 JumpRelSigned8 [] → []
0x23 JumpRelSigned16 [] → []
0x24 JumpRelSigned32 [] → []
0x25 JumpRelSigned64 [] → []
0x26 JumpIfZeroRelSigned8 [condition] → []
0x27 JumpIfZeroRelSigned16 [condition] → []
0x28 JumpIfZeroRelSigned32 [condition] → []
0x29 JumpIfZeroRelSigned64 [condition] → []
0x2A CallRelSigned8 [] → []
0x2B CallRelSigned16 [] → []
0x2C CallRelSigned32 [] → []
0x2D CallRelSigned64 [] → []
0x2E JumpAbsIndirect [address] → []
0x2F JumpIfZeroAbsIndirect [address, condition] → []
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 MemStore128 [address, w0, w1] → []
0x3B MemStore256 [address, w0, ..., w3] → []
0x3C MemStore512 [address, w0, ..., w7] → []
0x3D MemStore1024 [address, w0, ..., w15] → []
0x40–0x4F PickUnsigned4 [..., value, ...] → [..., value, ..., value]
0x50 PickUnsigned8 [..., value, ...] → [..., value, ..., value]
0x51 PickUnsigned16 [..., value, ...] → [..., value, ..., value]
0x52 PickUnsigned32 [..., value, ...] → [..., value, ..., value]
0x53 PickUnsigned64 [..., value, ...] → [..., value, ..., value]
0x54 RegGetIndirect [index] → [value]
0x55 RegSetIndirect [value, index] → []
0x56 MemSize [] → [size]
0x57 MemGrow [amount] → [old_size]
0x58 MemCopy [destination, source, length] → []
0x59 MemFill [address, value, length] → []
0x5A MemBytecodeCopy [destination, bytecode_source, length] → []
0x60–0x6F RegisterGet [] → [value]
0x70–0x7F RegisterSet [value] → []
0x80 DataUnsigned8 [] → []
0x81 DataUnsigned16 [] → []
0x82 DataUnsigned32 [] → []
0x83 DataUnsigned64 [] → []
0x84–0x8F DataLength1–DataLength12 [] → []
0x90–0x9F PushUnsigned4 [] → [0..15]
0xA0–0xAF PushSigned4 [] → [-8..7]
0xB0–0xBF CallRelSigned4 [] → []
0xC0–0xCF JumpIfZeroRelSigned4 [condition] → []
0xD0–0xDF JumpRelSigned4 [] → []
0xE0–0xFD HostCallDirect Host-call dependent
0xFE HostCallIndirect [id] → [] (excluding host-specific effects)
0xFF Halt [] → []

The following opcode ranges are currently unassigned:

  • 0x3E–0x3F
  • 0x5B–0x5F

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.
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.
JumpIfZeroAbsIndirect Pops a condition and an absolute byte address from the operand stack. Jumps to the address if the condition is zero; otherwise continues execution. Both values are consumed.
CallAbsIndirect Pops an absolute byte address, saves the return address, and calls it.
Return Returns to the most recently saved call address.
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 Pops a value and a destination address, then stores the low 8 bits of the value to linear memory.
MemStore16 Pops a value and a destination address, then stores the low 16 bits of the value to linear memory.
MemStore32 Pops a value and a destination address, then stores the low 32 bits of the value to linear memory.
MemStore64 Pops a 64-bit value and a destination address, then stores the value as 8 bytes in linear memory.
MemStore128 Pops two 64-bit values and a destination address, then stores the values as 16 consecutive bytes in linear memory.
MemStore256 Pops four 64-bit values and a destination address, then stores the values as 32 consecutive bytes in linear memory.
MemStore512 Pops eight 64-bit values and a destination address, then stores the values as 64 consecutive bytes in linear memory.
MemStore1024 Pops sixteen 64-bit values and a destination address, then stores the values as 128 consecutive bytes in 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.
MemCopy Pops a length, source address and destination address, then copies the specified number of bytes within linear memory, safely handling overlapping ranges.
MemFill Pops a length, value and destination address, then fills the specified number of bytes in linear memory with the low 8 bits of the value.
MemBytecodeCopy Pops a byte count, source bytecode address and destination linear-memory address, then copies the specified bytes from bytecode into linear memory.
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.
DataUnsigned8 Reads an unsigned 8-bit payload length and skips that many bytes in the bytecode.
DataUnsigned16 Reads an unsigned 16-bit payload length and skips that many bytes in the bytecode.
DataUnsigned32 Reads an unsigned 32-bit payload length and skips that many bytes in the bytecode.
DataUnsigned64 Reads an unsigned 64-bit payload length and skips that many bytes in the bytecode.
DataLength1–DataLength12 Skips 1–12 embedded bytes, with the payload length encoded directly in the opcode.
PickUnsigned4 Copies a value from the operand stack at a depth encoded in the opcode's low nibble.
PickUnsigned8 Copies a value from the operand stack at an unsigned 8-bit depth.
PickUnsigned16 Copies a value from the operand stack at an unsigned 16-bit depth.
PickUnsigned32 Copies a value from the operand stack at an unsigned 32-bit depth.
PickUnsigned64 Copies a value from the operand stack at an unsigned 64-bit depth.
RegGetIndirect Pops a register index and pushes the value stored in that register.
RegSetIndirect Pops a register index followed by a value, and stores the value in that register.
HostCall Pops a host-call identifier and invokes the corresponding host-defined operation. Arguments and results are defined by the individual host call.
HostCallDirect Invokes a host call with an ID between 0 and 29, encoded directly in the opcode byte. The ID is not popped from the operand stack.
HostCallIndirect Pops a host-call ID from the operand stack and invokes it. Supports any u64 host-call ID.
Halt Stops bytecode execution.

Assembler

The assembler provides a human-readable layer over the bytecode instruction set.

Mnemonic commands and label names are case-insensitive. Labels retain their original spelling in diagnostic messages, and names differing only by case are treated as duplicates.

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 accepts an integer literal, a named constant or a label name. Integer literals and named constants use the smallest suitable encoding, while label names resolve to absolute byte addresses and are encoded using Push64.

The standard host-call names (e.g. WROUT, WRERR, RDIN and TERM) are available as case-insensitive named constants and cannot be used as label names.

HOST uses a single-byte direct encoding for host-call IDs 0–29. For other IDs, it emits the smallest suitable PUSH encoding followed by HostCallIndirect.

The operands of JMP, JZ, CALL, RGET, RSET and HOST are optional. When an operand is omitted, the corresponding instruction obtains its target, register index or host-call ID from the operand stack.

String literals are double-quoted and single-line. Supported escapes are \n, \r, \t, \0, \\, \" and \xNN (two hexadecimal digits, interpreted as one raw byte). Semicolons within strings do not begin comments. Empty strings are allowed and simply push the length 0.

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_or_name_or_label Pushes an integer literal, named constant or absolute label address. Uses the smallest suitable encoding for integer values and Push64 for label addresses.
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.
RGET [index] Pushes the value of the specified register. If index is omitted, pops the register index from the stack.
RSET [index] Pops a value and stores it in the specified register. If index is omitted, pops the register index from the stack first.
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 Pops a value and a destination address, then stores the low 8 bits of the value to linear memory.
MEMST16 Pops a value and a destination address, then stores the low 16 bits of the value to linear memory.
MEMST32 Pops a value and a destination address, then stores the low 32 bits of the value to linear memory.
MEMST64 Pops a 64-bit value and a destination address, then stores the value as 8 bytes in linear memory.
MEMST128 Pops two 64-bit values and a destination address, storing 16 consecutive bytes in linear memory.
MEMST256 Pops four 64-bit values and a destination address, storing 32 consecutive bytes in linear memory.
MEMST512 Pops eight 64-bit values and a destination address, storing 64 consecutive bytes in linear memory.
MEMST1024 Pops sixteen 64-bit values and a destination address, storing 128 consecutive bytes in linear memory.
MEMSZE Pushes the current size of linear memory in bytes.
MEMGRW Grows linear memory by the requested number of bytes and pushes its previous size.
MEMCP Copies a byte range within linear memory, safely handling overlapping ranges.
MEMFL Fills a byte range of linear memory with the low 8 bits of a value.
PICK depth Copies the value at the specified unsigned stack depth, using the smallest suitable encoding.
DUP Alias for PICK 0. Emits the 0x40 opcode.
POP Removes the value at the top of the stack.
SWP Swaps the top two stack values.
JMP [label] Jumps to a label using the smallest suitable relative encoding. If omitted, pops an absolute target address from the stack.
JZ [label] Pops a condition and jumps to label if it is zero, using the smallest suitable relative encoding. If label is omitted, also pops an absolute target address from the stack and jumps to it if the condition is zero.
CALL [label] Calls a label using the smallest suitable relative encoding. If omitted, pops an absolute target address from the stack.
RT Returns to the most recently saved call address.
DATA name "string" Declares a named sequence of bytes embedded in the bytecode using a skippable data opcode.
MEMDTCP "string" or MEMDTCP name Copies inline or previously declared data into linear memory, preserving the destination address and pushing the byte length.
HOST [name_or_id] Invokes a host call using its symbolic name or numeric ID, automatically selecting the shortest encoding. If omitted, pops the host-call ID from the stack.
INC "path" Inserts another assembly source file at the current position. Supports nested includes and relative paths. Emits no opcode.
HALT Stops execution.

Including assembly files

The INC "path" directive inserts another assembly source file at the current position before assembly. It does not emit a VM opcode.

Paths are resolved relative to the directory containing the file with the INC directive. Nested includes are supported, and circular includes are rejected.

For example:

INC "lib/std/registers.sub.asm"

Included files share the same case-insensitive label and data-symbol namespace as the main file.

Each source file is included at most once per assembly operation. Subsequent INC directives referencing the same file are ignored, including when the file is reached through nested includes or different relative paths. File identity is determined using canonical filesystem paths.

Host calls

Host calls provide functionality supplied by the embedding host rather than by the VM itself.

The VM only dispatches host-call identifiers. The arguments, results and behaviour of each host call are defined by the host.

ID Assembly-code name Stack effect Description
0 WROUT [address, length] → [] Writes length bytes from linear memory starting at address to standard output.
1 WRERR [address, length] → [] Writes length bytes from linear memory starting at address to standard error.
2 RDIN [address, max_length] → [bytes_read] Reads up to max_length bytes from standard input into linear memory starting at address, returning the number of bytes read.
255 TERM [exit_code] → [] Terminates bytecode execution with the specified exit code.

Host calls can be invoked using either their symbolic names or numeric IDs. For example, HOST WROUT and HOST 0 are equivalent. Symbolic names are case-insensitive.

Host-call IDs 0–29 have compact single-byte encodings (0xE0–0xFD). Other IDs are supported through HostCallIndirect (0xFE), which obtains the ID from the operand stack. The operand-free assembly instruction HOST emits this indirect opcode.

Numeric IDs also allow programs to invoke custom host calls provided by the embedding host.

The stack effects shown above exclude the host-call identifier itself. Direct host-call instructions do not consume an identifier from the stack, while HostCallIndirect does.

Example

The following short example, in assembly code, writes the character A followed by a newline character to standard output:

; Allocate two bytes of memory.
PUSH 2
MEMGRW
POP         ; Discard the previous memory size.

; Store the ASCII value of 'A' at address 0.
PUSH 0
PUSH 0x41   ; This is the ASCII value of 'A' in hexadecimal notation.
MEMST8

; Store the ACSII value of '\n', a newline, at address 1.
PUSH 1
PUSH 0x0A
MEMST8

; Write both bytes, beginning at address 0, to standard output.
PUSH 0
PUSH 2
HOST WROUT

Program termination

A program can finish execution in the following ways:

Termination Behaviour
HALT Stops execution normally, without an explicit exit code.
End of bytecode Stops execution normally when the instruction pointer reaches the end of the program.
HOST TERM or HOST 255 Stops execution immediately and returns an explicit exit code to the host.

In all three cases, the VM returns the remaining operand stack.

Normal completion produces no explicit exit code. The command-line runner interprets this as a successful exit with status 0.

Invalid command-line arguments cause the runner to exit with a failure status.

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