Linear memory and function tables
WebAssembly has two indexed stores and they have nothing in common. A memory is untyped bytes, addressed by an integer, mutable by anyone with the address. A table holds typed references, which the host and the GC can see but the program cannot forge or inspect as bits. WAST declares both explicitly and every operation names its target — there is no default memory and no default table.
A linear-memory round trip
// One 64 KiB page, no maximum. `memory Scratch(1 4)` would cap growth at 4.
memory Scratch(1);
func memory_round_trip() -> i32 {
// Every memory op names its memory. There is no default memory, which is
// also why multi-memory modules need no extra syntax -- each site already
// says which one it means.
//
// `align` is a byte count, not a log2 exponent. It must be a power of two
// and must not exceed the opcode's natural alignment: align=8 on an
// i32.store is an error, align=1 is legal but pointlessly slow.
wasm.i32.store<Scratch, offset=0, align=4>(0, 42);
return wasm.i32.load<Scratch, offset=0, align=4>(0);
}
func low_byte() -> i32 {
wasm.i32.store<Scratch, offset=64, align=4>(0, 4275878552);
// `offset` is folded into the address operand, so offset=60 with address 4
// is byte 64 -- the same byte the store above wrote first. WebAssembly is
// little-endian, so that is the LOW byte: 0x98 == 152.
return wasm.i32.load8_u<Scratch, offset=60>(4);
}
func check() -> i32 {
if (memory_round_trip() != 42) { return 1; }
if (low_byte() != 152) { return 2; }
// The typed binding is load-bearing. Writing `wasm.memory.size<Scratch>()
// != 1` directly fails with "cannot determine the operand type for this
// operator" -- a raw operation supplies no type for a bare literal.
let pages: i32 = wasm.memory.size<Scratch>();
if (pages != 1) { return 3; }
return 0;
}
export memory Scratch as "memory";
export func check as "check";
Nothing in the surface language reads or writes memory; it all goes through the escape
hatch. That is deliberate. Memory access has no safe spelling — the address is an integer,
the size and signedness of the access are part of the opcode, and getting either wrong is
undiagnosable. i32.load8_u and i32.load8_s read the same byte and disagree about its
sign, and no type annotation will catch picking the wrong one. Making it look like array
indexing would imply a safety that is not there.
Addresses are always i32. There is no memory64 in this target, so the addressable range
is 4 GiB regardless of how many pages you declare, and memory32 limits must not exceed 65536 pages is a hard ceiling.
An initialized function table
// A table's element type must be a NAMED function type. `?func` is rejected:
// an untyped funcref gives call_indirect nothing to check against.
type Callback = func() -> i32;
func zero() -> i32 {
return 0;
}
func one() -> i32 {
return 1;
}
// The initializer is an active element segment at offset 0. Entries are
// `null` or `&name` -- a bare `zero` is a syntax error, because the `&` is
// what distinguishes a reference from a call. Slot 2 is left null.
//
// Each `&name` must match `Callback` EXACTLY. Table element types are
// invariant; there is no subtyping escape here.
table Callbacks(3 ?Callback) = { &zero, &one };
func check() -> i32 {
// `Callbacks[i]` is table.get and yields ?Callback, never &Callback --
// the type cannot know that slot 0 was initialized.
let callback: ?Callback = Callbacks[0];
// `#Callbacks` is table.size, evaluated at runtime, not the declared 3:
// a table can be grown with wasm.table.grow.
//
// These are two nested `if`s on purpose. `is` binds looser than `&&`, so
// `#Callbacks == 3 && callback is &Callback` parses as
// `(#Callbacks == 3 && callback) is &Callback` and fails with
// "value of type ?Callback is not assignable to i32".
if (#Callbacks == 3) {
if (callback is &Callback) {
// Narrowed to &Callback, so a direct call through the reference is
// allowed. Without the narrow this is a type error, not a null trap.
if (callback() != 0) { return 1; }
}
}
// The raw form: the table index goes LAST, after the call arguments.
// It skips the narrow, so a null slot is the engine's problem at runtime.
if (wasm.call_indirect<Callbacks, Callback>(1) != 1) { return 2; }
let tail: ?Callback = Callbacks[2];
if (tail is null) {
return 0;
}
return 3;
}
export table Callbacks as "callbacks";
export func check as "check";
The nullability is not pedantry. A table slot can be null, can be overwritten by
table.set from anywhere in the module, and can be extended by table.grow with whatever
fill value the caller passes. There is no point at which the compiler can prove
Callbacks[0] is populated, so table.get returns ?Callback and you narrow, or you use
call_indirect and accept the trap. Those two are not equivalent, and the choice is
yours to make explicitly.
Requiring a named function type follows from the same place. call_indirect compares
the callee's runtime type against a type index; with ?func there is no index to compare
against, so every indirect call would have to be unchecked. The exact-match rule on
initializer entries is WebAssembly's, not WAST's — table element types are invariant.
Expected result
Both check() functions return 0. Each module exports its memory or table so a host can
inspect it.
Related rules
- Memories and tables
- Data segments — active segments need exactly one
memory; passive ones are currently unusable, since
memory.initis not in the catalog - Raw memory and table operations
- Spec section 10