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-ld from the WASI SDK, from emsdk, or from an LLVM release.
  • [ ] Rust with a wasm32 target, if you build Rust (rustc invokes wasm-ld itself).
  • [ ] WABT for wasm-objdump, and llvm-nm for 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.

From object files to a module Compilers produce WebAssembly object files with symbols and relocations. wasm-ld resolves symbols across objects and archives, assigns function indices and memory addresses, removes unreachable code, and writes a final module with concrete import, export, memory and table sections. main.o, lib.a objects with relocations resolve symbols definitions across inputs layout indices, addresses, table gc-sections drop unreachable code app.wasm imports, exports, memory

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.

The linker's decisions, their defaults, and the flags that change them Five areas where wasm-ld shapes the output module, the default behaviour in each, and the main flags used to change it. decision default main flags exports memory + entry only export_name, --export, --no-entry imports undefined = error import_module, --allow-undefined memory ownership defined + exported --import-memory, --shared-memory memory layout data, stack, heap; 64 KiB stack -z stack-size, --stack-first dead code --gc-sections on fewer exports, LTO

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.

  1. 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
  1. 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;
}
  1. 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
  1. 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 link-time decisions show up at instantiation The browser compiles the module and reads its import section, which the linker wrote. JavaScript must supply env.report; the engine checks it, creates the memory with the linker's initial and maximum sizes, writes data segments, and returns the exports the linker chose. JavaScript engine module instantiate(bytes, { env: { report } }) imports required by linker → env.report found memory 32 pages (max 1024); data segments written exports: memory, process process(ptr, len) → may call env.report

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.

Effect of link-time choices on one C library's module size The same library and API linked five ways. Exporting everything keeps all code; explicit exports let gc-sections work; LTO removes more; stripping removes debug and name metadata. module size in KB (before compression) --export-all, debug info 488 KB --export-all 212 KB explicit exports 96 KB explicit exports + LTO 81 KB explicit + LTO + --strip-all 74 KB

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-sections removed 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-size and add --stack-first so the next overflow traps.
  • initial memory too small. --initial-memory is 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-ld itself. Run emcc -v to see the exact link command when comparing.

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

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.

← Back to Compilation Pipelines & Toolchain Setup