Globals and Tables
Linear memory holds bytes. Two other kinds of state hold things that are not bytes. A global holds a single typed value, which may be shared with the host. A table holds references, most often references to functions, and is what makes function pointers, virtual calls and closures possible in a machine whose code has no addresses.
#Globals11.1
A global is declared with a type, a mutability, and an initial value:
(module
(import "env" "print_i32" (func $print (param i32)))
(global $limit i32 (i32.const 100)) ;; immutable
(global $count (mut i32) (i32.const 0)) ;; mutable
(func (export "main")
(global.set $count (i32.add (global.get $count) (global.get $limit)))
(global.set $count (i32.add (global.get $count) (global.get $limit)))
(call $print (global.get $count))))
200
global.get pushes a global's value and global.set pops a value into a
mutable one. Setting an immutable global is a validation error.
The initial value must be a constant expression. Originally that meant a
single constant, or a global.get of an imported immutable global.
WebAssembly 3.0 adds i32.add, i32.sub, i32.mul and their i64
forms, so a compiler can write base + 16 as an initialiser:
(module
(import "env" "print_i32" (func $print (param i32)))
(global $base i32 (i32.const 1024))
(global $after_header i32 (i32.add (global.get $base) (i32.const 16)))
(func (export "main")
(call $print (global.get $after_header))))
1040
Engines treat immutable globals as constants, which they can fold into the code that uses them. Mutable globals cost a load or store, much like a static variable in native code, though engines often keep the most used ones, such as a stack pointer, in a register within a function.
#Globals shared with the host11.2
A global can be imported or exported. On the JavaScript side it is a
WebAssembly.Global object with a value property:
(module
(import "env" "step" (global $step i32))
(global $total (export "total") (mut i32) (i32.const 0))
(func (export "tick")
(global.set $total (i32.add (global.get $total) (global.get $step)))))
import fs from 'node:fs';
const step = new WebAssembly.Global({ value: 'i32', mutable: false }, 5);
const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'), { env: { step } });
const { tick, total } = instance.exports;
tick(); tick(); tick();
console.log(total.value);
total.value = 1000; // the host can write an exported mutable global
tick();
console.log(total.value);
15 1005
Imported immutable globals are how a module receives configuration that is fixed at instantiation: a memory base address in a dynamically linked module, say, or a feature flag. Since they are constants to the code that uses them, an engine can specialise the code for their values.
#Tables and indirect calls11.3
A WebAssembly function has an index but no address. Code cannot compute a
function index at runtime and call it; call takes its index as an
immediate, fixed in the code. To call a function chosen at runtime, you
put function references in a table and call through it with
call_indirect:
(module
(type $unop (func (param i32) (result i32)))
(table 3 funcref)
(elem (i32.const 0) $double $square $negate)
(func $double (param i32) (result i32) (i32.add (local.get 0) (local.get 0)))
(func $square (param i32) (result i32) (i32.mul (local.get 0) (local.get 0)))
(func $negate (param i32) (result i32) (i32.sub (i32.const 0) (local.get 0)))
(func (export "apply") (param $which i32) (param $x i32) (result i32)
(call_indirect (type $unop) (local.get $x) (local.get $which))))
apply(0, 7), apply(1, 7) and apply(2, 7)14 49 -7
The pieces:
(table 3 funcref)declares a table of three elements, each afuncref, a reference to a function. Elements start out null.(elem (i32.const 0) $double $square $negate)is an element segment: it puts references to those three functions into the table, starting at index 0, when the module is instantiated. Element segments are to tables what data segments are to memories.call_indirect (type $unop)pops a table index (last, on top of the arguments), looks up the function there, checks that its type is$unop, and calls it with the arguments below.
The type check is what keeps indirect calls safe. It is done at runtime, on every call, and a mismatch traps:
(module
(type $unop (func (param i32) (result i32)))
(table 2 funcref)
(elem (i32.const 0) $no_params)
(func $no_params (result i32) (i32.const 1))
(func (export "call") (param $i i32) (result i32)
(call_indirect (type $unop) (i32.const 5) (local.get $i))))
call(0)RuntimeError: null function or function signature mismatch
So does calling a null element, or an index past the end of the table:
(module
(type $v (func))
(table 2 funcref)
(func (export "call") (param $i i32)
(call_indirect (type $v) (local.get $i))))
call(7)RuntimeError: table index is out of bounds
#Virtual calls11.4
A vtable becomes a list of table indices in linear memory. An object holds a pointer to its class's vtable, and a virtual call loads the vtable pointer, loads the method's table index from it, and calls through the table:
(module
(import "env" "print_i32" (func $print (param i32)))
(memory 1)
(type $method (func (param i32) (result i32))) ;; takes `this`
(table 4 funcref)
(elem (i32.const 1) $square_area $rect_area)
;; vtables: one slot each, holding a table index
(data (i32.const 16) "\01\00\00\00") ;; Square's vtable: area = 1
(data (i32.const 20) "\02\00\00\00") ;; Rect's vtable: area = 2
;; objects: [vtable pointer, fields...]
(data (i32.const 64) "\10\00\00\00" "\05\00\00\00") ;; Square{side: 5}
(data (i32.const 80) "\14\00\00\00" "\03\00\00\00" "\04\00\00\00") ;; Rect{w: 3, h: 4}
(func $square_area (param $this i32) (result i32)
(i32.mul (i32.load offset=4 (local.get $this)) (i32.load offset=4 (local.get $this))))
(func $rect_area (param $this i32) (result i32)
(i32.mul (i32.load offset=4 (local.get $this)) (i32.load offset=8 (local.get $this))))
;; shape->area()
(func $area (param $shape i32) (result i32)
(call_indirect (type $method)
(local.get $shape) ;; this
(i32.load offset=0 ;; vtable[0]
(i32.load (local.get $shape))))) ;; shape->vtable
(func (export "main")
(call $print (call $area (i32.const 64)))
(call $print (call $area (i32.const 80)))))
25 12
Interfaces and trait objects work the same way, with the vtable pointer carried beside the data pointer as a pair rather than inside the object.
#Closures11.5
A closure is a function and its captured environment. In linear memory, it is a pair: a pointer to the environment, and a table index for the code. Calling it passes the environment as an extra first parameter:
(module
(import "env" "print_i32" (func $print (param i32)))
(memory 1)
(global $heap (mut i32) (i32.const 1024))
(type $closure_fn (func (param i32 i32) (result i32))) ;; env, x
(table 2 funcref)
(elem (i32.const 1) $adder_code)
;; the code of `|x| x + n`, with n in the environment
(func $adder_code (param $env i32) (param $x i32) (result i32)
(i32.add (local.get $x) (i32.load (local.get $env))))
;; make_adder(n) returns a closure: [env pointer, table index]
(func $make_adder (param $n i32) (result i32)
(local $env i32)
(local $clo i32)
(local.set $env (global.get $heap))
(i32.store (local.get $env) (local.get $n))
(local.set $clo (i32.add (local.get $env) (i32.const 4)))
(i32.store offset=0 (local.get $clo) (local.get $env))
(i32.store offset=4 (local.get $clo) (i32.const 1))
(global.set $heap (i32.add (local.get $clo) (i32.const 8)))
(local.get $clo))
(func $call_closure (param $clo i32) (param $x i32) (result i32)
(call_indirect (type $closure_fn)
(i32.load offset=0 (local.get $clo)) ;; env
(local.get $x)
(i32.load offset=4 (local.get $clo)))) ;; code
(func (export "main")
(local $add10 i32)
(local $add100 i32)
(local.set $add10 (call $make_adder (i32.const 10)))
(local.set $add100 (call $make_adder (i32.const 100)))
(call $print (call $call_closure (local.get $add10) (i32.const 1)))
(call $print (call $call_closure (local.get $add100) (i32.const 1)))))
11 101
If every function value in your language is a closure, give plain functions the same calling convention with an unused environment parameter, so a single kind of call works for both.
#Tables as runtime state11.6
Tables are not fixed. The table instructions read and change them at runtime, much as memory instructions do for bytes:
| Instruction | Type | Does |
|---|---|---|
table.get $t |
[i32] → [ref] |
reads an element |
table.set $t |
[i32 ref] → [] |
writes an element |
table.size $t |
[] → [i32] |
the number of elements |
table.grow $t |
[ref i32] → [i32] |
adds elements, set to the given reference; returns the old size or −1 |
table.fill $t |
[i32 ref i32] → [] |
sets a range of elements |
table.copy, table.init, elem.drop |
the bulk operations, as for memory | |
ref.func $f |
[] → [funcref] |
a reference to a function |
ref.null func |
[] → [funcref] |
the null reference |
ref.is_null |
[ref] → [i32] |
tests for null |
These let a program add functions to its table as it goes, which is what dynamic linking needs: a newly loaded module's functions are added to the shared table, and their new indices are its function pointers.
A function referenced with ref.func must be declared as referenceable,
by appearing in some element segment, so the engine knows ahead of time
which functions might be called indirectly. A declarative segment
exists only for this purpose:
(module
(type $v (func (result i32)))
(table 1 funcref)
(elem declare func $seven)
(func $seven (result i32) (i32.const 7))
(func (export "main") (result i32)
(table.set (i32.const 0) (ref.func $seven))
(call_indirect (type $v) (i32.const 0))))
7
#Host references11.7
Tables can hold another kind of reference: externref, an opaque
reference to anything the host likes, which in a browser means any
JavaScript value. WebAssembly code cannot look inside an externref; it
can only store it, pass it around, and hand it back.
This is the clean way for a module to hold on to host objects, such as
WebGL textures or DOM elements: keep them in a table, and use their table
indices as handles in linear memory. Before externref, toolchains kept a
JavaScript array beside every instance and passed integer handles across,
which works but leaves the bookkeeping to hand-written glue.
(module
(import "env" "describe" (func $describe (param externref)))
(table $objects (export "objects") 8 externref)
(func (export "show") (param $handle i32)
(call $describe (table.get $objects (local.get $handle)))))
import fs from 'node:fs';
const env = { describe: (obj) => console.log(`a ${obj.kind} called ${obj.name}`) };
const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'), { env });
const { objects, show } = instance.exports;
objects.set(3, { kind: 'texture', name: 'grass' });
objects.set(4, { kind: 'sound', name: 'jump' });
show(4);
show(3);
a sound called jump a texture called grass
The engine's garbage collector sees references in tables, so an object a table holds stays alive until the slot is overwritten or the table itself is collected.
#What you have seen11.8
Globals hold single typed values, initialised by constant expressions,
and can be shared with the host. Tables hold references. With
call_indirect, a table of funcrefs turns an integer into a function
call, checked against the expected signature at runtime, and that is how
function pointers, vtables and closures are compiled. Tables can be
changed at runtime, and can hold externrefs, opaque host values, which
are the tidy way to give a module handles to host objects.
That ends Part III. Part IV looks at how all of this is encoded in bytes.