Two sections earlier the format declared functions without giving them bodies: the type section says a signature and the function section says which signature each function has. Neither says what the function does. That is the code section's whole job, and it is the first part of the format you have reached that contains instructions rather than declarations.
The split exists for a reason worth knowing. A module's declarations are a small, highly-compressible set of facts that a validator wants to check exhaustively — every type, every index, every limit — before it trusts anything. Its code is a large stream of bytes that the same validator has to walk in full anyway. Separating them means a reader can load and check the declarations once, and then stream the code.
What makes the code section interesting is its local declaration encoding, which is the one place in the format where a compression trick changes the shape of the data in a way that is easy to misread. A function with seven locals does not have seven local entries. It has a group count, and the groups are run-length encoded — and the compiler merges adjacent locals of the same type, so the number of groups is not the number of declarations in your source either.
Two levels of framing. The section declares how many bodies; each body declares its own size:
Three fields per body, and the middle one is where the lesson is. The size field is what makes the code section seekable in the same way the section framing is: a reader can find the fifth function's body without decoding the first four, because each body says how long it is.
This is the part that surprises people. Locals are not listed one at a time. They are listed in groups, and each group is a count followed by a type:
So a function with ten i32 locals costs three bytes, not eleven. And here is the part that catches people out, verified from a real module: adjacent groups of the same type are merged. Write
(local i32) (local i32)and the compiler emits one group of 2 × i32, not two groups of 1 × i32. A reader that assumes one group per source declaration will count twice as many groups as there are, and then read the second one out of the middle of the instruction stream.
(local i32) (local f32) (local i32) is three groups — 1×i32, 1×f32, 1×i32 — because the two i32 declarations are not adjacent. And the order of the groups is the order the locals are numbered: local 0 is the first local of the first group, then local 1 is the second local of that group if the group has two, and so on. So local.get 1 in a function whose first group is 3 × i32 means the second of those three, and a reader that numbers groups rather than locals will be off by a varying amount that depends on the group sizes before it.A function with locals of four different types, written to make the grouping visible:
(func $locals (param i32) (result i32)
(local i32) (local i32) (local i64) (local f32) (local i32 i32 i32)
(local.get 1))Six local declarations in the source, of four distinct types, in three runs. Read the body out of the module:
body size = 12 0c 0 locals groups? no -- 04 FOUR groups 02 7f group 0: 2 locals of 0x7f (i32) 01 7e group 1: 1 local of 0x7e (i64) 01 7d group 2: 1 local of 0x7d (f32) 03 7f group 3: 3 locals of 0x7f (i32) 20 01 local.get 1 0b end
Now compare the source with the encoding, and every design decision is visible:
| Source | Encoded as | Why |
|---|---|---|
(local i32)(local i32) | one group 2 × i32 | adjacent and identical, so run-length encoded into one |
(local i64) | one group 1 × i64 | different type, so the run breaks |
(local f32) | one group 1 × f32 | different type, so the run breaks |
(local i32 i32 i32) | one group 3 × i32 | three in a row, one group |
Four groups, not six declarations, and the two i32 locals at the front have been absorbed into the first group. Seven locals total — two, one, one, three — across four groups, in eleven bytes of table.
And the crucial detail: the groups were not reordered to make a better encoding. The first group is 2 × i32 even though there is also a 3 × i32 group later, and the compiler could have emitted 5 × i32 if the i64 and f32 were not in between. It did not, because the group order is the local numbering. Local numbering is part of the function's interface — every local.get and every local.set in the body refers to it — so the encoding is obliged to preserve the order rather than optimise it.
That is worth pausing on, because it is the difference between an encoding that is merely compact and one that is also correct. A toolchain that sorted the groups by type would save a byte here and there and produce a module where local.get 1 means something different. The compression is applied within a run, never across one.
Two more functions from the same module, one with no locals and one with a mismatched pair, to show the frame:
$nolocals body size = 4 00 0 local groups 41 07 0b i32.const 7 ; end $multigrp body size = 9 02 2 local groups 01 7f 1 x i32 01 7c 1 x f64 41 01 1a 0b i32.const 1 ; drop ; end $t0 body size = 2 00 0 local groups 0b end
Three things to read off that. The group count is present even when it is zero — a function with no locals still spends a byte saying so, and a reader that skips it will read 0x41 as the group count. The body size counts the local table, not just the instructions, so a reader that treats it as an instruction-stream length will stop in the middle of the declarations. And end is 0x0b in every case — it terminates both a body and a constant expression, which is why the same walker decodes both.
Note also what the body size is not: it is not the number of instructions, and it does not include the size field itself. $locals's body is 12 bytes: 1 for the group count, 8 for four groups, and 3 for local.get 1 and end. The size byte that said 12 is not one of those twelve.
Resolving a local index, which is the operation the encoding exists to make cheap, and the arithmetic that goes wrong if you number groups instead of locals:
Run it on $locals, whose groups are 2×i32, 1×i64, 1×f32, 3×i32:
So the local numbering is a flattening of the group list, and N indexes that flattening rather than the groups. Now the failure mode, and it is a good one because it is mostly right:
local.get 0 and local.get 1 would be right and everything from local.get 2 onwards would be wrong.That last point is the interesting one. A number of local types being wrong is not something a size check can see, and it is not something a section walk can see. It surfaces only when a type-aware consumer — a validator, or a JIT that specialises on types — reads the body and finds an i32 operation on a local declared f64. Which is another way of saying that the run-length encoding is not merely a space optimisation: it makes the local type table a compressed representation, and every consumer has to decompress it before it can type anything.
There is one more consequence of the encoding being per-body rather than per-module, and it is a design choice rather than a limitation. Two functions in the same module that both declare (local i32) (local i32) each carry their own 2 × i32 group. There is no shared local table, because there could not be: locals belong to a function's frame, and a frame exists only when that function is called. Local declarations are the one piece of per-function metadata that cannot be factored out of the function, and the format treats them that way.
$ wasm-objdump -x body.wasm | sed -n '/^Code/,$p'
$ python3 courses/wasm/assets/samples/wasm_decode.py --hex body.wasm(local i32) (local f32) (local i32) and confirm it produces three groups, not two. Then write (local i32) (local i32) (local f32) and confirm two. The encoding is an RLE over a sequence, and that is the whole rule.-O2 function and read its body. You will usually find zero locals, because the optimiser promotes everything and the WebAssembly calling convention passes scalars in arguments rather than the stack. Contrast with -O0, where every C local becomes a WebAssembly local. That difference is the clearest possible picture of what an optimiser does to a function's frame.wasm-validate and see what it says. The validator is the other reader of this course, and this is the one place it catches something the section walk cannot.local.get 0 on a function with one parameter is the parameter and local.get 1 is the first declared local. Confirm that in the source and in the encoding together — it is the join between the type section and this one, and getting it wrong means a debugger labels an argument as a local.Without looking back: a function's local table is 04 02 7f 01 7e 01 7d 03 7f. How many groups, how many locals, what are their types in order, and what would a reader that assumed one group per source declaration conclude?
You are writing an interpreter. A function takes one i32 parameter and your test suite passes everything. One function in one module reports the wrong value for some inputs, and the module is otherwise fine: the right arity, the right body size, and every other function correct. The function declares (local i64) and (local i32), so its local table is 02 01 7e 01 7f. What is the most likely defect, and what would the evidence look like if you were wrong?
The general habit: when one function in a large module behaves differently, look for what makes it structurally different rather than what makes it numerically unusual. Group count, local count and type count are three different numbers, and conflating them is silent.
The run-length local table is the first genuinely clever encoding in the format, and it is worth comparing with the two other run-length schemes in this collection. The COFF symbol table uses an auxiliary record to mean "more symbols of the same kind follow" — a NumberOfAuxSymbols count, exactly analogous to a local group. And ELF's group and symbol tables use the same trick for a different reason, to avoid a second pass.
What WebAssembly's version does that COFF's does not is compress the type as well as the repetition. COFF's auxiliary records distinguish a section symbol from a weak external from a file symbol; they do not say "ten more integers follow". So the WebAssembly scheme is a more aggressive encoding, and the cost is the one this concept keeps returning to: every consumer must decompress before it can do anything, and the compression is only valid within a run.
The order-preservation point connects to the type section and to the COMDAT checksum. In both cases the format is optimising bytes while guaranteeing that an unrelated property stays fixed — here the local numbering, there the fact that two identical sections really were identical. A format that let an encoding change what a number means would not be a format; the compression is only allowed to be invisible, and a producer that reorders to compress better has produced a different module.
One thing is still missing from the body, and it is the thing the next concept covers: the instruction stream itself. local.get 1 and end have appeared in this concept only as bytes to be skipped over, and an opcode encoding is the one part of the format where a compact scheme would be most valuable and most dangerous — a dense one-byte opcode space means no room for extensions, which is exactly why the format kept the top four bits for a prefix and spent the recent proposals making that prefix do more work.
Next: Instruction Encoding.