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 printorwasm2watoutput 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.
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.
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%.
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.
Related
- Understanding the shadow stack in linear memory — the stack itself.
- Why Wasm cannot take the address of a local — why frames exist.
- Avoiding unnecessary stack allocation in Rust Wasm — Rust specifics.
- Fixing stack overflow in Wasm — overflows.
← Back to Stack vs Heap Execution Model