Every other section stores data. The code section stores a program, and a program's opcodes are the one part of a binary format that a reader has to understand completely to do anything at all. A reader that cannot decode an instruction stream cannot disassemble, cannot validate, cannot compile, and cannot find a function — there is no partial credit.
That constraint produced a decision that is visible in every single byte: the opcode space is one byte, and the top four bits of that byte are reserved for a prefix. Only 0x00 to 0x0f are opcodes directly, and anything from 0x10 to 0xff is either a prefix or an instruction in one of sixteen prefixed subspaces.
The reason is extension, and the reason it is worth thinking hard about is that it shows a genuine tension in format design. A dense one-byte space — 256 opcodes, no prefix, immediate-free — would be the most compact possible encoding and would fit a JavaScript JIT's dispatch perfectly. Taking a quarter of it away to guarantee room for future proposals spends a real, permanent cost on a speculative benefit. That trade was the right one, and the history of the format's growth is the evidence — but it is a trade, not a free win, and the reason it was accepted is worth being able to state.
An instruction is an opcode byte, then zero or more immediates. The immediates are the operands the encoding needs beyond the stack, and there are five kinds in the core format:
Only the opcode byte tells you which immediates follow. There is no length prefix on an instruction, no way to skip one without knowing what it is, and no way to walk the stream without a table. A disassembler is fundamentally a large dispatch on one byte, and the shape of that dispatch is the shape of the opcode space.
Here is the layout, and it is a genuinely clever piece of packing:
0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+---------------+ +---------------+
| 0 0 0 0 x x x | | p p p p x x x |
+---------------+ +---------------+
direct opcode p = the prefix number, x = the low 3 bits
0x00 - 0x0f : opcodes directly (unreachable, nop, block, loop, if, ...)
0x10 : call
0x20 : local.get / local.set / local.tee / global.get / global.set
0x28 - 0x3e : loads and stores (each has an alignment + offset)
0x3f : memory.size
0x40 : memory.grow
0x41-0x44 : the four const opcodes
0x45-0xc4 : comparison, arithmetic, conversion -- the bulk
0xd0 - 0xd2 : reference instructions
0xfc : a PREFIX -- the sub-opcode follows as a LEB128
0xfd : a PREFIX -- SIMD
0xfe : a PREFIX -- atomics
Read that carefully, because the low three bits are doing real work. Each prefixed subspace holds 128 instructions, numbered 0 to 127, and the prefix byte chooses the subspace. So 0xfc 0x00 is "prefix 0xfc, instruction 0" — a truncating float-to-int conversion, of which there are eight variants for signed and unsigned and 32-bit and 64-bit.
And the prefixed sub-opcode is a LEB128, not a byte. That is the detail that surprises people, and it is not an accident: it means the sub-space is not limited to 128 entries in principle, only in practice so far. The SIMD prefix has needed a few more than a byte would cleanly give, and the LEB128 form is why it did not have to be renumbered. A reader that reads the sub-opcode as a single byte works today and breaks the day a proposal needs instruction 128 in some subspace — which is a smaller version of the same lesson the LEB128 concept taught about section sizes.
A real compiled body, dumped with the byte encodings, and every row is verified against wabt's own disassembly:
local.get 0 20 00 i32.add 6a local.get 1 20 01 i64.add 7c local.get 2 20 02 f32.add 92 local.get 3 20 03 f64.add a0 drop 1a i32.eq 46 nop 01 else 05 return 0f end 0b i32.const 1 41 01 i64.const 1 42 01 f32.const 1.0 43 00 00 80 3f
Two things to notice before going further. The numeric opcodes are not contiguous by operation — i32.add is 0x6a, i64.add is 0x7c, f32.add is 0x92, f64.add is 0xa0, a gap of 18 each time. The opcode space is ordered by type family and then by operation, and the gaps are where a proposal would insert. And the type is in the opcode, not the immediate: there is no "add" opcode and no type field, there are four add opcodes. That is what lets a JIT dispatch on the opcode alone and know the operand types without decoding a byte.
The structured control instructions all take one block type immediate, and it is the most interesting immediate in the format:
04 40 if blocktype = 0x40 (empty: no params, no results) 02 40 block blocktype = 0x40 03 40 loop blocktype = 0x40 0b end 05 else
0x40 is the empty block type, and it is the third encoding of "nothing" in the format alongside a zero result count and a zero-length name. A block type can be:
0x40 the empty type: no parameters in, no results out
0x7f 0x7e 0x7d 0x7c a single result of that value type
0x70 0x6f (not legal as a block type, but adjacent in the
encoding space -- a block cannot produce a reference)
a signed LEB128 >= 0 a TYPE INDEX: this block takes parameters and
produces results, using the type section
and a NEGATIVE signed LEB128 is reserved for future extensions
The block type is overloaded, and the disambiguation is the sign. A byte 0x7f is a value type; a multi-byte LEB like 0x00 is type index 0. A reader that tries to read the block type as a LEB first will read 0x40 as the LEB value 64, which is a type index — and then go looking for type 64 in a module that has three. So the rule is: peek one byte; if it is a value type or 0x40, that is the whole block type; if it is anything else, it is a signed LEB index. That is a one-byte lookahead and it is genuinely fiddly, and it is why a disassembler's block-type handling is a common source of bugs.
0x00 elemkind byte in the element section's function forms, which a later concept covers, and kept the 0x40 empty block type even though it is not a value type.Loads and stores take a memarg, and it is two fields, not one:
28 02 00 i32.load align = 2, offset = 0
2c 00 04 i32.load8_s align = 0, offset = 4
28 02 00 the 0x28 is the opcode, then TWO LEB128s
align is a LOGARITHM, not a byte count:
align 0 -> 2^0 = 1 byte
align 1 -> 2^1 = 2 bytes
align 2 -> 2^2 = 4 bytes <- natural for an i32 load
align 3 -> 2^3 = 8 bytes <- natural for an i64 load
The alignment immediate is log2 of the byte alignment. That is a compression, and it is a good one — the value is almost always 0, 1, 2 or 3, so a single byte carries four bits' worth of meaning. But it means a reader that treats it as a byte count will compute a bounds check that is 4× too strict for an i32.load, and reject a module that is perfectly legal. Or, if it divides, it will compute offset / align and get nonsense.
And both immediates matter for correctness, and they are not the same kind of thing. The offset is a static constant baked into the instruction: it is an immediate, and because it is a LEB128 it can be enormous, up to 232-1. The align is a hint to the engine, promising the access is aligned; a runtime may use it to emit an aligned instruction. An engine that trusts the hint without checking, on a module it did not produce, gets a fast unchecked load on an unaligned address — and on some architectures that is an instant fault. The validator does check that the declared alignment is not greater than the natural alignment of the access, which prevents a module from lying upwards; it deliberately does not check that the address is aligned, because that is a run-time property.
The best example of the format growing is call_indirect:
11 00 00 call_indirect
| +-- table index = 0
+----- type index = 0
Two LEB128s. The original specification had only the type index, and the reference-types proposal appended a table index so that a call_indirect could name which table's entry to read. A reader written against the original spec reads one LEB, gets the type index right, and treats the table index byte as the next opcode — which will usually be something plausible, and will desynchronise the rest of the function.
Compare call, which has one:
10 00 call 0
and the two memory instructions, which also take a memory index even though a module may have only one:
3f 00 memory.size 0 40 00 memory.grow 0
Those trailing 00 bytes are pure forward compatibility. The multiple-memory proposal makes the index meaningful; today it is always zero, and a reader that omits it will be wrong the day it is not. Paying one byte per instruction today to avoid a format change tomorrow is a trade the design made deliberately, and it is the same instinct as reserving the top four opcode bits.
Disassembling a stream, which is the only operation that makes the design choices above pay off or cost. The inner loop is a dispatch on one byte:
Three things in that are consequences of the design, not of the algorithm. The opcode table is unavoidable — there is no length prefix, so a reader cannot skip an instruction it does not recognise, and refusing is the only safe response. That is a real cost of the format and it is why adding an opcode is a specification change that old readers cannot tolerate, unlike a prefixed opcode which old readers at least reject cleanly.
Immediate widths are a property of the opcode, so the table has to carry the arity and kind of every immediate. A disassembler that guesses "an opcode ending in a subscript digit probably has an index" will be wrong: memory.size and memory.grow take an index, and so do the control instructions, but i32.const takes a signed constant, and the two are encoded identically as a LEB128. The difference between an index and a constant is not in the encoding at all — it is semantic, and a reader cannot tell them apart from the bytes.
And the boundary check is the whole safety property. Every instruction that reads memory has a memory index, and every index has to be checked against the declared memory count before the access. 28 02 00 at the end of a stream with no memory is a trap, not a segfault — the module is rejected at validation, before a single instruction executes. Compare a native COFF relocation or a ELF relocation, where an out-of-range index is a link-time or load-time failure that a debugger may step straight past. WebAssembly moves that class of error from "the loader's problem" to "the validator's problem", and the reason it can is that the instruction encoding is fully self-describing.
One practical consequence worth knowing before you write a disassembler. The align immediate is a hint, and a compiler is free to emit a smaller alignment than the access actually needs — i32.load with align = 0 is perfectly legal, meaning "no alignment guaranteed". A disassembler that prints the effective alignment as 2^align is being precise; one that prints align raw is being terse. wabt prints the raw value, so i32.load 2 0 means align 2, offset 0 — and the first number is not a byte count, however much it looks like one.
$ wasm-objdump -d ops.wasm
$ wasm-objdump -d body.wasmwasm-objdump on a module using instructions your table does not have. The first missing one is instructive, because it is the moment a table stops being a complete answer — and the place to look for whether the prefix scheme made the extension clean or messy.align as a byte count instead of a log, and run it on ops.wasm. i32.load declares align 2, which your reader believes means 2 bytes, so it rejects a valid module. Producing a false rejection on purpose teaches you to read a validator's complaint carefully rather than assuming it is right.(block (result i32) (i32.const 1)) and (block (type $t)) where $t is (func (param i32) (result i32)). Read both block types back. The first is a single byte 0x7f; the second is a multi-byte LEB index. Same instruction, two different immediate widths, and the disambiguation is a one-byte peek — now find out what happens if you get the peek wrong.memory.size 0 and call_indirect byte by byte and account for every byte. Then confirm that the memory index is always zero in today's format, and note what a reader that omits it would do. Forward compatibility has a price, and this is the smallest possible example of paying it.-O0 and at -O2, and compare the instruction histograms. -O0 is dominated by local.get/local.set; -O2 by global.get and memory loads. That difference is the compiled-code analogue of the local declaration grouping finding — both are pictures of what an optimiser removes.Without looking back: three instruction encodings are 28 02 00, 11 00 00 and 04 40. Decode each one fully, and say what a reader written against the original 2017 specification would get wrong about the first two.
Your disassembler works on every module produced by your toolchain. On a module from a newer toolchain, it prints a plausible instruction stream for the first function, then drifts: line numbers stop matching, the function ends at the wrong place, and the next function's output is nonsense. The module validates with wasm-validate. The first function that misbehaves contains exactly one call_indirect. What is the defect, and why did the output stay plausible for a while instead of failing immediately?
The general habit: a format with no instruction length prefix makes a width error silent, because the misread bytes usually land in a valid opcode space. Detect misalignment rather than trusting continuity, and prefer an error over a plausible wrong listing.
The one-byte-opcode-plus-prefix design has an exact precedent in x86, and the comparison is the point. x86 reserves its top nibble for sixteen prefix bytes and uses the low three bits for the base opcode — the same 4/3 split — and x86 has spent forty years regretting it, because the prefixes are consumed in an order nobody can remember and every one of them costs a byte. WebAssembly's prefixes are ordered and uniform, which is the design lesson, and the two share the cost. This course has no x86 lesson to point at, which is itself worth noting: the format deliberately kept only the idea and none of the baggage.
The alternative designs make the trade concrete, and none of them is free. A fixed 4-byte instruction word — real RISC, and the PowerPC and SPARC encoding — has zero decoding cost, at the price of enormous code density loss, which is why a 32-bit ARM program is several times larger than the equivalent x86 one. A byte plus a length prefix, which is what several CISC formats do, is self-delimiting and costs an extra byte on every instruction, which is cheap when instructions average five bytes and expensive when they average one. WebAssembly took a third option that gets neither benefit fully: one byte of opcode, variable immediates, no length, which is the most compact of the three and the least robust, since a reader that loses sync has nothing to check against — and, as the final concept shows, a desync in a length-prefixed structure is the failure this format is most exposed to.
The memarg's two-field structure connects to the ELF p_align and p_offset and to the PE section alignment. In all three the alignment is a power of two and a loader has to round a file offset up to it, and the same arithmetic appears here as an exponent the decoder must evaluate. The difference is that WebAssembly stores the logarithm, which is one fewer byte and one more place to get it wrong — and the COFF characteristics word collects several such one-bit facts into a single 16-bit value, which is the same compression applied to a whole set of flags.
And the block type connects to COFF's auxiliary records and to DWARF's DW_TAG_ prefix, because all three are a one-byte-or-tag selector choosing among record shapes, and all three are overloading a small integer space. The WebAssembly case is the most interesting of the three because the selector is overloaded on a value that is itself meaningful — 0x7f is a value type in a block type context and a valtype everywhere — so the disambiguation is a lookahead rather than a table.
The format's remaining sections put these instructions to work. The element section fills the tables these instructions index, and the data section fills the memories these instructions address — and both have grown the same way call_indirect did, one proposal at a time, which is why the element section has eight encodings and the data section three.