Back to course

Why There Are So Many Kinds

24 minadvancedModule 1: The VocabularyContinues from reloc-arch-contrast
24 min · Module 1: The Vocabulary · Advanced

Why This Matters

The ELF x86-64 ABI defines about forty relocation types. A newcomer’s reaction is usually that this is untidy, and the instinct to look for a tidier mental model is right — but the tidiness is not “there are really only three”, it is “a relocation is a verb applied to a symbol, and the verbs are few”.

Here is one file exercising three of them, built three ways:

/* g.c */
extern int g;
int  abs_val(void)   { return g; }               /* the VALUE   */
int *abs_addr(void)  { int *p = &g; return p; } /* the ADDRESS */
int  rel_val(void)   { return g + 1; }
int  call_ext(void);
int  do_call(void)   { return call_ext(); }
$ for m in "-fno-pic -fno-pie" "-fPIE" "-fPIC"; do
    clang -O1 $m -c g.c -o g.o
    printf "%-16s " "$m"
    llvm-objdump-21 -r g.o | grep -oE 'R_X86_64_[A-Z0-9_]+' | sort -u | tr '\n' ' '
    echo
  done

-fno-pic -fno-pie   R_X86_64_32  R_X86_64_PC32  R_X86_64_PLT32
-fPIE               R_X86_64_PLT32  R_X86_64_REX_GOTPCRELX
-fPIC               R_X86_64_PLT32  R_X86_64_REX_GOTPCRELX

Three builds, three relocations, four distinct types across the lot. And note that PIE and PIC produce the same vocabulary for this source — the difference between them is not in the relocation set, it is in what the loader may do with it. That is the next module, and it is worth holding in mind that relocation type and code model are different axes that the names happen to blur together.

A Simple Model

Group the x86-64 types by what arithmetic they perform. Once grouped this way the apparent chaos is four ideas:

1. ABSOLUTE -- write the symbol's ADDRESS into the field R_X86_64_64 8 bytes the address R_X86_64_32 4 bytes the address, TRUNCATED R_X86_64_32S 4 bytes the address, must FIT in 32 signed 32S exists so the linker can ERROR instead of silently truncating. A 64-bit address that does not fit in 32 bits is a bug, and 32S is how it gets caught. 2. RELATIVE -- write a DISTANCE from this field to the symbol R_X86_64_PC64 8 bytes S - P R_X86_64_PC32 4 bytes S - P The only difference is width, and the 32-bit one is the ±2GB story (next concept). 3. INDIRECTION -- write the address of a GOT SLOT R_X86_64_GOTPCREL / _GOTPCRELX the slot's address R_X86_64_GOTPC32 the CONTENTS of the slot R_X86_64_GOTOFF64 the offset of the symbol from the GOT R_X86_64_GOTPC64 the address of a GOT slot, 64-bit 4. DYNAMIC -- the LOADER fills these in, not the linker R_X86_64_RELATIVE base + the addend R_X86_64_GLOB_DAT the address of a global R_X86_64_JUMP_SLOT ditto, for a function R_X86_64_COPY make me a copy of this datum R_X86_64_TLSGD / GOTTPOFF / TPOFF32 thread-local

Group 4 is the one that surprises people, and it is a genuinely different category. Groups 1–3 are the linker’s job: it knows every address because it chose every address. Group 4 is the loader’s job, because the loader is the only party that knows where things will actually be in memory.

That is the real dividing line in the whole table, and it is exactly the gap the symbol-resolution course found from the other side. R_X86_64_PC32 is resolved by ld; R_X86_64_GLOB_DAT is resolved by ld.so at load time; and the same source file can contain both, which is why an object file’s relocation list mixes two different resolution mechanisms with no marker distinguishing them other than the type value itself.

Does It Actually Work?

Groups 1–3 verified by watching the same source change:

$ llvm-objdump-21 -d --no-show-raw-insn g_nopie.o
0000000000000000 <abs_val>:
   0:  8b 05 00 00 00 00    movl  (%rip), %eax      # PC32
0000000000000000 <abs_addr>:
   0:  48 8d 05 00 00 00 00 lea   (%rip), %rax      # PC32, not 32

Both are PC32. Taking the address of a global and reading its value are the same relocation, and the difference between them is entirely in the instruction that consumes it. This is worth pausing on, because it is the reverse of the AArch64 finding: there, the type encoded the width; here, the type says nothing about the access and the opcode says everything. Same file format, opposite philosophy, because the two ISAs have different amounts of redundancy to exploit.

Where the 32 form appears is the interesting part — it needs a genuine absolute address:

$ cat vio.c
extern int ext_data;
int *leak(void){ return &ext_data; }
$ clang -O1 -fno-pic -c vio.c -o vio_nopic.o
$ llvm-objdump-21 -r vio_nopic.o | sed -n '/.text/,/^$/p'
0000000000000001 R_X86_64_32   ext_data
$ llvm-objdump-21 -d vio_nopic.o | sed -n '/<leak>/,/ret/p'
   0:  b8 00 00 00 00    movl  $0x0, %eax

R_X86_64_32 at offset 1, and the instruction is movl $imm32, %eax. The immediate field of that opcode is the only place a 32-bit absolute address fits, and the relocation sits inside it at offset 1 rather than at 0. Two details in one line: the offset tells you which part of the instruction the linker is patching, and the choice of mov $imm32 rather than lea is what forced an absolute form at all.

Now group 4, which is the loader’s half, from a real linked binary:

$ readelf -rW prog | grep -oE 'R_X86_64_[A-Z_]+' | sort | uniq -c
      4 R_X86_64_GLOB_DAT
      2 R_X86_64_JUMP_SLOT
$ readelf -rW prog | grep GLOB_DAT | head -2
 3fb0  ... R_X86_64_GLOB_DAT  __libc_start_main@GLIBC_2.34 + 0
 3fd8  ... R_X86_64_GLOB_DAT  lib_data + 0

JUMP_SLOT for the two PLT functions, GLOB_DAT for the four data references, and not one PC32 in the list — because at this point every intra-executable reference has already been resolved by ld and is no longer a relocation. The relocation section of a linked binary contains only what the loader still owes. That is the cleanest possible demonstration that groups 1–3 and group 4 are resolved at different times by different programs.

A Real Example

Why the vocabulary is this shape rather than some tidier shape: three encoding facts, each of which forces a group of types.

  FACT                          FORCED RELOCATION FAMILY

  no absolute 64-bit             R_X86_64_64 / PC64
  addressing mode exists         (the only way to name an
                                  address is PC-relative or
                                  through the GOT)

  displacement is 32-bit         R_X86_64_PC32  and
  signed                         the whole jump-stub
                                  apparatus for anything
                                  beyond ±2GB

  the one mov that can take a    R_X86_64_REX_GOTPCRELX
  GOT operand was originally     (REX exists purely to
  a call opcode                  disambiguate this)

The third row is the one that makes the table look arbitrary until you know the history. On x86-64 the instruction that loads from a GOT slot was originally the CALL opcode. When the design gave it a second, entirely different meaning, every existing object file that contained the old meaning became undecodable — so the format added a REX prefix byte whose only job is to say “this particular encoding means mov now”.

That single historical accident is why the most common relocation in modern position-independent code is called R_X86_64_REX_GOTPCRELX, and why the X is there. The Object Files course decomposed the name; this concept supplies the reason the decomposition was necessary at all. Three of the four tokens in that name are historical scar tissue.

And it has a measurable cost. A REX prefix is a byte, so the GOT-relative form is 7 bytes where the plain RIP-relative form is 6 — which is why Position Independence found that a data reference at offset 0x04 moved to 0x05 under -fPIC, and everything after it slid. The instruction grew by one byte because of a design decision made to preserve compatibility with binaries from 2003.

Try It Yourself

$ cd courses/reloc/assets/samples
$ ./build_samples.sh 2>&1 | sed -n '/F2/,/F3/p'
$ python3 crosscheck.py 2>&1 | sed -n '/\[2\]/,/\[3\]/p'

Then classify rather than recall. Take these and put each in the right group, with the reason:

  R_X86_64_PC32          R_X86_64_32S
  R_X86_64_PLT32         R_X86_64_GLOB_DAT
  R_X86_64_REX_GOTPCRELX R_X86_64_RELATIVE
  R_X86_64_GOTOFF64      R_X86_64_COPY

  1. Which are resolved by ld and which by ld.so?
  2. Which two exist only because a 64-bit value might not
     fit in 32 bits, and what is the difference in how they
     report that?
  3. R_X86_64_GOTPCREL writes the ADDRESS of a slot.
     R_X86_64_GOTPC32 writes the CONTENTS of a slot.
     Which is a LINK-time operation and which is a
     LOAD-time one? (Neither answer is "both".)
  4. Find a relocation in your own toolchain's output that
     is in group 4 and that you have never thought about.

Question 2 is the one that pays. 32 and 32S compute the same value; the difference is entirely in what happens when the value does not fit. 32truncates and continues, producing a binary that runs and is wrong. 32Sfails the link. The S is a suffix that buys you a diagnostic, and it is the only difference — which is a good example of a format encoding a policy decision in a type name.

Connect

This concept and One Relocation, or Two are one argument from two ends. That one asked why the count differs between architectures and answered with reach. This one asks why the set is large and answers with the same fact plus the historical accident of the REX prefix. Read together they say: the relocation vocabulary is a fingerprint of the instruction set, including the parts of the instruction set that were accidents.

Forward in the module, The Limits That Shaped the Table takes the ±2GB boundary that appears in group 2 and follows it to its consequence: what a linker does when a reference is further away than one instruction can reach. That is where jump stubs appear, and it is the one place in this course where a relocation record causes the linker to invent an instruction rather than patch a field.

Into the symbol course, group 4 is the group that course already owned. The PLT and the GOT introduced JUMP_SLOT and GLOB_DAT as the two things the loader writes at startup, and Who Owns the Storage is entirely about when a GLOB_DAT becomes a COPY. This concept is the format-level statement of what that course showed at the runtime level: group 4 is the set of relocations that survive the linker precisely so that something later can decide them.

Back to the object files course, the connection is a correction of emphasis rather than of fact. The Fixup Record presented the relocation record as a mechanism and asked what each field is for. This course asks the prior question — why there is a type field at all — and the answer is that the type is not bookkeeping, it is the entire instruction to the patcher. A relocation applier that reads the offset and the addend and ignores the type is not a simplification; on AArch64 it silently produces wrong addresses, as the previous concept showed.

And the TLS concept later in this course is group 4’s awkward member, because R_X86_64_TLSGD is a relocation that cannot be resolved by writing a number into a field. The One Relocation That Calls the Loader is where the four-group model has to be amended, and it is worth noticing that the amendment is forced by thread-local storage rather than by anything about the CPU.