For a long time, I treated the linker as a mysterious black box — I’d run nasm to assemble my code, then some other command with the word “link” in it, and suddenly an executable would appear. It wasn’t until I started writing multi-file Assembly projects and hit “undefined reference” errors that I actually had to understand what the linker was doing. It turns out the linker’s job is one of the most important — and most underappreciated — steps in turning Assembly source code into a running program.
The Build Pipeline: Where the Linker Fits
Before getting into specifics, it helps to see the full picture of how Assembly source becomes an executable:
flowchart LR
A[Assembly Source .asm/.s] --> B[Assembler e.g. NASM, GAS]
B --> C[Object File .o/.obj]
D[Other Object Files] --> E[Linker e.g. ld, link.exe]
C --> E
F[Library Files .a/.lib/.so] --> E
E --> G[Executable / Shared Library]
The assembler translates human-readable Assembly mnemonics into machine code, producing an object file. But an object file, on its own, is usually incomplete — it might reference symbols (functions, variables) defined elsewhere, and it certainly doesn’t know its final memory layout yet. That’s where the linker comes in.
What Does an Object File Actually Contain?
Before understanding the linker, it helps to understand what it’s operating on. An object file (ELF .o on Linux, COFF .obj on Windows, Mach-O on macOS) typically contains:
- Machine code (in a
.textsection) - Initialized data (
.datasection) - Uninitialized data placeholders (
.bsssection) - A symbol table — a list of names (functions, global variables) the file defines, and names it references but doesn’t define
- Relocation entries — a list of places in the machine code where an address needs to be “filled in” or adjusted once the final memory layout is known
The Linker’s Core Jobs
1. Symbol Resolution
If one Assembly file calls a subroutine defined in another file, the assembler can’t possibly know that subroutine’s final address — it only knows about the current file. So it leaves a placeholder and records an entry in the symbol table marking that name as “undefined, needs resolving.”
; file1.asm
extern print_message ; declares a symbol defined elsewhere
global _start
section .text
_start:
call print_message ; assembler leaves this as unresolved for now
mov eax, 60
xor edi, edi
syscall
; file2.asm
global print_message
section .text
print_message:
; ... implementation ...
ret
nasm -f elf64 file1.asm -o file1.o
nasm -f elf64 file2.asm -o file2.o
ld file1.o file2.o -o program
When the linker processes both object files, it builds a combined symbol table, matches print_message‘s reference in file1.o to its definition in file2.o, and patches the call instruction with the correct address. This process is called symbol resolution.
If a referenced symbol is never defined anywhere in any input file or library, you get the classic error:
undefined reference to `print_message'
2. Relocation
Even after symbols are resolved, the linker has to decide the final memory addresses everything will occupy, then go back and patch every instruction that referenced a symbol with the correct final address (or the correct relative offset, in the case of RIP-relative/PC-relative addressing). This step is called relocation.
Before linking (in file1.o):
call <placeholder> ; relocation entry: "patch this with address of print_message"
After linking (in final executable):
call 0x401040 ; actual resolved address
3. Section Merging and Memory Layout
The linker gathers all the .text sections from every object file and merges them into one contiguous .text segment in the final binary, does the same for .data and .bss, and assigns each segment a base address according to a linker script (a set of rules describing memory layout — where .text starts, where .data follows, alignment requirements, etc.).
Final executable memory layout (simplified, x86-64 Linux):
+----------------------+ 0x400000
| ELF header |
+----------------------+
| .text (code) | <- merged from all object files
+----------------------+
| .rodata (constants) |
+----------------------+
| .data (init'd globals) |
+----------------------+
| .bss (uninit globals) | <- no file space, zero-filled at load
+----------------------+
4. Library Linking
The linker also pulls in code from static libraries (.a on Linux, .lib on Windows) — copying in only the object modules that are actually referenced — or records dependencies on dynamic/shared libraries (.so, .dll), which get resolved at load time or runtime instead of at link time.
; Statically linking against a library
ld file1.o -lmylib -L/path/to/libs -o program
; Dynamically linking against libc (common when calling C functions from Assembly)
gcc file1.o -o program ; gcc invokes the linker with the right flags for libc
Static Linking vs. Dynamic Linking
| Aspect | Static Linking | Dynamic Linking |
|---|---|---|
| When resolved | At link/build time | Partly at build time, partly at program load/runtime |
| Executable size | Larger (includes library code) | Smaller (library code lives separately) |
| Memory sharing across processes | No — each process has its own copy | Yes — OS shares one copy of a .so/.dll in memory across processes |
| Update flexibility | Requires rebuilding to update a library | Update the shared library file, no rebuild needed |
| Startup time | Slightly faster (nothing to resolve at load) | Slightly slower (dynamic linker/loader resolves symbols) |
| Use case | Embedded systems, bootloaders, statically-linked utilities | Most desktop/server applications |
The Linker and the Assembler: A Clear Division of Labor
I found it useful to think of the assembler and linker as handling two very different jobs:
| Task | Assembler | Linker |
|---|---|---|
| Translate mnemonics to machine code | ✅ | ❌ |
| Compute local instruction offsets | ✅ | ❌ |
| Resolve symbols across multiple files | ❌ | ✅ |
| Assign final memory addresses | ❌ (only relative to its own file) | ✅ |
| Combine multiple object files | ❌ | ✅ |
| Link against libraries | ❌ | ✅ |
| Produce an executable or shared library | ❌ | ✅ |
Practical Use Cases
- Multi-file Assembly projects, where breaking large programs into logical modules (I/O routines, math routines, string routines) requires the linker to stitch them together
- Calling C library functions from Assembly (e.g.,
printf), which requires linking againstlibc - Building freestanding binaries for OS development, where a custom linker script precisely controls where the bootloader, kernel code, and stack are placed in memory
- Creating shared libraries from Assembly routines for use by C/C++/Rust programs, using
-sharedlinker flags
; Example: freestanding kernel-style linking with a custom linker script
ld -T linker.ld -o kernel.elf boot.o kernel.o
/* linker.ld — a minimal custom linker script */
ENTRY(_start)
SECTIONS
{
. = 0x100000;
.text : { *(.text) }
.rodata : { *(.rodata) }
.data : { *(.data) }
.bss : { *(.bss) }
}
Debugging Linker Errors
undefined reference to 'X'— you called/referenced a symbol that was never defined, or forgot to link the object file/library that defines it, or forgotextern/globaldeclarations.multiple definition of 'X'— the same symbol is defined in more than one object file being linked together; usually caused by defining a variable in a header-like file included in multiple sources, or forgettingstatic/local-scoping.relocation truncated to fit— happens when a relative displacement doesn’t fit in the field size the instruction encoding allows for (common when mixing position-independent and non-PIC code incorrectly, or targeting the wrong memory model).- Use
objdump -d programto inspect final resolved addresses, ornm program/nm file.oto inspect the symbol table directly, listing which symbols are defined, undefined, or global.
Common Mistakes
- Forgetting
global/externdeclarations (NASM) or.global(GAS) — without these, symbols default to file-local, and the linker won’t be able to see them from other files. - Mismatched calling conventions across files written by different people, causing corrupted registers or stack imbalance despite a “successful” link.
- Linking against the wrong architecture’s object files (e.g., mixing 32-bit and 64-bit
.ofiles), producing cryptic linker errors. - Ignoring linker script alignment/padding, causing sections to overlap or misalign in freestanding/embedded projects.
Best Practices
- Keep symbol names for public/cross-file routines clear and namespaced (e.g.,
math_add,str_len) to avoid clashes with library symbols. - Use
nmandobjdumpliberally to inspect object files before linking, especially when debugging “undefined reference” issues. - When targeting freestanding/bare-metal environments, write and maintain an explicit linker script rather than relying on default linker behavior.
- Prefer dynamic linking for general application development (smaller binaries, shared memory, easy updates) and reserve static linking for cases needing full control or minimal runtime dependencies.
FAQs
Q: Can I skip the linker and just run an object file directly? No — an object file typically isn’t a complete, loadable program; it lacks a proper executable header and may have unresolved symbols and relocations. The linker produces the final loadable binary.
Q: What’s the difference between a linker and a loader? The linker works at build time, producing a (mostly) finished executable or shared library. The loader works at run time, when the OS actually loads that binary into memory, mapping dynamic libraries and performing any remaining relocations (especially important for PIE/ASLR-enabled binaries).
Q: Why do I need extern in one file and global in another for the same symbol? global (or .global in GAS) tells the assembler “make this symbol visible outside this file” (i.e., export it). extern tells the assembler “this symbol is defined elsewhere — don’t complain, just leave a placeholder for the linker to resolve” (i.e., import it).
Q: Does the linker optimize code? Generally no — traditional linkers mainly resolve symbols and lay out memory. However, modern toolchains support Link-Time Optimization (LTO), where the linker (with compiler cooperation) can perform some cross-module optimizations, though this is much more relevant to compiled languages than hand-written Assembly.
Summary and Key Takeaways
The linker is the piece of the toolchain that turns a set of independently-assembled object files into a single, coherent, runnable program. It resolves symbol references across files, patches addresses through relocation, merges sections into a final memory layout, and pulls in library code as needed. Understanding the linker’s role — separate from the assembler’s — demystifies errors like “undefined reference,” makes multi-file Assembly projects far less intimidating, and is essential once you start working on anything from shared libraries to bare-metal kernels.
References
- GNU Linker (
ld) Documentation — sourceware.org/binutils/docs/ld - System V Application Binary Interface, Executable and Linkable Format (ELF) Specification — refspecs.linuxfoundation.org
- Intel® 64 and IA-32 Architectures Software Developer’s Manual (linker-relevant relocation background) — intel.com/sdm
- Microsoft PE and COFF Specification (for Windows linking) — learn.microsoft.com
- NASM Documentation, “extern” and “global” directives — nasm.us/doc