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 i32s (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) where i32 (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 params 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