Crates.io | extendable_vm |
lib.rs | extendable_vm |
version | 0.4.0 |
source | src |
created_at | 2021-07-18 12:22:42.865443 |
updated_at | 2021-07-29 17:41:29.432337 |
description | Library that simplifies implementation of stack VMs |
homepage | |
repository | |
max_upload_size | |
id | 424366 |
size | 45,814 |
Simplifies writing stack virtual machines in Rust
Just define your:
and then run the VM!
This was originally a part of jex_vm, a stack VM for my simple programming language Jex.
Just add extendable_vm to Cargo.toml
:
[dependencies]
extendable_vm = "<latest version>"
You can get the latest version from the Releases page.
If you are using extendable_vm in your binary executable and wish to view all VM logs
then add extendable_vm
to RUST_LOG
environment variable: RUST_LOG=extendable_vm
.
If your environment variable already defines a list of options (RUST_LOG=a,b,c
)
then just append extendable_vm: RUST_LOG=a,b,c,extendable_vm
For example,
RUST_LOG=extendable_vm ./your_binary_exec path/to/bytecode
The virtual machine reads Code which consists of several independent parts called Chunks, which contain executable code and constants (such as 1
, 2
, or "Hello World"
). The VM has an operand stack, a call stack and can jump inside one chunk or between chunks.
Executable code is just an array of bytes that encodes a list of instructions that should be run. Each instruction has its unique id -- opcode and a number of arguments that it accepts.
For example, if instruction A
with opcode = 7 accepts 2 arguments then we can run 7 1 2 7 3 4
which means run A(1, 2); run A(3, 4)
.
To construct your own VM you must define:
State of the VM is represented by a Machine<Constant, Value>
struct. It stores:
Constant
is the type of the constant values in bytecode.
Value
s are operands that the VM manipulates.
Each instruction has its unique ID -- op_code
, name
that is used for debugging.
And a function instruction_fn
that implements the logic of the instruction.
pub struct Instruction<Constant, Value> {
pub op_code: u8,
pub name: &'static str,
pub instruction_fn: InstructionFn<Constant, Value>,
}
InstructionFn
can be interpreted as a simple function that accepts the state of the VM
and a list of arguments that the instruction receives and mutates the VM state.
But it also has several features that simplify defining new instructions.
Const
, UnaryOp
and BinaryOp
simplify the creation on nullary, unary and binary operator instructions respectively.
pub enum InstructionFn<Constant, Value> {
// Simple function that I described above
Raw {
byte_arity: usize,
instruction_fn: RawInstructionFn<Constant, Value>,
},
// Instruction that generates a value and pushes it onto the stack
Const(fn() -> Value),
// Unary operator instruction that pops the value from stack,
// produces new value and pushes it onto the stack
UnaryOp(fn(value: Value) -> Result<Value, Exception>),
// The same as unary operator but pops 2 values
BinaryOp(fn(left: Value, right: Value) -> Result<Value, Exception>),
}
// Simple function that I described above
// (mut VM State, instruction arguments) -> may return Exception
pub type RawInstructionFn<Constant, Value> = fn(
machine: &mut Machine<Constant, Value>,
args_ip: InstructionPointer,
) -> Result<(), Exception>;
This section describes how bytecode can be accessed in API and how it is represented in a binary file.
In the context of binary data struct
s are used as a way to demonstrate what each byte means.
Each struct in this context should be viewed as an array of bytes
where each value directly follow the previous (without padding and packing).
For example, struct A
represents bytes a1 a2 b
where a1
and a2
correspond to a: u16
and b
to b: u8
.
struct A {
a: u16,
b: u8
}
Virtual machine reads Code
(bytecode) and executes it. Code
consists of several independent executable pieces -- Chunk
s. For instance, each function should be defined as a separate Chunk
.
// API
pub struct Code<Constant> {
pub chunks: Vec<Chunk<Constant>>,
}
// in binary file
struct _Code<Constant> {
chunks: [_Chunk<Constant>]
}
In a binary file Code
is represented as an array of bytes where all chunks are concatenated. For example, if chunk1
is represented by bytes 00 01
and chunks2
-- 02 03
. Then code [chunk1, chunk2]
is 00 01 02 03
.
Each Chunk
has several constants
and executable code
which is just an array of bytes.
// API
pub struct Chunk<Constant> {
pub constants: Vec<Constant>,
pub code: Vec<u8>,
}
// in binary file
struct _Chunk<Constant> {
// number of constants
n_constants: u8,
// array of constants of size `n_constants`
// each constant is encoded as an array of bytes and is parsed by a constant parser
constants: [Constant],
// number of bytes in `code`
n_code_bytes: u16,
// executable code
code: [u8]
}
CodeParser
and ConstantParser
are useful abstractions that simplify parsing bytecode.
However, using them is not necessary and you may create a Code
struct in any way you want.
CodeParser
assumes that all chunk constants are represented in a binary file by a unique id and an array of bytes.
Each type of constants should be parsed by a separate ConstantParser
.
For example, if we have IntConstant
that holds i32
we can define a parser:
// in binary file
struct _IntConstant {
// unique ID = 0
constant_type: 0 as u8, // used only to demonstrate binary data
// 4 bytes that represent i32
data: [u8]
}
const INT_CONSTANT_PARSER: ConstantParser<i32> = ConstantParser {
constant_type: 0 as u8,
parser_fn: parse_int_constant,
};
// parses `data` and returns i32 or on exception
fn parse_int_constant(
// the entire code
bytes: &RawBytes,
// points to the current reading position in `bytes`
// initially points to the start of `data`
pointer: &mut RawBytesPointer,
) -> Result<i32, Exception> {
// all read operations advance the `pointer`
Ok(bytes.read_i32(pointer).unwrap())
}
cargo build
cargo build --release
cargo test
I wanted to learn about compilers and programming languages and ended up reading this great book Crafting Interpreters and making my programming language Jex.
This was originally a part of a simple VM for my programming language jex_vm, my first Rust project.
The design of this library is inspired by stack_vm which helped a lot since I did not know anything about Rust before working on this project.