The relocations concept decoded the IMAGE_REL_I386_REL32 and IMAGE_REL_AMD64_REL32 families, and both of those are one instruction wide and mean the same thing: write a displacement. It would be easy to conclude that is what a COFF relocation is.
It is not. Those two are the easiest cases in the table, and they are easy precisely because x86-64 displacements are uniform: four bytes, relative, sign-extended. A different instruction set has different needs, and ARM64 needs things x86-64 never asks for. It has a 26-bit branch field that caps how far a bl can reach. It has a 12-bit immediate that can only address within a page, so reaching a symbol needs a second instruction. And it has two different ways to encode that second instruction, so the same relocation name means two different things depending on a letter in it.
So this concept does what the COFF course has done for every section: compile a real object for a real target and read the table. Five distinct relocation types come out of a file with four functions in it, and the interesting one is not any single type — it is the way two of them have to travel together.
Same ten bytes per entry, same two fields, as Module 2 established:
And the same structural observation from that concept applies with more force here: the two fields are independent, so a relocation's meaning is a function of both. The type says how to compute; the symbol says from what. A type table that is wrong for a target produces numbers, not errors.
The thing that changes across targets is the third field's repertoire, and it changes because of the instruction set. Three properties of ARM64 drive everything below:
| ARM64 property | Consequence for a relocation |
|---|---|
| A branch immediate is 26 bits, and bits 25 and 26 are fixed | Displacements are in units of 4 bytes and are signed 26-bit, so a call can reach ±128 MB and not one byte further |
| An unsigned 12-bit immediate offsets from the current PC | A data reference can only encode a 4 KB range, so anything further needs a second instruction holding the page number |
| There are two ways to form that page number | One form computes page − PCpage for a nearby page, the other uses an absolute 32-bit page number — and the relocation name says which |
Four functions, compiled for a Windows ARM64 target:
$ clang --target=aarch64-pc-windows-msvc -c arm64.c -o arm64.obj
$ llvm-readobj --relocations arm64.obj
Section (1) .text
0x00 IMAGE_REL_ARM64_PAGEBASE_REL21 gvar
0x04 IMAGE_REL_ARM64_PAGEOFFSET_12L gvar
0x18 IMAGE_REL_ARM64_PAGEBASE_REL21 gvar
0x1C IMAGE_REL_ARM64_PAGEOFFSET_12L gvar
0x38 IMAGE_REL_ARM64_PAGEBASE_REL21 arr
0x3C IMAGE_REL_ARM64_PAGEOFFSET_12A arr
0x64 IMAGE_REL_ARM64_BRANCH26 rd
0x74 IMAGE_REL_ARM64_BRANCH26 wrThe tool wraps that in a Relocations [ ] block, and the section is named because a relocation list that does not say which section it belongs to is not much use.
Five relocation types from four functions, and look at the offsets: 0x00 and 0x04, then 0x18 and 0x1C, then 0x38 and 0x3C. Three pairs, four bytes apart, each pair naming the same symbol. Then two lone branches. That is the whole shape of ARM64 relocation in one screen, and it is nothing like the x86-64 table.
Disassemble the first four bytes and the pairing stops being an observation:
0x00: PAGEBASE_REL21 gvar 0x04: PAGEOFFSET_12L gvar
Those are the two halves of a single address. PAGEBASE_REL21 fills a 21-bit signed field with the difference between the target's 4 KB page number and this instruction's page number — a relative page number, which keeps the immediate small and the code position-independent. PAGEOFFSET_12L fills a 12-bit unsigned field with the low 12 bits of the target's address, which is the offset within the page. Add them and you have the address:
Twenty-one bits of signed page delta reaches ±4 MB of pages, so about ±2 GB of reach, and the 12 bits cover the page itself. Between them, any 32-bit address — without a relocation for the absolute case, which is the whole reason the split exists.
And the third pair is the interesting one, because it uses a different opcode for the same job.0x38/code> and 0x3C are PAGEBASE_REL21 plus PAGEOFFSET_12A — the A variant, not the L one — and both name arr. The two suffixes are the difference:
| Name | Stands for | Fills |
|---|---|---|
PAGEOFFSET_12A | ADRP | the low 12 bits of the absolute address |
PAGEOFFSET_12L | literal, from LDR | the low 12 bits of the address, via a literal pool entry |
So the compiler chose ADRP plus a load for the array and ADRP plus a literal-pool load for the globals. Same relocation for the page number, different relocation for the offset, and the only thing distinguishing them is a letter inside the type name. A relocator with a table that maps PAGEOFFSET_12 to a single rule silently applies the wrong one to half the references in the file, and both results are valid 12-bit values.
The two BRANCH26 entries at 0x64 and 0x74 are the calls, and the type is a complete description of the computation:
Two things there are worth more than the arithmetic. The division by four is not optional, because the ARM64 branch field counts instructions, not bytes, and every ARM64 instruction is 4 bytes. A relocator that writes a byte displacement into a 26-bit field produces a call to a quarter of the intended offset. And the width is not a format decision at all — it is the instruction's own field size. The COFF table has 16 bits available for the type and uses them to say "26 bits, sign-extended, scaled by 4", because the format has to encode every target's branch widths in a shared namespace.
Which gives the type a real job beyond arithmetic: range checking. A 26-bit signed field scaled by 4 reaches ±128 MB. If the target is further away, no value fits, and the honest response is to fail the link. A relocator that truncates instead produces a call to a plausible address within 128 MB — and on a large program, that address is inside some other function. This is the one place in COFF relocations where a correct implementation must reject rather than approximate, and it is worth noting that the i386 and AMD64 tables have no equivalent obligation: their displacements are 32-bit and effectively always fit.
The same .text in an i386 object and an ARM64 object:
i386 .text char=0x60500020 arm64 .text char=0x60300020
Identical except in the alignment bits, which is the four-bit field from the characteristics concept. So a reader must take the alignment from the target's section header and must not assume it: the same section name, the same CNT_CODE, and a different alignment, purely because the target's preferred code alignment differs.
Applying the pair, by hand, to check the arithmetic a linker would do. Two pages: the instruction at 0x2000, the symbol at 0x2010. (Real numbers this time, from a linked image rather than an object, since a relocatable object has zeros.)
Now move the target across a page boundary, which is where the two fields start doing visibly different work. Instruction at 0x2ffc, target at 0x3008:
Both fields stay small: one bit of page delta, three bits of offset. That is the design working. Now the case that breaks it — instruction at 0x1000, target at 0x900000, eight and a half megabytes away:
So the relative form's range is symmetric and generous, and the arithmetic is simple. The case that genuinely cannot be encoded is a negative page delta beyond 2 MB, or a target more than 128 MB away for a branch — and there the correct behaviour is to fail.
Now the failure that is worth being able to recognise, because a relocator that misreads the A/L suffix produces it. Apply the L rule to a PAGEOFFSET_12A entry. For the arr pair at 0x38/code> and 0x3C, the 12-bit field is filled with the offset within the page — which is also what the L rule computes. So the two rules agree for the value and differ only in which instruction reads it, and the immediate consequence is that the value is right while the meaning is not.
That is the uncomfortable part, and it is the reason this concept is worth the trouble. A relocator cannot detect its own error here from the numbers: it wrote a valid 12-bit immediate, and the section still has the right size, and the link succeeded. The error surfaces only when the program runs, and it surfaces as a load from the wrong address. Which is the strongest argument in this whole module for range checking and for the checks a format gives you for free — a rule that says "26 bits, sign-extended, scaled by 4, and if it does not fit, fail" is a rule that converts a silent wrong answer into a build error, and no amount of care in the relocator's arithmetic can do that for a rule it does not know is conditional.
$ clang --target=aarch64-pc-windows-msvc -c arm64.c -o arm64.obj
$ llvm-readobj --relocations arm64.obj
$ llvm-objdump -d --section=.text arm64.obj | head -30adrp-free form. Each shape wants a different relocation, and the table you build from your own source is the one you will actually remember. Expect ADDR32, ADDR64, ADRL_IMMEDIATE and JUMP26 to turn up.ld supports no aarch64pe emulation — so do it in assembly by hand: assemble an adrp-based long jump, or compute the displacement yourself and show that no 26-bit value exists. Seeing the range limit as an arithmetic impossibility rather than a linker error is the point.0x00, 0x18 and 0x38 you should find three two-instruction sequences, and the third should use a different second instruction from the first two. Reading the actual opcodes is what turns "two relocations at adjacent offsets" into "an adrp and a load"..text is 0x60500020 on i386 and 0x60300020 on ARM64. Read the alignment out of each with the parser shipped with this course, and then check which alignment the ARM64 relocations actually require — a 4-byte-aligned instruction field inside a 16-byte-aligned section is a constraint the section header is not obliged to state.Without looking back: an object has relocations at 0x00 and 0x04, both naming the same symbol — the first IMAGE_REL_ARM64_PAGEBASE_REL21, the second IMAGE_REL_ARM64_PAGEOFFSET_12L. The instruction at 0x2000; after linking, the symbol is at 0x3008. What goes in each field, and why must a relocator treat the two entries as one operation rather than two?
You maintain a relocator that reads COFF objects for several targets. It is correct for i386 and x86-64 and has just been extended to ARM64. Objects for ARM64 now link without errors, but the program loads the wrong value from one global array and crashes somewhere unrelated. The same source, built for x86-64, works. Given this concept, what is your leading hypothesis, and what is the cheapest check that distinguishes it from the other two plausible causes?
The general habit: when a table-driven tool meets a new input, the most likely failure is a missing row rather than a wrong computation. Diff the key sets before you debug the arithmetic.
This is the payoff for the claim the relocations concept made and then had to hedge: that a single hard-coded table of relocation numbers is correct only for the target it was written for. The module's own exercise used an x64 reader meeting ARM64 objects as the illustration, and this concept is the demonstration. The lesson generalises past COFF — every object format has per-architecture relocation namespaces, and ELF's are separated by machine precisely so that a reader is forced to select rather than assume.
The pairing is the part that generalises too, and it is the same structural point as the COMDAT checksum in the COMDAT concept. A relocation here is not a self-contained request; it is half of a claim that only means something next to its partner. A linker that applies entries independently, without knowing which are related, gets no error — it gets two correct-looking numbers that do not add up to an address. That is the same failure mode as a relocator that trusts a checksum it never read, and it is worth noticing that both are cases where the format gave you the information and the consumer had to know to use it.
The BRANCH26 range check is the other end of the concept, and it connects to something the whole course has been circling. The link concept computed 0x401020 - 0x401012 = 0x0e and noted that a 32-bit displacement effectively always fits. Here the field is 26 bits, and it does not always fit. A relocation type is partly an arithmetic recipe and partly a promise about range, and the second half is the half that makes a linker safe: it is what turns "I could not represent this" into a build failure instead of a number.
Two more sections in this module, and neither is about relocations. One is a section that exists to carry text to the linker rather than code to the processor, and the other is the section that makes a variable's address depend on which thread is asking. Both are cases of COFF carrying information that is not part of the program.
Next: Thread Local Storage.