The WASM Basic C ABI: How C Programs Map to WebAssembly

The WebAssembly/tool-conventions repository defines a set of standards meant to keep WebAssembly tooling interoperable. Among those is the Basic C ABI, a specification for representing C programs in WebAssembly that Clang targets with wasm32. Rust is also adopting the ABI for its extern "C" code. The ABI is surprisingly straightforward, relying on two mechanisms: the abstract WASM value stack for scalars and a manually managed stack in linear memory for aggregates.

Two stacks: abstract and linear

The WASM value stack is purely abstract, holding the operands for instructions. Values are pushed and popped, and there is no notion of an address or growth direction in linear memory. When addressing is required, the ABI dictates that compilers manage a separate region of linear memory called the linear stack, with a global variable—$__stack_pointer—holding the current position on it. That pointer behaves much like a hardware stack pointer, just held in a global rather than a dedicated register.

Scalars flow through parameters

For basic C types like int, double, or char, the WASM function parameter list is sufficient:

int add_three(int a, int b, int c) {
  return a + b + c;
}

This compiles to a wasm function that accepts three i32 parameters directly. All integral types of 32 bits or fewer are passed as i32, and smaller types are sign- or zero-extended to the full width. For example, a function taking three char parameters would use i32.extend8_s on each incoming value to ignore everything above the low 8 bits. Larger oddities exist too, such as splitting __int128 into two i64 parameters.

Pointers behave as i32 integers

C pointers are scalars from the ABI’s perspective, passed as plain i32 values. There is nothing distinct about an i32 which happens to be an address; the instruction set treats it like any other integer until it is used in a load or store. When compiled, code that dereferences a pointer will emit the appropriate i32.load or i32.store instruction, with the stack arranged as [addr value] for a store. Values read from memory then participate in normal arithmetic.

Aggregates copy into the linear stack

Once a parameter is an aggregate larger than a single element, the ABI requires that it be copied into the callee’s linear stack frame, with the address of that copy passed as a pointer. Each function maintains its own frame in linear memory, with the stack growing downward. Consider two functions, one which passes a struct by value:

int do_work() {
  Pair pp = {1, 2};
  return pair_calculate(pp);
}

int pair_calculate(Pair p) {
  return p.x * p.y;
}

The caller first decrements $__stack_pointer by 16 bytes to allocate a frame, copies the struct into the top of that frame, and passes the address of the copy as a parameter. After the call returns, the stack pointer is restored to its original value. The prologue and epilogue of each function are simply those adjustments—decrement at entry, restore at exit.

This design composes cleanly for nested calls. Each function allocates the space it needs for parameters and local temporaries. Because every function knows its own frame size, it can always restore the pointer to the bottom of its caller’s frame upon return. The result after three nested calls have allocated space is a sense of precisely nested stack frames analogous to classic machine code.

Returning aggregates goes through a hidden pointer

Aggregate return values follow similar logic. The ABI prepends a pointer parameter to the function’s signature; the callee writes its result to that address instead of returning a value type on the WASM stack:

Pair make_pair(int x, int y) {
  Pair p;
  p.x = x;
  p.y = y;
  return p;
}

The caller allocates space on its own linear stack frame for the result, passes that address as an extra first parameter, and reads the stored struct from that location after the call. Alignment rules mandate that the stack pointer be kept to 16-byte boundaries, so it is common to observe extra allocation slots in functions that do not strictly need them.

Edge cases: red zones, dynamic allocation, and high alignment

A few corners of the ABI remain rarely exercised. A defined “red zone” grants leaf functions access to 128 bytes below the stack pointer, but observing it is hard in practice since leaf functions rarely need linear stack space at all. Other provisions call for a separate frame pointer for functions needing dynamic allocation, such as C’s variable-length arrays, and a separate base pointer when stack alignment requirements exceed 16 bytes.