Understanding Signature Checks in call_indirect

This page answers one question: indirect calls in WebAssembly — calls through function pointers, virtual methods, callbacks — carry a type and are checked at runtime, and code that runs fine natively traps in Wasm with “indirect call signature mismatch”. You want to understand what is checked, why, how compilers lower indirect calls, and what kinds of source code trigger the trap.

Prerequisites

  • [ ] Basic understanding of tables and call_indirect.
  • [ ] WAT reading skills.
  • [ ] Optionally, C or C++ code that uses function pointers.

What call_indirect checks

call_indirect takes a table index and calls the function stored there. The instruction itself names an expected type — (call_indirect (type $sig) ...) — and the engine checks, at the moment of the call, that the function in the slot has exactly that type: same parameter types in the same order, same result types. If not, it traps. It also traps if the index is out of the table’s bounds or the slot is null.

The check exists for safety. Wasm’s validator guarantees that every direct call passes the right number and types of arguments. Indirect calls choose their target at runtime, so the guarantee must be enforced at runtime: without it, calling a (i32) -> i32 function as (f64, f64) -> f64 would read garbage from the operand stack and break the type safety that the whole sandbox depends on.

What happens on call_indirect The instruction pops a table index, checks it is within the table's bounds, loads the function reference, checks it is not null, compares the function's type with the type named in the instruction, and only then calls it with the arguments. A failure at any check traps. pop table index from operand stack bounds check trap: out of bounds null check trap: uninitialised element type check trap: signature mismatch call target args already typed

Type equivalence is structural

Function types are compared by structure, not by name or index: a function defined as (func (param i32 i32) (result i32)) in one module matches a call_indirect (type $t) in another module whose $t is also (param i32 i32) (result i32). That allows tables shared between modules (dynamic linking, JavaScript-provided functions) to work. With the GC proposal, type equivalence for recursive and subtyped types follows more elaborate rules (iso-recursive canonicalisation), but for plain numeric signatures, “same parameter and result types” is the whole rule.

How compilers lower indirect calls

In C and C++, a function pointer is an index into the module’s function table (the linker places every address-taken function in it). Calling through the pointer compiles to call_indirect with the pointer’s declared type. Virtual calls in C++ load a function index from the vtable and do the same. In Rust, trait-object method calls (dyn Trait) and calls through fn pointers and &dyn Fn closures compile to call_indirect with the trait method’s signature.

typedef int (*binop)(int, int);
int add(int a, int b) { return a + b; }
int apply(binop f, int x, int y) { return f(x, y); }       // call_indirect (type (func (param i32 i32) (result i32)))

What triggers the trap

The trap appears when the function stored in the table has a different Wasm type from what the call expects. In C and C++, that happens when code casts a function pointer to an incompatible type and calls it — undefined behaviour that often “works” on native platforms because calling conventions tolerate it (extra arguments ignored, void versus int return mixed up), but traps in Wasm. Classic examples: calling a void f(void) through an int (*)(void) pointer; callbacks declared with fewer parameters than the caller passes; qsort comparators with slightly wrong signatures; casting between int and long parameter types on targets where they differ (on wasm32 long is 32 bits, long long 64).

Native tolerance versus Wasm's exact check Native calling conventions often tolerate mismatched function pointer casts, such as extra ignored arguments or an unused return value, so buggy code appears to work. Wasm compares the exact function type at every indirect call and traps on any difference, exposing the undefined behaviour. native platforms registers carry args loosely extra args ignored UB often "works" hides bugs WebAssembly exact type compared any mismatch traps UB becomes visible safe, strict

Avoiding mismatches

Fix the source: declare callbacks with exactly the signature callers use, write small wrapper functions with the expected signature instead of casting, and enable compiler warnings for incompatible function-pointer casts (-Wcast-function-type in clang). Emscripten has a mode (-sEMULATE_FUNCTION_POINTER_CASTS) that adds thunks to make some casts work, at a cost in size and speed; it is a porting aid, not a fix. Rust code that avoids unsafe transmutes of function pointers does not hit this.

Typed function references

The typed function references proposal adds call_ref, which calls a function reference whose static type is already known — the check happens at validation, not at the call. Compilers for languages with precise function types (and Wasm GC languages) can use it to avoid runtime signature checks and the table indirection. For C-style function pointers stored as integers, call_indirect remains the mechanism.

Reading the type in WAT

In WAT, the expected type appears in the instruction, either as a reference to a type definition or inline:

(type $cmp (func (param i32 i32) (result i32)))
(table $fns 16 funcref)
(func $sort_with (param $cmp_idx i32) (param $a i32) (param $b i32) (result i32)
  (call_indirect $fns (type $cmp) (local.get $a) (local.get $b) (local.get $cmp_idx)))

The last operand is the table index; the preceding operands are the arguments. When you investigate a trap, find the call_indirect in the trapping function, note its type, then look up which function sits at the index used at runtime (a debugger or a logged index helps) and compare its declared type. The mismatch is usually obvious once both are side by side — one extra parameter, an i64 where an i32 was expected, or a missing result.

Function pointers crossing the JavaScript boundary

Function pointers are just table indices, and JavaScript code sometimes holds them — for example, Emscripten’s addFunction returns an index for a JavaScript callback inserted into the table, with a signature string you provide. If that signature string does not match how C code later calls the pointer, the call traps with the same error. Keep signature strings and C declarations in one place, generate them where possible, and test each callback path once in the Wasm build. The same applies to function indices passed between dynamically linked modules: both sides must agree on the type, or the first call traps.

Variadic functions

C variadic functions (printf-style) are lowered on wasm32 by passing a pointer to a buffer of arguments, so their Wasm type differs from what the source signature suggests. Calling a variadic function through a non-variadic function pointer type — or the reverse — is a common source of signature mismatches in ported code, and another case where native platforms tolerate what Wasm rejects. Declare function-pointer types for variadic functions exactly as variadic.

A habit that prevents most traps

Compile ported C and C++ with -Wcast-function-type and -Werror for that warning, and run the test suite once with UBSan’s function-type checks natively. Most signature mismatches are found before the first Wasm build runs.

Expected output

You can explain that call_indirect checks bounds, null and exact function type; recognise that function types match structurally across modules; trace a “signature mismatch” trap to a function-pointer cast in C or C++; and choose fixes — correct declarations, wrapper functions, warnings — over emulation flags.

Gotchas

  • Casting function pointers in C. Undefined behaviour that traps in Wasm. Use wrappers.
  • Callbacks with fewer parameters. Natively tolerated, traps in Wasm. Match signatures exactly.
  • long versus long long assumptions. Sizes differ on wasm32. Use fixed-width types.
  • Relying on emulation flags. Slower and larger. Fix the code.
  • Assuming names matter. Types match structurally; names do not.
  • Variadic functions through non-variadic pointers. Their Wasm types differ. Declare pointer types as variadic.

Performance note

The signature check is a comparison of a type identifier — a few instructions — and engines often combine it with the bounds check; call_indirect costs about one to two nanoseconds more than a direct call in current engines.

Cost of call kinds Approximate nanoseconds per call for a direct call, a call_indirect with its bounds and signature checks, and a call_ref through a typed function reference. ns per call (approximate) direct call 1 ns call_indirect 2 ns call_ref (typed) 1.5 ns

Frequently Asked Questions

Why does the error only appear in Wasm? Native calling conventions tolerate many mismatched casts; Wasm checks types exactly.

Can JavaScript functions be stored in tables? Yes, via WebAssembly.Function (type reflection) or imports; they then have a Wasm type that is checked the same way.

Does the check prevent all control-flow attacks? It restricts targets to same-type functions; see control-flow integrity for what remains.

Are there tools to find bad casts? Clang warnings and sanitizers (UBSan’s function check) catch many before you run the Wasm build.

Why does a callback added with addFunction trap when called? Its signature string does not match the type C code uses for the call; keep them consistent.

How are variadic C functions lowered? Arguments are passed through a buffer pointer, so their Wasm type differs from the source signature’s appearance.

Which operand of call_indirect is the table index? The last one; the operands before it are the call’s arguments.

Do dynamically linked modules check types across modules? Yes — structurally, so both sides must agree on a function pointer’s type or the call traps.

Should cast warnings be errors in ported code? Yes — making -Wcast-function-type an error catches most mismatches before the Wasm build runs.

← Back to Tables & Dynamic Linking