This is an implementation of a custom virtual machine with its own (bytecode) instruction set. It is a hybrid stack and register based machine with support for interrupts, segmented heap and CPU privilege mode configuration.
(Please feel free to reach out or open an issue if you discover any VM bugs!)
After compiling the gvm with go build ., you can run the examples as follows:
- ./gvm examples/runtime.b examples/helloworld.b
- ./gvm examples/runtime.b examples/input.b
- ./gvm examples/runtime.b examples/loop.b
- ./gvm examples/poweroff.b
The GVM executable accepts a -debug flag as well for starting the program in debug mode. This mode supports single stepping through instructions, setting breakpoints and printing the final assembled program.
- Single core, 32-bit virtual architecture with little endian byte ordering
- Supports a set of hardware/software interrupts totaling 64
- 32 registers starting at index 0
-
- register 0 is the program counter
-
- register 1 is the stack pointer
-
- registers indexed 2 through 31 are general purpose
- 8 specialized registers (sr/srs) indexed after the last general purpose register
-
- sr 32 is the CPU "mode" - 0 means max privilege, 1 means unprivileged
-
- sr 33 is the frame counter used for
return/resumeinstructions
- sr 33 is the frame counter used for
-
- srs indexed 34-39 are currently unused
- Supports single stepping through instructions in VM debug mode
- Supports setting program breakpoints in VM debug mode
- Minimum of 65KB total memory (shared with interrupt addresses and process instructions)
- Stack grows down from max address -> min address
- Segmentation of heap when in non-privileged mode is possible by interfacing with memory controller device
- Supports 16 virtual devices
- Each communicates with the CPU asynchronously via response bus
- Currently first 4 device slots are occupied
-
- Slot/Port 0 (handler address 0x00) is the system timer
-
- Slot/Port 1 (handler address 0x04) is the power controller
-
- Slot/Port 2 (handler address 0x08) is the memory management unit
-
- Slot/Port 3 (handler address 0x0C) is the console IO
The majority of all instructions modify the stack in some way. They either push values to the stack, pop values from the stack, or both. However, a few instructions deal directly with modifying registers to enable storing temporary working values.
Since this virtual machine is mainly intended to be a software implementation that relies on instruction interpretation, going with a hybrid design was an attempt to strike a balance between the performance of a virtual register design and the simplicity of a virtual stack design. For more information on the performance tradeoffs in software VMs, see this paper.
Each bytecode occupies 1 byte, and when compiled into a machine instruction it is represented using 2 bytes. The lowest byte is the bytecode and the highest byte is the number of arguments (always 0, 1 or 2 arguments).
After the 2 instruction bytes, there are another 2 bytes that represent the register argument for register ops.
The final 4 bytes hold the 32-bit argument for the instruction (if needed).
In the following table, <> means the argument is required while [] means the argument is optional. For optional arguments, that generally means that if they are not supplied then the VM will get the arguments from the stack dynamically. For example:
pushwill pop stack[0] and use that as the byte count, whereas
push 81will use 81 as the byte count
The arithmetic and logical functions work similarly with their optional arguments. The optional argument determines whether the operation pulls 2 values from the stack, or just 1.
const 5
const 3
addiwill add 3+5 and push the result to the stack, whereas
const 3
addi 5will add 3+5 and push the result to the stack, but it used 1 less instruction and stack argument.
| Bytecode | Args | Description |
|---|---|---|
| nop | No operation | |
| byte | <constant> |
Pushes a 1-byte const value onto the stack |
| const | <constant> |
Pushes a 4-byte const value onto the stack |
| rload | <register> |
Loads value of register onto the stack |
| rstore | <register> |
Stores value of stack[0] into register and pops stack |
| rkstore | <register> |
Stores value of stack[0] into register and leaves stack unchanged |
| loadp8, loadp16, loadp32 | [offsetbytes] | Loads 8-, 16-, or 32-bit value from address at stack[0] onto stack (essentially stack[0] = *stack[0]) - result is widened to 32 bits. If offsetbytes is supplied, it becomes stack[0] = *(stack[0]+offsetbytes). |
| storep8, storep16, storep32 | [offsetbytes] | Narrows stack[1] to 8-, 16-, or 32-bits and writes it to address at stack[0] (essentially *stack[0] = cast(stack[1])). If offsetbytes is supplied, it becomes *(stack[0]+offsetbytes) = cast(stack[1]). |
| push | [constant] |
Reserve constant bytes on the stack |
| pop | [constant] |
Free bytes back to the stack |
| addi, addf | [constant] |
int and float add of either stack[0]+stack[1], or stack[0]+constant |
| subi, subf | [constant] |
int and float subtraction |
| muli, mulf | [constant] |
int and float multiplication |
| divi, divf | [constant] |
int and float division |
| remu, rems | [constant] |
unsigned and signed remainder after integer division of stack[0]/stack[1], or stack[0]/constant |
| remf | [constant] |
Remainder after floating point division of stack[0]/stack[1], or stack[0]/constant |
| not | Inverts all bits of stack[0] | |
| and | [constant] |
Logical AND between stack[0] and stack[1]/constant |
| or | [constant] |
Logical OR between stack[0] and stack[1]/constant |
| xor | [constant] |
Logical XOR between stack[0] and stack[1]/constant |
| shiftl | [constant] |
Shift stack[0] left by stack[1]/constant |
| shiftr | [constant] |
Shift stack[0] right by stack[1]/constant |
| jmp | [constant] |
Unconditional jump to address at stack[0]/constant |
| jz | [constant] |
Jump to address at stack[0]/constant if stack[1] (or stack[0] if constant is supplied) is 0 |
| jnz | [constant] |
Jump to address at stack[0]/constant if stack[1] (or stack[0] if constant is supplied) is not 0 |
| jle | [constant] |
Jump to address at stack[0]/constant if stack[1] (or stack[0] if constant is supplied) is <= 0 |
| jl | [constant] |
Jump to address at stack[0]/constant if stack[1] (or stack[0] if constant is supplied) is < 0 |
| jge | [constant] |
Jump to address at stack[0]/constant if stack[1] (or stack[0] if constant is supplied) is >= 0 |
| jg | [constant] |
Jump to address at stack[0]/constant if stack[1] (or stack[0] if constant is supplied) is > 0 |
| call | [address] |
Push next program address to the stack and jump either to [address] or stack[0] |
| return | [bytes] | Clear stack back to beginning of current stack frame and return to caller. If bytes argument is supplied, the top bytes on the stack are kept around for the caller. |
| resume | Similar to return, but for resuming previous execution after interrupt handler is done | |
| sysint | <address> |
Invokes a privileged interrupt handler at |
| raddi, raddf | <register> [constant] |
Add register to stack[0]/constant, update register and push new result to stack |
| rsubi, rsubf | <register> [constant] |
Subtract register from stack[0]/constant, update register and push new result to stack |
| rmuli, rmulf | <register> [constant] |
Multiply register with stack[0]/constant, update register and push new result to stack |
| rdivi, rdivf | <register> [constant] |
Divide register and stack[0]/constant, update register and push new result to stack |
| rshiftl | <register> [constant] |
Register shift left by stack[0]/constant, update register and push new result to stack |
| rshiftr | <register> [constant] |
Register shift right by stack[0]/constant, update register and push new result to stack |
| cmpu, cmps | unsigned and signed comparison between stack[0] and stack[1]: -1 if stack[0] < stack[1], 0 if stack[0] == stack[1] and 1 if stack[0] > stack[1] | |
| cmpf | floating point comparison between stack[0] and stack[1]: -1 if stack[0] < stack[1], 0 if stack[0] == stack[1] and 1 if stack[0] > stack[1] | |
| write | <port> <command> |
Performs a device write to request device at port perform some command (see below) |
| halt | Puts CPU into "waiting for next instruction" state, which is interruptible |
write <port> <command> is the primary way for privileged instructions to communicate with the different virtual devices connected to the CPU.
-
if
command= 0, performs get hardware device info (no arguments on stack are needed) -
- when this completes the stack will contain:
-
-
- stack[0] = HWID
-
-
-
- stack[1] = num metadata bytes (can be 0)
-
-
-
- stack[2]+ = metadata bytes
-
-
if
command= 1, performs get hardware device status (stack should contain 2 constant arguments of 0, 0 which means unused interaction id and no bytes as input) -
- when this completes it will push a 32-bit status code to the stack:
-
-
- 0x00 = device not found
-
-
-
- 0x01 = device ready (write req would succeed)
-
-
-
- 0x02 = device busy (write req would fail)
-
-
otherwise performs a device-specific operation
-
- input stack[0] should be the interaction id (for identifying request when response comes in)
-
- input stack[1] should be the number of bytes to write
-
- input stack[2] should be the start of the data to write
-
- when this completes the stack will contain a status code the same as if command = 1 (see above)
write 3 0 // gets device info for device #3
const 0 // no bytes
const 0 // unused interaction id
write 3 1 // gets device status for device #3
const 's'
const 4 // 4 byte input ('s')
const 0 // unused interaction id
write 3 2 // tells device 3 (console IO) to write a single character
const 0 // no bytes
const 123 // interaction id of 123 (32 bits)
write 3 4 // tells device 3 (console IO) to read a single 32-bit character-
command 2is "set new timer"
-
-
- expects 4 byte input representing microseconds
-
command 2is "perform restart"-
- expects no inputs
command 3is "perform poweroff"-
- expects no inputs
command 2is "set new min/max heap addr bounds" (only applies to non-privileged code)-
- expects 8 bytes of input
-
-
- first 4 bytes: min heap address
-
-
-
- next 4 bytes: max heap address
-
command 3is "update to previously set min/max based on privilege level"-
- expects no input
-
- if CPU mode is 0 (max privilege), unlocks entire memory address range
-
- if CPU mode is not 0 (non-privileged mode), resets to previous min/max heap addresses
command 2is "write a single 32-bit character"-
- expects 4 byte input
command 3is "write N bytes from address"-
- expects 8 byte input
-
-
- first 4 bytes: number of bytes to write
-
-
-
- next 4 bytes: address to start reading bytes from
-
command 4is "read 32-bit character"-
- expects no input
-
- when data comes in, it is forwarded to handler address 0x0C

