Decoding Wasm opcodes for debugging
This guide answers one task: given a runtime trap and an address like wasm-function[12]:0x1a4f in a stack trace, find the exact WebAssembly instruction that faulted and explain why. When wasm2wat and source maps are stripped or misleading, decoding the raw opcode at that offset is the deterministic fallback.
Prerequisites
- [ ]
wabt≥ 1.0.34 forwasm-objdump(disassembly with byte offsets) - [ ] The exact
.wasmthat produced the trap — not a rebuilt one, or the offsets will drift - [ ] The trap message and address copied verbatim from DevTools or the runtime
- [ ]
xxdfor spot-checking individual bytes, and the Wasm binary format section reference open alongside for theLEB128rules
Step-by-step procedure
-
Capture the trap address verbatim. In Chrome or Firefox DevTools the trace reads
wasm-function[12]:0x1a4f. The bracket index is the function index; the hex after the colon is a byte offset. Withwasm-objdump -d, that offset is the absolute offset into the module, which is what you match below.RuntimeError: memory access out of bounds at wasm-function[12]:0x1a4f at main (app.js:88:14) -
Disassemble with per-offset addresses.
wasm-objdump -dprints each instruction prefixed with its byte offset, so you can scan straight to the address from the trace.wasm-objdump -d app.wasm > app.disasm -
Locate the function and the offset. Find the
func[12]header, then the line whose offset matches0x1a4f. The disassembler has already done theLEB128decoding for you.grep -n "func\[12\]" app.disasm awk 'NR>=L && NR<=L+200' L=$(grep -n "func\[12\]" app.disasm | cut -d: -f1) app.disasm -
Read the opcode and its immediates. The line shows the raw byte, the mnemonic, and the decoded operands. For a memory access it includes the alignment and offset immediates that determine the effective address.
-
Confirm the byte directly when in doubt. If you suspect the disassembler aligned differently than the engine, dump the single byte at the offset and check it against the opcode table.
xxd -s 0x1a4f -l 1 app.wasm # expect 28 for i32.load, 29 for i64.load, 36/37 for stores -
Validate the stack effect. Walk a few instructions back from the fault to confirm what was on the value stack. An
i32.load(0x28) pops onei32address and pushes onei32; if the preceding instruction left anf64, you have atype mismatchrather than a bounds problem.
Expected output
wasm-objdump -d produces a disassembly where the offset before each instruction is exactly the number in the trap address. For the trace above, the faulting line is the i32.load whose offset is 0x1a4f:
app.wasm: file format wasm 0x1
Code Disassembly:
001a3e func[12] :
001a44: 20 01 | local.get 1
001a46: 41 02 | i32.const 2
001a48: 74 | i32.shl
001a4d: 20 00 | local.get 0
001a4f: 28 02 00 | i32.load 2 0 ; <-- trap address
001a52: 0b | end
The 28 02 00 decodes as opcode 0x28 (i32.load) with LEB128 immediates 02 (alignment 2² = 4-byte) and 00 (offset 0). The effective address is the i32 on top of the stack — here local.get 0 shifted into a base pointer — and the out of bounds trap means that address plus 4 exceeded linear memory.
Gotchas
-
wasm-objdump -doffsets are module-absolute; some engines report function-relative. Chrome’swasm-function[i]:0xNNNNis module-absolute and matches-ddirectly. If a tool gives you a function-relative offset instead, add the function body’s start offset (fromwasm-objdump -hor thefunc[i]header) before matching. -
Stripped builds hide the symbol but not the offset. A release build optimized with
wasm-opt -Oz --strip-debugdrops thenamesection, so you seefunc[12]with no<render_row>label. The disassembly offsets are still exact — decode the opcode, then map back to source separately via DWARF if you kept it. -
Multi-byte prefix families look like garbage. Hitting
out of boundsnear a SIMD path, you may seefd ...(the0xFDSIMD prefix) orfc ...(bulk memory). These are two-part opcodes; the byte after the prefix is a secondLEB128index. Reading the prefix alone gives a nonsense mnemonic and a wrong instruction boundary. -
The address points at the trapping instruction, not its cause. An
unreachable(0x00) trap is usually a panic landing pad emitted by the compiler; the real bug is upstream where the condition that branched to it was computed. Decode thebr_if/ifa few instructions earlier to find the actual logic.
Reading the immediates, not just the opcode
The opcode byte alone rarely tells the whole story; the LEB128 immediates that follow it carry the operands that actually determine behavior. Memory instructions take two immediates — an alignment hint and a static offset — both unsigned LEB128. In 28 02 00, the 02 is the alignment expressed as a power of two (2² = 4-byte natural alignment for an i32.load), and the 00 is a compile-time byte offset added to the dynamic address on the stack. A common real-world fault is a non-zero static offset pushing an otherwise-valid base pointer past the end of linear memory: the base looks fine in a debugger, but base + offset + 4 overflows. Always read both immediates before concluding the address itself was wrong.
Control-flow opcodes encode their targets as immediates too. A br (0x0C) or br_if (0x0D) carries a LEB128 relative label depth — 0 is the innermost enclosing block, 1 the next out, and so on — not an absolute byte target. A call (0x10) carries a function index; call_indirect (0x11) carries a type index and a table index, and traps if the runtime signature of the table entry does not match that type. When a type mismatch trap fires inside a call_indirect, decode its type-index immediate and compare it against the Type section: the table held a function whose real signature differs from the one the call site declared.
Mapping the offset back to source
Once you have the faulting opcode and its function index, the last step in triage is usually mapping it to a source line. If the build kept DWARF (-g for Rust or C/C++, not stripped by wasm-opt), the .debug_info and .debug_line custom sections let llvm-dwarfdump --lookup=0x1a4f app.wasm resolve the offset to a file and line. If the build instead emitted a JavaScript source map alongside the .wasm, wasm-sourcemap performs the same lookup. With neither, you are limited to the opcode and the function index — still enough to localize the bug to a function and an instruction, which is often all you need to form a hypothesis.
Performance note
Keep wasm-objdump -d output out of the hot path entirely — it is a build-time artifact. Generating the disassembly for a 2 MB module takes well under a second and produces a few megabytes of text; cache it as a CI artifact keyed on the module’s hash so trap triage is a grep, not a re-disassembly. The decoded offset itself costs nothing at runtime: it is the same byte offset the engine already tracks per instruction.
Frequently Asked Questions
Is the hex after the colon a function-relative or module offset?
With Chrome and Firefox it is the module-absolute byte offset, which lines up directly with the offsets wasm-objdump -d prints. You do not subtract the function start for those engines — only do that if a different tool hands you a relative offset.
Why does the disassembly show a different instruction than I decoded by hand?
You almost certainly mis-decoded a LEB128 immediate or split a 0xFC/0xFD prefix from its second byte, shifting every subsequent boundary. Trust wasm-objdump -d and re-walk your manual decode from the last offset that matched.
Can I get the source line instead of the opcode?
Yes, if the build kept DWARF: run llvm-dwarfdump or wasm-sourcemap against the same offset to map 0x1a4f back to the originating Rust or C line. Without debug info you are limited to the opcode and function index.
Related
- How to decode .wasm files manually — parse the surrounding section structure by hand.
- Debugging and profiling Wasm modules — source maps and DevTools workflows that complement raw decoding.
- Stack vs heap execution model — the value-stack rules behind a
type mismatchtrap.
← Back to Wasm Binary Format Deep Dive