Here is the statistic that should make this concept worth a page. In a WebAssembly module, there are exactly two fixed-width integers in the container framing: the four magic bytes and the four version bytes. Every other number — every section size, every name length, every count of entries, every index, every memory limit, every instruction operand — uses the same variable-width encoding. The header and the framing are the exception, not the rule.
That encoding is LEB128, and it is the single detail that a reader of this format must get exactly right, because the failure is silent in a way that almost nothing else in the format is. A decoder that reads a two-byte LEB128 correctly and a one-byte LEB128 correctly will still be wrong on every negative number, and it will be wrong by an amount that varies with the value.
The reason is worth stating before the mechanism, because it explains the design. Almost every number in a WebAssembly module is small. Section sizes, entry counts, type indices, local indices — the overwhelming majority fit in one byte, so a variable-width encoding that spends one byte on the common case and only more when necessary is close to free. A u32 field costs four bytes per number forever; a LEB128 costs one. In a module with a thousand instructions, that difference is thousands of bytes.
And the cost of the optimisation is this: the encoding no longer has a fixed width, so the reader cannot tell where the next number starts without decoding it. A fixed-width field is a stride you can jump over. LEB128 is a length you must walk. That is the trade, it is a good trade, and every decoder in the format has to implement it correctly before it can do anything else.
The mechanism, in one picture. A LEB128 is little-endian base 128: seven bits of value per byte, and the high bit says whether more bytes follow.
byte: 7 6 5 4 3 2 1 0
+-+-+-+-+-+-+-+-+
| | value 0 |
+-+-+-+-+-+-+-+-+
^ ^
| +-- bit 0
+---------------- the high bit: 1 means "another byte follows"
value = (byte0 and 0x7f)
+ (byte1 and 0x7f) << 7
+ (byte2 and 0x7f) << 14
+ ...
That is the whole unsigned encoding. Two properties are worth holding onto: the bytes are consumed least-significant group first, and a single byte with the high bit clear is a complete value with no lookahead. The second is why the format has no length prefix for its numbers — the encoding is self-delimiting, and a reader knows where it ended by looking at the last byte it read.
Signed LEB128 uses the same seven-bits-per-byte layout and differs in exactly one rule: the sign of the value comes from the high bit of the last byte, not the low bit. The decoder takes the value it has assembled and, if that final byte's bit 6 is set, subtracts one more group unit.
the final byte is b6 b5 b4 b3 b2 b1 b0 | 0
| | |
| | +-- continuation
| +---------- bit 6: the SIGN
+--------------------------------- the value bits
one-byte signed values, exhaustively:
00 -> 0 40 -> -64
01 -> 1 41 -> -63
3f -> 63 7f -> -1
40 -> -64 80 -> (continues, so it is not a one-byte value)
Read that table and the trap is visible. 0x3f is 63 and 0x40 is −64, with no gap between them — the range is continuous because 63 is followed immediately by its negation. And 0x7f is −1, not 127.
0x7f is −1 read as signed LEB128, 127 read as unsigned LEB128, and — once it has been widened to a 32-bit i32 and printed without a sign — 4294967295. All three are correct readings of two bytes, and the file contains nothing that distinguishes them. What tells you which is intended is the field: a i32.const operand is signed, a section size is unsigned. There is no marker in the encoding itself. This is the one place in the WebAssembly format where the same bytes are genuinely ambiguous, and it is why the format uses two different functions for the two cases rather than one function with a flag.Take a module with four constants, one of them positive, and read the four immediates out of the code section. The source is:
(func (export "neg") (param i32) (result i32)
local.get 0
i32.const -1
i32.add)
(func (export "big") (result i32) i32.const 1000000)
(func (export "minusbig") (result i32) i32.const -1000000)
(func (export "i64neg") (result i64) i64.const -1)Read the immediates with the decoder shipped with this course, and compare all three decodings side by side:
| Source | Bytes | Signed LEB128 (correct) | Unsigned LEB128 (wrong) | Widened unsigned (what wabt prints) |
|---|---|---|---|---|
i32.const -1 | 7f | −1 | 127 | 4294967295 |
i32.const 1000000 | c0 84 3d | 1000000 | 1000000 | 1000000 |
i32.const -1000000 | c0 fb 42 | −1000000 | 1097152 | 4293967296 |
i64.const -1 | 7f | −1 | 127 | 18446744073709551615 |
Three things to read out of that table, and the third is the important one.
First, the positive value is identical in all three columns.1000000 has the high bit of its final byte clear, so signed and unsigned decoding agree and so does the widening. A decoder with no signed LEB128 passes every positive value in the module. That is why the bug survives testing.
Second, the negative values are wrong by wildly different amounts in the unsigned column. −1 becomes 127, a small error. −1000000 becomes 1097152, an error of 2097152. The unsigned decoder is not off by a constant, so no single correction factor will rescue it, and the size of the error grows with the magnitude of the value.
Third, look at the last column and compare it with the disassembler's own output:
$ wasm-objdump -d neg.wasm | grep const
000049: 41 7f | i32.const 4294967295
00004f: 41 c0 84 3d | i32.const 1000000
000056: 41 c0 fb 42 | i32.const 4293967296
00005d: 42 7f | i64.const 18446744073709551615Every one of those matches the "widened unsigned" column exactly, including the 64-bit case at 18446744073709551615. So wasm-objdump decodes i32.const and i64.const operands and prints them as unsigned.
That is not a bug in the disassembler, and the distinction matters. The encoding is signed LEB128 — the signed column above proves it, because it recovers the source constants exactly. What the disassembler does is take the decoded signed value and display it in the unsigned form of its own type, which is a presentation choice and arguably a defensible one: a 32-bit value printed unsigned is unambiguous, where −1 could be mistaken for a label. But it means a reader who takes the disassembler's number as the constant will compute the wrong answer, and will compute it wrong for exactly the constants most likely to appear in real code: −1, and small negative offsets.
The negative one-million case, since it has all three features at once — several bytes, a final byte with bit 6 set, and a value that needs the sign step:
bytes: c0 fb 42
| | |
| | +-- 0x42, high bit clear: this is the LAST byte
| +-------- 0xfb, high bit set: continue
+-------------- 0xc0, high bit set: continue
groups, least significant first:
0xc0 and 0x7f = 0x40 = 64
0xfb and 0x7f = 0x7b = 123
0x42 and 0x7f = 0x42 = 66
value = 64 + (123 << 7) + (66 << 14)
= 64 + 15744 + 1081344
= 1097152
now the sign: the LAST byte is 0x42, whose bit 6 (0x40) IS set,
so this is negative. Subtract one more group:
1097152 - (1 << 21)
= 1097152 - 2097152
= -1000000
The step that matters is the last one. Notice that 0x42 has its high bit clear — so it is the last byte — and its bit 6 is set, which is what says "negative". Those are two different bits of the same byte doing two different jobs, and confusing them is the classic LEB128 bug. The high bit answers "is there another byte"; bit 6 answers "is the value negative".
Also worth noticing from the arithmetic: the intermediate value 1097152 is the same number the unsigned decoder produces. Signed and unsigned LEB128 assemble the same bits; the only difference is the final adjustment. So a decoder that gets the sign step wrong and a decoder that uses the wrong function produce identical bytes and identical intermediate values — which is why neither can be caught by comparing intermediate state.
The rule is not arbitrary, and it follows from what each number means:
| Field | Encoding | Why |
|---|---|---|
| section size, name length, entry counts | unsigned | A count is never negative, and the unsigned encoding of a small positive is one byte |
| indices: type, function, local, label, global | unsigned | Same. An index into anything in a module is a position, and positions are non-negative |
| limits: memory and table minimum and maximum | unsigned | Page counts and element counts |
i32.const, i64.const | signed | They are constants, and constants are negative roughly half the time |
br_table targets, and other relative offsets | unsigned | Relative in the instruction stream, so the sign is implicit in the arithmetic rather than stored |
The row that is easy to get wrong is the last one, and it is worth pausing on. A branch to an earlier label is a negative distance, and you would expect it to be signed. It is not: br_table depths are unsigned, because the label indices are unsigned and the target is looked up rather than computed. Meanwhile i32.const is signed, because it is a value. So "is this number a distance or a position" is the question, not "is it a distance". A reader that assumed signed-wherever-there-is-offset would be wrong here, and wrong in a way that produced an out-of-range index rather than a wrong constant.
Writing the decoder, because the two-function split is the design and the design is what prevents the bug. This is the version the course ships:
Compare the two loops. They are nearly identical, and the difference is two lines: signed increments shiftbefore testing the last byte, and applies the sign adjustment after. Unsigned does not need either, because a count has no sign and there is nothing to subtract.
Now the guardrails, because a decoder without them will be handed something it should refuse:
The third check is the one that matters most in practice, and it is worth being honest about why: there is no way to detect it from the bytes. The other two are self-evident properties of the encoding — a value cannot need more than ten bytes, and a 32-bit field cannot hold a 40-bit number. But given 0x7f with no field context, −1 and 127 are equally consistent with the file. A reader cannot be self-checking here. It has to be written correctly, from a table saying which fields are signed, and reviewed by someone checking that table.
That is a general property worth noticing about formats. Some of a format's rules are checkable at runtime and some are not. WebAssembly is unusually well designed in the checkable category — the validator verifies types, arities, indices, and memory bounds before a single instruction runs, which is why an untrusted module is safe to hand to a JIT. But the encoding layer sits below the validator, and a decoder has to be right before any validation can happen. The layer that decides what the bytes mean is the layer that cannot check itself, and that is true of every binary format ever designed.
$ python3 courses/wasm/assets/samples/wasm_decode.py --hex neg.wasm
$ wasm-objdump -d neg.wasm | grep constneg.wasm with wasm_decode.py, then decode each one three ways by hand. The one that matters is c0 fb 42, because it has all three features — three bytes, a final byte with bit 6 set, and a value that needs the subtraction. Work it on paper before you look at the answer.read_sleb128 in the shipped decoder to drop the sign adjustment, run it on neg.wasm, and confirm every negative constant becomes a large positive. Then change it to drop the shift += 7 before the test, which breaks multi-byte values only, and see which of the four constants that catches. The two bugs have different footprints, and a reader who has met one does not automatically avoid the other.0x3f and 64 is 0x40, one byte each, with the second being negative. 64 needs no second byte and is positive. Write the boundary out: −64, −63, ..., −1, 0, 1, ..., 63 — and check that the encoding is one byte for all of them. That is the property the sign bit buys, and seeing it is a hundred times clearer than being told it.long_custom.wasm is 244 bytes and encodes as f4 01. Add a section over 16,384 bytes and confirm it widens to three bytes. Two bytes is a coincidence of small files; three proves the encoding is general rather than a special case.0x80 bytes. A decoder with the guard rejects it; one without allocates indefinitely. This is the most practically important line in the decoder, and it is the one that is easiest to leave out because no legitimate file triggers it.i32.const and i64.const are the obvious ones. Find the ones you did not expect — there is at least one in the SIMD instruction set — and check each against real bytes. This table is the part of a decoder that cannot be verified by testing, so it deserves the most attention.Without looking back: a i32.const instruction is followed by the bytes c0 fb 42. Read the constant three ways, and say which one the program means.
You are writing an interpreter for WebAssembly. Arithmetic works. Comparisons work. Loops work. But one function is subtly wrong: it returns a plausible result, and that result is wrong only for some inputs, and always wrong in the same direction. i32.const, i32.add and local.get are all implemented and all tested against a reference interpreter on a large generated test suite. What is the most likely defect, and why did the test suite not find it?
The general habit: a differential test against your own model proves the implementation matches your understanding. To break a tie you need a case your model will not generate — and for a signedness bug, that means a negative number on purpose.
LEB128 is not WebAssembly's invention and it is not its only one. The same encoding appears in three formats in this collection, and each use teaches something slightly different about why it exists.
In DWARF the encoding is where a silent bug is easiest to make, because there the field's signedness is decided by the abbreviation table rather than by the format. A reader must consult a table to learn whether a given operand is signed, and getting that wrong is the bug the location-expressions concept warns about with 0x5c being −36 and not 92. In COFF's archive format the size field is decimal text rather than binary, which is a remarkable choice for a format that is otherwise entirely binary — and it is a reminder that a format can mix encodings when humans need to read the file. In ELF the LEB128 fields are comparatively rare, because ELF predates the technique.
WebAssembly adopted it wholesale, and the reason is visible in the numbers this concept opened with: a WebAssembly module is dominated by small integers — indices, counts, offsets, constants — so the one-byte case is the common case, and a variable-width encoding is close to free. A format with a dozen large fields, like ELF, gains little by switching and loses the ability to seek to a field by arithmetic on its offset.
One consequence of the encoding belongs to the container rather than to the numbers, and it is worth carrying forward. Because a LEB128 is self-delimiting, a section's content cannot be indexed without decoding it — there is no stride, so you cannot compute the offset of the third entry without walking past the first two. That is why the Code section has to be read in order to find the third function's body, and it is the reason the next module's first concepts are about how bodies are found rather than about what is in them.
That closes Module 1. The container is decoded end to end: eight fixed bytes, a framing of identifier and length, fourteen section kinds in a required order, and one variable-width encoding governing every number in between. What remains in the format is what those numbers mean — the declaration sections, the code section, and the two sections that place data — and those are the subject of the modules that follow.
Back to the course index.