Linkingtarget

Relocations

The compiler emits a zero and a note saying "this is an address, fix it later". The linker patches it once layout exists. Absolute versus PC-relative decides whether the code can be loaded anywhere — which is what position-independent code, the GOT and the PLT are all about.

The question

How does a call with four zero bytes in it turn into a working call, and what are the GOT and the PLT for?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Machine code with holes, plus a list describing each hole. A relocation record is a tuple — offset, symbol, type, addend — and the type names a small arithmetic function of the symbol address, the location being patched and the addend. This representation exists because the pipeline separates "what work happens" from "where it happens", and the second question has no answer until every translation unit has been laid out together.

What this phase may assume or do

A relocation may be applied only when its computed value fits the field being patched and satisfies the field's encoding constraints: a 32-bit PC-relative displacement requires the target to be within ±2GB of the patch site, a 26-bit branch offset requires it to be within its reach and correctly aligned, and an absolute relocation into a shared object is legal only if the object will never be loaded at a different base — which is exactly what position-independent code exists to avoid assuming. A relocation whose value does not fit must fail the link rather than truncate, because a truncated address is a jump to an arbitrary location.

Key points

  • A relocation is a record naming a location, a symbol, an arithmetic form and an addend; the linker evaluates it once addresses exist.
  • Absolute relocations bake in a load address; PC-relative ones encode a distance, which is unchanged if the whole module moves.
  • Position-independent code makes the module relocatable by concentrating all load-address dependence into a writable table of pointers, so code pages stay shared and read-only.
  • The GOT holds one patched pointer per externally-visible symbol; the PLT holds a stub per imported function so that call sites need no patching at all.
  • Lazy binding resolves a function on first call; full RELRO resolves everything at load and makes the GOT read-only, trading start-up time for a removed attack surface.
  • relocation truncated to fit is a reach failure, fixed with a code model or by the linker inserting a veneer — not a missing-symbol problem.

A relocation is a small arithmetic program

targetThese names and formulas are x86-64 ELF. AArch64 splits the same jobs across R_AARCH64_CALL26, ADR_PREL_PG_HI21 and ADD_ABS_LO12_NC, because a 64-bit address must be built from several instructions rather than encoded in one displacement — so the reach and the instruction count differ even though the concepts match exactly. Mach-O and PE have their own type sets again.

When the compiler emits a call to a function in another translation unit, it does not know the address, so it writes zeros into the displacement field and records: "at offset 0x11 in .text, there is a 32-bit PC-relative reference to printf, with an addend of -4". The type — R_X86_64_PLT32 in this case — names both the width and the arithmetic.

The linker, once layout is done, evaluates that arithmetic. For a PC-relative type the formula is S + A - P: the symbol's final address, plus the addend, minus the address of the field being patched. For an absolute type it is S + A. There are dozens of types per architecture and they are all variations on which of those terms appear and how many bits the result is stored in.

That is genuinely the whole mechanism. What makes relocations interesting is not the arithmetic but the consequences of choosing one form over another, because the choice decides whether the resulting code can be loaded at an address other than the one it was linked for.

The relocation forms that matter, x86-64 ELFtarget
TypeComputesUsed forConsequence
R_X86_64_64targetS + A — a full 64-bit absolute addressPointers in data: vtables, jump tables, initialised function pointersThe value depends on the load address, so it must be re-patched if the object moves
R_X86_64_PC32targetS + A - P — a 32-bit signed displacement from hereCalls and data references within one moduleLoad-address independent: if the whole module moves, the distance is unchanged
R_X86_64_PLT32targetL + A - P — displacement to the PLT entryCalls to functions that may live in another shared objectLets the call be a direct branch even when the target is not yet known
R_X86_64_GOTPCRELtargetG + GOT + A - P — displacement to this symbol's GOT slotData references that may resolve into another shared objectOne indirection buys a single writable slot per symbol instead of many patched sites
R_X86_64_RELATIVEtargetB + A — load base plus a constantEvery absolute pointer inside a PIE or shared objectApplied by the loader at every start-up, and the reason a large PIE has thousands of load-time relocations

Why position-independent code exists

typicalThat PIE is the default is typical of current Linux distributions, macOS and Windows, largely because ASLR depends on it. It is a distribution and toolchain default rather than a compiler law: -no-pie remains available, and on 32-bit x86 the cost was high enough that non-PIE was standard for much longer. Measure before assuming the overhead is negligible on your workload.

A shared library is loaded at whatever address is free, and the same library is loaded at different addresses in different processes. If its code contained absolute addresses, every one of them would have to be patched at load time, per process — which would make the code pages private and writable, destroying the one property that makes shared libraries worth having: that one physical copy of .text is shared by every process using it.

Position-independent code solves this by referring to everything relatively. A call to a function in the same module is a PC-relative branch: the distance between two things in one module is fixed no matter where the module lands. A reference to a global variable is PC-relative to a slot in the *global offset table*, which lives in a writable data page — so the only thing needing per-process patching is one word of data, not a code page.

That is the whole design. All the load-time patching is concentrated into a table of pointers in a writable section, and the code that reads that table is itself position-independent. Address-space layout randomisation then becomes cheap: the loader can place a module anywhere and only fix up a data table, which is why PIE executables are the default on essentially every modern platform.

The same global access, non-PIC and PIC (x86-64)
1; non-PIC: the address is an immediate baked into the instruction
2 mov eax, DWORD PTR counter[rip] ; assembler still uses rip here, but
3 mov eax, DWORD PTR ds:0x404028 ; a true absolute form looks like this
4 ; -> R_X86_64_32S, a fixed address. The module cannot move.
5
6; PIC: one indirection through the GOT
7 mov rax, QWORD PTR counter@GOTPCREL[rip] ; load the ADDRESS of counter
8 ; -> R_X86_64_GOTPCREL: displacement from here to counter's GOT slot
9 mov eax, DWORD PTR [rax] ; then load the value
10 ; the GOT slot is filled in by the loader; the code is unchanged

Count the memory accesses: PIC pays one extra load per access to a symbol that might come from another module. That is the running cost of position independence, and it is why -fno-pie still exists for workloads that measure it. Note also that x86-64 gave rip-relative addressing a full addressing mode precisely because 32-bit x86 had no cheap way to do this, and its PIC code had to call a helper to discover its own address.

The PLT, and why calls are indirect but look direct

A call to a function in another shared object has the same problem as a data reference, plus one more: the target address is not known until the loader has resolved the symbol, and resolving every symbol in every library at start-up is expensive when most are never called.

The procedure linkage table solves both. Each imported function gets a small PLT stub inside the calling module, and every call site branches to that stub with an ordinary PC-relative call — so call sites need no per-process patching at all. The stub jumps through a GOT slot. Initially that slot points back into the PLT at code that calls the resolver, which looks up the real address, writes it into the GOT slot, and jumps there. Every subsequent call goes straight through the now-filled slot.

That is lazy binding: the resolution cost is paid once, on first call, and only for functions actually called. It is also a writable code-adjacent table, which is a security concern — an attacker who can write a GOT slot redirects every future call through it. The mitigation is full RELRO (-Wl,-z,relro,-z,now), which resolves everything eagerly at load and then makes the GOT read-only, trading start-up time for the removal of the attack surface. [[symbol-resolution-order]] covers the resolution policy this machinery implements.

One call to a shared-library function, first time and thereaftertarget
  1. Call sitebuild time
    An ordinary PC-relative call to a local PLT stub.
    A fixed target inside this module, so no call site ever needs patching.
  2. PLT stubload time
    A jump through this symbol's GOT slot.
    One level of indirection, which is the only place the real address ever appears.
  3. First call: unresolved slotrun time
    The GOT slot points back into the PLT, at code that pushes an index and calls the resolver.
    A hook for the dynamic loader to do the lookup on demand.
  4. Resolverrun time
    The loader searching its scope for the symbol.
    The real address, written into the GOT slot.
    Any chance of the call being predicted well on this first execution.
  5. Subsequent callsrun time
    The same PLT stub jumping through a now-filled slot.
    Steady state: one extra indirect jump per call, forever.

Read it asThe steady-state cost is the last row: one indirect branch per cross-module call that a static link would have made direct. It is small and it is not zero, and it is one of the concrete reasons [[static-linking]] can be measurably faster. The first four rows are the start-up cost, and full RELRO moves all of it to load time in exchange for a read-only GOT.

When a relocation does not fit

A 32-bit PC-relative displacement reaches ±2GB. That is enormous until it is not: a binary with a very large .bss, a program with several gigabytes of static data, or a code model that places code and data far apart can put a target out of reach. The linker then fails with relocation truncated to fit, which is a genuinely correct thing to do — silently truncating would produce a branch to an arbitrary address.

The fix is a code model: -mcmodel=medium or -mcmodel=large on x86-64 tell the compiler to stop assuming everything is within 2GB and to materialise full 64-bit addresses, at the cost of more instructions and larger code. The small model is the default because it is right for essentially every program and the cost of the alternatives is real.

The same class of error appears on architectures with much tighter reach. AArch64's bl encodes a 26-bit offset, reaching ±128MB, and large binaries genuinely exceed it — at which point the linker inserts *veneers* (also called thunks): small trampolines within reach that perform a longer jump. That is a linker synthesising code, which is worth knowing about because it shows up in disassembly as functions nobody wrote.

  • relocation truncated to fit means the computed displacement did not fit the field. It is a reach problem, not a missing symbol.
  • Code models (-mcmodel=small|medium|large) change what the compiler assumes about reach, trading instruction count for range.
  • Linkers insert veneers or thunks when a branch cannot reach, which is why disassembly sometimes contains trampolines with synthetic names.
  • A relocation against a symbol in a shared object that requires a text relocation forces the code page to be writable, which most linkers now refuse by default.
  • The number of load-time R_*_RELATIVE relocations is a real start-up cost for large PIEs, and is what prelinking and RELR compression address.

How it works

The steps, in the order the compiler takes them.

  • The compiler emits a placeholder — usually zeros — wherever an address or a displacement would go, and records a relocation naming the offset, the symbol, the type and any addend.
  • The linker assigns final addresses to every section and hence to every symbol.
  • For each relocation it evaluates the type's formula over the symbol address, the patch location, the addend and the GOT or PLT base as required.
  • It checks that the result fits the field's width and alignment, failing the link rather than truncating, and inserting a veneer where the architecture and linker support one.
  • It writes the value into the target bytes, completing the code.
  • For a dynamic executable or shared object it leaves a residual set of relocations — RELATIVE entries for internal pointers, GLOB_DAT and JUMP_SLOT entries for the GOT — for the loader to apply once the actual load address and symbol bindings are known.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • The link fails with relocation truncated to fit: R_X86_64_PC32, and nothing in the source suggests a size problem — a large static array pushed a target beyond 2GB of reach.
  • A link fails with a demand for -fPIC when building a shared library, because an object compiled without it contains absolute relocations that would require the code page to be writable.
  • A program starts noticeably slowly and the time is in the dynamic loader, applying tens of thousands of RELATIVE relocations across a large PIE and its dependencies.
  • A GOT entry is overwritten by a memory-safety bug and every subsequent call to that function goes to attacker-chosen code, with the crash — if any — far from the corruption.
  • Performance regresses after enabling PIE, because every access to a global in a hot loop acquired an extra load through the GOT.
  • Disassembly contains small unnamed trampoline functions nobody wrote, and they turn out to be linker-inserted veneers for out-of-reach branches.

When it helps

  • Understanding why shared libraries can be shared at all: the answer is entirely that PIC keeps the load-address dependence out of the code pages.
  • Diagnosing link failures that mention relocation types, which are otherwise opaque and are actually saying something precise about reach or about position independence.
  • Reasoning about start-up time and about the security posture of a binary, both of which are dominated by how many relocations remain for the loader and whether the GOT stays writable.

When it hurts

  • Micro-optimizing around PIC without measuring. The extra indirection is real and is usually far below the noise floor of anything else in the program.
  • Treating relocation types as memorisable. There are dozens per architecture, they are documented in the psABI, and looking one up is faster and more reliable than recalling it.

What it costs

Every one of these is paid by something.

  • Position-independent code buys shared, read-only code pages and cheap address-space randomisation, and costs an extra indirection on cross-module symbol access plus a register's worth of addressing pressure on architectures without a PC-relative addressing mode.
  • Lazy binding buys start-up time proportional to what is actually called, and costs a writable GOT — an attack surface — plus unpredictable first-call latency, which matters for real-time and latency-sensitive code.
  • Full RELRO buys a read-only GOT and removes that surface, and costs resolving every symbol at load whether it will be used or not, which is measurable start-up time for a program with many dependencies.
  • A larger code model buys reach beyond 2GB and costs instruction count and code size on every affected reference, which is why the small model remains the default.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Static linking with a fixed load address, which removes relocations entirely at the cost of no sharing, no ASLR for the main image, and rebuilding for any library change. See [[static-linking]].
  • Load-time rebasing without PIC, as classic Windows DLLs did: patch the absolute addresses at load if the preferred base is taken. It works and makes the patched pages private, which is exactly what PIC avoids.
  • Prelinking, which computes and caches relocations ahead of time so start-up skips them. It conflicts with ASLR, which is why it has largely been abandoned in favour of compressed relocation formats such as RELR.
  • Formats that are position-independent by construction, such as WebAssembly modules, where there are no addresses in the module at all and the whole question does not arise — see [[wasm-model]].

See it for yourself

The flag, dump or tool that shows you this directly.

  • See the records: objdump -r file.o lists relocations; objdump -dr file.o interleaves them with the disassembly, which is where they make sense.
  • See what is left for the loader: readelf -r app on a dynamic binary shows the RELATIVE, GLOB_DAT and JUMP_SLOT entries; count them to estimate start-up relocation cost.
  • See lazy binding happen: LD_DEBUG=bindings ./app prints every symbol as it is resolved, and LD_BIND_NOW=1 ./app resolves everything at start-up instead.
  • Check the hardening posture: checksec --file=app reports PIE, RELRO and whether the GOT is read-only.
  • Provoke the reach failure deliberately: declare a multi-gigabyte global array and build without a larger code model. The error names the relocation type that did not fit.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Relocations are a legacy mechanism from before virtual memory." Virtual memory gives each process its own address space and does not tell a module where in it to sit. Shared libraries and ASLR both require exactly this machinery.
  • "PIC means the code is slower everywhere." It costs an indirection on cross-module symbol access. Calls and data within the module are PC-relative and cost nothing extra on architectures with a PC-relative addressing mode.
  • "The PLT exists to make calls faster." It exists so that call sites do not need patching and so resolution can be deferred. It makes each call marginally slower than a direct one would be.
  • "relocation truncated to fit means a missing symbol." It means the symbol was found and the distance to it did not fit the field. It is a layout and reach problem.

Misconceptions

The claim, and what is actually true.

The compiler leaves addresses blank because it is lazy.
It cannot know them. Addresses do not exist until every translation unit has been laid out together, which is the first thing the linker does that nothing before it could.
A shared library is loaded at a fixed address decided when it was built.
It is loaded wherever there is room, differently in each process, which is exactly why the code must not contain absolute addresses.
The GOT is part of the code.
It is a writable data table. Keeping it out of the code is what allows the code pages to stay read-only and shared between processes.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

When the compiler needs an address it does not know, it writes zeros and leaves a note. The linker, once it has decided where everything goes, reads the notes and fills in the blanks. Some blanks record a fixed address and some record a distance — and using distances is what lets a library be loaded anywhere, because the distance between two things inside it never changes.

practical

objdump -dr is the command that makes this concrete: zero displacements with relocation lines under them are the holes. For a shipped binary, readelf -r shows what the loader still has to do and checksec shows whether the GOT stays writable. If a link fails with "truncated to fit", you have a reach problem and want a larger code model; if it demands -fPIC, an object destined for a shared library was compiled without it.

advanced

The interesting design question is where to put the load-address dependence, because it cannot be eliminated — something must know where the module landed. Non-PIC puts it in the code, which is fast and makes the pages private and unrandomisable. PIC puts it in a data table, which keeps code shared and pays an indirection. Prelinking tried to move it to install time and collided with ASLR, since a fixed pre-computed base is exactly what randomisation forbids. RELR compresses the residual relative relocations, attacking the start-up cost without changing the model. Each of these is the same quantity of information relocated between build time, install time, load time and run time, with a different cost profile at each, and that is a pattern worth recognising: much of systems design is deciding at which of those four moments a piece of information gets fixed.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

targetAll relocation type names and formulas are x86-64 ELF. AArch64 needs several instructions and several relocation types to build a 64-bit address and has a 26-bit branch reach of ±128MB rather than x86-64's ±2GB; Mach-O and PE use different record structures entirely. The concept of a location, a symbol, a form and an addend is what transfers.
typicalThat PIE and lazy binding are the defaults is typical of current mainstream Linux distributions, with macOS requiring PIE and Windows using a different but analogous rebasing model. Defaults have changed repeatedly — full RELRO and -z now are increasingly default in hardened distributions — so check checksec on the binary rather than assuming.
simplifiedOur own backend emits no relocations at all and its assembly refers to symbols by name with no notion of address, which is one of several reasons it would not assemble. Everything in this lesson is what a real assembler and linker do with the output ours only pretends to produce.

If you were asked this in an interview

  • Why can a shared library be loaded at a different address in every process without patching its code pages?
  • What is the GOT for, what is the PLT for, and why are they two tables rather than one?
  • You get relocation truncated to fit. What has gone wrong and what are the fixes?

Connections

Domains that do not exist yet
  • Programming Languages & Runtime Internals — Patching code at runtime: inline caches, JIT stub rebinding and hot patching
    A JIT does the same job as a relocation — write an address into a placeholder — but at execution time and repeatedly, which raises coherence and concurrency questions a static linker never faces. The mechanism is the same; the runtime half of it is owned there.