Skip to content

The Linker (ld``lld``link.exe) is the final architect of the binary. While the compiler works In isolation on Translation Units (TUs), generating “relocatable object files,” the linker is Responsible for fusing these fragments into a coherent executable memory map.

It performs three critical atomic operations:

  1. Resolution: Matching “Use” sites (Undefined symbols) to “Definition” sites.
  2. Relocation: Patching code sections with concrete memory addresses.
  3. Deduplication: Discarding redundant code generated by templates and inline functions.

Input object files contain a Symbol Table. The linker aggregates these tables into a Global Symbol Table.

The linker maintains three sets:

  • E (Executable): The accumulated object files to be output.
  • U (Undefined): References to symbols currently missing a definition.
  • D (Defined): Symbols defined in E.

As the linker scans inputs (objects and libraries) from left to right on the command line:

  1. If input is an Object File (.o): It adds the file to E. It adds new definitions to D and resolves matching entries in U. Any new undefined references in the object are added to U.
  2. If input is a Static Library (.a): The linker checks if any symbol in the library’s member objects matches a symbol currently in the U set.
  • If a match is found, that specific member object is extracted and added to E.
  • If no match is found, the member object is ignored entirely.

:::danger The Archive Order Trap Because static libraries are searched only to resolve currently Pending undefined symbols, order matters. If LibA depends on LibB``LibA must appear before LibB in the linker command.

  • Correct: clang++ main.o -lA -lB
  • Incorrect: clang++ main.o -lB -lA (Linker fails: Symbols in A are undefined). :::

The linker classifies symbols into three categories that determine how duplicates are handled:

Strong symbols: Functions and initialized global variables. There must be exactly one strong Definition for each symbol across all object files. Duplicate strong definitions cause a “multiple Definition” linker error.

// Strong: initialized global
int config_value = 42;
// Strong: function
void process() { }

Weak symbols: Uninitialized globals (tentative definitions), or symbols explicitly annotated With __attribute__((weak)). If both a strong and weak definition exist, the strong one wins. Multiple weak definitions are permitted and the linker picks one arbitrarily.

// Weak: uninitialized global (tentative definition)
int tentative_value;
// Weak: explicit annotation (GCC/Clang)
__attribute__((weak))
void optional_hook() { }

Common symbols: These are a special case of tentative definitions. When GCC encounters an Uninitialized global in a C translation unit, it emits it as a “common” symbol (in the COM section Rather than BSS). Common symbols can be merged: if multiple TUs define int x; without Initialization, the linker allocates storage once and aligns to the largest requested alignment. The -fno-common flag (now default in GCC 10+) disables this behavior and makes uninitialized globals Ordinary weak symbols instead.

// Before GCC 10 (default -fcommon):
// File A: int x; -> COMMON symbol
// File B: int x; -> COMMON symbol
// Linker merges them into a single BSS allocation
// GCC 10+ (default -fno-common):
// File A: int x; -> weak BSS symbol
// File B: int x; -> weak BSS symbol
// Multiple weak definitions: OK, linker picks one

Symbol visibility controls which symbols are exported from shared libraries (.so``.dll). This Affects both dynamic linking and name mangling.

VisibilityELF FlagMeaning
Default(none)Exported; may be interposed by LD_PRELOAD
Hidden__attribute__((visibility("hidden")))Not exported; internal to the DSO
Protected__attribute__((visibility("protected")))Exported but cannot be interposed
Internal(implementation-defined)Not exported; may enable more aggressive optimization
// Hide all symbols by default, export only the API
#pragma GCC visibility push(hidden)
__attribute__((visibility("default")))
void public_api_function();
// Internal: not exported, not interposable
void internal_helper();
# CMake: hide all symbols by default
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)

When the linker encounters two strong definitions of the same symbol, it emits a fatal error. The Error message includes the object files containing the conflicting definitions:

duplicate symbol '_ZN3foo7processEv' in:
CMakeFiles/App.dir/A.cpp.o
CMakeFiles/App.dir/B.cpp.o
ld: fatal error: duplicate symbol

Common causes include:

  • Accidentally defining a non-inline function in a header file included by multiple TUs.
  • Forgetting inline on a function defined in a header.
  • Violating the ODR by defining a struct differently across TUs (detected at link time only if the mangled names differ).

Proof: Linker ODR Violation Detection Mechanism

Section titled “Proof: Linker ODR Violation Detection Mechanism”

The linker detects ODR violations through the One Definition Rule’s structural requirements. Per [N4950 S6.3], if two declarations of the same entity differ, the program is ill-formed (no Diagnostic required). The linker provides a partial diagnostic through its symbol resolution:

  1. Strong symbol conflict: If two TUs define the same function (same mangled name) as a strong symbol, the linker detects a “multiple definition” error because two global symbol table entries have the same key.
  2. COMDAT deduplication: For inline functions and templates, the compiler places each instantiation in a COMDAT section keyed by the mangled name. The linker keeps exactly one copy. If two COMDAT sections have the same key but different sizes or contents, the linker does not detect this (it keeps one arbitrarily). This is a silent ODR violation.
  3. The silent case: If two TUs define struct S { int x; }; in one and struct S { double x; }; in the other, and no function signature encodes S differently (e.g., both TUs only use S*), the linker cannot detect the violation. The mangled names are identical. This is the most dangerous form of ODR violation.

Conclusion: The linker detects ODR violations only when they produce different mangled names. Structural ODR violations (same name, different definitions) that do not affect function signatures Are invisible to the linker. This is why -Wodr (GCC) and modules are important for ODR safety.

Static libraries (.a files on Unix, .lib files on Windows) are not linked as a single unit. They Are archives of individual object files. The linker extracts only the member objects that Satisfy currently pending undefined references.

This default behavior causes problems when a library provides a plugin or callback mechanism: member Objects that register themselves via global constructors may never be pulled in because no symbol in U references them.

Terminal window
# Problem: libplugin.a defines init_plugin() via a global constructor,
# but nothing in main.o references init_plugin() explicitly.
clang++ main.o -lplugin # init_plugin() is NEVER extracted
# Solution: force the linker to include ALL members
clang++ main.o -Wl,--whole-archive -lplugin -Wl,--no-whole-archive

The --no-whole-archive after the library is critical. Forgetting it causes every subsequent Library on the command line to be linked in its entirety, bloating the binary.

Compilers generate object code assuming a start address of 0x0. Instructions referring to data or Other functions use placeholder values. Relocation is the process of patching these placeholders With actual addresses once the final memory layout is determined.

The compiler generates a .rela.text section containing instructions for the linker.

  • Instruction: “At offset 0x42 in section .textInsert the difference between the address of symbol foo and the current instruction pointer (RIP).”
  1. R_X86_64_PC32 (Relative): Used for function calls (call) and data access (lea) within the same binary. The value patched is S+APS + A - P (Symbol + Addend - Place). This enables Position Independent Code (PIC), allowing the binary to be loaded at any virtual address (ASLR).

  2. R_X86_64_64 (Absolute): Used for static data pointers (e.g., jump tables). Requires load-time patching if the binary base address changes.

  3. R_X86_64_GOTPCREL (GOT-relative): Used for accessing global variables through the GOT in position-independent code. The instruction loads the GOT entry address, then loads the value from the GOT.

When code references a symbol in a Shared Library (.so), the address is unknown until runtime. The linker cannot patch the code directly. Instead, it generates:

  • Global Offset Table (GOT): A data section (.got) acting as a directory of addresses.
  • Procedure Linkage Table (PLT): A code section (.plt) containing stubs.

Mechanism:

  1. Code calls printf (in libc).
  2. The call jumps to printf@plt (stub).
  3. The stub jumps to the address stored in printf@got.
  4. Lazy Binding: Initially, the GOT points back to the dynamic linker resolver. On the first call, the resolver finds the actual printf address, updates the GOT, and executes the function. Subsequent calls jump directly.

By default, ELF uses lazy binding (resolve symbols on first call). This can be disabled:

Terminal window
# Eager binding: resolve all symbols at load time
LD_BIND_NOW=1 ./app
# Compile-time: mark all GOT entries as requiring immediate resolution
clang++ -Wl,-z,now main.o -lfoo

Eager binding eliminates the first-call overhead and makes the startup behavior deterministic, which Is important for security (prevents GOT overwrite attacks from redirecting control flow) and for Real-time systems (no unpredictable latency on first function call).

C++ templates pose a unique challenge. If std::vector<int>::push_back is instantiated in A.cpp And B.cppBoth object files contain the machine code for that function. Naively linking them Would result in a “Multiple Definition” error.

The compiler marks template instantiations and inline functions with a special flag: COMDAT (Common Block).

  • Rule: “This section defines symbol X. If you have already seen a COMDAT for X, discard this section. If not, keep it.”
  • Result: The final binary contains exactly one copy of the template code.

Per [N4950 S6.3], inline functions with external linkage may be defined in multiple translation Units provided all definitions are identical. COMDAT is the linker mechanism that enforces this by Keeping only one copy.

Modern linkers (Gold, LLD, Mold) go further. They detect functions that are distinct in C++ but Compile to identical machine code.

Example: std::vector<int> and std::vector<unsigned int> often generate identical machine Code since they have the same element size and layout.

ICF Mechanism:

  1. The linker hashes the .text content of every section.
  2. If hashes match, it performs a byte-by-byte comparison.
  3. If identical, it keeps one copy and updates the symbol table so both vector<int>::push_back and vector<unsigned int>::push_back point to the same address.

CMake Configuration:

if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
# Enable ICF on LLD/Gold
add_link_options("-Wl,--icf=all")
endif()

:::caution ICF can break programs that compare function pointers for identity. If f() and g() Are folded into the same address, &f == &g becomes true even though they are distinct functions. This is rare but possible. Use -Wl,--icf=safe to fold only functions with identical relocations. :::

A static library is an archive of object files. At link time, the linker extracts the needed members And embeds them directly into the final binary.

  • Pros: No runtime dependency; deterministic behavior; simpler deployment.
  • Cons: Binary bloat (unused code may be pulled in); no sharing across processes; updates require re-linking.

A shared library is a fully linked binary that is loaded at runtime by the dynamic linker (ld.so On Linux, ntdll.dll on Windows).

  • Pros: Smaller executables; code sharing across processes (one physical copy in RAM); updates without re-linking.
  • Cons: Runtime dependency (missing .so causes launch failure); symbol interposition complexity; slight startup cost for dynamic resolution.

The linker processes inputs strictly left to right. This has a profound effect when mixing object Files and libraries:

Terminal window
# main.o references func_A() in libA and func_B() in libB
# libA also references func_B() in libB
# CORRECT: main.o first, then dependent libs in dependency order
clang++ main.o -lA -lB
# WRONG: libB before libA
clang++ main.o -lB -lA
# libB is scanned: no pending undefined refs -> nothing extracted
# libA is scanned: func_A extracted, now func_B is pending
# End of input: func_B is still undefined -> LINKER ERROR
# WRONG: libraries before object files
clang++ -lA -lB main.o
# libA scanned: no pending undefined -> nothing extracted
# libB scanned: no pending undefined -> nothing extracted
# main.o added: func_A and func_B become pending
# End of input: both undefined -> LINKER ERROR

Rule of thumb: Object files first, then libraries ordered by dependency (dependents before Dependencies). Circular dependencies between libraries require --start-group / --end-group:

Terminal window
clang++ main.o -Wl,--start-group -lA -lB -Wl,--end-group

This tells the linker to rescan the group until no more symbols can be resolved.

The original GNU linker. Written in C, slow for large projects, but universally available. Supports All obscure linker script features.

A faster linker written in C++, part of GNU Binutils. Introduced the plugin interface for LTO (Link Time Optimization). Largely superseded by LLD.

The LLVM linker. Written in C++. Key advantages:

  • Speed: 2-10x faster than GNU ld for large projects.
  • Cross-platform: Supports ELF (Linux), COFF (Windows), Mach-O (macOS), and WebAssembly.
  • Diagnostics: Improved error messages with source locations.
Terminal window
# CMake: use LLD
cmake -DCMAKE_LINKER=lld ..
# Or via compiler flag:
clang++ -fuse-ld=lld main.o -lA -lB

A modern linker designed for speed. Written in C++. Claims to be 5-100x faster than GNU ld.

Terminal window
cmake -DCMAKE_LINKER=mold ..
# Or:
clang++ -fuse-ld=mold main.o -lA -lB
FeatureGNU ld (BFD)GoldLLDMold
LanguageCC++C++C++
ELF SupportFullFullFullFull
COFF SupportNoNoYes (lld-link)No
Mach-O SupportNoNoYes (ld64.lld)No
Linker ScriptsFullPartialMostPartial
SpeedBaseline2-5x2-10x5-100x
LTOVia pluginNativeNativeVia plugin

A linker script (ld syntax) controls the layout of sections in the final binary. While most Developers never write them, understanding them is essential for embedded systems and OS Development.

/* Minimal linker script */
ENTRY(_start)
SECTIONS
{
. = 0x100000; /* Base address: 1 MB */
.text : { *(.text) } /* All .text sections concatenated */
.rodata : { *(.rodata) }
.data : { *(.data) }
.bss : {
__bss_start = .;
*(.bss)
__bss_end = .;
}
}

Key directives:

  • ENTRY(symbol): Sets the entry point.
  • . (location counter): Represents the current VMA (Virtual Memory Address).
  • *(.section): Wildcard pattern matching all input sections named .section.
  • PROVIDED(symbol) = value: Define a symbol only if not already defined.
  • KEEP(section): Prevent garbage collection from discarding the section (critical for .init_array).

To inspect the default linker script for your target:

Terminal window
ld --verbose | grep -A100 "SECTIONS"

LTO allows the linker to perform whole-program optimization by serializing the compiler’s Intermediate representation (IR) into the object file and re-running optimization passes at link Time.

  1. Compilation: The compiler generates LLVM IR (or GCC GIMPLE) instead of machine code and embeds it in a .o file alongside a thin object file for fast linking without LTO.
  2. Linking: The linker extracts the IR from all .o files, merges them, and runs interprocedural optimization (inlining, dead code elimination, devirtualization).
  3. Codegen: The optimized IR is compiled to machine code and linked.
TypeDescriptionBuild TimeBinary Quality
Full LTOMerges all IR into one unitSlow (high memory)Best
ThinLTOParallel summary-based optimizationModerateNear-full LTO
# CMake: enable LTO
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)
# Or per-target:
set_target_properties(app PROPERTIES INTERPROCEDURAL_OPTIMIZATION ON)

LTO can detect ODR violations that the linker alone cannot: the merged IR allows the compiler to Compare type definitions across TUs. GCC’s -Wodr and Clang’s -Wodr flags enable this diagnostic.

The linker operates on Mangled Names (decorated names that encode namespace, class, and argument Types).

  • Source: void foo::bar(int)
  • Mangled (Itanium): _ZN3foo3barEi

Linker errors report the mangled name, which is unreadable. Tools like c++filt translate them Back.

Diagnostic Workflow:

  1. Error: undefined reference to _ZN3foo3barEi

  2. Demangle:

    Terminal window
    c++filt _ZN3foo3barEi
    # Output: foo::bar(int)
  3. Inspect: Use nm to find which object file provides (or fails to provide) the symbol.

    Terminal window
    nm -C --defined-only libfoo.a | grep "foo::bar"

Undefined reference errors are the most common linker errors. A systematic debugging approach:

Terminal window
# Pipe the error through c++filt
clang++ main.o -lfoo 2>&1 | c++filt
Terminal window
# Check if the symbol is defined in any object file or library
nm -C libfoo.a | grep "missing_symbol"
nm -C main.o | grep "missing_symbol"

Ensure the object file referencing the symbol appears before the library providing it on the command Line.

If the symbol is defined in a C library but declared in C++ code without extern "C"The C++ Compiler will emit a mangled name while the library exports an unmangled name.

// WRONG: C++ mangling applied to C function
extern void c_library_init(); // Mangled as: _Z14c_library_initv
// CORRECT: disable mangling for C interface
extern "C" void c_library_init(); // Unmangled: c_library_init

Symbols may be hidden by -fvisibility=hidden or by .def / version scripts. Use nm -D to check The dynamic symbol table:

Terminal window
# Dynamic symbols only (what's exported from a .so)
nm -D libfoo.so | grep "missing_symbol"
nm Symbol TypeMeaningTypical Source
T / tText (code) sectionFunction definition
D / dData sectionInitialized global variable
B / bBSS sectionUninitialized global variable
UUndefinedExternal reference (needs resolution)
W / wWeak symbolTentative definition or __attribute__((weak))
AAbsoluteLinker script symbol
V / vWeak object (ELF)Weak symbol definition
  • Link order matters: Object files before libraries, dependents before dependencies. This is the single most common source of linker errors on Linux.
  • --whole-archive without --no-whole-archive: Forgets to close the whole-archive scope, causing massive binary bloat.
  • Mismatched extern "C": Forgetting extern "C" when calling C code from C++ causes undefined reference to the mangled name.
  • Inline functions in headers without inline: Defining a non-inline function in a header included by multiple TUs causes duplicate symbol errors.
  • Static library not rebuilt after header change: Changing a header only recompiles TUs that include it. If the static library was not rebuilt, stale object files may cause mysterious errors.
  • LTO with incompatible object files: Mixing LTO and non-LTO object files from different compilers can cause linker errors. Ensure all objects are compiled with the same LTO settings.
  • ICF folding distinct functions: Identical Code Folding may merge functionally distinct functions that happen to compile to the same machine code, breaking function pointer comparisons.

ELF shared libraries can use version scripts to control symbol visibility and provide ABI Versioning. This is critical for maintaining backward compatibility when evolving a library’s API.

libfoo.ver
VERS_1.0 {
global:
foo;
bar;
local:
*;
};
VERS_2.0 {
global:
baz;
} VERS_1.0;

Usage:

Terminal window
# Compile and link with version script
clang++ -shared -Wl,--version-script=libfoo.ver -o libfoo.so foo.cpp

The linker assigns a version tag to each symbol in the shared library. When a program links against The library, it records which version of each symbol it was linked against. At runtime, the dynamic Linker verifies that the requested version is available.

This allows the library to introduce new symbols (in VERS_2.0) without breaking existing programs That depend on VERS_1.0 symbols. If a symbol is removed or changed in VERS_2.0The dynamic Linker detects the version mismatch and reports an error rather than silently producing incorrect Behavior.

Version scripts are an alternative to -fvisibility=hidden for controlling which symbols are Exported:

{
global:
public_api_*;
local:
*;
};

This exports only symbols matching public_api_* and hides everything else, without requiring Source-level __attribute__((visibility("hidden"))) annotations.

11. Linker Deficiencies and Common Error Patterns

Section titled “11. Linker Deficiencies and Common Error Patterns”

”undefined reference to vtable for Foo

Section titled “”undefined reference to vtable for Foo””

This error occurs when a class with virtual methods is declared but the compiler-generated virtual Table and typeinfo are not linked in. Common causes:

  1. Virtual method declared but not defined: If Foo has a virtual method declared in the header but the definition is missing from all TUs, the compiler emits a reference to vtable for Foo but no definition. The linker reports the error.
  2. First virtual method is defined out-of-line but not linked: The compiler places the vtable in the TU that defines the first non-pure virtual function. If that TU is not linked, the vtable is missing.

”relocation truncated to fit: R_X86_64_PC32 against symbol”

Section titled “”relocation truncated to fit: R_X86_64_PC32 against symbol””

This error occurs when a 32-bit relative relocation is used for a symbol that is too far away (more Than 2 GB apart in the virtual address space). Common causes:

  1. Large binaries: If the .text section exceeds 2 GB, references from one end to the other exceed the 32-bit relative offset range.
  2. Missing -fPIC: Non-PIC code in shared libraries uses absolute addresses that may require 64-bit relocations.

Fix: Compile with -fPIC and/or use -mcmodel=medium or -mcmodel=large for large binaries.

”multiple definition of __builtin_expect

Section titled “”multiple definition of __builtin_expect””

This error occurs when a TU is compiled with -fno-builtin or with different compiler versions that Handle builtins differently. The builtin functions may be emitted as strong symbols in multiple Object files. Fix by ensuring consistent compiler flags across all TUs.

Link map files provide a detailed dump of the linker’s decisions, showing where each symbol is Placed in the output binary:

Terminal window
# Generate a link map file
clang++ -Wl,-Map=output.map main.o -lfoo -o app
# Inspect for specific symbols
grep "foo\|bar" output.map

The map file contains:

  • Memory addresses of each section and symbol.
  • Archive member extraction decisions (which .o files from .a were pulled in and why).
  • Symbol resolution (which definition satisfied each undefined reference).
  • Total binary size breakdown by section.

Link map files are invaluable for debugging linker issues in large projects where manually tracing Symbol resolution is impractical.

Section titled “13. Link Time Optimization (LTO) and ODR Detection”

As mentioned in Section 7, LTO can detect ODR violations that the linker alone cannot. When LTO is Enabled, the linker merges the intermediate representation (IR) from all object files and can Compare type definitions across TUs:

Terminal window
# GCC: enable ODR diagnostics
g++ -flto -Wodr main.o utils.o -o app
# Clang: LTO ODR detection is on by default
clang++ -flto main.o utils.o -o app

When -Wodr detects a mismatch (e.g., struct S defined with different members in two TUs), it Reports the conflicting definitions with source locations. This is a significant improvement over The linker’s silent acceptance of structurally different ODR violations.

However, -Wodr has limitations:

  1. It only checks types that are used in LTO-visible functions. If a type is only used in non-inline functions that are not optimized across TUs, the violation may go undetected.
  2. It produces false positives for types that are intentionally defined differently in different contexts (e.g., #ifdef guards that produce different struct layouts).
  3. It significantly increases link time because the linker must parse and compare IR from all TUs.

The --wrap flag allows intercepting calls to a specific symbol at link time. This is used for Testing (mocking) and for runtime instrumentation:

Terminal window
# Redirect all calls to 'malloc' to '__wrap_malloc'
clang++ -Wl,--wrap=malloc app.o -o app
// In the application or a test harness:
extern "C" void* __wrap_malloc(size_t size) {
// Custom implementation (logging, tracking, etc.)
return __real_malloc(size);
}
extern "C" void* __real_malloc(size_t size);

The linker rewrites every call to malloc to call __wrap_malloc instead. The __real_malloc Symbol is provided by the original library and can be called from within the wrapper. This mechanism Does not require modifying the original source code.

:::caution --wrap operates at the symbol level, not the function level. If malloc is inlined by The compiler, the wrapper will not intercept the inlined call. Use -fno-inline on the wrapping TU Or compile the wrapped TU separately without LTO.

This topic covers the essential concepts and techniques related to linker, including key principles and practical applications.

Key concepts include:

  • core concepts and definitions
  • key principles and frameworks
  • practical applications
  • common techniques and methods
  • evaluation and critical analysis

A thorough understanding of these concepts, combined with regular practice and review, is essential for mastery of this topic.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.

:::