Back to course

The Object Relocation Sets

22 minadvancedModule 3: The FixupContinues from obj-addends
22 min · Module 3: The Fixup · Advanced

Why This Matters

The second concept introduced a relocation record and worked through one of them. It did not tell you how many there are, and the honest answer is that the number is the first thing that makes the mechanism look impractical:

  X86-64     44 relocation types
  AArch64   147 for LP64, plus 86 more for the ILP32 ABI = 233

Forty-four sounds like a lot to implement. It is not, and the reason it is not is the single most useful thing in this concept: the table is not a list, it is a classification. Every entry carries a small set of property flags, and an implementation switches on the flags rather than on the name. Forty-four entries collapse to about seven distinct behaviours.

This matters for the mission directly. A learner building a linker has to face this table eventually, and the difference between "147 cases to memorise" and "seven properties to combine" is the difference between a course that can be finished and one that cannot. So this concept is about the structure of the table, not its contents — and the contents are enumerated anyway, because the mission says teach everything, with a clear statement of which parts a linker must implement and which it can defer.

A Simple Model

First, where the authoritative list actually comes from, because that is a practical question with a specific answer. Two sources, and they must agree:

SOURCE 1: /usr/include/elf.h the system C header. 44 R_X86_64_ names. Plain enum, no metadata. Names only. SOURCE 2: llvm/BinaryFormat/ELFRelocs/X86_64.def llvm/BinaryFormat/ELFRelocs/AArch64.def A machine-checked table where each entry carries bit flags, and the code generator READS those flags. This is the source that tells you what each type MEANS.

That asymmetry is worth dwelling on, because it is a general lesson about where format truth lives. elf.h tells you the names and numbers. The .def file tells you the semantics. And the reason is that the names are what a programmer writes while the flags are what an implementation needs — so LLVM's table is shaped by the requirement that a code generator be able to switch on a property rather than match 147 strings.

The flags, decoded, and this is the whole concept in one table:

  flag  bit  name       what it tells an implementation
  ----  ---  ---------  ------------------------------------------
  REL   0x1  Relative   divide by the field width: the value
                         already stored in the bytes is an
                         offset, not a final value
  SYM   0x2  Symbolic   the result is a plain symbol address,
                         NOT divided by anything
  PC    0x4  PCRelative subtract the patch address
  TYPE  0x8  Type       the field holds an ADDRESS, so the
                         result must fit in the field or the
                         link must fail
  SIZE  0x10 Size        the relocation needs TWICE the field
                         space, because a second quantity has
                         to be encoded alongside
  TLS   0x20 TLS         the symbol is thread-local
  IREL  0x40 Indirect    the value is a resolver function's
                         address, patched at load time

Every one of those seven is a thing a linker has to decide, and the table exists so the linker does not have to decide it by matching names. That is the whole design. A linker implements switch (flags & PC) and switch (flags & TYPE) and switch (flags & REL), and the forty-four names never appear in the code.

The Actual Detail

The counts, and then the twelve entries a linker implements first, with their flags decoded from the .def file on this machine:

  ARCH     TOTAL   PC-RELATIVE   TLS
  ------  ------  -----------  ----
  X86-64      44           18    18
  AArch64     147           71    53   (LP64)
  AArch64      86            -     -   (P32 / ILP32, separate ABI)

  AArch64's 233 is two ABIs in one table, which is worth
  knowing before you conclude anything from the number.

And the twelve that matter first, on x86-64:

  TYPE                          NUM  FLAGS        what it does
  ---------------------------  ---  -----------  ---------------------
  R_X86_64_NONE                   0  none          a placeholder; do nothing
  R_X86_64_64                     1  REL           S + A, 8-byte field
  R_X86_64_PC32                   2  SYM           S + A - P, 4-byte
  R_X86_64_GOT32                  3  SYM+REL       offset from the GOT
  R_X86_64_PLT32                  4  PC            PC-relative, and
                                                  a call: may need a PLT
  R_X86_64_GOTPCREL               9  TYPE+REL      address of a GOT slot
  R_X86_64_32                    16  SIZE          absolute, must FIT
  R_X86_64_32S                   17  SIZE+REL      signed absolute
  R_X86_64_16                    18  SIZE+SYM      16-bit absolute
  R_X86_64_8                     20  SIZE+PC       8-bit PC-relative
  R_X86_64_PC64                  36  TLS+PC        PC-relative, 8 bytes
  R_X86_64_GOTOFF64              37  TLS+PC+REL    offset from the GOT

Three things in that table are worth more than the rest.

R_X86_64_PC32 is flagged SYM, not PC

The name says PC-32 and the flag says SYM. That looks like an error and it is not — the flags are LLVM's implementation classification, and they do not attempt to restate the mnemonic. The number after the mnemonic is the field width; the flags are the behaviour. Two different facts, two different places, and confusing them is the most common way to misread this table.

PLT32 carries the PC flag and PC32 does not

This is the closure of the finding from the second concept, and it is satisfying. That concept measured that R_X86_64_PC32 and R_X86_64_PLT32 perform identical arithmetic, with the only difference being a hint that a PLT entry might be needed. Here is where the difference lives: PLT32 is flagged PC and PC32 is not.

So the hint is not a separate boolean. It is the PC flag itself, and the reason is that on x86-64 a call is genuinely PC-relative in a way a data reference is not — which is also why PLT32 is the type that needs a PLT and PC32 is not. The mnemonic says PC and the flag says SYM because the name describes the field and the flag describes the relocation. Read both, and you have the whole type.

The SIZE flag on AArch64, and what it is really about

Now the one that explains the AArch64 doubling, and it is the best evidence in the whole course that the pairing is a real constraint rather than a convention:

  R_AARCH64_ADR_PREL_PG_HI21          275  SIZE+SYM+REL
  R_AARCH64_ADR_PREL_PG_HI21_NC       276  SIZE+PC
  R_AARCH64_ADD_ABS_LO12_NC           277  SIZE+PC+REL
  R_AARCH64_LDST32_ABS_LO12_NC        285  SIZE+TYPE+PC+REL
  R_AARCH64_LDST64_ABS_LO12_NC        286  SIZE+TYPE+PC+SYM
  R_AARCH64_CALL26                   283  SIZE+TYPE+SYM+REL
  R_AARCH64_JUMP26                    282  SIZE+TYPE+SYM
  R_AARCH64_ADR_GOT_PAGE              311  TLS+SIZE+PC+SYM+REL
  R_AARCH64_LD64_GOTOFF_LO15          310  TLS+SIZE+PC+SYM

Every single AArch64 relocation that participates in an ADRP/LO12 pair carries the SIZE flag. And the flag's meaning is "this relocation needs twice the field space, because a second quantity has to be encoded alongside."

That is the machine-checked form of the finding from the second concept. One reference to an address on AArch64 needs two relocations because the page delta and the page offset are two separate fields, and LLVM flags both halves as needing extra space precisely because they will have to be combined with their partner. The pairing is not a convention a code generator and a linker agree on by documentation; it is recorded in a bit that says this one is not independent.

And here is the trap a linker author must design around. A linker that treats relocations independently — that reads each record, computes, writes, moves on — produces a binary where half the AArch64 relocations have been computed against an incomplete expression. It links cleanly, because nothing in the object file is malformed. It faults at run time, on the first access to any global through a pointer, and only on AArch64. The SIZE flag is the format telling you that independence is the wrong model, and it is telling you in the only channel available: a bit in a table.

A Real Example

What a linker actually has to implement, which is a much shorter list than either table, and it is worth writing down because the gap between "147 types" and "nine cases" is the difference between a feasible and an infeasible project:

A LINKER MUST HANDLE, on any architecture: 1. absolute S + A into an address field 2. PC-relative S + A - P into a disp field 3. GOT-relative S + A - GOT for -fPIC data 4. PLT/call PC-relative, + PLT for calls 5. overflow check the TYPE flag: if the value does not fit the field, FAIL THE LINK rather than truncate 6. paired entries the SIZE flag: combine with a partner before writing 7. TLS the TLS flag: resolve through the thread-local block 8. IRELATIVE the IREL flag: a resolver address patched at load time 9. NONE the no-op, which must be recognised and skipped EVERYTHING ELSE in the 44 or 147 is a WIDTH or a SIGN VARIANT of one of these. R_X86_64_8, _16, _32, _64 and _32S are the same case with a different field size.

Which is the honest answer to "how do I implement 147 relocations", and the answer is: you implement nine and derive the rest. Step 5 deserves emphasis because it is the one a beginner forgets and the one that produces the worst class of bug: the TYPE flag exists because a 32-bit absolute relocation to an address outside 32 bits is not a truncation, it is a failure. A linker that silently truncates produces a binary that runs and reads the wrong memory; a linker that fails produces an error you can act on. Every SIZE-flagged absolute entry in the table — R_X86_64_32, R_X86_64_16, R_X86_64_8 — is a place where that check is mandatory, and they are flagged SIZE precisely so the check cannot be forgotten.

Where the table is genuinely large is TLS, and the numbers explain why: 18 of x86-64's 44 entries and 53 of AArch64's 147 are TLS. Thread-local storage is not one relocation, it is a family of about a dozen per architecture, because a thread-local address has several components — the thread pointer, an offset into the TLS block, a module offset, a symbol offset — and each instruction form combines a different subset. Compare that with the four non-TLS absolute and PC-relative cases. Relocation table size tracks instruction-set complexity in the area being addressed, not the number of addressing modes.

Which is why "read the waste to read the history" has a limit worth stating. A 44-entry table is not 44 design decisions; it is about nine decisions expanded by field width, by sign, and by TLS. And the TLS entries are the newest — they accumulate as thread-local models were standardised across architectures, which is the same pattern as the JVM attribute mechanism and the ELF section types. A format's table is a growth record, and the growth clusters where new capabilities arrived rather than spreading evenly.

Finally, the question a learner will ask and the answer is worth having ready: which of these are object-only? The answer is that the table is shared. The same R_X86_64_PC32 appears in a relocatable object and in a linked executable, and R_X86_64_32 is common in objects and forbidden in position-independent executables — where a build with -fPIE has no absolute relocations at all, and the presence of one is what control flow hardening checks for. So the table is not partitioned by file type; the compiler's choices determine which subset appears, and a linker must handle the union. That is a design fact worth holding, because it means a linker cannot assume it will only ever see the narrow set, and a PIE-aware linker has to reject the wide one deliberately rather than by omission.

Try It Yourself

$ grep -c R_X86_64_ /usr/include/elf.h
$ grep -cE '^ELF_RELOC' /usr/lib/llvm-21/include/llvm/BinaryFormat/ELFRelocs/AArch64.def

Check Your Understanding

Without looking back: an AArch64 object has R_AARCH64_ADR_PREL_PG_HI21_NC at offset 0 and R_AARCH64_LDST32_ABS_LO12_NC at offset 4, for one reference to a global. What does the SIZE flag on both of them tell a linker, what happens if the linker processes them independently, and why is the failure a run-time fault rather than a link error?

Apply It

You are writing the relocation-application pass of a linker. It reads each record, computes the value from the type's flags, and writes it, in list order, with no interaction between records. It is correct on every x86-64 object you have tested. On AArch64 it links without error and every pointer to a global is wrong, while calls to functions are correct. The SIZE flag is right there in the table and your code ignores it. What is the smallest change that makes the pass correct, and why is a per-record fix impossible rather than merely tedious?

The general habit: check the flags that say a record is not independent before deciding your pass can be a single loop over records. A record that needs a partner does not contain enough information to produce the right answer on its own — that is a stronger constraint than a missing ordering.

Connect

This concept is the payoff for the one before it and the one after. A Hole and a Record established that a relocation answers four questions and that the third — the arithmetic — is a small per-architecture enum. This is that enum, and the finding is that it is not an enum at all: it is a set of independent properties. That reframing is worth more than the table itself, because it is the difference between memorising 147 names and implementing seven flags.

The ELF course's relocation types concept teaches the same table for a single architecture, and the honest division of labour between the two is worth stating: that concept is the reference, and this one is the structure. You need this one to write a linker and the other one to read a manual. And the AArch64 pairing it explains is the same fact the second concept measured as "eight records where x86-64 needs four" — here it is a bit in a table rather than an observation about a file, which is the stronger form of the same claim.

The TYPE flag and the overflow check connect straight to the collapsed Relocations, PIC and PIE course, and to a security consequence rather than a correctness one. A 32-bit absolute relocation is incompatible with position independence, because the whole point of PIE is that the load address is not known until run time. So a PIE-aware toolchain must emit no absolute relocations, and the hardened-executable checks look for their presence as a signal that a binary is not position-independent. A flag in a relocation table is therefore also a security property, and that is the moment the format stops being about addresses and starts being about trust.

The TLS entries connect to the COMMON concept in an unexpected way, and it is worth naming. Both mechanisms exist because C has a declaration whose storage is decided somewhere other than where it is written: COMMON because the definition might be elsewhere, TLS because the storage is per-thread and therefore cannot be assigned at link time at all. One is resolved by merging and the other by indirection through a thread pointer, but both are the same underlying problem — a symbol whose address is not a constant, and therefore cannot be the answer to any relocation's arithmetic. A linker that implements COMMON and TLS is implementing the same idea twice, in two vocabularies.

And the growth story is the format-history story again, at a larger scale. 18 of x86-64's 44 entries and 53 of AArch64's 147 are TLS — a single capability, arriving after the core table was designed, and expanding it by a factor of three. That is precisely the pattern of the JVM's attribute mechanism, of ELF's section types, and of the mission's own observation that formats grow additively. A relocation table is a record of what the instruction set ended up needing, not of what anyone planned, and reading the distribution tells you which capabilities were hard to retrofit. On both architectures the answer is thread-local storage, which is a genuinely useful thing to know and is not written down anywhere in the specifications.

Next: position independence, which is where this table stops being a reference and becomes a design tool — because choosing which of these types to emit is what position independence is.