C++ Linking In-Depth: Static, Dynamic, and Everything Between

Master the linking process in C++ including symbol resolution, static vs dynamic linking, relocations, GOT/PLT, and solving common linking errors.

Abhik SarkarAbhik Sarkar
30 min

Best viewed on desktop for optimal interactive experience

Introduction

Linking is where separate object files unite to form an executable. It's where "undefined reference" errors lurk, where static meets dynamic, and where symbols find their definitions. This article demystifies the linking process with interactive visualizations.

The Linking Process Overview

Linking Process Visualizer

Linking Pipeline

1
Input Processing
Read object files and libraries
Parsing ELF headers and section tables...
2
Symbol Collection
Build global symbol table
3
Symbol Resolution
Match undefined symbols with definitions
4
Section Merging
Combine similar sections
5
Relocation
Fix addresses and offsets
6
Output Generation
Write executable file

Input Object Files

Linked Libraries

libm.so
dynamic156 KB
dynamic
Provides: sqrt, log, sin +3 more
libc.so
dynamic2.1 MB
dynamic
Provides: printf, sprintf, malloc +2 more

Linker Command

ld -o program main.o math.o utils.o -lm -lc --dynamic-linker /lib64/ld-linux-x86-64.so.2

The linker performs several critical tasks:

  1. Symbol Resolution: Matching undefined symbols with definitions
  2. Relocation: Adjusting addresses to final locations
  3. Section Merging: Combining similar sections from different objects
  4. Library Handling: Including required functions from libraries

Understanding Object Files

Before linking, let's understand what object files contain:

# Examine object file sections objdump -h main.o # Typical sections: # .text - Machine code # .data - Initialized global variables # .bss - Uninitialized global variables # .rodata - Read-only data (string literals, const) # .symtab - Symbol table # .strtab - String table # .rela.* - Relocation entries

Object File Structure

// main.cpp #include <iostream> int global_var = 42; // → .data section int uninit_var; // → .bss section const char* msg = "Hello"; // → .rodata section void function() { // → .text section std::cout << msg; } int main() { // → .text section function(); return 0; }

Symbol Resolution

The linker's primary job is matching undefined symbols with their definitions.

Symbol Resolution

Symbol Table

Resolution Process

Select a symbol to see resolution process
Successful Resolution

All symbols found and linked correctly

Symbol Types

// Strong symbols (definitions) int x = 10; // Strong symbol void func() { } // Strong symbol // Weak symbols int y; // Weak symbol (uninitialized global) __attribute__((weak)) int z = 5; // Explicitly weak symbol // Undefined symbols (references) extern int external_var; // Undefined symbol void external_func(); // Undefined symbol

Symbol Resolution Rules

  1. Multiple strong symbols: Error
  2. One strong, multiple weak: Choose strong
  3. Multiple weak symbols: Choose any (usually first)
  4. No definition found: Undefined reference error

Common Symbol Resolution Errors

// Error: Multiple definitions // file1.cpp int global = 1; // file2.cpp int global = 2; // Error: multiple definition of 'global' // Solution: Use static or namespace static int global = 1; // File-local // or namespace { int global = 1; } // Anonymous namespace

Static Linking

Static linking copies all required code into the final executable.

Creating Static Libraries

# Compile object files g++ -c math_utils.cpp -o math_utils.o g++ -c string_utils.cpp -o string_utils.o # Create static library (archive) ar rcs libutils.a math_utils.o string_utils.o # View library contents ar t libutils.a nm libutils.a # Link with static library g++ main.cpp -L. -lutils -o program # or g++ main.cpp libutils.a -o program

Advantages of Static Linking

  • Self-contained executable
  • No runtime dependencies
  • Predictable performance
  • Easier distribution

Disadvantages

  • Larger executable size
  • Memory duplication (each program has its own copy)
  • Updates require recompilation
  • License implications (LGPL)

Dynamic Linking

Dynamic linking defers symbol resolution to runtime.

Static vs Dynamic Linking

Linking Process

1

Compile

Create object files

2

Archive

Bundle into .a library

3

Link

Copy code into executable

4

Run

Everything loaded at once

Advantages

  • Self-contained executable
  • No runtime dependencies
  • Faster startup time
  • Predictable performance

Disadvantages

  • Larger executable size
  • Memory duplication
  • No shared updates
  • Longer link time

Performance Metrics

Executable Size
2.5 MB
Memory Usage
10 MB per instance
Startup Time
5 ms

Command Examples

$ gcc -c math.c -o math.o
$ ar rcs libmath.a math.o
$ gcc main.c -L. -lmath -static -o app

Creating Shared Libraries

# Compile with Position Independent Code (PIC) g++ -fPIC -c math_utils.cpp g++ -fPIC -c string_utils.cpp # Create shared library g++ -shared -o libutils.so math_utils.o string_utils.o # Or in one step g++ -fPIC -shared math_utils.cpp string_utils.cpp -o libutils.so # Link with shared library g++ main.cpp -L. -lutils -o program # Set library path for runtime export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH ./program

SONAME Versioning

# Create versioned library g++ -shared -Wl,-soname,libutils.so.1 -o libutils.so.1.2.3 *.o # Create symlinks ln -s libutils.so.1.2.3 libutils.so.1 # SONAME link ln -s libutils.so.1 libutils.so # Development link # Link against library g++ main.cpp -lutils -o program

Position Independent Code (PIC)

PIC allows code to execute at any memory address without modification.

Relocation Process

Relocation Entries

TypeSymbolOffsetBeforeAfter
R_X86_64_32global_var0x100000 00 00 0000 50 40 00
R_X86_64_PC32function_call0x2000FC FF FF FF3C 20 00 00
R_X86_64_GOT32external_func0x300000 00 00 0000 60 40 00
R_X86_64_PLT32printf0x4000FC FF FF FF5C 10 00 00

Before Relocation

Placeholder Values

All addresses are zeros or relative

0x1000:
00000000
⚠ Needs relocation to global_var

After Relocation

Final Addresses

Relocated to base 0x400000

0x1000:
00504000
✓ Points to global_var

Relocation Formula

S + A

S = Symbol value, A = Addend

32-bit absolute relocation

Why PIC Matters

// Without PIC (absolute addressing) int global = 42; int* get_global() { return &global; // Address fixed at link time } // With PIC (relative addressing) // Compiler generates: // - PC-relative addressing for code // - GOT-relative addressing for data

PIC Performance Impact

# Non-PIC (slightly faster, not shareable) g++ -c file.cpp # PIC (shareable, required for .so) g++ -fPIC -c file.cpp # PIE (Position Independent Executable) g++ -fPIE -pie main.cpp -o program

GOT and PLT

The Global Offset Table (GOT) and Procedure Linkage Table (PLT) enable dynamic linking.

GOT/PLT Mechanism

Global Offset Table (GOT)

printf@GOT0x601018
→ PLT stub 0x400420
malloc@GOT0x601020
→ PLT stub 0x400430
sqrt@GOT0x601028
→ libc.so.6:sqrt

Procedure Linkage Table (PLT)

printf@PLT:
jmp *printf@GOT
push $printf_index
jmp PLT[0]

Lazy Binding Process

Program

PLT

GOT

Resolver

How GOT/PLT Works

  1. First call: Goes through PLT stub
  2. PLT stub: Jumps to GOT entry
  3. GOT entry: Initially points back to PLT
  4. Dynamic linker: Resolves symbol, updates GOT
  5. Subsequent calls: Direct jump via GOT

Examining GOT/PLT

# View PLT entries objdump -d -j .plt program # View GOT entries objdump -d -j .got program # View dynamic relocations readelf -r program # Trace dynamic linking LD_DEBUG=bindings ./program

Lazy vs Immediate Binding

# Lazy binding (default) ./program # Immediate binding (resolve all symbols at startup) LD_BIND_NOW=1 ./program # Compile with immediate binding g++ -Wl,-z,now main.cpp -o program

Relocation

Relocation adjusts addresses when the final memory layout is determined.

Types of Relocations

// R_X86_64_64: Absolute 64-bit relocation void* ptr = &global_var; // R_X86_64_PC32: PC-relative 32-bit call function // R_X86_64_GOT32: GOT-relative mov rax, variable@GOT // R_X86_64_PLT32: PLT-relative call function@PLT

Viewing Relocations

# Relocations in object file readelf -r main.o # Dynamic relocations in executable readelf -r program # Relocation processing LD_DEBUG=reloc ./program

The order of libraries on the command line can affect linking success.

Link Order Matters

Links Successfully

Current Link Order

gcc
main.o
liba.a
libb.a
main.o

Needs:

func_afunc_b

Provides:

main
liba.a

Needs:

func_b

Provides:

func_a
libb.a

Needs:

None

Provides:

func_b

Why Order Matters

The linker processes libraries left-to-right and only pulls in object files that resolve currently undefined symbols. Libraries should be ordered from most dependent to least dependent.

Dependency Resolution

# Wrong order (may fail) g++ -lB -lA main.cpp # If A depends on B # Correct order g++ main.cpp -lA -lB # Objects first, then dependencies # Circular dependencies g++ main.cpp -lA -lB -lA # Repeat if necessary # Or use groups g++ main.cpp -Wl,--start-group -lA -lB -Wl,--end-group
  1. Object files before libraries
  2. Libraries in dependency order
  3. More specific before more general
  4. Static before shared (when mixing)

Solving Common Linking Errors

Undefined Reference

// undefined reference to `function()' // Causes: // 1. Missing implementation void function(); // Declaration only // 2. Name mangling mismatch extern "C" void c_function(); // C linkage // 3. Template instantiation template<typename T> void tmpl_func(T t) { } // Need explicit instantiation or definition in header // 4. Missing library // Solution: Add -llibrary flag

Multiple Definition

// multiple definition of `variable' // header.h int var = 10; // Wrong: Definition in header // Solutions: // 1. Use extern declaration extern int var; // In header int var = 10; // In one .cpp file // 2. Use inline (C++17) inline int var = 10; // In header // 3. Use static (file-local) static int var = 10; // Each translation unit gets its own

Library Not Found

# cannot find -llibrary # Solutions: # 1. Specify library path g++ main.cpp -L/path/to/library -llibrary # 2. Use full path g++ main.cpp /path/to/library/liblibrary.a # 3. Update library path export LIBRARY_PATH=/path/to/library:$LIBRARY_PATH g++ main.cpp -llibrary # 4. For runtime export LD_LIBRARY_PATH=/path/to/library:$LD_LIBRARY_PATH

Advanced Linking Topics

Weak Symbols

// Provide default implementation __attribute__((weak)) void optional_feature() { std::cout << "Default implementation\n"; } // Can be overridden by strong symbol void optional_feature() { std::cout << "Custom implementation\n"; }

Symbol Visibility

// Control symbol visibility in shared libraries // Default visibility (exported) __attribute__((visibility("default"))) void public_function(); // Hidden visibility (not exported) __attribute__((visibility("hidden"))) void internal_function(); // Compile with default hidden // g++ -fvisibility=hidden -fPIC shared.cpp

Linker Scripts

/* custom.ld - Custom linker script */ SECTIONS { . = 0x400000; /* Start address */ .text : { *(.text) } .data : { *(.data) } .bss : { *(.bss) } } /* Use: g++ main.cpp -T custom.ld */

Link Time Optimization (LTO)

Optimization Pipeline

Dead Code Elimination

Remove unused functions and variables

Function Inlining

Inline small functions across modules

Devirtualization

Convert virtual calls to direct calls

Constant Propagation

Propagate constants across modules

Without LTO

Build Time12s
Executable Size125 KB
Runtime450ms
Memory Usage32 MB

With LTO

Build Time35s
Executable Size72 KB
Runtime380ms
Memory Usage28 MB

Compiler Flags

gcc
Basic:
-flto
clang
Basic:
-flto

Best Practices

  • • Use LTO for release builds to maximize performance
  • • Consider thin LTO for faster build times with good optimization
  • • Profile-guided optimization (PGO) works well with LTO
  • • May increase build time significantly for large projects
# Enable LTO g++ -flto -c file1.cpp g++ -flto -c file2.cpp g++ -flto file1.o file2.o -o program # Whole program optimization g++ -flto -fwhole-program main.cpp lib.cpp -o program # Parallel LTO g++ -flto=auto -O3 *.cpp -o program

Library Dependencies

Understanding and managing library dependencies is crucial.

Library Dependencies

ldd output visualization

Dependency Tree

Dependencies of app

Direct Dependencies
libmath.so
libutil.so
Indirect Dependencies
libc.so
libm.so
$ ldd app
libmath.so => /usr/lib/libmath.so (0x00007ffff7a00000)
libutil.so => /usr/lib/libutil.so (0x00007ffff7a00000)
libc.so => /lib/x86_64-linux-gnu/libc.so (0x00007ffff7800000)
libm.so => /lib/x86_64-linux-gnu/libm.so (0x00007ffff7800000)
Dependency Management

Use tools like pkg-config, CMake, or package managers to handle complex dependency chains and version conflicts automatically.

Viewing Dependencies

# Direct dependencies ldd program # Recursive dependencies ldd -v program # Unused dependencies ldd -u program # Missing symbols nm -u program # Library search order LD_DEBUG=libs ./program

Managing Dependencies

# RPATH (built into executable) g++ main.cpp -Wl,-rpath,/custom/lib/path -lutils # Check RPATH readelf -d program | grep RPATH # RUNPATH (can be overridden by LD_LIBRARY_PATH) g++ main.cpp -Wl,--enable-new-dtags,-rpath,/path # Remove unnecessary dependencies g++ main.cpp -Wl,--as-needed -lutil1 -lutil2

Debugging Linking Issues

Verbose Linking

# GCC/G++ verbose g++ -v main.cpp -lutils # Show linker invocation g++ -### main.cpp # Linker verbose g++ -Wl,--verbose main.cpp # Trace library search g++ -Wl,--trace main.cpp -lutils # Show link map g++ -Wl,-Map=output.map main.cpp

Useful Tools

# nm - List symbols nm -C program # Demangled C++ symbols nm -D libshared.so # Dynamic symbols only nm -u program # Undefined symbols # objdump - Display object information objdump -t program # Symbol table objdump -T program # Dynamic symbol table objdump -p program # Private headers # readelf - Display ELF information readelf -s program # Symbol table readelf -d program # Dynamic section readelf -r program # Relocations # c++filt - Demangle symbols nm program | c++filt # patchelf - Modify ELF executables patchelf --set-rpath /new/path program patchelf --add-needed libneeded.so program

Platform-Specific Considerations

Linux (ELF)

# ELF-specific tools eu-readelf -a program # Alternative readelf elfutils program # Various ELF utilities

macOS (Mach-O)

# macOS-specific otool -L program # Like ldd otool -t program # Text section install_name_tool -change old.dylib new.dylib program

Windows (PE)

# Windows/MinGW objdump -p program.exe | grep DLL # List DLLs dumpbin /dependents program.exe # MSVC

Best Practices

  1. Minimize shared library dependencies

    • Reduces startup time
    • Improves portability
  2. Use symbol visibility

    • Hide internal symbols
    • Reduce symbol table size
  3. Version your libraries

    • Use SONAME for ABI compatibility
    • Semantic versioning
  4. Prefer static linking for distributions

    • Simpler deployment
    • No dependency hell
  5. Use LTO for release builds

    • Better optimization
    • Smaller binaries
  6. Avoid circular dependencies

    • Restructure code
    • Use forward declarations
  7. Be careful with global constructors

    • Initialization order issues
    • Use lazy initialization
  8. Test with sanitizers

    • AddressSanitizer for memory issues
    • UndefinedBehaviorSanitizer for UB
  9. Document library requirements

    • Minimum versions
    • Optional features
  10. Use pkg-config for libraries

    g++ `pkg-config --cflags --libs gtk+-3.0` main.cpp

Performance Considerations

Dynamic Linking Overhead

  • Startup cost: Symbol resolution
  • Runtime cost: PLT indirection
  • Memory cost: GOT entries

Optimization Strategies

# Prelinking (deprecated but instructive) prelink -a # Prelink all system libraries # Use -fno-plt for direct calls g++ -fno-plt main.cpp # Combine multiple .so into one g++ -shared obj1.o obj2.o obj3.o -o combined.so # Use static linking for hot paths # Dynamic for rarely used features

Conclusion

Linking transforms separate object files into working programs. Understanding this process helps you:

  • Debug linking errors effectively
  • Choose between static and dynamic linking
  • Optimize program startup and runtime
  • Create robust, portable software

The journey from object files to executable involves sophisticated symbol resolution, relocation, and platform-specific mechanisms. Master these concepts to build better C++ applications.

References

  1. Linkers and Loaders by John Levine
  2. ELF Specification
  3. GNU ld Documentation
  4. How to Write Shared Libraries by Ulrich Drepper
  5. PLT and GOT - The Key to Code Sharing
Abhik Sarkar

Abhik Sarkar

Machine Learning Consultant specializing in Computer Vision and Deep Learning. Leading ML teams and building innovative solutions.

Share this article

If you found this article helpful, consider sharing it with your network

Mastodon