Language
Inline assembly
Available since Burn
26.1.0-experimental-5.
An asm block puts code for the machine straight into a Burn function. Burn builds for several targets, so one block
holds a section for each target it supports, and the compiler picks the section for the target it builds:
fun add(a: int, b: int): int {
var r = 0
unsafe {
asm {
x86_64 {
mov rax, {a}
add rax, {b}
mov {r}, rax
}
arm64 {
ldr x9, {a}
ldr x10, {b}
add x9, x9, x10
str x9, {r}
}
bvm {
load {a}
load {b}
iadd
store {r}
}
js {
{r} = {a} + {b};
}
}
}
return r
}
print(add(2, 40)) // 42 on every targetAssembly is unsafe code: an asm block must be in an unsafe block or an unsafe fun.
examples/low_level.bn in the Burn repository puts
inline assembly and raw memory together in one small program that runs on every target.
Sections
| Section | Used by | Written in |
|---|---|---|
x86_64 |
native executables for x86-64 (Linux and macOS) | x86-64 assembly in Intel syntax, as the GNU assembler and clang read it |
arm64 (or aarch64) |
native executables for 64-bit ARM (Apple silicon, Linux on arm64) | AArch64 assembly |
bvm |
burn app.bn, burni, burn run, the REPL, and bvm and bar builds |
bvm instructions |
js |
burn build --target js |
JavaScript statements |
else |
every target that has no section of its own | Burn |
The sections can come in any order, except that else comes last, and each target can appear once. The code of a
section is everything between its braces, so the braces inside it must balance, comments included.
Which native section is used depends on the machine the executable is built for: the one burn build runs on, or
the one BURN_PLATFORM names when cross-compiling.
The else section
else holds ordinary Burn code for every target without a section. It is checked like any other code, so it is the
natural place for a portable version of what the assembly does, and a good reference to test the assembly against:
fun popcount(x: int): int {
var n = 0
unsafe {
asm {
x86_64 {
popcnt rax, {x}
mov {n}, rax
}
else {
var v = x
while (v != 0) {
v = v & (v - 1)
n += 1
}
}
}
}
return n
}Missing sections
Without an else, a block only works on the targets it has sections for:
A native or JavaScript build stops with an error that names the block:
the `asm` block at app.bn:4:9 has no `js` section and no `else` section, so it cannot be built for jsOn bvm the program still runs, and reaching the block is a runtime error. That lets one program use native-only assembly behind a check, and still run with
burn app.bnwhile you work on it.
Operands
{name} in the code of a section stands for the variable name. It can be a local variable, a parameter, a global
variable or a variable that a closure captures, and it must be an int, a uint64 or a float:
error: `s` has type string, but an asm operand must be an `int`, `uint64` or `float`Each operand is copied into a slot of its own before the code runs, and copied back into the variable after it, so the code can read and change it in place. A constant is copied in but not back: changing it in the assembly has no effect. Using the same name twice gives the same slot.
What {name} turns into depends on the target:
| Target | {x} becomes |
For example |
|---|---|---|
x86_64 |
a memory operand for the slot | mov rax, {x} → mov rax, qword ptr [rbp - 24] |
arm64 |
an address for the slot | ldr x9, {x} → ldr x9, [x29, #16] |
bvm |
the number of the slot, for load, store and tee |
load {x} → load 3 |
js |
the name of the JavaScript variable | {x} += 1; → l3 += 1; |
Values are stored as 64 bits: an int in two's complement, a uint64 as an unsigned number, and a float as an
IEEE 754 double. In JavaScript, all three are numbers.
Only a name directly between braces is an operand. Anything else in braces is left alone, so in JavaScript write a
block that holds a single name with spaces, { x }.
Rules for native code
The code of an x86_64 or arm64 section is copied into the function's assembly line by line, without being
checked. It has to fit into the code around it:
Registers. Treat the block like the body of a C function. Caller-saved registers are free to use. Callee-saved registers must hold their old values when the block ends.
Free to use Restore before the end Never change x86-64 rax,rcx,rdx,rsi,rdi,r8–r11,xmm0–xmm15rbx,r12–r15rsp,rbparm64 x0–x17,v0–v7,v16–v31x19–x28,d8–d15sp,x29,x18The stack. Leave
rsp(orsp) where it was. Pushing and popping inside the block is fine as long as it balances. Burn's frame pointer (rbporx29) addresses the operands, so it must not move.Labels. The compiler may emit a function more than once (a generic function, once per type), so named labels can clash. Use numeric local labels:
1:defines one,1bjumps back to the closest1:before, and1fforward to the closest after.Calls. Calling other code from assembly is possible but rarely a good idea: you must align the stack (to 16 bytes on both targets) and save what the callee may change. Call a Burn function from Burn instead, before or after the block.
Syntax. x86-64 code uses Intel syntax without
%prefixes (mov rax, qword ptr [rbx + 8]), since the file is assembled with.intel_syntax noprefix.
Mistakes in the assembly itself, like a wrong mnemonic, show up as errors from the system assembler when the
executable is linked. burn dump --asm app.bn prints the whole assembly of the program with your code in place.
bvm
A bvm section holds bvm instructions, one per line. They work on bvm's operand stack: instructions take their
inputs from the top of the stack and push their result. ; starts a comment, and a line name: is a label.
fun popcount(x: int): int {
var n = 0
unsafe {
asm {
bvm {
top:
load {x}
const 0
ieq
jnz done ; stop when x == 0
load {x}
load {x}
const 1
isub
and
store {x} ; x = x & (x - 1)
load {n}
const 1
iadd
store {n} ; n += 1
jmp top
done:
}
else {
var v = x
while (v != 0) {
v = v & (v - 1)
n += 1
}
}
}
}
return n
}These are the instructions a section can use:
| Instructions | Stack | What they do |
|---|---|---|
const n |
→ value | push a whole number written in decimal |
load {x} |
→ value | push an operand |
store {x} |
value → | pop the top value into an operand |
tee {x} |
value → value | copy the top value into an operand and keep it |
pop |
value → | drop the top value |
iadd isub imul |
a b → r | integer arithmetic that wraps around |
iadd.ovf isub.ovf imul.ovf |
a b → r | integer arithmetic that stops the program on overflow |
idiv irem |
a b → r | division and remainder, which stop the program when b is 0 |
ineg.ovf |
a → r | negation that stops the program on overflow |
and or xor shl shr ushr |
a b → r | bitwise operations and shifts |
fadd fsub fmul fdiv |
a b → r | float arithmetic |
fneg |
a → r | float negation |
i2f f2i |
a → r | convert between int and float |
ieq ine ilt ile igt ige |
a b → bool | compare ints (u… for uint64, f… for floats) |
not |
bool → bool | logical not |
jmp label |
jump | |
jz label jnz label |
value → | jump when the value is zero or false (jz), or not (jnz) |
Unlike native code, the compiler checks a bvm section before it builds anything. Unknown instructions, missing
arguments, labels that do not exist and slot numbers written by hand are errors, and so is code that leaves the stack
unbalanced:
error: `iadd` needs 2 values on the stack, but there are only 1
--> app.bn:7:17
|
7 | iadd
| ^^^^The stack has to hold the same number of values on every path to a label, and the section has to end with the stack
as it found it. Runtime errors from idiv, irem and the .ovf instructions point at the bvm section.
burn dump --bvm app.bn prints the bytecode the section became.
JavaScript
A js section holds JavaScript statements that are copied into the generated function, with each operand replaced
by the name of its variable. Write statements, not a function body: a return would leave the Burn function without
its result. burn dump --js app.bn shows the generated JavaScript.
Formatting
burn fmt and burnfmt do not change the code of x86_64, arm64, bvm and js sections. They only move a
section as a whole to the indentation of its block, and keep the indentation of its lines relative to each other, so
labels written further left stay further left. The else section is Burn and is formatted like the rest.
Under the hood
The parser reads each section, other than else, as one piece of raw text. The type checker resolves the operands,
gives each one a slot, and checks bvm code. The IR keeps the code of every section,
the statements that copy operands in and out, and the else code, and each code generator picks the section for its
own target. That is why the else code, and the operands, behave exactly like the rest of the function, including
smart casts, closures and memory management.
The compiler uses inline assembly itself. Turning a float constant into its bits for bvm or native code is a single
copy in assembly, so floatToBits and floatFromBits in compiler/src/vm/bits.bn have bvm, x86_64 and arm64
sections, with the arithmetic version as the else fallback:
pub fun floatToBits(f: float): int {
if (isNan(f)) {
return CANONICAL_NAN
}
var bits = 0
unsafe {
asm {
bvm {
load {f}
store {bits}
}
x86_64 {
mov rax, {f}
mov {bits}, rax
}
arm64 {
ldr x9, {f}
str x9, {bits}
}
else {
bits = portableToBits(f)
}
}
}
return bits
}This works because bvm keeps every value as 64 bits and checks only the height of its stack, not what a slot
holds: storing a float in an int slot keeps its bits as they are.