Back to course

Tables, Memories and Limits

20 minintermediateModule 2: DeclarationsContinues from wasm-imports
20 min · Module 2: Declarations · Algorithms

Why This Matters

Two of the four things a module can declare are spaces: a table of indirect function references, and a memory of bytes. They look unrelated and share almost all of their encoding, which makes them a good place to learn something that will be true for the rest of the format.

Both are declared with a limits record, and the limits record is one flag byte followed by one or two counts. That is the whole thing. And the flag byte is a small case study in how a format that started minimal accumulates, because it now carries three independent bits and they were not all there on day one.

The idea underneath is a resource bound, and it is the format's answer to "how much can this module ask for". A memory is a minimum number of pages, optionally a maximum. A table is a minimum number of elements, optionally a maximum. No upper bound unless the module states one, which is a decision the module gets to make and a host gets to override — and the reason the maximum is optional is that a module should be able to grow to whatever the host can give it.

A Simple Model

The two sections, both tiny, both using the same record:

table section:                          memory section:
  count                                 count
  elemtype, limits   repeated           limits       repeated

elemtype is ONE byte:
  70  funcref                      a table holds function references
  6f  externref                    a table holds opaque host references

And the limits record, which is where the flags are:

  00                      01                      02                     03
  |                       |                       |                      |
  |                       |                       |                      +-- shared AND has max
  |                       |                       |                      +----- has a maximum
  |                       |                       +------------------------------ SHARED
  |                       +-------------------------------------- HAS A MAXIMUM
  +-------------------------------------------------- minimum only

Two bits, and the third arrived later. Bit 0 is "there is a maximum, and it follows"; bit 1 is "this memory is shared between threads"; bit 2 is "the counts are 64-bit" — the memory64 proposal.

Read the flag byte as a set of independent bits, never as an enumeration. There are only four combinations in use today, and they are 0, 1, 2 and 3, which makes an enum tempting. It is the wrong model, because a reader written as a switch will have no case for bit 2 and will reject a valid 64-bit memory rather than reading it. The same mistake the COFF characteristics invites and this course has already documented once — a bitfield whose values happen to be contiguous right now.

The Actual Detail

Both sections from the declarations module, in full:

0046: 01 70 00 04          table section: 1 table
      |  |  |  |
      |  |  |  +------ minimum = 4
      |  |  +--------- flags = 0x00: minimum only
      |  +------------ 0x70 = funcref
      +--------------- count = 1

004c: 01 01 02 08          memory section: 1 memory
      |  |  |  |
      |  |  |  +------ maximum = 8
      |  |  +--------- minimum = 2
      |  +------------ flags = 0x01: HAS A MAXIMUM
      +--------------- count = 1

Four bytes each, and every byte is load-bearing. The memory section is the more instructive of the two because it is the only place in the core format where a flag bit changes the shape of what follows: with flags 0x00 the record is one count, and with 0x01 it is two. A reader that reads a count and then unconditionally reads a second one will consume the first byte of the next record as a maximum, and the damage propagates silently.

  read flags = 0x00, read min = 2, and then?
    wrong: assume a maximum follows, read 0x08 as it
           the next byte is the MEMORY SECTION's count, not a maximum
    right: flags & 0x01 is 0, so there is no maximum

So the flag byte is not decoration and not a type tag. It is a length in disguise, and a reader that treats it as anything else will desynchronise. This is the third instance of that pattern in three sections: a LEB128 is self-delimiting, a body size delimits a body, and a limits flag delimits a record. Almost every field in this format says how long the next thing is, and that is what makes a streaming reader possible.

The shared and 64-bit bits

The two bits beyond the maximum are worth reading even though this course's samples do not use them, because they change the record's arithmetic and not just its shape:

That last failure is the sharpest argument for reading flags as bits and validating what you read. A reader that checks the flag against the bits it knows about, and rejects a value with a bit it does not recognise, turns that silent wrong answer into a rejected module. Reject what you do not understand is the rule; it is the one place where being strict costs nothing, because a module using a feature you have not implemented was never going to run correctly anyway.

Why a maximum is optional at all

It is tempting to read the optional maximum as laziness in the specification. It is not — it is a deliberate split of responsibility. The module says what it needs; the host says what it will give. A module that writes (memory 2) has declared a minimum of two pages and no ceiling, so it will accept whatever the host hands it, up to the host's own limit. A module that writes (memory 2 8) has asked for a bounded resource, and a host that cannot give it eight pages should refuse at instantiation rather than at validation.

This is the same division as an imported global having no initial value. The file states requirements; the environment supplies resources. And it is why a memory.grow instruction at run time can fail: the module asked for a minimum and the host said yes, and growth is where the answer can change. A growth request beyond a declared maximum is a trap, and one beyond the host's limit is a return value rather than an exception, because growing a memory that other code may be reading is not something a runtime can do by force.

A Real Example

The instantiation-time checks, and why each one is a different kind of failure. A host is handed a module and must decide whether to run it, and the tables and memories give it four questions to ask:

for each memory in the module, in index space order: if declared_minimum > what_this_host_can_provide: reject the module a resource question, decided by the host for each table: same check for each import of kind TABLE or MEMORY: if the import is SHARED and the host's object is not shared: reject a type agreement, not a size question if the host's object is larger than the declared maximum: reject the module asked for at most this much at run time, memory.grow: if current + delta > declared_maximum (if any): return -1 not a trap: the caller may handle it if the host cannot back it: return -1 also not a trap

Look at the two failure modes at the bottom. Instantiation failures reject the module; memory.grow failures return a value. That asymmetry is a design decision about who is in a position to handle the error, and it generalises across the whole format: a function body that does something invalid traps, because the code cannot be trusted to check; a resource request returns a code, because the code asked and can look.

And the shared check is the interesting one, because it is a type agreement rather than a size one. A shared import must be satisfied by a shared object, and the reason is a race rather than a budget: the threads proposal makes a shared memory's contents visible to several agents at once, and satisfying a shared import with an unshared object would let the module observe that and reason about it. The type system is doing a memory-safety job here, which is the first time in this course a type has carried a property that is not about arithmetic.

One more thing the tables concept has to earn, and the reason the two sections share an encoding. A table of funcref is the only indirection in the core format, and that is a security decision as much as a design one. In a native image a function pointer is an address, and storing one is storing the ability to call anything at all — which is why control-flow integrity is a separate, large feature on native platforms. A WebAssembly table stores an index, and the only thing index n can do is call the function at index n. A table entry cannot be forged into an arbitrary address, so call_indirect through a validated table is a bounded operation.

That is the deep reason the table exists rather than "so you can have a switch statement". It gives the format a way to express indirect calls that a validator can check, and it is the reason weak externals and table entries feel related: both are about referring to something that may not be there.

Try It Yourself

$ wasm-objdump -x decls.wasm | sed -n '/^Table/,/^Global/p'
$ wasm-objdump -x decls.wasm | sed -n '/^Memory/,/^Global/p'

Check Your Understanding

Without looking back: a memory section is the three bytes 01 01 02. What do they say, and what does a reader that unconditionally reads both a minimum and a maximum do with the file?

Apply It

Your runtime loads a module from a toolchain you do not recognise. It reports the module as valid, allocates a memory, and the program then behaves as if it had a quarter of the address space it was written for — writing past the end of what it thinks is a page, into memory the host never gave it, with no error. Everything else in the module decodes correctly. What is the most likely cause, and what is the one-line change that would have turned this into a clean rejection?

The general habit: on untrusted input, reject any bit you do not understand rather than ignoring it. A spare bit in a flag is a promise that someone will eventually use it, and your reader is the thing that has to notice.

Connect

The limits record is the WebAssembly version of something every executable format has to express, and the comparison makes the differences legible. A PE section's VirtualSize and SizeOfRawData are a required size and an allocated size, and the two may differ. An ELF program header's p_memsz and p_filesz are the same idea: what the program will use, and how much of it is in the file. WebAssembly's limits record is the degenerate case, where there is no file backing at all — every page is zero until written.

What is new here is the optional maximum, and that has no counterpart in those formats. A PE section and an ELF segment are bounded by the file; a WebAssembly memory is bounded by the host, and the module states a preference rather than a requirement. It is the same division of responsibility as an imported global having no initial value, and it is why a module can be written once and run in a browser tab and in a 512 MB sandbox without either of them being wrong.

The flag-as-length pattern connects to LEB128 and to the body size field, and to the same idea in the DWARF offset_entry_count, where a count determines the shape of everything after it. The recurring lesson is that a field which is a count, a size, or a "more follows" bit is not a field you can read and forget — it is the field that tells you what the reader's position should be afterwards. Position is derived, and these are the fields that derive it.

The security point about tables is a good one to carry into the element section, which is what fills them. A table is only as safe as its initial contents and as the checks a call_indirect performs, and the element section is where the initial contents come from — which is eight different encodings, and the reason is a long history of proposals.

Next: Globals and Initialisers.