刚开始学 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 做页对齐。换成人话,计算分三步:
- 把当前指令地址的低 12 位清零,得到当前 PC 所在的 4KB 页基址。
- 取出指令编码里的 21 位立即数,先符号扩展成 64 位,再左移 12 位。左移意味着这个立即数的单位是“页数”,不是字节数。
- 将两部分相加,得到目标页的基地址。
所以完整公式是:
目标页基址 = (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 | 近距离字符串、函数本地标签 |
| ADRP | 4KB 页基地址 | ±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 testmain 函数里会出现这样一段典型指令序列(地址和偏移会因为环境不同略有差异):
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 相对寻址的起点,一旦把它练熟了,读反汇编的速度会提升一个档次。