Back to course

ARM64 Relocations

22 minadvancedModule 5: Other Targets and Other SectionsContinues from coff-comdat-linking
22 min · Module 5: Other Targets and Other Sections · Algorithms

Why This Matters

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.

A Simple Model

Same ten bytes per entry, same two fields, as Module 2 established:

VirtualAddress 4 bytes where in the section the fixup applies SymbolTableIndex 4 bytes which symbol it refers to Type 2 bytes what to compute and how wide the result is

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 propertyConsequence for a relocation
A branch immediate is 26 bits, and bits 25 and 26 are fixedDisplacements 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 PCA 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 numberOne form computes page − PCpage for a nearby page, the other uses an absolute 32-bit page number — and the relocation name says which
The consequence worth internalising. On x86-64 a relocation is a self-contained instruction: apply it and the instruction is done. On ARM64 some relocations are not self-contained — they are half of a two-instruction idiom, and the two halves are two separate relocation entries at adjacent offsets, applying to the same symbol. That is not a compiler quirk or a toolchain accident. It is a property of the instruction set, faithfully recorded, and a linker that applies the halves independently produces an address that is wrong in a way that looks like a plausible address.

The Actual Detail

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         wr

The 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.

The pair

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:

address = (page_of(target) - page_of(this_instruction)) * 4096 + (target and 0xfff) 21 bits of signed field 12 bits of unsigned field

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:

NameStands forFills
PAGEOFFSET_12AADRPthe low 12 bits of the absolute address
PAGEOFFSET_12Lliteral, from LDRthe 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 branch, and why 26 bits is a hard limit

The two BRANCH26 entries at 0x64 and 0x74 are the calls, and the type is a complete description of the computation:

displacement = (target - (address_of_this_instruction + 4)) / 4 value = displacement and 0x03ffffff (26 bits) then the linker verifies: (value sign-extended as 26 bits) * 4 must equal the displacement it just computed

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 section flags, for contrast

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.

A Real Example

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.)

instruction at 0x2000, target gvar at 0x2010 PAGEBASE_REL21 page_of(target) = 0x2010 / 0x1000 = 2 page_of(this insn) = 0x2000 / 0x1000 = 2 delta = 2 - 2 = 0 fits in 21 signed bits? yes -> the field receives 0 PAGEOFFSET_12L low 12 bits of 0x2010 = 0x010 fits in 12 unsigned bits? yes -> the field receives 0x010 the pair now encodes: page 2, offset 0x010 = 0x2010

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:

PAGEBASE_REL21 page_of(target) = 0x3008 / 0x1000 = 3 page_of(this insn) = 0x2ffc / 0x1000 = 2 delta = 3 - 2 = +1 page = +0x1000 in bytes PAGEOFFSET_12L low 12 bits of 0x3008 = 0x008 combined: 0x2000 + 0x1000 + 0x008 = 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:

PAGEBASE_REL21 page delta = (0x900000 / 0x1000) - (0x1000 / 0x1000) = 0x900 - 1 = 0x8ff = 2303 pages 21 signed bits holds -1048576 .. 1048575, so 2303 FITS still fine -- the relative form reaches 2048 pages = 8 MB either way

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.

Try It Yourself

$ 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 -30

Check Your Understanding

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?

Apply It

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.

Connect

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.