About WebAssembly
WebAssembly is an open specification for:
- a stack-based virtual machine (standalone or embeddable in a host environment like a web browser or any other program)
- modules, executed as standalone programs or libraries of functions on this VM
- a binary encoding for modules, usually saved as
.wasm
files: useful for network transfer, disk storage and actually running wasm code - a textual representation of modules, usually saved as
.wat
files: it is based on S-expressions and is useful for humans to write and debug wasm modules by hand.
This is a generic and flexible middle-level virtual machine:
-
unlike JVM/CLR/v8, it is not tied to a GC and object system. Its basic types are just primitive numbers (
i32
,i64
,f32
,f64
), function references and opaque references. The computation happens on its internal stack and can only use a strictly isolated linear memory. -
unlike actual machine instruction sets like x86_64/aarch64/risc-v, it is strongly typed and has structured control flow that makes arbitrary jumps impossible.
-
it provides strong and verifiable isolation and is a hermetic sandbox; imported and exported WASM functions are entirely controlled by the host; no host capabilities (I/O, DOM API, ...) are accessible by default.
-
despite "Web" in its name, it does not require JavaScript or any other web technologies, even though it has a standardized JavaScript API. For example, wasm3 is an implementation that successfully runs on microcontrollers.
-
the stack-based bytecode is simple, compact and easy to (JIT-)compile to native code
Data Types and Stack Machine
a quickstart example
Every computation in WASM happens on the stack. For examples of stack-based languages see Forth. JVM or CLR are another examples of abstract stack machines.
As an example, subtracting two i32
s (32-bit signed integers) looks like this:
;; the run-time stack: [...]
i32.const 3
;; [... 3], constant value 3 of type i32 is pushed on the stack
i32.const 2
;; [... 3 2]
i32.sub
;; [... 1] - the result 1 is left on the stack
This can also be writen in a more structured, folded form:
(i32.sub
(i32.const 3)
(i32.const 2))
Both formats translate into the same binary encoding:
41 03 ;; i32.const 3
41 02 ;; i32.const 2
6b ;; i32.sub
Stacks are local to a function call.
(func $sub (result i32)
(i32.sub (i32.const 3) (i32.const 2))
;; always returns 1
)
Here:
$sub
gives this function an optional human-readable name, which is an alias to the number (index, idx) of this function in its module.(func (result i32) ...)
is also valid.- if a function takes any parameters, each of them is listed after its name as
(param $opt-arg-name i32)
wherei32
(or any other type) is the argument type.$opt-arg-name
is again an optional human-readable name and an alias to the parameter index inside the function. - if a function returns a value, it should be specified as
(result i32)
(i32
or any other type). - the VM instructions follow
running a module
In order to be runnable from outside, functions must be wrapped in a module and export-ed from it:
(module
(func (export "sub") (result i32)
(i32.sub (i32.const 3) (i32.const 2))
))
You can run this example with a WASM runtime like wasmtime, wasmer, wasm3 and many others. NodeJS/Firefox/Chrome also contain a built-in WASM runtime accessible via a Javascript API:
$ wasmtime run --invoke sub sub.wat
1
.wat
files can be translated into binary modules using e.g. wasm-as
from the binaryen utilities suite:
$ wasm-as sub.wat -o sub.wasm
$ xxd sub.wasm
00000000: 0061 736d 0100 0000 0105 0160 0001 7f03 .asm.......`....
00000010: 0201 0007 0701 0373 7562 0000 0a09 0107 .......sub......
00000020: 0041 0341 026b 0b .A.A.k.
or disassembled back into wat using wasm-dis
:
$ wasm-dis sub.wasm
(module
(type $0 (func (result i32)))
(export "sub" (func $0))
(func $0 (result i32)
(i32.sub
(i32.const 3)
(i32.const 2)
)
)
)
We will look into the details of the binary encoding later.
The produced sub.wasm
can also be transfered and executed somewhere, e.g. in NodeJS:
#!/usr/bin/env node
const fs = require('fs');
async function main(wasmPath, wasmFunc, ...args) {
const wasmBuffer = fs.readFileSync(wasmPath);
let wasmModule = await WebAssembly.instantiate(wasmBuffer);
let func = wasmModule.instance.exports[wasmFunc];
let result = await func(...args);
console.log(result);
}
const process = require('process');
main(...process.argv.slice(2)).catch(e => {
console.error(e);
process.exit(1);
})
$ ./wasmrun.js sub.wasm sub
1
module validation
There's an invisible step that happens before running a module: module validation. WASM specification has a rich set of invariants that are statically verified for a module, e.g. all of the following fails validation:
- setting a variable of one type to a value of another type without explicit casting.
- referring to a variable that does not exist
- trying to call a value as a function (unless it's a
funcref
) - having more than one value on the stack of a function when returning one value from it
- stack underflow is statically impossible
- ...
data types and operations
In the example above, we have already seen the data type i32
and instructions that work on it, like i32.const
, i32.sub
. What else is there?
TODO: numeric types
TODO: conversions between numerics types
TODO: funcref
TODO: externref
dropping values
If something produces a value that you don't need (e.g. a function called for its side effects, which also produces a value on the stack which can be ignored), it can be discarded from the stack with drop
:
i32.const 42 ;; [ 42 ]
i32.const -1 ;; [ 42 -1 ]
drop ;; [ 42 ]
Locals and Globals
function parameters and local variables
The quickstart example is rather useless, because it always returns 1. Let's do an actual computation by passing arguments to a function:
# compute (x - y)^2:
$ wasmtime run --invoke sqrsub sqrsub.wat 8 5
9
Every WASM function has a list of locals. Their list starts with param
s passed to the function, with (local <name>? <type>)
definitions after that.
Locals are referred to by their statically known index (e.g. 0
, 1
) or index alias (e.g. $x
, $y
). Locals can be loaded on the stack with local.get <idx>
and set to a value from stack with local.set <idx>
(this value is popped from the stack). The type of the value on the stack must match the type of the local, otherwise the module will be rejected during validation; i.e. (local $x i64) (local.set $x (f32.const 42))
does not pass module validation.
(module
;; compute (x-y)^2
(func (export "sqrsub") (param $x f64) (param $y f64) (result f64)
;; declare a local variable $diff of type f64
(local $diff f64)
;; assign a local:
(local.set $diff
(f64.sub (local.get $x) (local.get $y)))
;; return a value computed using a local:
(return (f64.mul (local.get $diff) (local.get $diff)))
)
)
The same module in a more low-level form reads as
(module
(func ;; (func 0)
(param f64) ;; local 0, $x above
(param f64) ;; local 1, $y above
(result f64)
(local f64) ;; local 2, $diff above
;; the stack: []
local.get 0 ;; [ $x ]
local.get 1 ;; [ $x $y ]
f64.sub ;; [ ($x-$y) ]
local.set 2 ;; []
local.get 2 ;; [ $diff ]
local.get 2 ;; [ $diff $diff ]
f64.mul ;; [ ($diff*$diff) ], one f64 return value
)
(export "sqrsub" (func 0))
)
Note that the WASM VM does not have a dup
opcode, an intermediate value must be saved into a local if it is used twice.
The locals of sqrsub
are:
- two function parameters with
<localidx>
0 and 1 (aliased to$x
and$y
in the first example) - one local variable with
<localidx>
2 (aliased to$diff
).
Functions themselves are indexed within a module, e.g. (func 0)
refers to the first function in the module.
This example can be optimized using local.tee
and reusing one of parameters as a variable:
(func ;; (func 0)
(param f64) ;; local 0, $x above
(param f64) ;; local 1, $y above
(result f64)
;; the stack: []
local.get 0 ;; [ $0 ]
local.get 1 ;; [ $0 $1 ]
f64.sub ;; [ ($0-$1) ]
local.tee 0 ;; set `local 0` to the value, but also keep it on the stack
local.get 0 ;; [ $0 $0 ]
f64.mul ;; [ ($0*$0) ], one f64 return value
)
globals
Modules can have their own top-level constants, globals. Globals have their own index namespace (i.e. globalidx=0, localidx=0, funcidx=0 all refer to different things).
(module
(global ;; a declaration of a global
$the-answer ;; an alias for globalidx=0
i32 ;; its (constant) type
(i32.const 42) ;; its initializer expression
)
(func (export "the_answer") (result i32)
(global.get $the-answer)) ;; push its value on stack
)
Values of globals can be pushed on stack using global.get <idx>
.
$the-answer
is a constant: it's initialized once and cannot change its value. Only a restricted set of instructions for constant expressions is allowed in an initializer context.
mutable globals
Globals can be mutable globals when their type is specified with (mut <type>)
. Their value can be popped from stack and set with global.set <idx>
.
For example:
(module
(global $call-times (mut i32)
(i32.const 0))
;; increment the counter on every call
(func (export ("call_me"))
(global.set $call-times
(i32.add
(i32.const 1)
(global.get $call-times))))
;; get the counter
(func (export "stats") (return i32)
(global.get $call-times))
)
TODO: more about initialization
stack validation
All WASM instructions have statically known effects on the stack. This means that:
- instructions always pop a known number of values
- instructions always push a known number of values
- it's possible to know the depth of the stack at each instruction
- it's possible to know the maximum depth of the stack during a function call
- stacks for then/else branches of
if
must be balanced. - loops cannot grow stack dynamically; linear memory must be used for that instead.
TODO: param/result notation
stack tricks
Some neat stack-based tricks apply.
swapping two values
local.get $a ;; [ $a ]
local.get $b ;; [ $a $b ]
local.set $a ;; [ $a ]
local.set $b ;; [ ]
Control Flow
WebAssembly has structured control flow.
Unlike native CPU instructions which are unrestricted in their jumps, control flow in WebAssembly is always expressed in function calls and blocks inside a function.
nop
and unreachable
The unreachable
instruction aborts, or traps, the execution of a WASM module. When unreachable
instruction is executed, the host of the WASM environments gets something similar to an exception in OOP-style languages or panic!()
in Rust:
unreachable ;; bye WASM world, the host must handle this.
Handling this situation is up to the host environment and is outside of the WASM spec. The host environment can report the failure to the user; if this is a breakpoint, it can patch back the real instruction and resume execution; etc.
Helpfully, unreachable
is encoded as 0x00
in binary, so any zero-initialized memory is full of unreachable
when (mis)executed.
Another occasionally useful instruction is nop
. It does not do anything and can be used as padding to a nicer address or a placeholder for other commands written in its place later.
nop ;; just blank space, nothing to see here, go ahead
nop
;; actually useful instructions
blocks and branching
Jumps (i.e. non-sequential execution of instructions) inside a function are called branching in WASM and always happen to (maybe nested) blocks. Branching instruction names usually start with br
.
Unconditional branching is done with the br <blockidx>
instruction.
The simplest kind of block is introduced by block
and end
WASM instructions (indentation is not significant here):
block
br 0 ;; jump to the end of the innermost, "0th" block
unreachable ;; any instructions until `end` are unreachable
end
;; execution continues from here
A more high-level, folded representation uses parenthesis and omits end
:
(block
br 0
unreachable)
;; the jump destination is at the end of the `block`
nested blocks
When nested, blocks are indexed by their distance from the current instruction: the innermost current block has index 0, its outer block has index 1, the next outer block is indexed 2, and so on and so forth.
(block ;; 1st
(block ;; 0th
(br 1) ;; skip one level of blocks, jump to the end
;; unreachable
)
;; also unreachable
(br 0) ;; would jump out one level up
)
;; jumps here
Blocks can be given aliases that can be used by branching instructions. For example:
(block $outer ;; blockidx 1
(block $inner ;; blockidx 0
(br $outer)
unreachable)
unreachable)
;; to here
block results
If a block leaves something on the stack, this must be declared as a (result <type>)
just after block
:
;; stack before: [...]
(block (result i32)
i32.const 42
br 0
unreachable)
;; stack after: [... 42]
TODO: more than one result
TODO: stack validation
conditional branching
The only thing that the previous example can do is to introduce dead code which cannot be executed. Let's do something useful, e.g. TODO
TODO: if
/else
/end
, (if ... (then ...) (else ...))
, select
TODO: br_if
loops
Another kind of blocks is loop
. Unlike block
, jump destination is at the start of the loop
:
;; this is an infinite loop
(loop
(br 0) ;; continue
unreachable)
TODO: a simple counter loop example
TODO: while loop example
TODO: break and continue example
TODO: call
and return
TODO: tables and call_indirect
TODO: the tailcalls extension
Linear Memory
TODO: memory
- exporting and importing a memory
TODO: data segments and initializing memory
TODO: growing a memory
TODO example: simple bump allocator?
TODO: the bulk memory operations extension
Binary Encoding of Modules
TODO