Skip to content

AArch64 ADRP, ADD, ADR, and LDR Literal Explained

On 64-bit Raspberry Pi, ADR, ADRP, ADD, and LDR often appear together because A64 instructions are fixed at 32 bits and cannot embed an arbitrary 64-bit address directly. The correct sequence depends on whether you need an address, the value stored at that address, or an entry from the Global Offset Table.

Quick comparison

Form Result Approximate PC-relative range Typical use
adr x0, symbol Address of symbol ±1 MiB Nearby code or data
adrp x0, symbol 4 KiB page address containing symbol ±4 GiB First half of a page-relative address
add x0, x0, :lo12:symbol Adds offset within the 4 KiB page 0–4095 Completes ADRP address calculation
ldr x0, symbol 64-bit value stored near symbol ±1 MiB Literal load from nearby data
ldr x0, =symbol Address/value selected by assembler pseudo-instruction Depends on expansion Convenient literal-pool syntax

The most common address sequence is:

adrp x0, message
add  x0, x0, :lo12:message

The first instruction computes the page base. The second adds the symbol's low 12-bit offset.

Address versus value

This distinction prevents most mistakes.

Given:

1
2
3
4
.data
.align 3
counter:
    .quad 42

Load the address of counter:

adrp x0, counter
add  x0, x0, :lo12:counter

Load the 64-bit value stored at counter:

adrp x0, counter
ldr  x1, [x0, :lo12:counter]

After the first sequence, x0 == &counter. After the second, x1 == counter, which is 42.

How ADR works

ADR adds a signed PC-relative immediate to the address of the instruction and writes the result to a general-purpose register:

adr x0, nearby_label

Use it when the linker can place the target within approximately ±1 MiB. It produces the address; it does not dereference memory.

1
2
3
4
5
6
7
8
9
.text
.global get_message
get_message:
    adr x0, message
    ret

.section .rodata
message:
    .asciz "hello"

Whether this links depends on final section placement. A small source file does not guarantee that .text and .rodata remain within range in the final executable.

How ADRP works

ADRP is page-relative. Conceptually:

result = page(PC) + signed_page_offset

Both the current address and target are considered at 4 KiB page granularity. This provides an approximate ±4 GiB range.

adrp x0, message

At this point, x0 contains the base of the 4 KiB page holding message, not the exact address of message.

Complete the address with:

add x0, x0, :lo12:message

The assembler emits relocations that allow the linker to fill in the page delta and low 12-bit offset.

ADRP + ADD for an address

Complete example callable from C:

.section .rodata
.align 3
message:
    .asciz "ADRP plus ADD"

.text
.align 2
.global get_message
.type get_message, %function
get_message:
    adrp x0, message
    add  x0, x0, :lo12:message
    ret
.size get_message, .-get_message

x0 is the AAPCS64 integer/pointer return register, so C can declare:

1
2
3
4
5
6
7
8
#include <stdio.h>

extern const char *get_message(void);

int main(void) {
    puts(get_message());
    return 0;
}

Build on a 64-bit Raspberry Pi:

1
2
3
4
gcc -c address.S -o address.o
gcc -c main.c -o main.o
gcc address.o main.o -o address-demo
./address-demo

ADRP + LDR for a value

When the target is data and you want its contents:

.data
.align 3
counter:
    .quad 42

.text
.global read_counter
.type read_counter, %function
read_counter:
    adrp x0, counter
    ldr  x0, [x0, :lo12:counter]
    ret
.size read_counter, .-read_counter

The LDR addressing mode adds the relocated low offset to the page base and loads memory. It does not leave the address in x0; it replaces x0 with the 64-bit value.

Store a new value with the same address construction:

1
2
3
4
// x0 contains the new uint64_t value
adrp x1, counter
str  x0, [x1, :lo12:counter]
ret

LDR literal is different

This syntax:

ldr x0, nearby_constant

is an A64 literal load. It uses a PC-relative address and loads the 64-bit contents at the label. Its signed scaled immediate provides approximately ±1 MiB of range.

1
2
3
4
5
6
7
8
9
.text
.global read_literal
read_literal:
    ldr x0, constant_42
    ret

.align 3
constant_42:
    .quad 42

This returns 42, not the address of constant_42.

LDR x0, =expression is a pseudo-instruction

An equals sign changes the meaning:

ldr x0, =0x123456789abcdef0
ldr x1, =message

GNU as treats these as pseudo-instructions. It may place a value in a literal pool and emit a PC-relative load, or choose another valid expansion. The exact machine instructions are not guaranteed by the source spelling.

Inspect the result:

1
2
3
gcc -c pseudo.S -o pseudo.o
objdump -dr pseudo.o
readelf -rW pseudo.o

Use .ltorg only when you understand literal-pool placement. A literal pool placed outside the instruction's range causes an assembly or link failure.

Position-independent code and the GOT

For a symbol that can be interposed or whose address must come through the Global Offset Table, compilers commonly use a GOT sequence:

adrp x0, :got:external_symbol
ldr  x0, [x0, :got_lo12:external_symbol]

This loads the address stored in the GOT entry. A further LDR is needed if you want the value at that external address.

Do not replace a compiler-generated GOT sequence with direct ADRP + ADD unless symbol visibility and the linking model make that transformation valid.

See what GCC generates

Create a small C file:

1
2
3
4
5
6
7
8
9
extern long external_value;

long *address_of_external(void) {
    return &external_value;
}

long read_external(void) {
    return external_value;
}

Compile position-independent assembly:

gcc -O2 -fPIC -S example.c -o example.s
sed -n '1,160p' example.s

Then compare with non-PIC output:

gcc -O2 -fno-pic -S example.c -o example-no-pic.s

Compiler output varies by GCC version, optimisation, code model, visibility, and linker relaxation. Record those conditions when explaining a sequence.

Inspect relocations before and after linking

Object-file disassembly alone can show zero or placeholder immediates because the linker has not applied relocations.

1
2
3
4
5
6
7
gcc -c address.S -o address.o
readelf -rW address.o
objdump -dr address.o

gcc address.o main.o -o address-demo
objdump -d address-demo
readelf -sW address-demo | grep message

Typical relocation names include page-high and low-12 forms. Exact names depend on the assembler syntax and whether the access is direct or through the GOT.

The linker may relax an ADRP + ADD sequence when the final target is close enough. Judge the linked executable, not only the relocatable object.

Common mistakes

Using ADRP as though it returned the exact address

Wrong:

adrp x0, message
bl puts

x0 points to the page base. Add :lo12:message before passing it to puts.

Confusing ldr x0, label with ldr x0, =label

  • Without =, literal LDR loads memory at the label.
  • With =, the assembler pseudo-instruction materialises the expression, commonly through a literal pool.

Always inspect disassembly when teaching or optimising the distinction.

Loading 32 bits when the data is 64 bits

ldr w0, [...] loads 32 bits and zero-extends into x0. Use ldr x0, [...] for a 64-bit object.

Ignoring symbol visibility

Direct access may be invalid for an interposable external symbol in position-independent code. Use the compiler's GOT sequence or constrain visibility deliberately.

Copying macOS syntax into GNU/Linux

Mach-O, ELF, GNU as, and Clang integrated assembler use related but not identical relocation spellings. This guide targets GNU/Linux ELF on 64-bit Raspberry Pi OS.

Range summary

Instruction Immediate model Effective range
ADR Signed 21-bit byte offset approximately ±1 MiB
ADRP Signed 21-bit page offset, scaled by 4096 approximately ±4 GiB
LDR literal Signed 19-bit offset, scaled by 4 approximately ±1 MiB
ADD :lo12: Unsigned offset within page 0–4095 bytes

Arm's A64 overview documents the PC-relative forms and their ranges. For calling convention details, use the current AAPCS64 specification.

FAQ

Why does AArch64 use ADRP followed by ADD?

Together they construct a PC-relative address across a wide range while keeping each instruction 32 bits. ADRP finds the target page and ADD supplies the offset within it.

When should I use ADR instead?

Use ADR when the target is guaranteed to remain within its smaller range and the linking model permits direct access. The linker may also relax a longer sequence automatically.

Does LDR literal load an address?

It loads data stored at a PC-relative location. The pseudo-instruction LDR x0, =symbol is different and may arrange for the symbol address to be stored in a literal pool.

Why does objdump show different instructions after linking?

Relocations are resolved and linker relaxation can replace a sequence with a shorter equivalent. Compare both objdump -dr file.o and objdump -d executable.