Reading a Compiled Function’s Frame in WAT

This page answers one task: reading WAT produced by Rust or C compilers, you see functions that start by subtracting from a global and end by adding it back, with stores and loads in between at fixed offsets. You want to read these frames confidently — what the compiler put on the shadow stack and why — so you can diagnose stack overflows, understand performance, and relate WAT back to source.

Prerequisites

  • [ ] A function compiled from Rust or C/C++ that uses local arrays, structs or address-taken variables.
  • [ ] wasm-tools print or wasm2wat output with names (keep the name section).
  • [ ] Basic WAT reading skills.

Two stacks, two kinds of locals

WebAssembly functions have locals — typed variables managed by the engine, like registers — and an implicit operand stack. Neither lives in linear memory, and neither can be pointed to. Compilers therefore keep most scalar variables in Wasm locals. But some data must live in memory: arrays and structs whose address is taken or that are too big for locals, variables passed by pointer (&mut x in Rust, &x in C), and values that must be laid out contiguously. For those, compilers use a shadow stack in linear memory, whose top is tracked by the mutable global __stack_pointer. A function that needs shadow-stack space allocates a frame there on entry and releases it on exit.

A function frame on the shadow stack On entry the function subtracts its frame size from the stack pointer. The frame holds an address-taken local, a local array and saved values at fixed offsets from the new stack pointer. On exit the function adds the frame size back, releasing the space. The stack grows downward toward lower addresses. shadow stack (grows down) during $process caller frames saved/spills buf[64] x (addr taken) lower addr new SP + 144 new SP + 16 new SP

Step 1 — find the prologue and epilogue

A typical prologue and epilogue in compiler output:

(func $process (param $input i32) (param $len i32) (result i32)
  (local $sp i32) (local $result i32)
  ;; prologue: allocate 160 bytes
  global.get $__stack_pointer
  i32.const 160
  i32.sub
  local.tee $sp
  global.set $__stack_pointer
  ;; ... body uses (local.get $sp) + offsets ...
  ;; epilogue: release the frame
  local.get $sp
  i32.const 160
  i32.add
  global.set $__stack_pointer
  local.get $result)

The constant (160 here) is the frame size. Functions that need no memory-resident data have no prologue at all — a sign that everything fits in Wasm locals.

Step 2 — map frame offsets to source variables

Inside the body, accesses like (i32.store offset=12 (local.get $sp) ...) or (local.get $sp) (i32.const 16) (i32.add) refer to frame slots. Correlate them with the source: a 64-byte local array appears as a 64-byte region whose base address ($sp + 16) is passed to callees or used in loops; an address-taken integer appears as a 4-byte slot whose address is passed to a function. Debug builds with DWARF make this mapping explicit in debuggers; reading optimised WAT requires matching sizes and uses.

Step 3 — spot unexpected memory traffic

In optimised builds, scalars should live in Wasm locals. Loads and stores to frame slots for simple variables usually mean the variable’s address was taken somewhere — passing &x to a function the compiler could not inline, or a reference held across a call. That forces memory traffic in hot loops. Restructuring the source to avoid taking addresses (return values instead of out-parameters, pass by value for small types) often removes the frame entirely.

A frame-free function versus one with a frame A function whose variables all fit in Wasm locals has no prologue or epilogue and no memory traffic for locals. A function with address-taken variables or local arrays adjusts the stack pointer and loads and stores frame slots, which costs time in hot code and uses shadow-stack space. no frame all values in Wasm locals no stack pointer updates no loads/stores for locals ideal for hot code with a frame SP adjusted on entry/exit arrays + addr-taken vars memory traffic per access needed for some data

Step 4 — estimate stack usage

The frame size per function, summed along the deepest call chain, is the stack the program needs. For recursive functions, frame size times maximum recursion depth dominates. Large frames — kilobytes for big local arrays — combined with recursion are the usual cause of shadow-stack overflow. LLVM can report stack sizes (-fstack-usage in clang, or -Z emit-stack-sizes in nightly Rust) so you can check frames without reading WAT by hand.

Step 5 — recognise debug-build frames

Unoptimised builds look different: nearly every local variable gets a frame slot, values are stored and reloaded constantly, and a frame pointer may be kept. That helps debuggers but makes debug builds much slower and stack-hungrier than release builds — a program that overflows the stack only in debug builds is usually seeing this effect, not a real bug.

Leaf functions and the red zone

Small functions that call nothing else (leaf functions) can sometimes use stack space without moving the stack pointer at all. Some native ABIs reserve a “red zone” below the stack pointer that leaf functions may use freely; LLVM’s Wasm target does not use a red zone by default, so even leaf functions that need memory-resident data adjust __stack_pointer. What you will often see instead is the optimiser eliminating the need entirely: after inlining a small helper that took a pointer, the variable no longer needs an address and moves into a Wasm local, so the frame disappears. Comparing a function’s WAT before and after inlining (with #[inline(never)] versus the default) is an instructive way to see how much frame code is a consequence of call boundaries rather than of the algorithm.

Frames and exceptions

When a function can unwind — C++ exceptions with Wasm exception handling, Rust panics with unwinding enabled — the compiler must make sure the stack pointer is restored even when control leaves through an exception. That shows up in WAT as try/catch or try_table blocks around calls, with landing pads that restore __stack_pointer and run destructors before rethrowing. With panic = "abort" in Rust, those landing pads vanish, which is one reason abort-on-panic builds are smaller and their frames simpler. If you see unexpected exception-handling blocks in a hot function’s WAT, check whether unwinding is enabled for code that does not need it.

Correlating WAT with source using names

Optimised output loses most variable names but keeps function names (with the name section) and sometimes local names. Compile a debug build of the same function for reference: its WAT is verbose but every frame slot corresponds to a named variable, and its structure follows the source closely. Reading the debug and release versions side by side is the quickest way to learn which source constructs produce which frame layout in your compiler version.

Stack size settings

The shadow stack’s total size is fixed at link time (-z stack-size for wasm-ld, STACK_SIZE in Emscripten). Frame analysis tells you whether the setting is generous or tight for your deepest call chain.

Tools that report frame sizes

Rather than reading every function, ask the compiler. Clang’s -fstack-usage writes per-function stack sizes to .su files, and nightly Rust can emit stack size sections; sort by size to find the largest frames, then read only those in WAT.

Expected output

You can identify a function’s frame size from its prologue, explain why a function has or lacks a frame, map frame offsets to the arrays and address-taken variables in the source, spot avoidable memory traffic in hot functions, and estimate stack usage for deep call chains.

Gotchas

  • Treating Wasm locals as stack memory. They are not in linear memory. Only the shadow stack is.
  • Taking addresses in hot loops. Forces frames and memory traffic. Pass by value.
  • Large local arrays in recursion. Overflows the shadow stack. Move them to the heap.
  • Judging performance from debug WAT. Debug frames are inflated. Read release output.
  • Assuming frame sizes from source. Padding and alignment add bytes. Read the prologue.
  • Unwinding enabled where it is not needed. Landing pads complicate frames. Use abort-on-panic when possible.

Performance note

Removing an out-parameter (fn step(state: &mut f64) replaced by fn step(state: f64) -> f64) eliminated a 16-byte frame and its loads and stores from a hot function, speeding the loop up by about 18%.

Hot loop time with and without a frame Relative time of a hot loop calling a small function that takes an out-parameter, forcing a shadow-stack frame, compared with a version passing and returning the value directly. relative loop time out-parameter (frame) 1.2 × by value (no frame) 1 ×

Frequently Asked Questions

Why does the stack grow downward? By convention in LLVM’s Wasm target, like most native platforms; the stack pointer decreases on allocation.

Is the frame aligned? Yes — compilers keep the stack pointer 16-byte aligned on function boundaries.

Can the engine optimise away frame traffic? Rarely; memory stores must be kept because other code could read them.

Do Wasm locals have a size limit? Engines limit total locals per function, but limits are high; compilers spill to memory only for addressability, not count.

Why does my leaf function still adjust the stack pointer? LLVM’s Wasm target does not use a red zone; any memory-resident data needs a frame unless inlining removes it.

What are the try blocks around calls in my function? Landing pads that restore the stack pointer and run destructors when exceptions or panics unwind through the frame.

How can I learn which source constructs create frames? Compare debug and release WAT of the same function, and try inlining toggles to see frames appear and disappear.

Where is the total shadow-stack size set? At link time, with -z stack-size for wasm-ld or STACK_SIZE in Emscripten.

← Back to Stack vs Heap Execution Model