Here is a source file. Nothing about it is architecture-specific, and nothing about it is position-dependent — every symbol is extern, so there is nothing local to reason about:
/* v.c -- one extern datum of each access width, and a call */
extern int g_i; /* 4 bytes */
extern int *g_p; /* 8 bytes */
extern int g_arr[8]; /* element is 4 bytes */
extern char g_c; /* 1 byte */
void sink(int);
int use(void) {
sink(g_i); sink(*g_p); sink(g_arr[3]); sink(g_c);
return 0;
}
Compile it twice, once per architecture, both with -fno-pic, and count the relocations each datum reference needed:
$ clang -O1 -fno-pic -fno-pie -c v.c -o v_x86.o $ clang -O1 -fno-pic --target=aarch64-linux-gnu -c v.c -o v_arm.o
x86-64 emits one relocation per datum reference. AArch64 emits two. Not because AArch64 is worse engineered, and not because the language differs. Because the two architectures can address memory over different ranges, and a wider range costs an extra instruction, and an extra instruction needs its own relocation.
That is the whole concept in one sentence, and it has a consequence you will meet for the rest of this course: the relocation table is a property of the target, not of the program. A linker author does not implement “one relocation per reference”. They implement a vocabulary, and the vocabulary is whatever the target’s instruction set happens to need.
Before the numbers, the shape. A CPU can only put a limited distance into a single instruction. A reference to a symbol therefore has to be expressed as some instruction that can reach that symbol, and if the symbol is further away than one instruction can reach, you need more than one.
So the two-relocation pattern is a consequence of a wider per-instruction reach, not a worse design. AArch64 traded one extra instruction for the ability to address the whole address space from a single ADRP. If x86-64 had a ±4GB displacement field it would need one relocation too — the extra instruction is the price of the range.
And the corollary, which is the more useful half: if the reach were the same, the relocation counts would be the same. This is why you cannot reason about a relocation table without knowing the ISA, and why every tool that presents “the ELF relocations” as one list is quietly assuming x86-64.
The two dumps, side by side, and the pattern is immediate:
x86-64 aarch64
OFFSET TYPE VALUE OFFSET TYPE VALUE
0000..0003 R_X86_64_PC32 g_i-0x4 0000..0008 R_AARCH64_ADR_PREL_PG_HI21 g_i
0000..0008 R_X86_64_PLT32 sink-0x4 0000..000c R_AARCH64_LDST32_ABS_LO12_NC g_i
0000..000f R_X86_64_PC32 g_p-0x4 0000..0010 R_AARCH64_CALL26 sink
0000..0016 R_X86_64_PLT32 sink-0x4 0000..0014 R_AARCH64_ADR_PREL_PG_HI21 g_p
0000..001c R_X86_64_PC32 g_arr+0x8 0000..0018 R_AARCH64_LDST64_ABS_LO12_NC g_p
0000..0021 R_X86_64_PLT32 sink-0x4 0000..0020 R_AARCH64_CALL26 sink
0000..0028 R_X86_64_PC32 g_c-0x4 0000..0024 R_AARCH64_ADR_PREL_PG_HI21 g_arr+0xc
... 0000..0028 R_AARCH64_LDST32_ABS_LO12_NC g_arr+0xc
... 0000..002c R_AARCH64_CALL26 sink
... 0000..0030 R_AARCH64_ADR_PREL_PG_HI21 g_c
... 0000..0034 R_AARCH64_LDST8_ABS_LO12_NC g_c
Read the AArch64 column in pairs. ADR_PREL_PG_HI21 at 0x30, then LDST8_ABS_LO12_NC at 0x34. Four bytes apart, every time. That is one ADRP and one LDRB:
$ python3 crosscheck.py -v | grep -A2 'every LO12'
ok the two counts agree (one HI21 per LO12) (4, 4)
ok every LO12 sits exactly 4 bytes after its HI21 [4, 4, 4, 4]
Every single one. Not approximately — all four, checked programmatically. The pair is not a convention; it is the encoding, and the 4-byte gap is the width of the ADRP instruction.
Now the second finding, which is the more interesting one and the reason to read the AArch64 type names carefully:
g_p is a pointer -> R_AARCH64_LDST64_ABS_LO12_NC g_arr is an int element -> R_AARCH64_LDST32_ABS_LO12_NC g_c is a char -> R_AARCH64_LDST8_ABS_LO12_NC
Three different relocation types for “load a datum”, differing only in width. The x86-64 column has exactly one form, R_X86_64_PC32, for all three — because in an x86 instruction the width is already implied by the opcode, and the relocation does not repeat it.
That is the finding that generalises: the relocation vocabulary is a function of the instruction encoding. AArch64 has fewer addressing modes than x86-64 but records more per relocation, because each of its load/store instructions covers a size class and the low field has to say which. x86-64 has thousands of opcodes and a uniform displacement, so its relocations are correspondingly uniform.
What this costs in practice, because the two-relocation pattern is not free. A PIC AArch64 build of the same file:
$ clang -O1 -fPIC --target=aarch64-linux-gnu -c v.c -o v_arm_pic.o $ llvm-objdump-21 -r v_arm_pic.o | sed -n '/.text/,/^$/p' 0000000000000010 R_AARCH64_ADRP_PREL_PG_HI21 :got:g_i 0000000000000014 R_AARCH64_LDST64_ABS_LO12_NC :got:g_i:g_i 0000000000000018 R_AARCH64_RELR ...
The symbol name in each slot is the giveaway. :got:g_i is the GOT entry forg_i, and :got:g_i:g_i is the same slot read at a 64-bit width. Even in PIC, AArch64 needs two relocations per datum, because the GOT access is still a two-instruction sequence. x86-64 needs one, because its GOT access is a single mov.
So the count is a property of the architecture that survives the code model. That has a real consequence for tool authors: a relocation-processing loop written as “for each symbol, patch the reference” is wrong on AArch64, and wrong in a way that produces plausible-looking output rather than an error, because a half-patched ADRP/ADD pair is still a valid instruction encoding pointing at the wrong place. The relocations must be processed as a set with an ordering constraint, not individually.
One more thing worth noticing, and it is the asymmetry that trips people up: the call is one relocation on both architectures.R_X86_64_PLT32 and R_AARCH64_CALL26 are each a single PC-relative branch, because a call instruction encodes a 26-bit or 32-bit displacement in the instruction itself on both. Neither architecture needs a page/base split for control transfer, so neither pays the double cost.
$ cd courses/reloc/assets/samples $ ./build_samples.sh 2>&1 | sed -n '/F1/,/F2/p' $ python3 crosscheck.py 2>&1 | sed -n '/\[1\]/,/\[2\]/p'
Then establish the rule rather than memorising the table:
1. Add a double to v.c. Recompile for both. How
many relocations does the new reference get on each?
2. Add a `char*` (a pointer to a pointer). Which
LDST*_ABS_LO12_NC does AArch64 pick, and why is the
answer not "LDST128"?
3. Add a function call to a function DEFINED in the same
file (not extern). Does it get a relocation on
AArch64? Why is the answer different from the datum?
4. Find an x86-64 build where a datum needs TWO
relocations. (Hint: it needs a displacement that does
not fit in 32 bits. You will need -mcmodel=.)
Question 4 is the one that closes the loop, and it is the reason this concept is not merely an AArch64 curiosity. x86-64 does have a two-relocation case — the -mcmodel=large and -mcmodel=medium code models exist precisely because a 32-bit displacement is not always enough, and they use the same movabs-plus-add shape that AArch64 uses unconditionally. The pattern is universal; the threshold is what differs. x86-64 pays it rarely, AArch64 pays it always.
This is the opening concept of the course and it exists to reframe something the Object Files course had to state without being able to explain. The Fixup Record recorded that a linker implements “a vocabulary per target”, and Position Independence showed one x86-64 vocabulary changing under -fPIC. This concept supplies the reason the phrase exists: the vocabulary is dictated by how far one instruction can reach, and that is the one fact about an ISA that determines how many relocations a reference needs.
Into the rest of the module: Why There Are So Many Kinds takes the x86-64 half of this comparison and organises it — absolute against relative, and the three or four things a relocation can be asked to compute. The Limits That Shaped the Table is the same reach argument turned on x86-64 specifically, and it is where the ±2GB boundary and the jump-stub response live.
The connection into the symbol course is about ordering, and it is the practical consequence of the two-relocation case. The Algorithm, Measured showed that a linker is a demand-driven left-to-right pass, and it did not need to say in what order the relocations within one object are applied. This concept is where that order stops being arbitrary: an ADRP must be applied before the ADD that consumes it, so a linker that processes relocations in file order and assumes independence produces code that assembles cleanly and computes the wrong address. That is the same class of bug as the map-file reading in the symbol course: a tool that is silently order-dependent and reports success.
Finally, out to the platform layer. COFF Relocations measured that COFF packs the type into the top four bits of a 32-bit field and that the i386 target distinguishes DIR32 from REL32 where x86-64 does not. That is the same lesson from a third direction: the relocation table is a target-specific artifact, and three formats disagree about where the type even lives. Once you accept that the vocabulary follows the encoding, COFF’s four-bit type field stops looking like a space-saving trick and starts looking like a consequence — a target with few relocation kinds can afford a narrow field.