☰
ADRP指令详解:ARM64汇编PC相对寻址的核心
2026/9/29 7:27:59 网站建设 项目流程

刚开始学 arm64 汇编的时候,我盯着反汇编窗口里的adrp x0, 0x100000000愣了很久。当时我在追一段崩溃日志,栈回溯里全是地址,真正卡住我的却是反汇编里连续出现的 ADRP 指令——它既不像普通 MOV 那样直观,也不像 BL 那样让人清楚知道做了一次跳转。后来我才想明白,不理解 ADRP,基本等于没看懂 arm64 上任何一份真实程序的反汇编。

这篇文章把 ADRP 指令掰开揉碎讲清楚:它到底是干什么的、地址是怎么算出来的、编译器为什么频繁生成它、手写汇编时怎么正确用,以及我在实际排查里因为忽略它踩过的坑。适合刚接触 arm64 汇编的开发者,也适合那些指令表背得熟、但一面对真实二进制还是懵的同学。

1. 先弄明白:为什么 arm64 会发明 ADRP 这条指令

很多初学 arm64 汇编的人会问:我想访问一个全局变量,直接LDR x0, =0x412030不就行了?为什么要绕一圈用 ADRP?这个问题的答案,恰好解释了现代操作系统的可执行文件加载机制。

现在的系统基本都开了 ASLR(地址空间布局随机化)。这意味着同一个程序,每次启动时代码段、数据段、栈、堆的基地址都可能不一样。如果汇编代码里写死了一个绝对地址,那程序加载到另一个地址后,这个立即数就完全失效了——跳过去是乱码,读出来是野指针。为了应对这个问题,可执行文件里的代码必须采用位置无关代码(PIC),也就是所有访存和跳转尽量都基于“当前位置 PC 的值”来计算目标地址,而不是直接指定一个固定地址。

PC 相对寻址的逻辑很直观:执行某条指令时,PC 的值是确定的,以 PC 为基准,加上一个偏移量,就能定位到目标。这个偏移量是在链接期由链接器计算出来的,运行时无论代码被加载到哪里,偏移量都不变,于是目标地址总能被正确计算出来。

但是单条指令的编码空间有限。arm64 里 ADR 指令只能表示 ±1MB 范围的字节偏移,这对现代大型程序来说远远不够。一个动态库随便就有几 MB,全局数据和字符串可能离引用它的代码十万八千里。怎么办?ARM 的解决办法就是 ADRP:不直接定位到某个字节地址,而是先定位到目标地址所在的 4KB 页面,用 21 位有符号立即数表示“目标页相对当前页的偏移”,范围覆盖 ±4GB。目标页找到之后,再用一条 ADD 或 LDR 指令补上页内偏移(低 12 位)。

所以实际编译产物里你经常看到这样的固定套路:

adrp x0, 目标符号的页基址 add x0, x0, #页内偏移

或者

adrp x16, 外部符号对应的 GOT 页 ldr x16, [x16, #GOT 页内偏移]

前者用于读全局数据,后者用于做位置无关的动态链接调用。理解了 ADRP 的出现动机,后面所有细节都顺理成章了。

2. 拆解 ADRP 的地址计算:一个公式走天下

ADRP 的语义官方说法是:Xd = PC + SignExtend(immhi:immlo, 21) << 12,但严格来说不是用 PC 完整值直接加,而是先对 PC 做页对齐。换成人话,计算分三步:

  1. 把当前指令地址的低 12 位清零,得到当前 PC 所在的 4KB 页基址。
  2. 取出指令编码里的 21 位立即数,先符号扩展成 64 位,再左移 12 位。左移意味着这个立即数的单位是“页数”,不是字节数。
  3. 将两部分相加,得到目标页的基地址。

所以完整公式是:

目标页基址 = (PC & ~0xFFF) + (SignExtend(imm21) << 12)

注意:ADRP 算出来的地址低 12 位永远是 0。它给出的只是“页头地址”,不是目标变量的精确地址。要得到精确地址,必须后续再用 ADD、LDR、STR 等指令补上低 12 位偏移。

2.1 手动算一次:从指令地址反推目标页

假设当前指令地址是0x100003000,指令里携带的立即数是0x102:

目标页基址 = (0x100003000 & ~0xFFF) + (0x102 << 12) = 0x100003000 + 0x102000 = 0x100102000

页内偏移如果由下一条ADD x0, x0, #0x40提供,那么最终访问的地址就是0x100102040。

实际看反汇编的时候,编译器经常不直接显示0x102这种立即数,而是显示重定位后的符号页地址。我在一次排查中看到这样的片段:

0x104098: adrp x0, 0x104000 0x10409c: add x0, x0, #0x2a8

这里0x104000就是 ADRP 计算出的目标页,0x2a8是页内偏移,最终地址是0x1042a8。整个过程在脑子里过一遍只需要几秒钟,看多了之后你会形成肌肉记忆。

2.2 ADR 和 ADRP 的区别,别再用混

很多文章会把 ADR 和 ADRP 放在一起讲,因为助记符只差一个字母,指令编码也确实共享一部分逻辑。两者最核心的区别是:ADR 直接计算目标符号的精确字节地址,范围 ±1MB;ADRP 计算目标符号所在 4KB 页的基地址,范围 ±4GB。

指令结果粒度偏移范围典型用途
ADR精确字节地址±1MB近距离字符串、函数本地标签
ADRP4KB 页基地址±4GB大型程序中访问全局变量、GOT 表
ADRP + ADD/LDR精确字节地址±4GB现代编译器最喜欢生成的组合

从汇编器视角来看,adr x0, symbol如果链接时发现距离超过 1MB 会直接报错;而adrp x0, symbol加add x0, x0, :lo12:symbol几乎没有这个烦恼。这也是为什么你在大型项目反汇编里见到的基本都是 ADRP 组合拳。

3. 反汇编一个真实小程序,看编译器怎么打 ADRP 组合拳

概念讲再多,不如看一段真实代码。我写了一个最简单的 C 程序,里面引用了一个全局变量:

#include <stdio.h> static int g_count = 3; int main(void) { printf("%d\n", g_count); return 0; }

用 aarch64 交叉编译器编译并反汇编:

aarch64-linux-gnu-gcc -O0 -o test test.c aarch64-linux-gnu-objdump -d test

main 函数里会出现这样一段典型指令序列(地址和偏移会因为环境不同略有差异):

400550: 90000011 adrp x0, 410000 <g_count> 400554: 91002000 add x0, x0, #0x8 400558: b9400000 ldr w0, [x0] 40055c: 93407c00 sxtw x0, w0

这段代码干的事就是:通过adrp x0, 410000拿到全局变量g_count所在页的基址0x410000,再用add x0, x0, #0x8把页内偏移 8 字节补上,得到真正的地址0x410008。之后ldr w0, [x0]才真正把g_count的值从内存里读出来。

编译器为什么不直接用一条指令搞定?因为 arm64 是 RISC 架构,加载大范围地址必须拆成“页基址 + 页内偏移”两步,这是指令编码长度决定的。ADRP 解决“页基址”,ADD/LDR 解决“页内偏移”。

再举一个动态链接场景。当代码需要调用外部函数printf时,由于它是动态库导出符号,不能直接跳转,得先通过 GOT(全局偏移表)拿到真实地址。反汇编常见形态如下:

400590: 90000016 adrp x16, 420000 <printf@GOT> 400594: f9400210 ldr x16, [x16, #0x0] 400598: d61f0200 br x16

这里 ADRP 用于定位 GOT 表项所在的页,LDR 从 GOT 表项中取外部函数的实际地址,最后 BR 跳过去。这个套路在 Apple Silicon 的 Mach-O 里同样常见,只是汇编语法里会写成adrp x16, _printf@GOTPAGE、ldr x16, [x16, _printf@GOTPAGEOFF]这样的可读形式。本质和 Linux ELF 完全一致。

不同访存场景下 ADRP 的组合方式我整理成一个简单的对照表:

目标场景指令组合作用
读全局变量ADRP + ADD + LDR先算地址,再从地址读取
写全局变量ADRP + ADD + STR先算地址,再把寄存器值写入
取字符串地址ADRP + ADD得到字符串精确地址,传给函数
调用外部函数ADRP + LDR + BR从 GOT 取函数地址并跳转

看到没有,ADRP 基本是所有这些组合的起点。它本身不访问内存,只负责算页基址,真正干活的是后面的指令。

4. 手写汇编实例:用 ADRP 写一个”Hello"程序

光看不练是学不会汇编的。下面我用 GNU 汇编语法写一个可直接运行的 Linux aarch64 程序,完整展示 ADRP 的用法和链接过程。

.data msg: .asciz "Hello, ADRP!\n" .text .global _start _start: // 获取 msg 的精确地址 adrp x0, msg add x0, x0, :lo12:msg mov x1, #13 // 消息长度 mov x2, #1 // fd = 1,标准输出 mov x8, #64 // Linux write 系统调用号 svc #0 mov x0, #0 // 退出码为 0 mov x8, #93 // Linux exit 系统调用号 svc #0

这里最关键的一行是adrp x0, msg配合add x0, x0, :lo12:msg。adrp负责把msg所在页基址放到x0,:lo12:msg是重定位修饰符,表示取msg相对页基址的低 12 位偏移,由链接器在链接阶段填进 ADD 指令的立即数里。两者缺一不可。

编译和运行:

aarch64-linux-gnu-as -o demo.o demo.s aarch64-linux-gnu-ld -o demo demo.o ./demo

如果你手上没有真机或交叉编译环境,也可以用qemu-aarch64直接跑用户态程序:

qemu-aarch64 -L /usr/aarch64-linux-gnu/ ./demo

输出就是Hello, ADRP!。

注意一个细节:我在 ADD 指令里明确写了:lo12:msg,而不是直接写add x0, x0, #0。因为msg到底在页内偏移多少,汇编器在生成目标文件时还不一定知道,必须靠重定位信息告诉链接器。adrp x0, msg实际上会生成一个R_AARCH64_ADR_PREL_PG_HI21重定位,add x0, x0, :lo12:msg生成R_AARCH64_ADD_ABS_LO12_NC重定位。理解这一点,你就能看懂链接器报错信息里的那些奇怪术语了。

如果你的代码是要编进动态库的,情况还要更复杂一点。动态库里访问一个外部全局符号,不能直接 ADRP 符号本身,而是要访问它的 GOT 表项:

adrp x0, extern_var@GOTPAGE ldr x0, [x0, extern_var@GOTPAGEOFF]

这个写法在 macOS 汇编里经常看到,Linux ELF 里也支持类似语法。原因是动态库加载时符号地址要经过动态链接器重定位,直接按静态链接方式访问外部符号会出错。

5. 我在实际项目里踩过的 ADRP 大坑与排查思路

下面这部分是我最想分享的。ADRP 这个指令本身不难,难的是在真实工程里你会遇到各种反直觉的情况,有些排查起来相当费劲。

5.1 坑一:把 ADRP 当 ADR 用,取回来的地址直接解引用崩了

我最开始手写汇编时就犯了这毛病。心想adrp x0, msg不是把 msg 地址给 x0 了吗?结果后续直接ldr x1, [x0],程序一跑就段错误。原因很简单:x0里装的是msg所在页的基址,不是msg的开头地址,页基址和真实地址之间差着一个页内偏移。忘了加add x0, x0, :lo12:msg,地址自然不对。

排查这类问题,最好的办法是拿调试器看寄存器值。在 GDB 里:

break *_start+4 info registers x0

然后手动计算一下:x0是不是正好等于msg地址向下对齐到 4KB 的值?如果是,说明 ADRP 本身没错,问题出在后续页面偏移指令缺失或写错。

5.2 坑二:以为 ADRP 的“页”和 OS 的虚拟内存页大小一致

arm64 架构下 OS 可以配置不同的内存页大小,常见的有 4KB、16KB、64KB。但 ADRP 指令定义里的“页”永远按 4KB 粒度计算,跟系统实际配置的虚拟内存页大小没有关系。

这个坑在排查性能问题时特别容易踩。我当时想当然地认为系统如果用了 64KB 页,ADRP 算出来也应该是 64KB 对齐地址。结果手动算出来的地址和调试器里的值差了一截,折腾半天才发现是概念理解错了。ARM ARM 的伪代码写得很清楚:

address = (PC[63:12] + SignExtend(immhi:immlo, 21) << 12) : Zeros(12)

这个[63:12]就是 4KB 对齐,跟 OS 无关。记住这点,后面看内核代码、查页表相关问题时能少走很多弯路。

5.3 坑三:链接期报relocation truncated to fit: R_AARCH64_ADR_PREL_PG_HI21

这个错误很吓人,实际含义是 ADRP 的立即数范围不够,装不下当前指令与目标符号之间的页面偏移。按前面的计算,ADRP 覆盖范围是 ±4GB,正常程序不会超。但如果你在做一个超大单体二进制,或者代码段和数据段分布异常,就可能触发。

我当时遇到这个报错时,第一反应是代码太大,后来仔细看才发现是链接脚本里把数据段放在了离代码段好几个 GB 的位置。解决办法有几个方向:

  • 检查链接脚本和段布局,尽量让数据段靠近代码段。
  • 把访问方式改成通过 GOT 间接访问,因为 GOT 表项通常就在附近,ADRP + LDR的寻址距离也能满足。
  • 对特别远的全局对象,使用ldr x0, =addr这种从文字池加载绝对地址的方式。

遇到这类链接错误,先别急着改代码,用ld -Map或map文件看段布局,通常一眼就能找到问题。

5.4 坑四:手动推算目标地址时搞错参考 PC

ADR/ADRP 计算时使用的 PC 是当前正在执行指令的地址,不是当前地址加 8 或加 4。这一点和某些早期 ARM32 体系结构里的“PC 偏移量”不太一样。如果你在看老资料,很容易被带偏。

我在一次做反汇编计算时,按“当前地址+8”推算了半天,结果对不上调试器里的值,后来翻 ARM ARM 的 Pseudocode 才确认 arm64 的 ADRP 直接用当前指令地址作为基准。这个细节平时不会出问题,但手写解析器、Capstone 反汇编后自己算目标地址时,基准一错全盘皆错。

5.5 坑五:非位置无关编译出的代码,放进动态库直接崩

这个坑不完全算 ADRP 的锅,但和 ADRP 的使用场景强相关。如果你用普通静态编译方式手写汇编,然后强行打进共享库里,代码里的重定位可能是绝对地址形式,加载后地址对不上,运行必崩。

解决办法是编译时给 C 代码加-fPIC,手写汇编则主动使用 ADRP + GOT 组合来访问外部符号。在 ELF 里检查是否位置无关,用readelf -d看有没有TEXTREL,如果有,说明你的 .text 段里混入了需要运行时重定位的绝对地址,这是动态库的大忌。

5.6 一个实用的检查清单

被坑过几次之后,我给自己整理了一个检查清单,每次手写 ADRP 相关代码或分析反汇编时都会过一遍:

检查项操作
是否忘了补页内偏移确认 ADRP 后紧跟 ADD/LDR 并带:lo12:或@PAGEOFF
目标符号是否在 ±4GB 范围内用 readelf/objdump 查看符号与代码段距离
是否把页基址当成精确地址调试器里看低 12 位是否全为 0
是静态访问还是 GOT 访问动态库中外部符号必须走 GOT
基准 PC 是否取对以当前指令地址为准,不加减
页大小是否搞混ADRP 固定按 4KB 粒度计算

这个清单帮我解决过不少问题,也推荐给你直接收藏。

最后再分享一个小技巧。我现在看反汇编时,不会逐条去算 ADRP 的目标地址,而是一眼锁定adrp和它后面的add/ldr组合,先在脑内把目标页位置确定下来,再想符号大概放在哪个段。如果用 lldb 或 gdb,直接在 ADRP 执行后打印x0寄存器,就能立刻看到页基址。对于 arm64 汇编来说,ADRP 是所有 PC 相对寻址的起点,一旦把它练熟了,读反汇编的速度会提升一个档次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询