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 for wasm-objdump (disassembly with byte offsets)
  • [ ] The exact .wasm that produced the trap — not a rebuilt one, or the offsets will drift
  • [ ] The trap message and address copied verbatim from DevTools or the runtime
  • [ ] xxd for spot-checking individual bytes, and the Wasm binary format section reference open alongside for the LEB128 rules

Step-by-step procedure

  1. 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. With wasm-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)
  2. Disassemble with per-offset addresses. wasm-objdump -d prints 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
  3. Locate the function and the offset. Find the func[12] header, then the line whose offset matches 0x1a4f. The disassembler has already done the LEB128 decoding 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
  4. 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.

  5. 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
  6. 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 one i32 address and pushes one i32; if the preceding instruction left an f64, you have a type mismatch rather 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 -d offsets are module-absolute; some engines report function-relative. Chrome’s wasm-function[i]:0xNNNN is module-absolute and matches -d directly. If a tool gives you a function-relative offset instead, add the function body’s start offset (from wasm-objdump -h or the func[i] header) before matching.

  • Stripped builds hide the symbol but not the offset. A release build optimized with wasm-opt -Oz --strip-debug drops the name section, so you see func[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 bounds near a SIMD path, you may see fd ... (the 0xFD SIMD prefix) or fc ... (bulk memory). These are two-part opcodes; the byte after the prefix is a second LEB128 index. 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 the br_if/if a 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 depth0 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.

← Back to Wasm Binary Format Deep Dive