Understanding LEB128 Encoding
This guide answers one task: read and decode the LEB128 integers that make up most of a WebAssembly
binary — section sizes, indices, immediates and counts — so that a hex dump of a module becomes
intelligible.
Prerequisites
- [ ] A
.wasmfile and a hex viewer such asxxd. - [ ] Comfort with binary and hexadecimal.
- [ ]
wasm-objdump, for checking your decoding against a tool. - [ ] No tooling beyond that; this is deliberately a by-hand exercise.
Why a variable-length integer
A WebAssembly module is dense with small numbers: function indices, local counts, section lengths, byte offsets. Encoding each as a fixed four bytes would waste three bytes on nearly all of them.
LEB128 — little-endian base 128 — encodes an integer in as many bytes as it needs. Each byte carries
seven bits of the value in its low bits, and its high bit indicates whether another byte follows.
byte: 1xxxxxxx more bytes follow
byte: 0xxxxxxx last byte
Values under 128 take one byte, under 16,384 take two, and so on. Since the great majority of numbers in a module are small, the saving across a whole binary is substantial — commonly 20–30% against a fixed-width encoding.
Decoding unsigned by hand
The algorithm is short: take each byte’s low seven bits, shift by seven per byte consumed, stop when a byte has its high bit clear.
function decodeULEB128(bytes, offset = 0) {
let result = 0, shift = 0, i = offset;
for (;;) {
const byte = bytes[i++];
result |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) break;
shift += 7;
}
return { value: result >>> 0, next: i };
}
decodeULEB128(Uint8Array.from([0xe5, 0x8e, 0x26]));
// { value: 624485, next: 3 }
Working it through by hand: 0xE5 is 1110 0101, so the continuation bit is set and the payload is
110 0101 = 101. 0x8E is 1000 1110, continuation set, payload 000 1110 = 14, shifted by 7 gives
1792. 0x26 is 0010 0110, continuation clear, payload 38, shifted by 14 gives 622592. The sum is
624485.
The signed form is different
Signed values use a variant with sign extension, and confusing the two is the most common source of
nonsense when hand-decoding. Integer constants — the immediate of i32.const — are signed; indices and
lengths are unsigned.
function decodeSLEB128(bytes, offset = 0) {
let result = 0, shift = 0, i = offset, byte;
do {
byte = bytes[i++];
result |= (byte & 0x7f) << shift;
shift += 7;
} while (byte & 0x80);
if (shift < 32 && (byte & 0x40)) result |= (~0 << shift); // sign extend
return { value: result | 0, next: i };
}
The extra step is the sign extension: if the final byte’s second-highest bit is set, the value is negative
and the remaining high bits are filled with ones. Decoding 0x7f as unsigned gives 127; as signed it gives
−1, and both are correct for their context.
0x7f unsigned → 127 signed → -1
0xc0 0xbb 0x78 unsigned → 1973696 signed → -123456
Reading it in a real module
Everything in a module’s structure is length-prefixed with these integers, which makes a hex dump navigable once you can decode them.
xxd -l 32 dist/engine.wasm
# 00000000: 0061 736d 0100 0000 0107 0160 027f 7f01 .asm.......`....
# 00000010: 7f03 0201 0007 0a01 0670 726f 6365 7373 .........process
Reading it: 00 61 73 6d is the magic number, 01 00 00 00 the version. Then 01 is the type section’s
identifier, 07 is its size in bytes as a LEB128, 01 is the count of types, 60 marks a function
type, 02 is the parameter count, 7f 7f are two i32 parameters, 01 is the result count and 7f the
result type.
Then 03 is the function section, 02 its size, 01 the count, 00 the type index. Then 07 is the
export section, 0a its size, 01 the count, 06 the name’s length, and the six bytes spelling
process.
Every one of those small numbers is a LEB128, and almost all of them are one byte because almost all of
them are small.
Encoding, for when you write bytes
Writing the encoding is as short as reading it, and is occasionally needed — patching a module, generating one, or building a probe module by hand.
function encodeULEB128(value) {
const out = [];
do {
let byte = value & 0x7f;
value >>>= 7;
if (value !== 0) byte |= 0x80;
out.push(byte);
} while (value !== 0);
return out;
}
function encodeSLEB128(value) {
const out = [];
for (;;) {
const byte = value & 0x7f;
value >>= 7; // arithmetic shift, preserves sign
const signBit = byte & 0x40;
if ((value === 0 && !signBit) || (value === -1 && signBit)) { out.push(byte); return out; }
out.push(byte | 0x80);
}
}
The signed encoder’s termination condition is the subtle part: it stops when the remaining value is all zeros or all ones and the sign bit of the last emitted group agrees, which is what makes the decoder’s sign extension recover the original value.
One consequence worth knowing: patching a value in place is only safe when the new value encodes to the same number of bytes. Changing a section size from 100 to 200 makes it two bytes instead of one, which shifts everything after it — which is why tools rewrite a module rather than editing it, and why a hand-edited binary so often fails validation in a place unrelated to the edit.
Where parsers go wrong
Three mistakes account for most LEB128 bugs in hand-written parsers.
Not advancing the cursor by the encoded length. The number of bytes consumed varies, so a parser must use the decoder’s returned position rather than assuming one byte. Assuming one works for every small value and fails the first time a section exceeds 127 bytes — which is to say, immediately in any real module.
Using the unsigned decoder for a signed value. An i32.const -1 decodes as 127 rather than −1, and the
module appears to contain plausible nonsense.
Ignoring the maximum length. A malicious or corrupt module can encode a value with many redundant continuation bytes, which a naive decoder will happily consume forever. The specification bounds the encoding at five bytes for a 32-bit value and ten for a 64-bit one; a parser reading untrusted input must enforce that.
function decodeULEB128Safe(bytes, offset, maxBytes = 5) {
let result = 0, shift = 0, i = offset, count = 0;
for (;;) {
if (++count > maxBytes) throw new Error('LEB128 too long');
if (i >= bytes.length) throw new Error('LEB128 truncated');
const byte = bytes[i++];
result |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) break;
shift += 7;
}
return { value: result >>> 0, next: i };
}
Expected output
Decoding a module’s header by hand should agree with the tool:
wasm-objdump -h dist/engine.wasm | head -3
# Type start=0x0000000a end=0x00000011 (size=0x00000007) count: 1
# Function start=0x00000013 end=0x00000015 (size=0x00000002) count: 1
# Export start=0x00000017 end=0x00000021 (size=0x0000000a) count: 1
Those sizes — 7, 2, 10 — are exactly the LEB128 values at offsets 9, 18 and 22 in the hex dump. Matching
them by hand once is the fastest way to be confident you have understood the encoding, and it makes the
binary format legible rather than opaque from then on.
Gotchas
- Assuming one byte. Correct until a value exceeds 127, which happens quickly.
- Signed versus unsigned. Indices are unsigned;
constimmediates are signed. - No length bound. A corrupt input can loop forever in a naive decoder.
- Shifting past 31 bits in JavaScript. Bitwise operators work on 32-bit integers; a five-byte value
needs care, and a 64-bit one needs
BigInt. - Sign extension skipped. Negative constants decode as large positive numbers.
- Assuming the encoding is canonical. Redundant trailing zero groups are legal in some contexts, so two encodings can represent the same value.
Performance note
Decoding a LEB128 costs a few nanoseconds and appears in every module parse, so a parser reading a large
module performs millions of them. The naive loop above is fast enough for tooling; a decoder in a hot path
benefits from the common-case shortcut of checking whether the first byte has its high bit clear and
returning immediately, which covers the majority of values in a typical module.
Frequently Asked Questions
Why little-endian base 128 rather than a simpler scheme? It is compact for small values, self-terminating so no length prefix is needed, and simple to decode with a shift and a mask. It also predates WebAssembly — DWARF uses it, which is where the name comes from.
Do I ever need to write this by hand? Rarely, and it is worth being able to. Reading a hex dump, writing a small tool that inspects modules, or debugging a binary a tool has mangled all become straightforward once the encoding is familiar.
Are the encodings always minimal? Not necessarily. The specification allows some non-minimal encodings in certain positions, so two byte sequences can decode to the same value — which matters if you are comparing modules byte for byte.
Related
- How to decode .wasm files manually — the whole format, section by section.
- Decoding Wasm opcodes for debugging — the instructions these integers appear inside.
- Validating binaries with wasm-validate — checking a module you have been reading by hand.
← Back to Wasm Binary Format Deep Dive