Linking Wasm Objects with wasm-ld
Every C, C++ and Rust build that produces WebAssembly ends in the same place: wasm-ld, LLVM’s WebAssembly linker, combining
object files into one module. Most of the time it runs invisibly inside clang, rustc or emcc, and most of the time its
defaults are fine. But the linker is where a module’s public shape is decided — what it imports from the host, what it exports,
how its linear memory is laid out, how large its stack is, and which code survives into the final binary. When one of those is
wrong, the symptom shows up somewhere else entirely: a LinkError in a browser, a function missing from instance.exports, a
stack overflow that corrupts static data, a module twice the size it should be. This topic explains what the linker does and how
to take control of it.
Prerequisites
- [ ] LLVM with WebAssembly support: clang and
wasm-ldfrom the WASI SDK, from emsdk, or from an LLVM release. - [ ] Rust with a
wasm32target, if you build Rust (rustc invokeswasm-lditself). - [ ] WABT for
wasm-objdump, andllvm-nmfor inspecting object files. - [ ] Familiarity with compiling at least one small module, for example from the Rust to Wasm compilation guide or C/C++ to Wasm with Emscripten.
What the linker sees and what it produces
A compiler targeting WebAssembly does not produce modules; it produces object files. An object file is itself a WebAssembly
binary, but an incomplete one: function bodies refer to other functions and data by symbol rather than by index, addresses of
global data are not yet known, and a linking custom section plus relocation sections describe everything still to be resolved.
Static libraries (.a files) are archives of such objects.
wasm-ld takes those inputs and does what any linker does, translated into WebAssembly terms. It resolves each referenced symbol to
a definition, from another object or an archive. It assigns final function indices, so a symbolic call becomes a call instruction
with a concrete index. It lays out every piece of static data in linear memory and patches the addresses. It merges the type
sections, deduplicates function signatures, and builds the table for indirect calls. It decides what the module imports and exports.
And with --gc-sections, which is on by default, it removes every function and data segment not reachable from an export or the
entry point.
The key difference from native linking is that a WebAssembly module has a host. A native executable must define everything it calls, except functions from dynamic libraries resolved by the operating system. A Wasm module can declare any function as an import and leave it to the host — JavaScript, a WASI runtime, an embedder — to provide at instantiation. That flexibility is why several of the linker’s decisions, especially around undefined symbols, need explicit guidance.
Inspecting an object file before linking
It helps to see the input once. Compile a C file to an object and look at its sections and symbols:
clang --target=wasm32 -O2 -c math.c -o math.o
wasm-objdump -h math.o
llvm-nm math.o
Sections:
Type start=0x0000000e end=0x0000001b (size=0x0000000d) count: 2
Import start=0x0000001d end=0x0000004a (size=0x0000002d) count: 2
Function start=0x0000004c end=0x0000004f (size=0x00000003) count: 2
Code start=0x00000052 end=0x0000007c (size=0x0000002a) count: 2
Custom start=0x0000007e end=0x000000a5 (size=0x00000027) "linking"
Custom start=0x000000a7 end=0x000000bc (size=0x00000015) "reloc.CODE"
00000001 T add
00000014 T dot3
U __linear_memory
The object imports its memory and function table — placeholders the linker replaces — and carries relocation entries in
reloc.CODE for every address the linker must fill in. T marks defined functions; U marks undefined references, which the
linker must resolve or turn into imports.
The decisions the linker makes for you
Five groups of linker decisions shape every module, and each has its own guide in this topic.
Exports. By default the linker exports the memory and the entry point and little else. Functions become exports through an
export_name attribute in the source, #[no_mangle] in a Rust cdylib, or --export=name on the link. Broad switches such as
--export-all exist but defeat dead-code elimination and turn internals into public API. The details are in
exporting symbols with wasm-ld flags.
Imports. Undefined functions are errors by default. Declared imports (import_module attributes) and the
--allow-undefined family turn them into imports from the host. Deciding which undefined symbols are intended is the subject of
fixing undefined symbol errors from wasm-ld.
Memory ownership. By default the module defines its memory; --import-memory makes the host create it, which threads and
multi-module setups require, and --shared-memory marks it shared. See
importing memory from the host with --import-memory.
Memory layout. The linker places static data, the shadow stack and the heap, chooses the stack size, and sets the initial and maximum memory. Getting these wrong produces crashes that look like memory corruption; see setting stack size and memory limits at link time.
What survives. Garbage collection of sections removes unreachable code, and its effectiveness depends on how few roots — exports and the entry point — the module has. LTO, discussed below, extends this across compilation units.
Step-by-step: linking a small module by hand
Running wasm-ld directly, without a compiler driver in front, makes every decision visible. The workflow below links a C library
and a JavaScript-facing entry point into a module with explicit exports, an intended import, and a defined memory layout.
- Compile each source to an object, with hidden visibility so nothing is exported by accident:
clang --target=wasm32 -O2 -fvisibility=hidden -c src/core.c -o build/core.o
clang --target=wasm32 -O2 -fvisibility=hidden -c src/api.c -o build/api.o
llvm-ar rcs build/libcore.a build/core.o
- Declare the import the API needs from JavaScript, in the source rather than with a blanket flag:
// src/api.c
__attribute__((import_module("env"), import_name("report")))
void report(int code);
__attribute__((export_name("process")))
int process(const unsigned char *buf, unsigned len) {
int r = core_process(buf, len);
if (r < 0) report(r);
return r;
}
- Link with the layout and memory flags you want:
wasm-ld build/api.o build/libcore.a -o build/app.wasm \
--no-entry \
--initial-memory=2097152 --max-memory=67108864 \
-z stack-size=262144 --stack-first \
--strip-debug
- Inspect the result before anything else consumes it:
wasm-objdump -x build/app.wasm | grep -E '^(Import|Export|Memory)\[' -A4
Import[1]:
- func[0] sig=0 <- env.report
Memory[1]:
- memory[0] pages: initial=32 max=1024
Export[2]:
- memory[0] -> "memory"
- func[3] -> "process"
One import, two exports, a memory sized as requested. Everything in libcore.a that process does not reach has been removed.
Instantiating what the linker produced
The import and export sections the linker writes are exactly what JavaScript must satisfy and can use. The module above needs an
env.report function and exposes process and memory:
const { instance } = await WebAssembly.instantiateStreaming(fetch("app.wasm"), {
env: { report: (code) => console.warn("core reported", code) },
});
const { memory, process } = instance.exports;
const input = new Uint8Array(await (await fetch("/sample.bin")).arrayBuffer());
const ptr = 1 << 20; // a region above the stack and static data
new Uint8Array(memory.buffer, ptr, input.length).set(input);
console.log("result", process(ptr, input.length));
Choosing ptr by hand works for a demonstration because --stack-first and the known data size leave the area above 1 MiB
free; a real module exports an allocator instead. If the import object is missing report, instantiation fails with a LinkError
naming env.report — the linker’s import decision surfacing in the browser, as described in
handling CompileError and LinkError.
How each toolchain drives the linker
The same linker sits behind several toolchains, and each one wraps it with its own defaults. Knowing those defaults explains most “it works with one compiler but not the other” surprises.
clang with the WASI SDK runs wasm-ld with wasi-libc’s startup files and libraries, an entry point of _start for programs
(or --no-entry for -mexec-model=reactor libraries), and imports from wasi_snapshot_preview1 for every system call libc makes.
Undefined symbols are errors. This is the closest to a plain native toolchain, and the easiest to reason about.
rustc invokes wasm-ld for every wasm32 target. For wasm32-unknown-unknown it links no libc, uses --stack-first and a
1 MiB stack, exports what the crate marks for export, and passes --gc-sections. Extra linker flags go through
-C link-arg=..., either in RUSTFLAGS or in .cargo/config.toml. wasm-bindgen then post-processes the linked module, so the
exports you see after bindgen are not exactly the linker’s.
emcc drives wasm-ld with dozens of flags and then post-processes the result with Binaryen. It allows undefined symbols that
its JavaScript library implements, turns EXPORTED_FUNCTIONS into linker exports, sizes the stack with STACK_SIZE, and controls
memory with INITIAL_MEMORY and MAXIMUM_MEMORY. Its settings are a friendlier interface to the same decisions, and emcc -v
reveals the underlying linker command when you need to compare.
Whichever you use, the module’s interface — imports, exports, memory — is ultimately written by the linker, so inspecting the output
with wasm-objdump is the common ground for comparing builds from different toolchains.
Optimization flags and trade-offs
The linker contributes to size and speed in three ways.
Dead-code elimination. --gc-sections is on by default and removes whatever no export reaches. Its effectiveness is entirely
determined by the export list: every exported function is a root, and everything it calls survives. A module with three explicit
exports is often half the size of the same code linked with --export-dynamic.
Link-time optimization. When objects contain LLVM bitcode rather than finished WebAssembly — compiled with -flto in C or with
lto settings in Rust — wasm-ld runs LLVM’s optimizer over the whole program before generating code. That enables inlining across
files and libraries and removes dead code at a finer grain than sections. The size and build-time trade-offs are measured in
tuning LTO and codegen-units for Wasm.
Stripping. --strip-debug removes DWARF sections and --strip-all also removes the name section. Stripping changes nothing
about execution speed and can remove the majority of a debug build’s bytes, but the name section is what makes stack traces readable,
so many projects strip at the linker for release and keep an unstripped copy for symbolication.
None of these flags affects how fast the code runs once compiled, with the exception of LTO, which usually makes it slightly faster through inlining. The linker’s main performance contribution is fewer bytes to download and compile — which, on phones, is often the larger share of a module’s cost.
Gotchas and failure modes
entry symbol not defined (pass --no-entry to suppress): _start. A library-style module is being linked as a command. Add--no-entry.- Exports missing at runtime. The function was neither marked for export nor listed with
--export, and--gc-sectionsremoved it. Mark it explicitly. - An unexpected
env.*import. An undefined symbol became an import because undefined symbols were allowed. Find the missing definition instead of providing the import. - Static data corrupted by deep recursion. The stack overflowed into data. Raise
-z stack-sizeand add--stack-firstso the next overflow traps. initial memory too small.--initial-memoryis below the size of static data plus the stack. Raise it or let the linker compute it.- Different results from clang and Emscripten for the same code. Emscripten passes many flags to
wasm-lditself. Runemcc -vto see the exact link command when comparing.
Debugging a link that fails or misbehaves
Linker problems fall into two groups, and the first diagnostic step is deciding which one you have. In the first group the link
fails: wasm-ld prints an error naming a symbol, a section, or a size. Those are usually quick to fix, because the error points at
the cause — a missing library, a name mismatch, an initial memory too small. In the second group the link succeeds and the module
misbehaves later: an export is missing, an unexpected import fails instantiation, or the program corrupts its own data. Those take
longer, because the cause is a link decision that was legal but wrong.
For the second group, three tools cover nearly everything. --trace-symbol=name makes the linker print every object that defines or
references a symbol, which shows why something was kept, dropped or turned into an import. --Map=link.map writes a map file listing
every input section, its size and where it landed, which answers both “why is this module so big” and “what lives at this address”.
And --verbose prints the linker’s decisions about archive members, which explains why a symbol you expected from a library was not
pulled in. Keep the map file as a build artifact for release builds; comparing two maps is often the fastest way to explain a size
change or a regression that appeared after a dependency upgrade.
Verification
A short checklist confirms a link did what you intended, and it is worth scripting into CI for any module with a public interface:
wasm-validate build/app.wasm # well-formed, valid types
wasm-objdump -x -j Import build/app.wasm # only intended imports
wasm-objdump -x -j Export build/app.wasm # only intended exports
wasm-objdump -x -j Memory build/app.wasm # initial and maximum as configured
wasm-objdump -x build/app.wasm | grep -c '__stack_pointer' # stack global present
Comparing the import and export lists against committed expected files turns accidental interface changes into build failures. For
deeper inspection of what the linker emitted, wasm-tools print or wasm2wat show the full module; see
inspecting modules with wasm-tools.
Guides in this topic
- Exporting symbols with wasm-ld flags —
export_name,--export,--no-entryand visibility, and why a small export list makes a small module. - Importing memory from the host with --import-memory — letting JavaScript create and own the memory, for threads and multi-module setups.
- Setting stack size and memory limits at link time — the data, stack and heap layout, and the flags that keep overflows from corrupting data.
- Linking C and Rust objects into one module — compiling C with clang inside a cargo build, sharing one allocator, and cross-language LTO.
- Building a Wasm module without libc —
freestanding C with
-nostdlib: hundreds of bytes, zero imports, no glue. - Fixing undefined symbol errors from wasm-ld — missing libc, name mangling, intended imports and archive order.
Frequently Asked Questions
Do I ever need to call wasm-ld directly? Rarely in day-to-day work — the compiler drivers call it with sensible defaults. Calling it directly is useful when debugging a link, when building freestanding modules, and when you need to know exactly which flags are in effect.
How do I see the link command my toolchain runs?
Pass -v to clang or emcc, or -C link-args=-Wl,--verbose (or --print link-args) to rustc. Each prints the full wasm-ld
invocation, which is the starting point for any linker investigation.
Is wasm-ld the only WebAssembly linker? It is the standard one for LLVM-based languages. Other toolchains — Go, AssemblyScript, Kotlin, .NET — produce modules through their own compilers and do not use object-file linking at all, which is why their size and interface controls differ.
Does the linker support dynamic linking?
wasm-ld can produce position-independent shared modules (-shared, --experimental-pic) that a loader links at runtime, which
Emscripten uses for side modules; see
linking side modules at runtime.
Static linking remains the norm.
Can the linker reorder functions for faster startup?
Function order affects how quickly the engine can start running hot code in some configurations. wasm-ld accepts a symbol
ordering file with --symbol-ordering-file, and Binaryen’s wasm-split can use a startup profile to move cold code out entirely,
as described in splitting a Wasm module for lazy loading.
Why is my object file’s memory an import? Objects always import a placeholder memory and table, because the final layout is not known until link time. The linker replaces them with the module’s real memory — defined or imported, depending on your flags.
Related
- Compilation Pipelines & Toolchain Setup — the section overview this topic belongs to.
- Wasm Binary Format Deep Dive — the sections the linker writes.
- Wasm Optimization Flags & Size Reduction — what happens after linking.
- Tables & Dynamic Linking — indirect calls and runtime linking.
← Back to Compilation Pipelines & Toolchain Setup