burn / docs

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
120

disassemble 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) each

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