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. 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.