This post explores how GNU Assembler and LLVM integrated assembler generate relocations, an important step to generate a relocatable file. Relocations identify parts of instructions or data that cannot be fully determined during assembly because they depend on the final memory layout, which is only established at link time or load time. These are essentially placeholders that will be filled in (typically with absolute addresses or PC-relative offsets) during the linking process.
Relocation generation: the basics
Symbol references are the primary candidates for relocations. For
instance, in the x86-64 instruction movl sym(%rip), %eax
(GNU syntax), the assembler calculates the displacement between the
program counter (PC) and sym
. This distance affects the
instruction's encoding and typically triggers a
R_X86_64_PC32
relocation, unless sym
is a
local symbol defined within the current section.
Both the GNU assembler and LLVM integrated assembler utilize multiple passes during assembly, with several key phases relevant to relocation generation:
Parsing phase
During parsing, the assembler builds section fragments that contain
instructions and other directives. It parses each instruction into its
opcode (e.g., movl
) and operands (e.g.,
sym(%rip), %eax
). It identifies registers, immediate values
(like 3 in movl $3, %eax
), and expressions.
Expressions can be constants, symbol refereces (like
sym
), or unary and binary operators (-sym
,
sym0-sym1
). Those unresolvable at parse time-potential
relocation candidates-turn into "fixups". These often skip immediate
operand range checks, as shown here:
1 | % echo 'addi a0, a0, 2048' | llvm-mc -triple=riscv64 |
A fixup ties to a specific location (an offset within a fragment), with its value being the expression (which must eventually evaluate to a relocatable expression).
Meanwhile, the assembler tracks defined and referenced symbols, and
for ELF, it tracks symbol bindings
(STB_LOCAL, STB_GLOBAL, STB_WEAK
) from directives like
.globl
, .weak
, or the rarely used
.local
.
Section layout phase
After parsing, the assembler arranges each section by assigning
precise offsets to its fragments-instructions, data, or other directives
(e.g., .line
, .uleb128
). It calculates sizes
and adjusts for alignment. This phase finalizes symbol offsets (e.g.,
start:
at offset 0x10) while leaving external ones for the
linker.
This phase, which employs a fixed-point iteration, is quite complex. I won't go into details, but you might find Clang's -O0 output: branch displacement and size increase interesting.
Relocation decision phase
Then the assembler evaluates each fixup to determine if it can be resolved directly or requires a relocation entry. This process starts by attempting to convert fixups into relocatable expressions.
Evaluating relocatable expressions
In their most general form, relocatable expressions follow the
pattern relocatin_specifier(sym_a - sym_b + offset)
,
where
relocation_specifier
: This may or may not be absent. I will explain this concept later.sym_a
is a symbol reference (the "addend")sym_b
is an optional symbol reference (the "subtrahend")offset
is a constant value
Most common cases involve only sym_a
or
offset
(e.g., movl sym(%rip), %eax
or
movl $3, %eax
). Only a few target architectures support the
subtrahend term (sym_b
). Notable exceptions include AVR and
RISC-V, as explored in The
dark side of RISC-V linker relaxation.
Attempting to use unsupported expression forms will result in assembly errors:
1 | % echo -e 'movl a+b, %eax\nmovl a-b, %eax' | clang -c -xassembler - |
PC-relative fixups
PC-relative fixups compute their values as
sym_a + offset - current_location
. (I’ve skipped
- sym_b
, since no target I know permits a subtrahend
here.)
When sym_a
is a local symbol defined within the current
section, these PC-relative fixups evaluate to constants. But if
sym_a
is a global or weak symbol in the same section, a
relocation entry is generated. This ensures ELF symbol
interposition stays in play.
Resolution Outcomes
The assembler's evaluation of fixups leads to one of three outcomes:
- Error: When the expression isn't supported.
- Resolved fixups: When the fixup evaluates to a constant, the
assembler updates the relevant bits in the instruction directly. No
relocation entry is needed.
- There are target-specific exceptions that make the fixup unresolved.
In AArch64
adrp x0, l0; l0:
, the immediate might be either 0 or 1, dependant on the instructin address. In RISC-V, linker relaxation might make fixups unresolved.
- There are target-specific exceptions that make the fixup unresolved.
In AArch64
- Unresolved fixups: When the fixup evaluates to a relocatable
expression but not a constant, the assembler
- Generates an appropriate relocation (offset, type, symbol, addend).
- For targets that use RELA, usually zeros out the bits in the instruction field that will be modified by the linker.
- For targets that use REL, leave the addend in the instruction field.
If you are interested in relocation representations in different object file formats, please check out my post Exploring object file formats.
Examples in action
Branches
1 | % echo -e 'call fun\njmp fun' | clang -c -xassembler - -o - | fob -dr - |
Absolute and PC-relative symbol references
1 | % echo -e 'movl a, %eax\nmovl a(%rip), %eax' | clang -c -xassembler - -o - | llvm-objdump -dr - |
(a-.)(%rip)
would probably be more semantically correct
but is not adopted by GNU Assembler.
Relocation specifiers
Relocation specifiers guide the assembler on how to resolve and encode expressions into instructions. They specify details like:
- Whether to reference the symbol itself, its Procedure Linkage Table (PLT) entry, or its Global Offset Table (GOT) entry.
- Which part of a symbol's address to use (e.g., lower or upper bits).
- Whether to use an absolute address or a PC-relative one.
This concept appears across various architectures but with
inconsistent terminology. The Arm architecture refers to elements like
:lo12:
and :lower16:
as "relocation
specifiers". IBM's AIX documentation also uses this term. Many GNU
Binutils target documents simply call these "modifiers", while AVR
documentation uses "relocatable expression modifiers".
Picking the right term was tricky. "Relocatable expression modifier" nails the idea of tweaking relocatable expressions but feels overly verbose. "Relocation modifier", though concise, suggests adjustments happen during the linker's relocation step rather than the assembler's expression evaluation. I landed on "relocation specifier" as the winner. It's clear, aligns with Arm and IBM’s usage, and fits the assembler's role seamlessly.
For example, RISC-V addi
can be used with either an
absolute address or a PC-relative address. Relocation specifiers
%lo
and %pcrel_lo
could differentiate the two
uses. Similarly, %hi
, %pcrel_hi
, and
%got_pcrel_hi
could differentiate the uses of
lui
and auipc
.
1 | # Position-dependent code (PDC) - absolute addressing |
Why use %hi
with lui
if it's always paired?
It's about clarify and explicitness. %hi
ensures
consistency with %lo
and cleanly distinguishes it from from
%pcrel_hi
. Since both lui
and
auipc
share the U-type instruction format, tying relocation
specifiers to formats rather than specific instructions is a smart,
flexible design choice.
Relocation specifier flavors
Assemblers use various syntaxes for relocation specifiers, reflecting architectural quirks and historical conventions. Below, we explore the main flavors, their usage across architectures, and some of their peculiarities.
expr@specifier
This is likely the most widespread syntax, adopted by many binutils
targets, including ARC, C-SKY, Power, M68K, SuperH, SystemZ, and x86,
among others. It's also used in Mach-O object files, e.g.,
adrp x8, _bar@GOTPAGE
.
This suffix style puts the specifier after an @
. It's
intuitive—think sym@got
. In PowerPC, operators can get
elaborate, such as sym@toc@l(9)
. Here, @toc@l
is a single, indivisible operator-not two separate @
pieces-indicating a TOC-relative reference with a low 16-bit
extraction.
Parsing is loose: while both expr@specifier+expr
and
expr+expr@specifier
are accepted (by many targets),
conceptually it's just specifier(expr+expr)
. For example,
x86 accepts sym@got+4
or sym+4@got
, but don't
misread—@got
applies to sym+4
, not just
sym
.
%specifier(expr)
MIPS, SPARC, and RISC-V favor this prefix style, wrapping the expression in parentheses for clarity. In MIPS, parentheses are optional, and operators can nest, like
1 | # MIPS |
Like expr@specifier
, the specifier applies to the whole
expression. Don't misinterpret %lo(3)+sym
-it resolves as
sym+3
with an R_MIPS_LO16
relocation.
1 | # MIPS |
expr(specifier)
A simpler suffix style, this is used by AArch32 for data directives. It's less common but straightforward, placing the operator in parentheses after the expression.
1 | .word sym(gotoff) |
:specifier:expr
AArch32 and AArch64 adopt this colon-framed prefix notation, avoiding the confusion that parentheses might introduce.
1 | // AArch32 |
Applying this syntax to data directives, however, could create
parsing ambiguity. In both GNU Assembler and LLVM,
.word :plt:fun
would be interpreted as
.word: plt: fun
, treating .word
and
plt
as labels, rather than achieving the intended
meaning.
Recommendation
For new architectures, I'd suggest adopting
%specifier expr
, and never use @specifier
. The
%
symbol works seamlessly with data directives, and during
operand parsing, the parser can simply peek at the first token to check
for a relocation specifier.
( %specifier(...)
resembles %
expansion in
GNU Assembler's altmacro mode. 1
2
3.altmacro
.macro m arg; .long \arg; .endm
.data; m %(1+2)
Anti-patterns
RISC-V favors %specifier(expr)
but clings to
call sym@plt
for legacy
reasons.
AArch64 uses :specifier:expr
, yet
R_AARCH64_PLT32
(.word foo@plt - .
) and PAuth
ABI (.quad (g + 7)@AUTH(ia,0)
) cannot use :
after data directives due to parsing ambiguity.
TLS symbols
When a symbol is defined in a section with the SHF_TLS
flag (Thread-Local Storage), GNU assembler assigns it the type
STT_TLS
in the symbol table. For undefined TLS symbols, the
process differs: GCC and Clang don’t emit explicit labels. Instead,
assemblers identify these symbols through TLS-specific relocation
specifiers in the code, deduce their thread-local nature, and set their
type to STT_TLS
accordingly.
1 | // AArch64 |
Composed relocations
Most instructions trigger zero or one relocation, but some generate two. Often, one acts as a marker, paired with a standard relocation. For example:
- PPC64
bl __tls_get_addr(x@tlsgd)
pairs a markerR_PPC64_TLSGD
withR_PPC64_REL24
- RISC-V linker relaxation uses
R_RISCV_RELAX
alongside another relocation.
These marker cases tie into "composed relocations", as outlined in the Generic ABI:
If multiple consecutive relocation records are applied to the same relocation location (
r_offset
), they are composed instead of being applied independently, as described above. By consecutive, we mean that the relocation records are contiguous within a single relocation section. By composed, we mean that the standard application described above is modified as follows:
In all but the last relocation operation of a composed sequence, the result of the relocation expression is retained, rather than having part extracted and placed in the relocated field. The result is retained at full pointer precision of the applicable ABI processor supplement.
In all but the first relocation operation of a composed sequence, the addend used is the retained result of the previous relocation operation, rather than that implied by the relocation type.
Note that a consequence of the above rules is that the location specified by a relocation type is relevant for the first element of a composed sequence (and then only for relocation records that do not contain an explicit addend field) and for the last element, where the location determines where the relocated value will be placed. For all other relocation operands in a composed sequence, the location specified is ignored.
An ABI processor supplement may specify individual relocation types that always stop a composition sequence, or always start a new one.
GNU Assembler internals
GNU Assembler utilizes struct fixup
to represent both
the fixup and the relocatable expression.
1 | struct fix { |
The relocation specifier is part of the instruction instead of part
of struct fix
. Targets have different internal
representations of instructions.
1 | // gas/config/tc-aarch64.c |
In PPC, the result of @l
and @ha
can be
either signed or unsigned, determined by the instruction opcode.
LLVM internals
LLVM integrated assembler encodes fixups and relocatable expressions separately.
1 | class MCFixup { |
It encodes relocatable expressions as MCValue
, with:
RefKind
as an optional relocation specifier.SymA
as an optional symbol reference (addend)SymB
as an optional symbol reference (subtrahend)Cst
as a constant value
This mirrors the relocatable expression concept, but
RefKind
—added
in 2014 for AArch64—remains rare among targets. (I've recently made
some cleanup to some targets. For instance, I migrated PowerPC's @l and @ha folding to use RefKind
.)
AArch64 implements a clean approach to select the relocation type. It dispatches on the fixup kind (an operand within a specific instruction format), then refines it with the relocation specifier.
1 | // AArch64ELFObjectWriter::getRelocType |
MCSymbolRefExpr
issues
The expression structure follows a traditional object-oriented hierarchy:
1 | MCExpr |
MCSymbolRefExpr::VariantKind
enums the relocation
specifier, but it's a poor fit:
- Other expressions, like
MCConstantExpr
(e.g., PPC4@l
) andMCBinaryExpr
(e.g., PPC(a+1)@l
), also need it. - Semantics blur when folding expressions with
@
, which is unavoidable when@
can occur at any position within the full expression. - The generic
MCSymbolRefExpr
lacks target-specific hooks, cluttering the interface with any target-specific logic.
Consider what happens with addition or subtraction:
1 | MCBinaryExpr |
Here, the specifier attaches only to the LHS, leaving the full result uncovered. This awkward design demands workarounds.
- Parsing
a+4@got
exposes clumsiness. AfterAsmParser::parseExpression
processesa+4
, it detects@got
and retrofits it ontoMCSymbolRefExpr(a)
, which feels hacked together. - PowerPC's @l @ha optimization needs
PPCAsmParser::extractModifierFromExpr
andPPCAsmParser::applyModifierToExpr
to convert aMCSymbolRefExpr
to aPPCMCExpr
. - Many targets (e.g., X86) use
MCValue::getAccessVariant
to grab LHS's specifier, thoughMCValue::RefKind
would be cleaner.
Worse, leaky abstractions that MCSymbolRefExpr
is
accessed widely in backend code introduces another problem: while
MCBinaryExpr
with a constant RHS mimics
MCSymbolRefExpr
semantically, code often handles only the
latter.
MCTargetExpr
encoding relocation specifiers
MCTargetExpr
subclasses, as used by AArch64 and RISC-V,
offer a cleaner approach to encode relocations. We should limit
MCTargetExpr
to top-level use to encode one single
relocation and avoid its inclusion as a subexpression.
1 | AArch64MCExpr |
MCSymbolRefExpr::VariantKind
as the legacy way to encode
relocations should be completely removed (probably in a distant future
as many cleanups are required).
Our long-term goal is to migrate MCValue
to use
MCSymbol
pointers instead of MCSymbolRefExpr
pointers.
1 | // Current |
AsmParser:
expr@specifier
In LLVM's assembly parser library (LLVMMCParser), the parsing of
expr@specifier
was supported for all targets until I
updated it to be an
opt-in feature in March 2025.