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.
How does a call with four zero bytes in it turn into a working call, and what are the GOT and the PLT for?
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.
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 fitis 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
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.
| Type | Computes | Used for | Consequence |
|---|---|---|---|
R_X86_64_64target | S + A — a full 64-bit absolute address | Pointers in data: vtables, jump tables, initialised function pointers | The value depends on the load address, so it must be re-patched if the object moves |
R_X86_64_PC32target | S + A - P — a 32-bit signed displacement from here | Calls and data references within one module | Load-address independent: if the whole module moves, the distance is unchanged |
R_X86_64_PLT32target | L + A - P — displacement to the PLT entry | Calls to functions that may live in another shared object | Lets the call be a direct branch even when the target is not yet known |
R_X86_64_GOTPCRELtarget | G + GOT + A - P — displacement to this symbol's GOT slot | Data references that may resolve into another shared object | One indirection buys a single writable slot per symbol instead of many patched sites |
R_X86_64_RELATIVEtarget | B + A — load base plus a constant | Every absolute pointer inside a PIE or shared object | Applied by the loader at every start-up, and the reason a large PIE has thousands of load-time relocations |
Why position-independent code exists
-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.
1; non-PIC: the address is an immediate baked into the instruction2 mov eax, DWORD PTR counter[rip] ; assembler still uses rip here, but3 mov eax, DWORD PTR ds:0x404028 ; a true absolute form looks like this4 ; -> R_X86_64_32S, a fixed address. The module cannot move.5 6; PIC: one indirection through the GOT7 mov rax, QWORD PTR counter@GOTPCREL[rip] ; load the ADDRESS of counter8 ; -> R_X86_64_GOTPCREL: displacement from here to counter's GOT slot9 mov eax, DWORD PTR [rax] ; then load the value10 ; the GOT slot is filled in by the loader; the code is unchangedCount 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.
- Call sitebuild timeAn ordinary PC-relative
callto a local PLT stub.A fixed target inside this module, so no call site ever needs patching. - PLT stubload timeA jump through this symbol's GOT slot.One level of indirection, which is the only place the real address ever appears.
- First call: unresolved slotrun timeThe 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.
- Resolverrun timeThe 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.
- Subsequent callsrun timeThe 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 fitmeans 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_*_RELATIVErelocations is a real start-up cost for large PIEs, and is what prelinking andRELRcompression 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 —
RELATIVEentries for internal pointers,GLOB_DATandJUMP_SLOTentries 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
-fPICwhen 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
RELATIVErelocations 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.olists relocations;objdump -dr file.ointerleaves them with the disassembly, which is where they make sense. - See what is left for the loader:
readelf -r appon a dynamic binary shows theRELATIVE,GLOB_DATandJUMP_SLOTentries; count them to estimate start-up relocation cost. - See lazy binding happen:
LD_DEBUG=bindings ./appprints every symbol as it is resolved, andLD_BIND_NOW=1 ./appresolves everything at start-up instead. - Check the hardening posture:
checksec --file=appreports 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 fitmeans 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.
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.
-z now are increasingly default in hardened distributions — so check checksec on the binary rather than assuming.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
- Programming Languages & Runtime Internals — Patching code at runtime: inline caches, JIT stub rebinding and hot patchingA 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.