Back to course

Imports and Index Spaces

22 minintermediateModule 2: DeclarationsContinues from wasm-types
22 min · Module 2: Declarations · Algorithms

Why This Matters

Every function, table, memory and global a WebAssembly module can refer to has a number, and those numbers are the format's only name. What decides a thing's number is not where it is declared but whether it is imported, and that one rule explains more about the format than anything else in this module.

Imports come before definitions in the section order, and they come before definitions in the index space too. A module that imports one function and defines two has three functions, numbered 0, 1 and 2, and the two it defined are 1 and 2 — not 0 and 1. The same rule holds independently for tables, memories and globals, each with its own numbering.

It is worth understanding why a format would do this rather than number definitions first. An import is a claim about something outside the module, and a definition is a claim inside it. Putting the outside claims first means every index below the import count is guaranteed to exist, and everything above it is something the module can prove. A validator can check the boundary once and know the provenance of every subsequent number. It is the same reasoning as a linker's symbol resolution order, and it is why the rule is not negotiable from a reader's point of view even though a producer could have chosen otherwise.

A Simple Model

The section's shape, and the part that trips people:

imports: count how many imports follow import, repeated `count` times: module_name a length-prefixed name, e.g. "env" field_name a length-prefixed name, e.g. "log" kind ONE byte: 0 func, 1 table, 2 memory, 3 global, 4 tag ...and then a body that DEPENDS ON kind

The last line is the whole difficulty. What follows the kind byte is a different structure for each kind, and a reader that treats it uniformly will read a limits flag as a type index and produce a plausible nonsense:

KindBodyShape
0 functype indexone LEB128 — the smallest body in the format
1 tableelement type + limitsa valtype byte, then a limits record
2 memorylimitsjust a limits record
3 globalvalue type + mutabilitytwo bytes — note: no initialiser, because the host supplies the value

That third row deserves a sentence of its own. An imported global has no initial value in the file, and a global.get of it before the host sets it is a trap the validator cannot catch. The host owns the initial contents of everything it imports, which is the entire point of importing it.

The Actual Detail

Three imports of three different kinds in a thirty-five byte section. Here are all of them:

001c: 03 03 65 6e 76 03 6c 6f 67 00 02 03 65 6e 76 05
002c: 74 61 62 6c 65 01 70 00 01 03 65 6e 76 02 67 76
003c: 03 7f 00

Walk all three, because the point of the exercise is that they are three different structures:

03                    THREE imports

import 0 -- a function
  03 65 6e 76           module "env"
  03 6c 6f 67           field  "log"
  00                   kind 0: FUNC
  02                   type index 2

import 1 -- a table
  03 65 6e 76           module "env"
  05 74 61 62 6c 65     field  "table"
  01                   kind 1: TABLE
  70                   element type: funcref
  00                   limits flags: minimum only
  01                   minimum = 1

import 2 -- a global
  03 65 6e 76           module "env"
  02 67 76              field  "gv"
  03                   kind 3: GLOBAL
  7f                   value type: i32
  00                   mutability: const

Three bodies, three shapes, and the sizes alone would let you tell them apart: 10 bytes, 14 bytes, and 10 bytes. Now the consequence that matters more than any of the bytes, which is what this does to the numbering.

What the imports do to every other number

The same module also declares two functions, a table, a memory and two globals. wabt's own reading of the whole module, which numbers everything in the global index space:

Import[3]:
 - func[0]   sig=2 <env.log>
 - table[0]  type=funcref initial=1 <env.table>
 - global[0] i32 mutable=0 <env.gv>
Function[2]:
 - func[1]   sig=0 <add>
 - func[2]   sig=1
Table[1]:
 - table[1]  type=funcref initial=4
Memory[1]:
 - memory[0] pages: initial=2 max=8
Global[2]:
 - global[1] i32 mutable=0 - init i32=42
 - global[2] i64 mutable=1 - init i64=-1
Export[3]:
 - func[1] <add> -> "add"
 - memory[0] -> "mem"
 - global[0] -> "gv"

Read the numbers and the rule is visible without being told:

Four independent counters, not one. The mistake here is to assume a single numbering scheme. There are five separate index spaces — functions, tables, memories, globals and tags — and each counts its own imports first. A module can have func[0] be an import while table[0] is a definition, and there is no relationship between the two counters. A reader that keeps "the next number" in a single variable will be correct on the first kind it meets and wrong on the second, which is the most annoying possible failure: it works on simple modules and breaks on the first real one.

Why the count is the boundary

Because imports are counted first and definitions second, the import section's count is a boundary in every index space, and the boundary for each space is the number of imports of that kind. In this module:

  functions   1 imported  ->  func[0]     is imported
  tables      1 imported  ->  table[0]    is imported
  memories    0 imported  ->  memory[0]   is a definition
  globals     1 imported  ->  global[0]   is imported

That is the property a validator exploits. It can read the import section, note that func[0] must be supplied by the host, and then treat every index it meets ≥ the count as something the module itself has to define. A number below the boundary is someone else's problem; a number above it is this module's. And the count also gives a free existence check: an export or a call naming an index below the boundary is referring to an import, which is legal, while one naming an index at or above the boundary for something that was never defined is a validation error.

That is a real structural property, not a convenience. It means a validator can answer "is this index valid" without having read the whole module, for the lower half of every space, and it is the same kind of separation that lets a module be linked in stages: resolve the imports, and the definitions become a self-contained unit with a known base.

A Real Example

Resolving a call, which is the operation that makes the whole rule matter. A call instruction carries a function index, and the job is to turn that number into something executable:

resolve_call(module, index): imported = count imports of kind FUNC in the import section if index < imported: # the number names something the HOST provides return module.imports[func_position_of(index)] # the number names a definition in this module local_index = index - imported if local_index >= number of entries in the function section: error: call to a function that does not exist return module.function_bodies[local_index]

Two things in that are easy to get wrong, and both are silent. The subtraction is not optional — a reader that forgets it will resolve a call to the wrong function, and since the body count usually exceeds the import count the wrong function is usually a real one, so nothing complains. And the per-kind count matters: a module that imports three functions and one table has an imported function count of 3 and an imported table count of 1, and a reader that used one number for both would be off by two on every table reference after the first.

That failure is worth spelling out because it is invisible in the common case. Write a module with no imports at all: the counts are all zero, the subtraction is a no-op, and a reader with no offset logic passes every test you can write. Add one import and every call in the module starts pointing one function too low — at a valid function, of a valid signature, which is why the module keeps working until something happens to make the wrong answer visible. A bug that only appears when you add data is a bug your test suite was not built to find, and the way to build for it is to test the smallest case and the boundary case, not the middle.

The same structure, with the kind byte selecting which counter to use, is what an export needs — and it is why the export section's kind byte is the mirror of the import section's. An export says "this name refers to that kind, at that number", and the two fields together pick the index space. The export of "gv" as global[0] in this module is a case where the number is below the boundary, so it names an import; exporting a definition would have said global[1].

Try It Yourself

$ wasm-objdump -x decls.wasm | sed -n '/^Import/,/^Function/p'
$ python3 courses/wasm/assets/samples/wasm_decode.py --hex decls.wasm

Check Your Understanding

Without looking back: a module imports two functions, one table, zero memories and one global. It then defines three functions, one table, one memory and two globals. What are the indices of the defined memory and of the third defined function, and what does the exported index 0 in the global space refer to?

Apply It

Your tool links a module by replacing calls to imported functions with the module's own. It works on small modules. On a large one, several functions return values that belong to entirely different functions — plausible values, right types, wrong answers — and only for modules that import anything. Modules with no imports are fine, which is the clue. Nothing errors and the module is valid. What is the defect, and what is the shortest test that would have caught it before a user did?

The general habit: when a bug only appears when an optional structure is present, find the branch that handles that structure. And prefer a differential test to a unit test — comparing two runs that should agree catches what comparing a function to itself cannot.

Connect

The index-space rule is the WebAssembly member of a family this collection has met twice. In the PE import directory and in ELF's dynamic symbol table, the same problem appears in a different shape: a module refers to an external symbol by name at link time and by index at load time, and the loader has to build the mapping. WebAssembly inverts it — the index is the only reference that survives, and the name is resolved before the file is ever used.

That inversion is what makes a WebAssembly module self-contained in a way an ELF object is not. An object file is full of undefined symbols and meaningless without a link step; a WebAssembly module is fully resolved except for its declared imports, and those are named explicitly with a module and field. The format chose to make the unresolved set explicit and small, rather than implicit and large, which is the same trade a .lib archive makes by carrying a symbol index so a linker can find a member without opening every member.

The per-kind bodies connect to the type section and the COFF section characteristics. In both cases a single-byte tag selects among several record shapes, and in both cases a reader that ignores the tag will not fail loudly — it will read a byte from one structure as if it belonged to another. The reason a tag exists at all is that the format cannot be extended by adding a new structure type without one, and that is a lesson worth carrying to any format you meet next.

One more thing the import section sets up, which the last module of this course returns to: an imported global has no initial value in the file. The host owns it. That is a small fact and it is the cleanest example in the format of the difference between what a file can express and what it deliberately leaves to somebody else — the same division the object format draws when it puts a linking section in a custom section the core specification knows nothing about.

Next: Tables, Memories and Limits.