Back to course

Sections

23 minintermediateModule 1: The ContainerContinues from wasm-header
23 min · Module 1: The Container · Algorithms

Why This Matters

After the eight-byte header, everything in a WebAssembly module is a section, and the framing is identical for all of them. One byte of identifier, one LEB128 length, then that many bytes of content. There is no section header structure, no alignment padding, no per-section flags, and no name for the standard sections beyond the identifier.

That uniformity is the format's main structural decision, and it is what makes a reader short enough to be trustworthy. A complete section walker is a dozen lines. But the uniformity is also where a subtle ambiguity hides, because the framing says "these n bytes are this section's content" and the two reference tools do not agree on what n means for one kind of section.

This concept decodes the framing, enumerates the fourteen identifiers, works out the ordering rule, and then looks at that disagreement — which is not a bug in either tool but a genuine difference in what each one is reporting, and which produces a trap that is invisible unless you go looking for it.

A Simple Model

Three fields per section, and one of them is not what you would guess:

+--------+--------+---------------------------+ | id | size | content | | 1 byte | LEB128 | `size` bytes | +--------+--------+---------------------------+ id which of the fourteen standard sections this is, or 0 for "custom" size how many bytes of content follow, as an UNSIGNED LEB128 content the section's own payload, in a layout the id determines

Read a real section, from the hand-written sample in this course, byte by byte. Its header is 00 61 73 6d 01 00 00 00, then the Type section begins at offset 8:

0008: 01 0a 01 05 60 01 7f 01 7f 01 60 00 00
      |  |     |  |  |  |  |  |  |  |  |  |  |  |  |
      |  |     |  |  |  |  |  |  |  |  |  |  |  |  +-- 0x00: no children
      |  |     |  |  |  |  |  |  |  |  |  |  |  +----- 0x00: no results
      |  |     |  |  |  |  |  |  |  |  |  |  +-------- 0x60: a function type
      |  |     |  |  |  |  |  |  |  |  |  +---------- 0x01: one result
      |  |     |  |  |  |  |  |  |  |  +------------- 0x7f: i32
      |  |     |  |  |  |  |  |  |  +---------------- 0x01: one parameter
      |  |     |  |  |  |  |  |  +------------------- 0x7f: i32
      |  |     |  |  |  |  |  +---------------------- 0x60: a function type
      |  |     |  |  |  |  +------------------------- 0x01: two types
      |  |     |  |  |  +---------------------------- 0x60: a function type
      |  |     |  |  +------------------------------- 0x05: five entries
      |  |     |  +---------------------------------- 0x0a: TEN bytes of content
      |  +-------------------------------------------- 0x01: section id 1 (Type)
      +----------------------------------------------- content starts at 0x0a

Count the content bytes: offsets 0x0a through 0x13 inclusive is exactly ten, and the next section starts at 0x14. The length field is the number of bytes after the length field, not the distance from the section's start and not the distance to the next section. It excludes the id byte and it excludes the length bytes themselves. Two bytes of framing, then n bytes of content.

The fourteen identifiers

All fourteen, in the order they must appear. The order is not arbitrary: it is the order in which a module is conceptually built, and it is why the format can require it.

IdNameDeclares
0customanything at all; not interpreted by the format
1typefunction signatures
2importfunctions, tables, memories, globals this module needs from outside
3functionwhich signature each locally-defined function has
4tableindirection tables
5memorylinear memories
6globalmodule-level variables
7exportwhat this module offers to the outside
8startthe function to run at instantiation
9elementinitial table contents
10codefunction bodies
11datainitial memory contents
12datacounthow many data segments there will be
13tagexception-handling tags

Read the order and the design becomes visible. Types come first because everything else refers to them. Imports come next because imported things occupy index space before locally-defined ones. The function section declares signatures without bodies, and the code section supplies bodies — split in two so that a function's type is known before its body is parsed. Tables, memories and globals are all declarations of space; the export and start sections are how the host will interact; and element and data are the contents of that space, which is why they come after the space exists.

And datacount is at 12, after data at 11, which looks wrong until you know why it exists: it is a forward declaration so that memory.init and data.drop can refer to a data segment by index inside a function body, and a function body is in section 10, before the data it refers to is declared in section 11. The count has to be known early, so it gets its own section placed before the code. A section order is a dependency order, and reading the order tells you the dependency graph.

The Actual Detail

The ordering rule, and how it fails

The rule: non-custom sections must appear in increasing identifier order, and each may appear at most once. Custom sections, id 0, may appear anywhere, any number of times, including between any two standard sections.

Here is the rule being broken, by duplicating the Type section of the hand-written sample and appending a copy at the end:

$ wasm-validate dup.wasm
  0000063: error: multiple Type sections

$ llvm-objdump -h dup.wasm
llvm-objdump: error: 'dup.wasm': out of order section type: 1

$ wasm-objdump -h dup.wasm
  ...prints a section table, no complaint...

Three tools, three messages, one rule. The first is a validator naming the rule it broke and the offset. The second is a reader expressing the same rule as a consequence — in LLVM's model, a repeated section is an out-of-order section, and the message is more general than the situation. The third is a printer doing its job.

What makes this worth a table of its own is that the failure is otherwise silent. Nothing about a duplicated Type section is locally wrong: the section is well-framed, its length is right, its content parses. It is the file that is illegal, and the only way to know is to have checked the thing you did not think to check. That is the general risk with a file format that has few redundant fields, and it is why the ordering rule is worth knowing as a rule rather than as an assumption.

Where two reference tools disagree about a size

Now the finding. Take the same custom sections from two real object files and ask wasm-objdump and llvm-objdump what their sizes are.

custom section   declared   wabt    llvm    name bytes   payload after name
  linking            32      0x20    0x18        8              24
  producers          56      0x38    0x2e       10              46
  target_features   148      0x94    0x84       16             132
  reloc.CODE         17      0x11      --       11               6

Neither tool is wrong, and neither is guessing. They are reporting different quantities, and the file settles it unambiguously. Compute the payload size yourself from the bytes:

custom section content, in order: name length, as a LEB128 1 byte for a short name the name, that many bytes 8 for "linking", 10 for "producers", 16 for "target_features", 11 for "reloc.CODE" whatever else, unnamed the rest payload size after the name = declared size - (1 + name length)

Every row checks out. linking: 32 − 8 = 24 = 0x18, which is what LLVM printed. producers: 56 − 10 = 46 = 0x2e. target_features: 148 − 16 = 132 = 0x84. And wabt's numbers are the declared sizes: 32 = 0x20, 56 = 0x38, 148 = 0x94, 17 = 0x11.

So the finding is exactly this:

Why this is a trap rather than trivia. The difference is 1 + the name length, which varies per section. A tool that takes wabt's number and uses it as a length to skip will land correctly, because wabt's number is the declared size. A tool that takes LLVM's number and does the same will stop 1 + name length bytes early, inside the section, and start parsing whatever follows as if it were a section header.

Nothing errors. The bytes just past the end of a custom section's payload are the next section's identifier and length, which are small valid numbers, so the reader produces a plausible section that does not exist — and every row after that point is wrong. The error is constant per section and different for each section, because the name lengths differ, which is the same signature the DWARF .debug_frame concept used to identify a coordinate-system error.

Which is why this course's own crosscheck compares standard sections with both tools but compares custom-section names rather than sizes, and says so in a comment. A harness that blindly compared sizes would have reported a disagreement on every file, and the tempting conclusion — "one of the tools is wrong about custom sections" — would have been false.

A length field that is two bytes wide

Every size in the samples so far has been under 128, so every length LEB128 has been a single byte with the high bit clear. That stops being true quickly. The hand-written sample has a section appended with a 43-character name and 200 bytes of filler:

$ wasm-objdump -h long_custom.wasm | tail -1
 Custom start=0x00000064 end=0x00000158 (size=0x000000f4) "really_long_custom_section_name_for_testing"
0064: 00 f4 01 2b 72 65 61 6c 6c 79 5f 6c 6f 6e 67 5f
      |  |  |  |  |  |
      |  |  |  |  |  +-- 0x2b = 43 decimal: the name length
      |  |  |  |  +----- "really_long_custom_section_name_for_testing"
      |  |  |  +-------- name starts here, after the length byte
      |  |  +----------- 0x01: the length continues
      |  +-------------- 0xf4: low 7 bits set, high bit set, so it continues
      +----------------- 0x00: section id, custom

Work the LEB128: the first length byte is 0xf4. Its high bit is set, so the value continues in the next byte. Its low seven bits are 0x74 = 116, and the next byte 0x01 contributes 1 × 128 = 128. Total 244, which is 0xf4 — and wabt printed size=0x000000f4, with 0x158 - 0x64 = 0xf4 confirming the distance.

And the name length is itself a LEB128, 0x2b = 43, one byte. It is worth noticing how much everything here is a LEB128: the section size, the custom section's name length, the count of entries in every section, every index, every limit. There are exactly two fixed-width integers in a WebAssembly module's framing — the four magic bytes and the four version bytes — and everything else is variable-width. That is the next concept, and it is the single most important encoding detail in the format.

A Real Example

The section walker, in full. It is thirteen lines, and it is the whole reader for the container layer of the format:

p = 8 # past the header while p < len(file): off = p # for error messages id = file[p] # 1 byte, no LEB p += 1 size, p = read_uleb128(file, p) content = file[p : p + size] p += size if id == 0: # custom: the content starts with a name, and # "content" above therefore INCLUDES the name name, q = read_name(file, p) # so the real payload starts at q, not p else: # standard: content is the payload itself pass

That last indentation difference is the whole finding, and it is a convention rather than a rule. The framing is identical either way: size bytes of content follow the length field. But a custom section's content begins with a length-prefixed name, so whether the reported "size" includes that name decides where a consumer starts reading. wabt includes it, LLVM excludes it, and the file is unambiguous about which is which.

Three things the walker deliberately does not do, and each is a decision:

That last point generalises past this format, and it is the reason length-prefixed framing is so common. A file where every region declares its own size can be walked by a reader that understands nothing about the contents, using only the sizes. It is why an ar archive can be listed without parsing a single object inside it, why a DWARF .debug_cu_index can be located without decoding a unit, and why wasm-objdump can print a section table for a file that will not validate. The length field is the interface between a reader that understands the format and a reader that only needs to find things in it.

Try It Yourself

$ wasm-objdump -h simple.o
$ llvm-objdump -h simple.o
$ python3 courses/wasm/assets/samples/wasm_decode.py --hex simple.o

Check Your Understanding

Without looking back: a custom section is 56 bytes long by its length field, and its name is producers, which is 9 characters. A tool reports the section's size as 0x2e. Which tool is it, and what would go wrong if the other tool's number were used to skip to the next section?

Apply It

Your tool reads a WebAssembly module by walking sections and skipping unknown ones. It works on every module you have tested. A customer's module produces a section table with about four too many rows, and the extra rows are all small ids with plausible small sizes. Nothing errors. The customer says it was produced by a toolchain newer than yours, and asks whether their file is malformed. What do you conclude, how do you confirm it, and what is the one thing you should check about your own code first?

The general habit: when a walk-based parser finds the wrong number of things, suspect it lost sync. A file cannot be both well-framed and mis-framed; it is one or the other, and the count is a better signal than any single value.

Connect

The length field is the oldest idea in binary formats and this course has now met it four times. The ar archive gives each member a 60-byte header with a decimal size, so a reader can step member to member without understanding any of them. The DWARF location list table is framed by a section-level length field for the same reason. ELF frames its sections the same way and then adds a section table so a reader can seek rather than scan.

WebAssembly takes the idea and makes it the only framing mechanism, and adds two things the others do not have. The first is the ordering rule, which lets a reader be a straight-line loop with no state. The second is the custom section, which is a promise that the format will not interpret bytes it did not define — and that promise is why a module can carry compiler provenance, target feature requirements, and linker metadata without the specification having to mention any of them.

That second one is the more interesting, and it connects straight to the COFF .drectve concept, which is about a section whose contents are not the program's contents. Both formats discovered the same escape hatch, for the same reason: the specification is frozen and the world is not. The difference is that COFF marks the escape with a section characteristic, while WebAssembly marks it with the identifier zero — and LLVM's own object format leans on the WebAssembly one, so a .wasm file from clang is full of sections the core specification says nothing about.

Next: LEB128, the encoding that almost every other number in the format uses.