Everything so far has described a finished module: a self-contained thing a host can validate and run. But a compiler does not produce finished modules. It produces object files that refer to functions defined elsewhere, and something has to join them together — and that something needs information the core format has no place for.
Where does that information go? Into custom sections, which the core specification does not define at all. A custom section is section id 0, and its payload begins with a name. The specification's entire requirement is that a reader which does not recognise the name skip it — which it can, because the section size is right there. Everything after the name is somebody else's format.
This is the single most important architectural fact about the object format, and it is worth understanding as a design position rather than a shortcut. The core specification did not need to know about symbol tables or relocations, so it did not define them, so a runtime that only implements the core can load any object file without erroring — it ignores what it does not know. Compare a PE file, where a runtime either understands the import directory or produces a broken image, or DWARF, where the debugging information is in sections with declared ids that a non-debugging reader still has to know to skip. The custom section is the format saying: you do not need to know me, and I will not pretend you do.
The custom section itself, and it is a three-line structure:
Three real object files, and the custom sections they contain. This is a complete listing of what wasm-objdump reports, so nothing is hidden:
simple.o 7 sections
Type, Import, Function, Code, and custom:
"linking" 0x20 = 32 bytes
"producers" 0x38 = 56 bytes
"target_features" 0x94 = 148 bytes
externs.o 10 sections
Type, Import, Function, DataCount, Code, and custom:
"linking" 46 bytes
"producers"
"target_features"
externs.o also has, when relocations are needed:
"reloc.CODE" 17 bytes
Four names, and they divide cleanly by purpose. linking holds the symbol table — every function and data item the object defines or needs, so a linker can resolve between objects. reloc.CODE holds the relocations, one custom section per section that needs them, named after the section they patch. producers records who built the file, and target_features records what the code requires — which features were used, so a linker or runtime can reject code using features it cannot support.
The naming convention for the relocation sections is the detail worth noticing: reloc.CODE means "relocations against the code section", and there would be a reloc.DATA if a data segment needed patching. The name is the addressing scheme. It is a convention rather than something the format enforces, which is both its weakness and its flexibility — a producer can invent reloc. plus any section name and the core will skip it just the same.
Here is the first eight bytes of a real object file, produced by LLVM 21:
0000: 00 61 73 6d 01 00 00 00 00 85 80 80 80 00
| | | | | +-- 0x00: high byte, no more bits
| | | | +----- 0x80: continuation, zero bits
| | | +-------- 0x80: continuation, zero bits
| | +----------- 0x80: continuation, zero bits
| +-------------- 0x85: low 7 bits = 5, continuation
+------------------------------------- 0x00: section id = CUSTOM
Read the version field first: 01 00 00 00 — the 32-bit version is written as four plain bytes, not a LEB128, which is correct and specified. Now the section size at 0x8: 85 80 80 80 00. That is five bytes encoding the value 5, where one byte would do. And it is not a one-off:
every section in externs.o, with the width of its size LEB128:
0x08 5 bytes 0x13 5 bytes 0x31 5 bytes
0x39 5 bytes 0x40 5 bytes 0x56 5 bytes
0x66 5 bytes -> the linking section
all eight. Not one of them uses a minimal encoding.
A producer may pad a LEB128 out to the maximum width for its type.85 and 05 are the same number; the specification says the value is what matters, not the width. LLVM emits the full five bytes for every u32, because it writes the value with a fixed-width encoder that happens to be a LEB128 algorithm, and a constant-time encoder is easier to get right than a variable-time one.
85 80 80 80 00, compute 5, and then be one byte out of position immediately — it will treat 0x80 as the start of the next section. The failure is not subtle and not rare: every single section of every LLVM-produced object triggers it. Three readers were used to verify this course, and all three read these files correctly, so the specification's intent is unambiguous; the point is that a decoder has to be written against the specification's rule rather than against the shortest input that satisfies it, and the shortest satisfying input is exactly the one that never appears in a real toolchain's output.Confirmed by an independent walk of the bytes:
0066: 00 ae 80 80 80 00 07 6c 69 6e 6b 69 6e 67
| | | | | | | +-- "linking", 7 characters
| | | | | | +----- name length = 7
| | | | | +----------- payload starts here
| | | | +--------------- 5-byte size LEB128 = 46
| | | +------------------- 0x00 = CUSTOM section
+------------------------------ 0x66 = the section starts here
0074: 02 version = 2
0075: 08 subsection id 8 = the SYMBOL TABLE
0076: 8f 80 80 80 00 subsection size = 15 <- ALSO five bytes
007b: 02 symbol count = 2
Three layers of the same structure, and the repetition is the design working: a subsection is a section, which is an id, a size, and a payload. The subsection size is also a five-byte LEB128, so the padding convention is applied at the inner level too. A reader that handles the outer level correctly and the inner one with a minimal-encoding assumption will fail twice as deep into the file, which is the kind of bug that only shows up once you have a nested length-prefixed structure to walk.
And wabt's own reading of the symbol table, which is authoritative for the semantics even though this course could not fully reconcile the record layout from a single sample:
- 0: F <sum> func=0 [ binding=global vis=hidden ] - 1: D <g> segment=0 offset=0 size=4 [ binding=global vis=hidden ] segment info [count=1] - 0: .bss.g p2align=2
Read the properties and the analogy to every other format's symbol table is exact. F and D are the kind — a function, a data item — and the index is the section-relative number. binding=global versus local is the internal-linkage distinction, exactly as a COFF StorageClass or an ELF symbol binding distinguishes them. vis=hidden means "not exported to the world" — the same concept as a COFF symbol being static rather than external, and the reason a linker will not put sum in the export table. And a data symbol carries segment, offset and size, which is the triple an ELF symbol's st_value and st_size carry between them.
0x8a plainly say 5. Those are small plausible integers, so nothing errored; the parser simply kept reading the wrong fields and produced entirely fictional subsections. That is what a length-prefixed structure looks like when a record inside it is mis-sized, and it is why the discipline is to walk records and to check your position against the enclosing size rather than to trust that a sequence of plausible values is a sequence of correct values. No claim about the data symbol's field order is made here.The smallest and most legible of the object sections, and a complete reading of it:
- name: "reloc.CODE"
- relocations for section: 4 (Code) [1]
- R_WASM_MEMORY_ADDR_LEB offset=0x000007(file=0x00004d) symbol=1 <g>
Four facts and nothing else, and the names say what each one is for. The section index says which section is being patched — section 4, the code section, because this object is a relocatable one where the code section is id 4 rather than 10. The relocation type says what kind of fix-up to apply, and MEMORY_ADDR_LEB is the important one: an address that must be written as a LEB128 into a code section, so the patch has to be a signed LEB128 of the right width, and a linker that wrote a fixed-width value would corrupt the instruction stream. The offset is where in that section to patch, and the parenthetical is the same number in file coordinates. The symbol index says what to patch it with — symbol 1, which the symbol table names as g, the data item in .bss.g.
That one relocation type is the whole reason a linker can process code, not just data. A relocation into a data section patches a fixed-width field and nothing has to move. A relocation into a code section patches a variable-width one, so the linker must know the value before it can size the patch, and the patch can change the file's length. This is a genuinely harder problem than the data case, it is the reason the object format has a separate custom section per relocatable section rather than one relocation table, and it is the same class of problem as a PE base relocation needing to know the width of the field it is patching. The difference is that the WebAssembly case is worse, because a wrong width does not produce a wrong address — it produces a misaligned instruction stream that may still decode as something plausible.
The last custom section, and the smallest idea. It is 148 bytes in simple.o and wabt reports its presence without expanding it, so this course records what it is for rather than what its bytes mean. Its job is to declare which proposals the code actually uses — and that is a real safety property, not metadata for its own sake. A runtime can reject an object whose feature list it does not implement, at load time, rather than accepting it and hitting an unknown opcode mid-execution. It is the object-format equivalent of the limits flags rule: a producer declares what it needs so a consumer can refuse cleanly instead of misreading.
What a linker actually does with these three sections, and why the order matters more than the content:
link(a.o, b.o) -> out.wasm:
# 1. GATHER. Every object contributes a symbol table from its
# "linking" section. Build one global namespace:
# defined here -> this object owns the definition
# undefined in both -> an error, or resolved from an import
# undefined in one -> resolve to the other object's definition
# defined in both -> a duplicate, unless they are COMDAT groups
# 2. RELOCATE. For each object's reloc.CODE / reloc.DATA:
# for each relocation:
# value = the resolved symbol's address in the OUTPUT
# if the type is a LEB128 patch into CODE:
# THE PROBLEM: encoding the value may need more bytes
# than the placeholder reserved
# -> either the producer must reserve slack, or the
# linker must rewrite the instruction stream
# write value at the recorded offset, at the recorded width
# 3. DISCARD. Drop the "linking", "reloc.*" and "target_features"
# sections. The output is a MODULE, and a module has no symbol
# table -- the whole point of linking is that the result needs none.
# 4. WRITE, in ascending section-id order, per the module format.
Step 3 is the one that shows what the object format is for. A finished WebAssembly module has no symbol table, no relocations and no unresolved names — that is the property that makes it safe to hand to an untrusted runtime, and it is exactly what steps 1 and 2 produce. The object format exists to hold the intermediate state that the module format deliberately cannot represent, and it holds it in sections the module format ignores, so that a reader of the output needs to know nothing about the input's provenance.
Step 2 is where this course's biggest limit sits, and it should be stated rather than glossed. No wasm-ld or lld is available in this environment, so linking behaviour could not be observed: the code in that block is derived from the relocation record's structure and from the width problem it implies, not from a linked output. The one thing that was verified is the input side — the relocation record, the symbol table, the section names — because those are in files on disk. A reader that wants to see the output side needs a linker installed, and the exercise below says exactly what to check when they have one.
Step 1's duplicate case connects to a lesson from another course in this collection, and it is worth naming because it is the same mechanism reached from a different direction. A COFF linker's COMDAT handling discards a duplicate section based on its name, ignoring the checksum, and silently produces wrong code when two same-named sections differ. WebAssembly's object format has COMDAT groups too, in a dedicated linking subsection — and the same failure is available to it, because a group is identified by a name and the format's job is deduplication rather than verification. Every deduplication-by-name mechanism can discard something that should have been kept, and the only defence is a checksum that is actually checked.
$ wasm-objdump -x externs.o
$ clang --target=wasm32 -c -O0 -o sym.o sym.c
$ python3 courses/wasm/assets/samples/wasm_decode.py externs.owat2wasm, which emits minimal encodings, and compare. Two producers, same format, five bytes versus one, and every decoder in this course has to handle both — which is exactly why the sample set contains both kinds of file.externs.o. Then make it handle non-minimal encodings and run it again. Doing this on purpose is the point: the first version will look like it works on the hand-built samples and will fall apart on the real one, and that is the failure mode you want to recognise rather than discover.externs.o's symbol table and try every plausible field order until all fifteen are accounted for and the next subsection id reads 5. If you cannot, you have reproduced this course's exact result — and the two unaccounted bytes plus the wrong downstream ids are the evidence of where it went wrong. A negative result with a documented symptom is worth more than a guess.externs.o has its code section as id 4; a finished module has it as id 10, because the element and datacount sections are absent from the object. Dump both and account for the difference. Relocatable files do not share the module's section layout, and a reader that assumes they do will reject every object it meets..c file with no globals and no external calls produces no reloc.CODE. Add a global variable and it appears. Two compiles, one section, and a clear picture of what makes a relocation necessary — the object format is not paying for relocations it does not need.wasm-ld available, link two objects where one calls the other's function, and read the result: the symbol table and reloc sections should be gone, the callee should be a direct call with no import, and the function indices should have been renumbered. Then read the import section of the output and see whether any imports survived — that is the part this course could not observe, and it is the last thing standing between an understanding of the format and an understanding of the toolchain.Without looking back: a section's size field in a real LLVM object file is the five bytes 85 80 80 80 00. What number does it encode, is the encoding legal, and what does a decoder that reads one byte and moves on do with the rest of the file?
Your module loader validates every module a compiler produces and rejects a fraction of them with a generic "section out of order" error. Modules assembled with wat2wasm load perfectly. Nothing in your code branches on anything unusual, and you have never tested against an object file before. What is the defect, and why is the error message actively misleading about where to look?
The general habit: the input family that works is evidence about the code, not reassurance. When a bug appears only on inputs your tests never contained, the test suite's assumptions are the defect — and an error message that names a format rule is pointing at a layer above wherever the fault actually is.
The custom section is the cleanest architectural decision in this format, and the collection has a spectrum of alternatives to measure it against. At one end, COFF and ELF assign meaning to section ids: a COFF reader that meets section 1 knows it is text and a reader that meets an unassigned number has a malformed file, so extension requires the specification to grow. In the middle, DWARF reserves a large id space and documents that unknown ids must be skipped — the same intent as a custom section, expressed as an id range rather than as a name. At the WebAssembly end, a custom section is uninterpreted by construction, which means extension needs no specification change at all, and it also means there is nothing to validate. That trade — zero cost to add, zero guarantees from the core — is why the object format could be specified by other people without the core changing.
The relocation model connects most closely to PE base relocations, and the similarity is instructive. Both patch addresses into already-placed code, both must know the width of the field they are writing, and both are the reason a linker can produce a working image without understanding the instructions. The differences are where the difficulty lives: a PE relocation patches a fixed-width field, so its size is known from the relocation type alone, while a MEMORY_ADDR_LEB relocation patches a variable-width encoding inside an instruction stream, so the linker must know the value before it can size the patch, and a width mistake yields a valid-looking but misaligned instruction stream rather than a wrong address. The variable-width case is strictly harder, and it is the direct consequence of the compact immediate encodings the instruction format chose. The same class of problem appears in the PE import directory, where a thunk's target is patched after the fact, and in COFF's IMAGE_REL_I386_REL32, which is fixed-width precisely because it is patching x86 code.
The five-byte LEB128 is the thread back to the LEB128 concept, and it is the best single lesson in the course about how variable-width encodings interact with real producers. A format's encoding rules are a permission, not an obligation, and a producer with a legitimate reason to pad will pad. The failure is entirely on the reader, and it is invisible until real toolchain output meets a decoder written against the smallest legal examples. Write decoders against the widest legal input, not the shortest — and the best way to find the widest is to look at what the tools in the ecosystem actually emit, which is why this course's sample set deliberately contains hand-built and compiler-built files side by side.
That closes the format. A WebAssembly module is a magic number, a version, and thirteen section kinds in a fixed order, with every size a LEB128, every index relative to an import-first space, and every instruction a one-byte opcode with the top four bits reserved for the future. The object format wraps that in custom sections the core ignores, and a linker removes them. Back to the beginning to see the whole thing as one shape.