Back to course

Relocations

20 min · Module 2: Symbols and Relocations · Algorithms

Why This Matters

Every relocation is one unanswered question. "At offset 0x56 inside .text, the 4-byte value currently zero should become the address of the thing called g_data." Ten bytes, and that is the entire mechanism by which a compiler hands layout decisions to a linker.

They are also the last place in this course where a parser can be subtly, silently wrong — because the COFF specification documents a packed layout for x64 that the compiler does not actually emit, and a reader that implements the documented version gets a type of zero for every record in the file.

A Simple Model

Three fields, ten bytes, answering three separate questions:

  1. Where?VirtualAddress — an offset within the section this table belongs to, not a file offset and not a virtual address. Both of those would be meaningless in a relocatable file.
  2. What?SymbolTableIndex — an index into the symbol table, not a name. The relocation never spells out a symbol name.
  3. How?Type — a machine-specific number saying what arithmetic to perform and how wide the result is.

Two things follow from that, and they are the whole design. First, relocations belong to a section, so each section header points at its own table and carries its own count. Second, the type numbering is per-machine, so a reader that knows the Machine field knows which table of type numbers applies — and a reader that ignores it will decode an i386 object using the AMD64 table and produce plausible nonsense.

The Actual Detail

The .text relocation table: three records of ten bytes at file offset 0x1E8, immediately after the 108 bytes of code they describe.

000001e8: 5600 0000 1700 0000 0400   addr 0x56  sym 23  type 4
000001f2: 5d00 0000 1400 0000 0400   addr 0x5d  sym 20  type 4
000001fc: 6200 0000 1500 0000 0400   addr 0x62  sym 21  type 4

And llvm-objdump -r reading the same bytes:

RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE                     VALUE
0000000000000056 IMAGE_REL_AMD64_REL32    g_data
000000000000005d IMAGE_REL_AMD64_REL32    add
0000000000000062 IMAGE_REL_AMD64_REL32    call_through

Cross-referencing the symbol indices against the table from the symbol concept: 23 is g_data, 20 is add, 21 is call_through. Every field agrees.

So the verified layout is the plain one:

OffsetSizeFieldNote
0x004VirtualAddressoffset within the owning section
0x044SymbolTableIndexlow 16 bits used
0x082Typemachine-specific numbering

The type numbers you will actually meet

On IMAGE_FILE_MACHINE_AMD64, the types in our file are 1, 3 and 4:

ValueNameMeaningWhere we saw it
0IMAGE_REL_AMD64_ABSOLUTEno operation; used as paddingnot present
1IMAGE_REL_AMD64_ADDR64write the 8-byte absolute address.data, 2 records
2IMAGE_REL_AMD64_ADDR32write the 4-byte absolute addressnot present
3IMAGE_REL_AMD64_ADDR32NB4-byte address, subtract the image base.pdata, 9 records
4IMAGE_REL_AMD64_REL3232-bit relative displacement.text, 3 records

The distinction that matters most is REL32 versus everything else. REL32 is a displacement: the linker computes target - (address of this field + 4) and writes that, which is exactly what a call or jmp instruction with a 32-bit relative operand needs. It is position-independent within the section, which is why code can move without being rewritten. Everything else writes a bare address, which is why it is not position-independent and why the code holding it must be rebuilt if the image moves beyond 2 GB.

The NB in ADDR32NB means "not based": the linker subtracts the image base, storing a relative address in a field that looks absolute. The .pdata records are all of this type, pointing at .text and .xdata from an exception-unwinding table — data that references code, and therefore needs to survive rebasing.

A Real Example

Decode all fourteen relocations in the file, then confirm against both tools. This is the table every claim in this concept rests on:

SectionOffsetSymbolTypeMeaning
.text0x5623 g_data4REL32 — the mov that loads the global
.text0x5D20 add4REL32 — the call add
.text0x6221 call_through4REL32 — the call call_through
.data0x0812 the string literal1ADDR64 — g_ptr, an 8-byte pointer
.data0x1023 g_data1ADDR64 — g_addr, an 8-byte pointer
.pdata0x000 .text3ADDR32NB
.pdata0x040 .text3ADDR32NB
.pdata0x086 .xdata3ADDR32NB
.pdata0x0C0 .text3ADDR32NB
.pdata0x100 .text3ADDR32NB
.pdata0x146 .xdata3ADDR32NB
.pdata0x180 .text3ADDR32NB
.pdata0x1C0 .text3ADDR32NB
.pdata0x206 .xdata3ADDR32NB

Every row was produced by an independent parser and required to match llvm-readobj --relocations, which agrees on all fourteen offsets, symbol indices and types. The file has 1200 such records in total across the five sample objects, and all of them matched.

Read the .pdata group as a pattern: 3 RUNTIME_FUNCTION entries, each 12 bytes, each needing a .text address and a .xdata address. Two relocations per entry, nine in total because the .pdata section is 36 bytes and the last entry's second field is covered by the section's own extent. The triple of 0x00, 0x04, 0x08 is one entry; 0x0C, 0x10, 0x14 is the next; 0x18, 0x1C, 0x20 is the third.

The second specification-versus-reality discrepancy

The PE/COFF specification documents an overlapping x64 relocation layout: bits 0–11 of the first dword are the offset, bits 12–15 of that same dword are the type, and bits 16–31 of the second dword are the symbol index. Under that reading, our first record — bytes 56 00 00 00 17 00 00 00 04 00 — decodes as:

overlapping reading: offset = 0x56 (low 12 bits, fine)
type = 0x00 (bits 12-15 are zero!) -> ABSOLUTE
symbol = 0x00 (bits 16-31 of 0x00000017) -> symbol 0

Every one of the fourteen records decodes that way to IMAGE_REL_AMD64_ABSOLUTE, symbol 0 — the padding type, referencing nothing. That is obviously wrong, and it is the signal that the reading is wrong rather than the data.

The plain reading gives offset 0x56, symbol 23, type 4 — which matches llvm-objdump exactly, including the REL32 that a call instruction obviously requires. clang emits the plain layout, and so does every object in this course.

This is recorded as an observed discrepancy, not as a claim that the specification is wrong. Some documentation and some linkers do describe the packed form, and a tool that has to read any COFF file should know both exist. But a reader built only to the documented variant will report fourteen zero relocations in this object, and nothing in the file will tell it that something is off.

Try It Yourself

$ clang --target=x86_64-pc-windows-msvc -c sample.c -o sample_msvc.obj
$ llvm-objdump -r sample_msvc.obj
$ xxd -s 0x1e8 -l 32 sample_msvc.obj
$ llvm-readobj --relocations sample_msvc.obj | head -20

Do both decodings and watch one of them fail. This is a five-minute experiment that is worth more than the table above:

# The reading that FAILS on real files (documented, not emitted):
type   = (dword0 >> 12) & 0xF
offset = dword0 & 0xFFF
symbol = (dword1 >> 16) & 0xFFFF
#   -> type 0 (ABSOLUTE) and symbol 0, for every record

# The reading that matches the bytes:
offset = dword0
symbol = dword1 & 0xFFFF
type   = word2
#   -> 0x56/23/4, 0x5d/20/4, 0x62/21/4

Then confirm that the type numbering really is per-machine, by looking at the same source compiled for i386:

$ clang --target=i686-pc-windows-msvc -c sample.c -o sample_m32.obj
$ llvm-objdump -r sample_m32.obj | head -6
RELOCATION RECORDS FOR [.text]:
0000000000000057 IMAGE_REL_I386_DIR32     _g_data
000000000000005d IMAGE_REL_I386_DIR32     _add
0000000000000069 IMAGE_REL_I386_REL32     _call_through

Different type names and different numbers for the same source constructs, and a leading underscore on every symbol because i386 uses the cdecl decoration that x64 dropped. The container is byte-identical in layout; Machine is what tells you which dialect of the type table applies.

Check Your Understanding

Without looking back: a .text relocation record reads addr 0x5D, symbol 20, type 4. Which of the three fields is a section-relative offset rather than a file offset or a virtual address, and what does type 4 mean the linker will do?

Apply It

You ship a COFF-reading library used on both x64 and ARM64 builds. It decodes relocation types with a single hard-coded table of AMD64 numbers. On the ARM64 objects it reports plausible types and produces a linked image with silently wrong pointers. What is the actual defect, and what is the minimum correct design?

The general habit: a numeric code inside a file is only meaningful relative to a declared context. If the file says what it is, read that first — and if you meet a code you have no table for, say so instead of falling back to a nearby one.

Connect

This is the ELF relocation table with a different container, and the comparison is instructive. ELF keeps relocations in a section (.rela.text), typed by a section index, and the symbol table gives the name. COFF keeps them in a side table that the section header points at, typed by a machine-specific number, and the symbol table gives an index. Same job, and ELF's version is easier to extend — a new relocation type is a new section — while COFF's is a new number in a table that has to be kept in sync with every machine.

The REL32 idea is the same one the ELF memory mapping concept relies on: code that stores a displacement rather than an address can be placed anywhere. PE base relocations exist precisely because some data cannot be relative, and ADDR32NB is the COFF-object half of that same problem.

One mechanism remains, and it is the one that makes C++ linking work at all.

Next: COMDAT and Duplicate Sections — how a linker is told which of two identical sections to keep.