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.
Three fields per section, and one of them is not what you would guess:
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.
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.
| Id | Name | Declares |
|---|---|---|
| 0 | custom | anything at all; not interpreted by the format |
| 1 | type | function signatures |
| 2 | import | functions, tables, memories, globals this module needs from outside |
| 3 | function | which signature each locally-defined function has |
| 4 | table | indirection tables |
| 5 | memory | linear memories |
| 6 | global | module-level variables |
| 7 | export | what this module offers to the outside |
| 8 | start | the function to run at instantiation |
| 9 | element | initial table contents |
| 10 | code | function bodies |
| 11 | data | initial memory contents |
| 12 | datacount | how many data segments there will be |
| 13 | tag | exception-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 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.
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:
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.
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.
The section walker, in full. It is thirteen lines, and it is the whole reader for the container layer of the format:
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.
$ wasm-objdump -h simple.o
$ llvm-objdump -h simple.o
$ python3 courses/wasm/assets/samples/wasm_decode.py --hex simple.oproducers section in simple.o, find its length byte in the hex, read the name length that follows, subtract, and check you get LLVM's number and not wabt's. Then write a one-line script that prints both numbers for every custom section in every sample, and you have built the check that would have caught this.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?
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.
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.