The Burn VM
Embedding bvm
bvm is written in Burn, in compiler/src/bvm/ of the Burn repository, and its
parts are ordinary Burn modules. A Burn program that imports them can build bvm modules, write and read them, link
them and run them in its own process, with functions of its own that the bytecode calls. The bvm command, the Burn
command line and Ember all work this way.
| Module | Gives |
|---|---|
compiler/src/vm/module.bn |
the module types: VModule, VFunc, Op, Desc, Import, Table, Sig, annotations |
compiler/src/bvm/binary.bn |
emptyModule(), encode(module) and decode(bytes): .bvmc bytecode |
compiler/src/vm/asm.bn |
disassemble(module): .bvm assembly text |
compiler/src/bvm/assemble.bn |
assemble(text): a module from assembly, with the line of the first error |
compiler/src/bvm/verify.bn |
analyze(module): the verifier, with the deepest stack of each function |
compiler/src/bvm/link.bn |
link(modules, allowUnresolved, skipMixins): the linker and mixins |
compiler/src/bvm/archive.bn |
decodeArchive, encodeArchive, linkArchive: archives |
compiler/src/bvm/interp.bn |
prepare, runEntry, call: the interpreter and the JIT |
compiler/src/bvm/files.bn |
readBytes(path) and writeBytes(path, bytes) |
bvm calls the C library and the runtime directly (through @Extern), so a program that
embeds it is built as a native executable: burn build host.bn -o host.
Building a module
A VModule is plain data: types, strings, source locations, globals, imports, functions, dispatch tables,
annotations and the entry function. emptyModule() starts one with the built-in types (3 is int, 6 is
string, see values and the runtime library). A function is a VFunc whose code is a list of Op
values; jumps name the index of the instruction they go to.
import "burn/compiler/src/bvm/binary.bn"
import "burn/compiler/src/bvm/files.bn"
import "burn/compiler/src/bvm/interp.bn"
import "burn/compiler/src/vm/asm.bn"
import "burn/compiler/src/vm/module.bn"
import "burn/compiler/src/check/hir.bn"
import "std/process"
fun main() {
var m = emptyModule()
m.imports.push(Import { name: "square", argc: 1 })
var fact = VFunc {
name: "fact",
params: 1,
locals: 1,
names: ["n"],
code: [
Op.Load(0), Op.Const(1), Op.ICmp(Cmp.Le), Op.Jnz(11),
Op.Load(0), Op.Load(0), Op.Const(1), Op.ISub, Op.Call(0), Op.IMul, Op.Ret,
Op.Const(1), Op.Ret
],
external: false,
sig: null
}
var main = VFunc {
name: "main",
params: 0,
locals: 0,
names: [],
code: [
Op.Const(10), Op.Call(0), Op.Host(0), Op.Const(3), Op.Rt("ToStr"), Op.Rt("Print"), Op.Pop,
Op.RetVoid
],
external: false,
sig: null
}
m.funcs = [fact, main]
m.entry = 1
print(disassemble(m))
writeBytes("fact.bvmc", encode(m))
var square = fun(args: [int]): int {
return args[0] * args[0]
}
var prog = prepare(m, [HostFn { name: "square", argc: 1, call: square }])
if (prog.error != "") {
eprint(prog.error)
exit(1)
}
runEntry(prog)
print(call(prog, 0, [5]))
}With the repository checked out next to it as burn/:
burn build host.bn -o host && ./host; bvm module, format 2
import square 1
entry main
func fact(n)
load n
...
13168189440000
120disassemble prints the module as assembly, encode turns it into .bvmc bytecode, and prepare
verifies, links and prepares it to run. runEntry runs the entry function, waits for the tasks it started and writes
out what it printed; call(prog, f, args) calls function f with arguments and gives its result.
Reading modules
var d = decode(readBytes("app.bvmc") ?? [])
var a = assemble(readFile("app.bvm"))
var linked = link([d.module, a.module], false, false)
var check = analyze(linked.module)Every function that can fail gives a value with an error text, empty when it worked, instead of stopping the
program, so a host can report problems its own way: d.error, a.error (at line a.line), linked.error and
check.error, which is empty when the module verifies.
Host functions
A HostFn names a function the bytecode imports, the number of arguments it takes and the Burn function that runs it.
Its arguments come as raw 64-bit words, and it returns one: numbers as they are, strings and other objects as the
address of their heap object. prepare fails with an error when the module imports a function the host does not
provide, or imports it with a different number of arguments. Burn code declares host functions with
@Native("name") (native interop), and assembly with import name <argc>.
programGlobals(prog) and setProgramGlobals(prog, values) read and set the globals of a prepared program, which is
how the REPL keeps a session's variables between inputs.
The bytecode format
.bvmc files are little-endian:
"BVM\0" u16 format version (2) u16 flags (0)
name the module name (u32 length, UTF-8 bytes)
types u32 count, then one descriptor per type from id 12 on
strings u32 count, then (u32 length, UTF-8 bytes) each
locs same layout as strings
globals same layout as strings
imports u32 count, then (name, u32 argc) each
tables u32 count, then (name, u32 argc, u32 entries, (u32 type id, u32 function) each) each
entry u32 function index, or 0xffffffff for none
runtime u32 count, then the names of the runtime functions the code uses
functions u32 count, then (name, u8 flags, [signature], u32 params, u32 locals, u32 name count, names,
u32 op count, ops) each; flag 1 = extern, flag 2 = has a signature (u32 count, type ids, u32 result)
notes u32 count, then (u8 target kind, u32 index, name, u32 argument count, (key, typed value) each) eachFormat 2 added the module name, extern functions, signatures and annotations (the notes section). bvm still
reads format 1 files.
Each instruction is a one-byte opcode followed by fixed-size operands (u32 indexes, a u64 for const, one byte for
a comparison). rt operands index the file's runtime name list rather than the VM's internal numbering, so
bytecode keeps working when the runtime gains functions. The decoder checks every length and index. A damaged
file is rejected with an error instead of crashing the VM.