Linking and Loading

On this page

Linking organizes symbols, sections, and relocations from object files into executable images or shared objects; loading places the image into the process address space and completes the dynamic preparation required for execution. The two work together but are not the same stage.

The specific format and formulas in this article use Linux ELF and the x86-64 System V ABI as examples. Object formats, calling conventions, and dynamic linking strategies for other platforms should be consulted in their respective specifications.

What else is in an object file besides machine code?

ContentPurpose
.text, .data, .rodataCode and data with different attributes
. bss-like NOBITS sectionsDescribes space that needs zero-initialization; does not store an equivalent payload of zero bytes
Symbol tableDefinitions, undefined references, binding, visibility, etc.
Relocation entriesTells subsequent tools how to patch address-dependent fields
Debug informationAssociates machine locations with source code and types

Sections are the unit of organization from the linker's perspective; program headers describe segments from the loader's perspective. At runtime, mappings are typically done according to program headers like PT_LOAD, not by mmaping each section name individually. .bss has metadata, but it doesn't need to save every zero byte it represents in the file.

Symbol Resolution and Layout: Deciding Who to Reference First

One object file defines a helper, and another object file references helper. The linker decides which definition corresponds to the reference based on binding, visibility, version, and linking rules, then lays out the relevant content and applies relocations.

Same names do not always conflict: local symbols have their own scope, weak and strong symbols have different rules, and dynamic symbols are also affected by visibility and search scope. Conversely, dynamic symbol resolution cannot be simplified to "whichever library loads first is always used."

Relocation: Accurate Formulas for Each Type

Let be the target symbol address, be the addend, and be the address of the relocation field. R_X86_64_PC32 uses , and R_X86_64_64 uses . Different types also specify field width and overflow checks. AMD64 ABI Relocation Table

PC-relative relocation: P is the field to be patched, not the next instruction call opcode Address 0x1000 4-byte displacement field P = 0x1001 Next instruction Address 0x1005 S = 0x2000, A = −4: S + A − P = 0xFFB CPU adds displacement to the next address: 0x1005 + 0xFFB = 0x2000. Do not subtract 4 again.

In a typical call rel32 encoding, the CPU references the next instruction, while the ABI formula uses the field position . The common addend of −4 already accounts for the difference between them. If the formula is written as while is also −4, you would subtract 4 twice.

External function calls may use R_X86_64_PLT32, where the target involves a PLT entry and may be relaxed to a direct call by the linker; you cannot require every compiler's external call to show PC32. After understanding the formula, you should check the actual relocation types.

Check Signed Range Before Writing Four Bytes

The rel32 displacement is a signed 32-bit integer. The displacement can be negative when the target is before the call site, using two's complement for little-endian fields; if the target is too far, you cannot simply truncate to the lower 32 bits, or the CPU will jump to another address. The following only models the PC32 calculation for a determined direct target, without simulating symbol lookup or PLT selection.

def pc32(symbol, addend, place):
    displacement = symbol + addend - place
    if not -(1 << 31) <= displacement < (1 << 31):
        raise OverflowError("PC32 relocation out of range")
    return displacement.to_bytes(4, "little", signed=True)


assert pc32(0x2000, -4, 0x1001) == bytes.fromhex("fb 0f 00 00")
backward = pc32(0x1000, -4, 0x2001)
assert 0x2005 + int.from_bytes(backward, "little", signed=True) == 0x1000
for displacement in (-(1 << 31), (1 << 31) - 1):
    assert int.from_bytes(pc32(displacement, 0, 0), "little", signed=True) == displacement
for displacement in (-(1 << 31) - 1, 1 << 31):
    try:
        pc32(displacement, 0, 0)
    except OverflowError:
        pass
    else:
        raise AssertionError("truncated relocation accepted")

Common translation of and does not change the displacement, which explains why some PC-relative references within a module do not need to be patched again with the load base address; however, definitions outside the module, absolute address fields, and TLS still have their own rules. You cannot generalize from a single internal relative call to "PIC has no relocations."

Which distance is already accounted for by the −4 addend?

Mark the four-byte field P and the next instruction P+4 to make the addend concrete. Move the target backward, translate the module, or subtract4 twice. Overflow produces no truncated patch: a number writable to a file does not guarantee the CPU reaches the intended address.

Preparing the visual
Which distance is already accounted for by the −4 addend?

PC32 writes S+A−P. For this call A=−4 already bridges field P to next-instruction P+4. Translating S,P together preserves displacement; subtracting4 again misdirects the call.

A Minimal Object File Experiment

Create two files in a separate experiment directory:

/* caller.c */
extern int twice(int);
int main(void) { return twice(21) == 42 ? 0 : 1; }
/* twice.c */
int twice(int x) { return x * 2; }
cc -O0 -c caller.c -o caller.o
cc -O0 -c twice.c -o twice.o
readelf -sW caller.o
readelf -rW caller.o
objdump -dr caller.o
cc caller.o twice.o -o demo
./demo

First, find the undefined twice symbol, then look at the corresponding relocation, and finally observe how the linked program runs. Specific offsets, instructions, and PIE defaults vary with the toolchain; the focus of the experiment is to map symbol references to patching fields.

Static Libraries: Extracting Object Members According to Rules

.a is an archive of object files. Traditional Unix linkers typically extract members from an archive when handling unresolved references, so command-line order can matter: placing the requester before the static library that provides the definition is a common practice.

Circular dependencies can be handled through mechanisms like repeated scanning of library groups; GNU ld and other linkers support corresponding options, but specific scanning strategies differ. LLD also saves lazy symbols from archives and extracts them upon subsequent references; you cannot describe all linkers as performing an identical left-to-right single scan.

Linker GC retains content reachable from roots based on section and relocation references; roots can also include exported symbols and KEEP in link scripts. It is not just tracing call instructions by disassembling from _start. Splitting sections by function or data helps fine-grained reclamation, but not all inputs are naturally split.

Continue using the previous caller.c, and extend twice.c to two functions: twice is called by main, and unused_helper has no references. Use GNU ld.bfd for the following experiment to fix the linker and avoid mistaking its archive scanning strategy for a rule of all tools.

/* twice.c: replace the previous version */
int twice(int x) { return x * 2; }
int unused_helper(void) { return 99; }
gcc -O0 -ffunction-sections -fdata-sections -c twice.c -o twice.o
ar rcs libtwice.a twice.o
gcc -fuse-ld=bfd caller.o ./libtwice.a -o with-unused
gcc -fuse-ld=bfd caller.o ./libtwice.a -Wl,--gc-sections -o trimmed
nm with-unused
nm trimmed
./with-unused
./trimmed

The first link extracts the entire twice.o due to the reference from caller.o to twice, so both functions enter the input set. The second link will still extract the same member, but section reclamation finds that .text.unused_helper is not in the retention graph and can remove it. Both programs return 0, and unused_helper should only appear in the un-reclaimed version in nm. Here, O0 is used and LTO is not enabled to avoid mixing frontend inlining/deletion effects into the explanation.

StageGranularityDecision in this Example
Archive SearchObject MemberExtract twice.o to satisfy the twice reference
Section ReclamationInput Sections and Reference GraphRetain the section containing twice, remove the unreferenced function section
Final Layout and RelocationAddresses and Fields of Retained ContentPatch the final displacement for the call in main

If libtwice.a is placed before caller.o, GNU ld.bfd may not extract the member because there is no unresolved reference to twice at that time, ultimately reporting an undefined reference. Restoring the order fixes this; unconditionally using --whole-archive to solve this ordering issue expands the input range, and it itself does not guarantee that all sections are spared from subsequent GC. GNU ld Archive and Section Reclamation Options

Extract archive members, then retain reachable sections caller.o Unresolved reference: twice Extract twice.o twice + unused_helper --gc-sections Keep only reachable twice Function sections give finer granularity; roots and references determine retention. An unextracted archive member does not enter layout merely because its sections exist.

How can extracting an object still keep only one function?

Separate membership in linker input from final retention. Traditional archive scanning may skip the member before an unresolved twice exists; forced extraction still allows GC to delete the independent unused section. The model fixes a GNU ld.bfd boundary instead of projecting it onto different linkers such as LLD.

Preparing the visual
How can extracting an object still keep only one function?

Archive extraction selects members; section GC retains root-reachable sections. whole-archive forces input membership but not section retention; traditional order errors can miss the definition entirely.

GOT and PLT: Separating Indirect Access and Resolution Timing

GOT provides address slots, and PLT provides call stubs; they are part of some position-independent and dynamic call paths. In classic lazy function binding, the first call goes through the resolver to find the target and update the corresponding slot, and subsequent calls use the already saved address.

Immediate binding advances the relevant resolution to the loading stage; for example, the Linux dynamic linker can be controlled via LD_BIND_NOW. It does not automatically change the original PLT call instruction to a direct call without indirect overhead just because resolution is advanced. Whether PLT is used, whether it can be relaxed, and whether it is called via GOT still depends on compilation and linking choices. Linux ld.so

Dynamic symbol interposition is also affected by search scope, binding, and visibility. It brings flexibility to replacement implementations and preloading, but may also limit inlining or local binding assumptions; you cannot assert that all malloc calls necessarily go through the exact same path.

Loading and dlopen

Dynamic ELF startup: map, relocate, then enter the program Kernel reads program headers of the executable file Establish address space according to loadable segments, identify interpreter 1 Resolve dependencies and required relocations Resolve symbols, TLS, etc., handle binding according to strategy 2 Execute runtime initialization Shared library initialization and language startup logic 3 Program entry proceeds toward main main is not the only entry concept directly jumped into by the kernel 4 The diagram range is common Linux dynamic ELF; static, other formats, and platform processes differ.

dlopen loads shared objects during process execution, dlsym looks up symbols, and dlclose reduces load references, etc. Actual plugin interfaces also need to define ABI, ownership, thread, and unloading conditions, and check for errors; you cannot arbitrarily unload while function pointers or threads are still executing library code. dlopen manual

Dynamically loading ready-made libraries is different from JIT publishing machine code. JIT can handle objects, relocations, and executable permissions in memory, without requiring a unified file process of "write .o, link .so, dlopen". See JIT Compilation.

LTO: Letting Optimization See Cross-Compilation Unit Information

Ordinary independent compilation limits the function bodies the optimizer can see. LTO preserves IR or related intermediate information, performs cross-module analysis and optimization during the linking stage combined with symbol resolution results, and then generates machine code to continue linking. Even without LTO, the linker still performs relocations, section reclamation, and other link optimizations; it cannot be called just concatenation.

Full LTO and ThinLTO have different organizational methods. ThinLTO combines module summaries for global analysis and selective function import, then executes module backends in parallel, and can also use caching. Clang explicitly selects this mode with configurations like -flto=thin; it is not the natural default LTO method for all gold/LLD calls. Clang ThinLTO Documentation

Whether to inline is determined by cost models and visibility constraints, not limited to "only importing hot call sites." LTO may also not know about all dynamic plugins and external definitions; the so-called whole-program boundary needs to be combined with linking and symbol assumptions. Performance gains, memory, and linking time should be measured in actual projects, without giving a unified percentage increase.

Quick Troubleshooting Guide

PhenomenonPriority Check
undefined referenceWhether definition is generated, library order, name mangling, ABI, and visibility
relocation overflowRelocation width, layout, and code model
Startup cannot find shared libraryDependency records, search paths, and platform loading rules
Same-named symbol bound to unexpected implementationSearch scope, version, preloading, and visibility
LTO has no expected effectCompilation and linking configuration, IR availability, and optimization boundaries

References

Keywords: ELF, symbol resolution, relocation, PC32, PLT32, archive, GOT, PLT, lazy binding, dlopen, LTO, ThinLTO