Skip to main content

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.