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. Reed 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";
Reading and writing individual bytes still goes entirely 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. The surface language does have dedicated
syntax for the bulk operations (.grow(...), .fill(...), .copy<Other>(...)), since
those don't carry the same per-access signedness ambiguity.
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 Reed's — table element types are invariant.
Shared memory and atomics
A memory declared shared can be reached by more than one agent at a time, and is what the
atomic operations require. A shared memory must declare a maximum, because an unbounded one
is not representable — one agent could grow it while another holds a stale view of its size.
memory Counter(1 4 shared);
export func check as "check";
func check() -> i32 {
wasm.i32.atomic.store<Counter>(0, 10);
// A read-modify-write returns the value that was there BEFORE it ran, which is the whole
// reason to use one rather than a load followed by a store.
let previous: i32 = wasm.i32.atomic.rmw.add<Counter>(0, 5);
let now: i32 = wasm.i32.atomic.load<Counter>(0);
return previous + now;
}
That returns 25: the RMW reported the old 10 and left 15 behind.
cmpxchg writes its replacement only when the current value matches what you expected, and
returns the previous value either way. Continuing the module above, where the counter now
holds 15:
// Expected 15, actual 15: swaps to 100, returns 15.
let swapped: i32 = wasm.i32.atomic.rmw.cmpxchg<Counter>(0, 15, 100);
// Expected 999, actual 100: no write, returns 100.
let unchanged: i32 = wasm.i32.atomic.rmw.cmpxchg<Counter>(0, 999, 7);
memory.atomic.wait32/wait64 block until a location changes or a timeout elapses, and
memory.atomic.notify wakes waiters. atomic.fence is the odd one out: it takes no memory
at all, so it needs no shared declaration and works in a module with no memory.
Using an atomic on an ordinary memory is a source error, reported at the operation rather than at the declaration — the memory is not wrong, and the fix belongs where it is needed:
memory Plain(1);
wasm.i32.atomic.load<Plain>(0)
type error: 'i32.atomic.load' requires a shared memory, but 'Plain' is not
declared 'shared'
64-bit memories
A memory declared i64 addresses more than 4 GiB. The index type belongs to the memory, not
the module, so a module can hold both kinds — and the address operand's type follows whichever
memory you name:
memory Big(i64 1);
memory Small(1);
export func check as "check";
func check() -> i32 {
wasm.i32.store<Big>(16, 42); // address is an i64
wasm.i32.store<Small>(0, 8); // address is an i32
return wasm.i32.load<Big>(16) + wasm.i32.load<Small>(0);
}
Getting the width wrong is a type error, not a surprise at validation time:
memory Big(i64 1);
let addr: i32 = 4;
wasm.i32.load<Big>(addr)
type error: value of type i32 is not assignable to i64
The page ceiling rises with the index type — 65536 pages for a 32-bit memory, 2^48 for a
64-bit one — but it does not disappear. The two flags compose: memory Both(i64 1 2 shared);
is a 64-bit shared memory whose atomics take i64 addresses.
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; a passive segment is not a dead end, since
Mem.init<...>(...)/.drop()are dedicated method-postfix syntax that lower tomemory.init/data.drop. Neither opcode has awasm.<opcode>(...)spelling, but the dedicated syntax reaches them fine (see segment init and drop) - Raw memory and table operations
- Spec section 10.2 —
the normative rules for
sharedandi64memories - Spec section 12.2 — the shared-memory requirement, alignment, and each atomic operation's result
- Spec section 10