Fixing Undefined Symbol Errors from wasm-ld

This page answers one task: a WebAssembly link fails with undefined symbol, or succeeds but the module then fails to instantiate with a missing import — find which symbol is missing, why, and how to supply it.

Prerequisites

  • [ ] A failing link from clang, rustc or emcc targeting wasm32, with the full error output.
  • [ ] wasm-objdump and llvm-nm (from the same LLVM) to inspect object files and archives.
  • [ ] The ability to add linker flags to the build.

Two ways an undefined symbol can surface

On native targets, a symbol that no object defines is always a link error. On WebAssembly, it may not be. A Wasm module can import functions from its host, so wasm-ld has a choice when it finds an undefined function: fail, or turn it into an import and let the host provide it at instantiation. Which it does depends on flags and on how the symbol was declared.

By default, an undefined function is an error. With --allow-undefined, or for symbols listed with --allow-undefined-file, or for functions declared with an import_module attribute, the linker makes it an import from module env (or the named module). Emscripten turns on importing broadly because its JavaScript runtime provides many functions. So the same missing symbol can show up either at link time or, much later, as a LinkError when the module is instantiated in a browser. Knowing which mode you are in is the first step.

Where a missing function ends up When wasm-ld finds a function with no definition, it either reports an undefined-symbol error, or — when undefined symbols are allowed or the declaration names an import module — emits it as an import. An import the host does not provide then fails at instantiation with a LinkError. A function is called but no object defines it default wasm-ld Link error undefined symbol: foo --allow-undefined / import_module Becomes an import env.foo in the import section import not provided by host LinkError at instantiate import object field 'foo' is not a Function

Step 1 — read the error carefully

wasm-ld names the symbol and the object that referenced it:

wasm-ld: error: lib/libparser.a(lexer.o): undefined symbol: strtod
wasm-ld: error: src/main.o: undefined symbol: _ZN6Parser5parseEPKc
wasm-ld: error: src/main.o: undefined symbol: js_log

Three different causes are already visible. strtod is a libc function — the build has no libc for this target, or it was linked without one. _ZN6Parser5parseEPKc is a mangled C++ name, so something compiled as C++ calls a function that was compiled as C (or not compiled at all). js_log looks like a function meant to come from JavaScript. Demangle C++ names to read them:

echo _ZN6Parser5parseEPKc | llvm-cxxfilt     # Parser::parse(char const*)

Step 2 — find who defines the symbol, if anyone

Search the objects and archives you link for a definition:

for f in lib/*.a src/*.o; do llvm-nm -A "$f" 2>/dev/null; done | grep -E ' [TtDd] (strtod|js_log|_ZN6Parser5parseEPKc)$'

T means a defined function, U an undefined reference. If a definition exists somewhere you are not linking, add that object or archive. If it exists in an archive you are linking, check the order: wasm-ld, like other Unix linkers, scans archives in command-line order, and an archive listed before the object that needs it may be skipped. Move it later, or wrap archives with --start-group and --end-group to let the linker rescan them.

Step 3 — supply libc functions

Missing libc functions — strtod, printf, memcpy, malloc — mean the C code expects a standard library that this link does not include. The fix depends on the toolchain:

# WASI SDK: link wasi-libc by using its clang and sysroot (the default)
$WASI_SDK_PATH/bin/clang --target=wasm32-wasip1 parser.c -o parser.wasm

# freestanding builds: provide the few functions you need yourself, or enable builtins
clang --target=wasm32 -nostdlib -mbulk-memory ...    # memcpy/memset become instructions

For Rust builds that link C code on wasm32-unknown-unknown, there is no libc to add; either avoid libc in the C code, provide the functions from Rust, or switch to wasm32-wasip1. The options are compared in linking C and Rust objects into one module.

Step 4 — declare intended imports explicitly

When a function is meant to come from the host, say so in the declaration rather than allowing all undefined symbols. In C:

__attribute__((import_module("env"), import_name("js_log")))
void js_log(const char *msg, unsigned len);

In Rust:

#[link(wasm_import_module = "env")]
extern "C" { fn js_log(msg: *const u8, len: usize); }

The linker now emits a deliberate import, and any other undefined symbol is still an error. That is much safer than --allow-undefined, which turns every typo and every missing library function into an import — so the link “succeeds” and the failure moves to instantiation in a browser, where the message is less helpful and the feedback loop much longer.

If you inherit a build that uses --allow-undefined, list the imports the module ended up with and check each one is intended:

wasm-objdump -x -j Import app.wasm
Four causes of undefined symbols and their fixes The common reasons wasm-ld reports an undefined symbol, how to recognise each from the symbol name, and the corresponding fix. cause recognisable by fix no libc for the target strtod, printf, malloc WASI sysroot or provide them C/C++ name mismatch mangled _Z… names extern "C" on the declaration host function your own js_* or env names import_module attribute archive order / missing file defined in an unlinked .a reorder or --start-group

Why WebAssembly makes this harder than native linking

Native developers are used to undefined symbols being a purely build-time concern: the linker either finds a definition or the build fails, and there is nothing in between. WebAssembly adds the in-between, and that is the root of most of the confusion. A module’s import section is a list of symbols it expects the host to define, which means “undefined at link time” can be a perfectly valid design — the function really is provided by JavaScript or by a WASI runtime — or a bug that has been deferred. The linker cannot tell which, so it relies on you to say.

That has consequences for how errors reach you. A link error appears in the build log, next to the code that caused it, with the object file named. A missing import appears as a LinkError in a browser console, possibly on a user’s machine, naming only the import’s module and field. The second is much harder to trace back to a cause, especially when a dependency’s build quietly added the import. Keeping undefined symbols as link errors, and declaring every intended import explicitly, keeps the failures in the place where they are easiest to fix.

It also explains why toolchains behave differently. Emscripten generates JavaScript that provides hundreds of imports, so it must allow undefined symbols that match its library and then checks the rest itself. Rust’s wasm32-unknown-unknown target leaves undefined symbols as imports from env in some configurations, which is why a Rust build that links C code can succeed and produce an unexpected env.malloc import. The WASI SDK links a full libc, so most libc symbols are defined and the remaining imports are the WASI ones. Knowing which behaviour your toolchain has tells you where to look first.

Step 5 — fix C and C++ mismatches

A mangled name in the error means C++ code calls a function using C++ linkage, but the definition was compiled with C linkage (or the reverse). The header shared between C and C++ needs extern "C" guards:

// parser.h
#ifdef __cplusplus
extern "C" {
#endif
int parse(const char *src);
#ifdef __cplusplus
}
#endif

With the guards, both sides agree on the unmangled name parse, and the link succeeds. The same mismatch appears with Rust: a function exported from Rust with #[no_mangle] extern "C" must be declared extern "C" in C++ to be found.

Expected output

After fixing, the link is silent and the import section contains only intended imports:

Import[1]:
 - func[0] sig=1  <- env.js_log

And instantiation in JavaScript succeeds with an import object that provides exactly those names:

await WebAssembly.instantiate(bytes, { env: { js_log: (ptr, len) => console.log(readString(ptr, len)) } });

Gotchas

  • The link succeeds but instantiation fails with LinkError. The build allowed undefined symbols and one became an import the host does not provide. Remove --allow-undefined and fix the real cause.
  • undefined symbol: __stack_chk_guard. Stack protection was enabled by a compiler default. Add -fno-stack-protector or provide the symbol.
  • undefined symbol: main in a library build. The module has no entry point. Link with --no-entry.
  • Emscripten reports undefined symbol as a warning. Emscripten defaults to allowing undefined symbols for its own runtime; set -sERROR_ON_UNDEFINED_SYMBOLS=1 (the modern default) and read the warnings.

Performance note

Imports cost a little more per call than internal calls — a call through the host boundary instead of a direct call within the module. In a parser that called a host-provided strtod per number, replacing the import with a linked libc implementation made number-heavy input 38% faster to parse, because two million boundary crossings became internal calls.

Parsing time with a host-imported versus a linked strtod A JSON parser converting two million numbers, with strtod provided by a JavaScript import and with strtod linked from libc inside the module. ms to parse a 40 MB number-heavy JSON file strtod imported from JavaScript 612 ms strtod linked from wasi-libc 379 ms

Frequently Asked Questions

Is --allow-undefined ever the right choice? For quick experiments, and for toolchains such as Emscripten that generate the matching imports automatically. In hand-maintained builds, declare imports explicitly instead.

What is --allow-undefined-file? A file listing symbol names that may become imports, one per line. It is a middle ground: a reviewed allow-list instead of everything.

Why does the import come from env? It is the conventional default module name for C imports. Choose another with import_module if your host groups functions differently.

How do I see which object pulled in an unexpected symbol? wasm-ld --trace-symbol=strtod prints every object that defines or references it, which shows the dependency chain.

← Back to Linking Wasm Objects with wasm-ld