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.
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.
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 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:
shared (bit 1). The memory is shared between threads, which means it must be declared shared in the import and at instantiation, and a shared memory cannot have a maximum — because growth would race. The threads proposal requires exactly that pairing, so the flag is not decorative: it changes what a legal module looks like.memory64 (bit 2). The two counts are 64-bit rather than 32-bit, which is a proposal rather than core. It is here to illustrate a general point: a bitfield lets a format add a feature without adding a field, but it does so by making old readers wrong on the new values. A reader written before bit 2 existed sees flag 0x04, tests bit 0 (clear), reads one 32-bit count, and carries on with a memory that is really 64-bit. It will not error; it will produce a memory a quarter the intended size.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.
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.
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:
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.
$ wasm-objdump -x decls.wasm | sed -n '/^Table/,/^Global/p'
$ wasm-objdump -x decls.wasm | sed -n '/^Memory/,/^Global/p'(memory 1), (memory 1 2), (table 1 funcref) and (table 1 2 funcref) and read all four back. Two flag bytes, two lengths, and seeing them side by side is what makes the "flag is a length" idea stick.flags & 0x01 test, so it always reads a maximum. Run it on the declarations module and confirm it consumes the memory section's count byte as a maximum and then goes wrong. A desync that is silent is the worst kind, and producing one on purpose teaches you to recognise it.(memory 1 1 shared) — and check whether this wabt accepts it. If it does, read the flags byte and confirm bit 1 is set. If it does not, the rejection message tells you the tool predates the proposal, which is itself a useful data point about what a toolchain supports.memory64, emit one and read the counts. If it does not, hand-build the record with bit 2 set and confirm that wasm-validate rejects it — which is the correct behaviour, and the rejection is the point.(memory 1 2) and call memory.grow by 5. The result is a return value, not a trap, and the module keeps running. Confirm both halves, because a host that traps instead of returning has changed the module's semantics.memory.grow calls the compiled code emits. Seeing that a compiler emits grow loops is what makes the optional maximum make sense — it is a dynamic request, not a fixed allocation.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?
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.
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.