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-objdumpandllvm-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.
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
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-undefinedand fix the real cause. undefined symbol: __stack_chk_guard. Stack protection was enabled by a compiler default. Add-fno-stack-protectoror provide the symbol.undefined symbol: mainin a library build. The module has no entry point. Link with--no-entry.- Emscripten reports
undefined symbolas 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.
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.
Related
- Exporting symbols with wasm-ld flags — the other side of the symbol table.
- Writing an import object by hand — satisfying the imports that remain.
- Handling CompileError and LinkError — when the failure reaches the browser.
- Migrating legacy C code to WebAssembly — where these errors appear in bulk.
← Back to Linking Wasm Objects with wasm-ld