刚开始学 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 直接计算目标符号的精确字节地址范围 ±1MBADRP 计算目标符号所在 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 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 printfGOT 400594: f9400210 ldr x16, [x16, #0x0] 400598: d61f0200 br x16这里 ADRP 用于定位 GOT 表项所在的页LDR 从 GOT 表项中取外部函数的实际地址最后 BR 跳过去。这个套路在 Apple Silicon 的 Mach-O 里同样常见只是汇编语法里会写成adrp x16, _printfGOTPAGE、ldr x16, [x16, _printfGOTPAGEOFF]这样的可读形式。本质和 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_varGOTPAGE ldr x0, [x0, extern_varGOTPAGEOFF]这个写法在 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 *_start4 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 坑四手动推算目标地址时搞错参考 PCADR/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 相对寻址的起点一旦把它练熟了读反汇编的速度会提升一个档次。