搞ARM64底层开发的朋友不管是写启动代码、调内核模块还是逆向分析一个so文件反汇编里几乎绕不开adrp这条指令。我第一次在反汇编窗口看到adrp x0, 0x550000的时候愣了一下好好的地址不直接给非要算个页出来这不是多此一举吗后来把这条指令彻底搞明白之后才发现它几乎是ARM64世界里所有地址访问的地基。你想拿全局变量、访问字符串常量、调外部函数编译器最终生成的代码里大概率都有adrp。这篇就把adrp指令的作用、使用场景、真实反汇编例子还有我踩过的几个坑一次说清楚。1. adrp到底干了件什么事1.1 一条指令是如何“迂回”定位符号的adrp的全称是“Form PC-relative address to 4KB page”直译就是“基于PC计算一个4KB页的地址”。它的伪代码长这样Xd (PC ~0xFFF) (SignExtend(imm21, 64) 12)拆开说就是先把当前指令的地址PC低12位清零得到一个4KB对齐的页基址再加上一个21位有符号立即数左移12位后的值结果写到目标寄存器X d里。举个例子。假设当前这条adrp指令在0x400468那么PC ~0xFFF等于0x400000。如果链接器算出来的立即数是0这条指令执行完x0 0x400000。这个值不是一个变量或函数的完整地址而只是它所在的4KB页的起始地址。真正的完整地址需要再加上低12位的页内偏移。这就是很多新手最容易懵的地方adrp给出的是“页基址”不是“符号地址”。为什么这样设计因为ARM64指令是定长32位一条指令里放不下64位地址必须“分步走”。adrp负责定位到某个4KB页接下来的add或ldr负责补上页内偏移两步合起来才能拿到完整的符号地址。1.2 为什么非要跟“4KB页”较劲这个“页”的概念一开始我也觉得绕。后来想明白了一个点ARM64的adrp能覆盖的范围是±4GB是因为立即数有21位左移12位后就是2的32次方换算成范围正好是±4GB。但为什么不直接设计成一条指令返回完整地址非要跟页较劲原因很简单如果21位立即数直接作为偏移最多只能表示±1MB范围要想覆盖更大的空间就必须在“精度”和“范围”之间做取舍。ARM64选择的是牺牲低12位的精度换来±4GB的覆盖范围。低12位留空之后汇编器再用另一条add指令补上偏移指令编码里可以用12位立即数刚好一一对应。所以你看adrp add这个组合其实是一种“分页寻址”思想adrp管“哪一页”add管“页内偏移”。两条指令合在一起当前PC附近±4GB范围内任意一个符号的地址都能算出来并且整个过程不依赖绝对地址天然支持位置无关代码。1.3 一个具体的计算例子拿一个最简单的场景手算一遍。假设当前adrp指令地址0x400468想访问的全局变量global_var地址0x400800汇编器在处理adrp x0, global_var时会算两步当前指令所在页0x400468 ~0xFFF 0x400000目标符号所在页0x400800 ~0xFFF 0x400000两个页基址一样所以页偏移是0adrp立即数为0。执行完x0 0x400000。然后再执行add x0, x0, #0x800也就是把global_var在页内的偏移0x800加上去得到x0 0x400800这才是global_var的真实地址。如果目标符号在另一个页比如0x412000那么目标页基址就是0x412000当前页基址是0x400000差值0x12000右移12位等于0x12。adrp的立即数就是0x12执行完x0 0x400000 (0x12 12) 0x412000。接着再补低12位0x000就得到0x412000。提示手算时最关键的一步是永远记得adrp执行完寄存器低12位一定是0。看到反汇编里adrp x0, 0x550000可以先把它当作“x0现在指向0x550000这个页的起始地址”再往后找配合的add或ldr。2. adrp、adr、ldr三条容易混淆的指令2.1 先用一张表分清它们ARM64里跟取地址相关的指令有好几条最容易搅在一起的是adr、adrp和ldr字面量加载。我整理了一个对比表指令寻址方式可寻址范围结果粒度典型用途adrPC相对地址±1MB完整地址近距离取符号地址adrpPC相对页地址±4GB4KB页基地址远距离定位符号所在页ldr字面量PC相对内存加载±1MB从内存读取64位值从文字池加载绝对地址或64位常量adr的伪代码是Xd PC SignExtend(imm21)偏移直接加结果是完整符号地址但范围只有±1MB。adrp则先清低12位再加大偏移范围扩大到±4GB但也因此只拿到页基址。ldr字面量加载的形式是ldr x0, symbol这会告诉汇编器在代码段附近放一个8字节的文字池条目里面存的是symbol的完整64位地址然后通过PC相对偏移从这个文字池里把地址读出来。这种方式能一次拿到完整绝对地址但代价是需要一次内存访问并且文字池条目会占用额外空间。2.2 为什么编译器默认选择adrp而不是adr我刚开始学的时候有个疑问既然adr一步就能拿到完整地址为什么编译器生成的代码里全是adrp add很少看到adr答案是范围限制。adr只有±1MB这在大型程序里太容易超范围了。代码段和数据段在链接后的距离经常超过1MB尤其动态库、PIE程序里全局变量、字符串常量、GOT表可能被放在离当前指令很远的段里。adr一旦超出范围链接器直接报错。adrp add则是两条算术指令只需要2个周期左右没有内存访问不受文字池大小限制能覆盖±4GB对绝大多数用户态程序足够了。所以GCC和Clang在-O2下默认就用adrp add只有极近距离的地址才可能被优化成adr。2.3 一个反直觉的点adrp本身不碰内存从x86转过来的朋友特别容易把adrp和lea混在一起想但adrp更纯粹——它完全不访问内存。它就是在做算术运算拿PC、清位、加立即数、写寄存器仅此而已。真正访问内存的是它后面跟着的那条ldr或str。所以如果你看到反汇编里只有一条孤零零的adrp后面没跟add也没跟ldr/str那它大概率只是为了算一个基地址给后面的指令用或者是为了做某种地址对齐的准备工作。这里还要提一个ARM64的特点没有一条指令能直接生成任意64位立即数。x86有movabs可以把64位立即数放进寄存器ARM64没有这种操作。如果你想在ARM64里构造一个任意64位常数一般用movzmovk组合或者用ldr x0, constant从文字池加载。这也是为什么adrp add那么重要——在不需要绝对常量的场合用PC相对寻址最经济。3. 实战从C代码到反汇编看adrp的真面目3.1 环境准备交叉编译器或qemu模拟看adrp最直接的办法就是自己编译一个ARM64程序然后反汇编。如果你手头没有ARM64开发板在x86的Linux机器上装个交叉编译器就行sudo apt install gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu如果还想直接运行ARM64程序可以用qemu-usersudo apt install qemu-user这样在x86上就能直接执行ARM64的静态或动态链接程序配合gdb-multiarch还能单步调试。3.2 示例代码与反汇编分析写一个简单的C程序#include stdio.h int global_var 0x12345678; const char *msg hello adrp; int get_global(void) { return global_var; } int main(void) { printf(%s %d\n, msg, get_global()); return 0; }编译aarch64-linux-gnu-gcc -O2 -o demo demo.c aarch64-linux-gnu-objdump -d demo在反汇编输出里get_global函数大概是这个样子的0000000000400580 get_global: 400580: 90000000 adrp x0, 400000 global_var 400584: b9400000 ldr w0, [x0, #0x0] 400588: d65f03c0 ret第一眼看到adrp x0, 400000你可能以为它取的是绝对地址0x400000。没错它取的就是global_var所在页的页基址。紧接着的ldr w0, [x0, #0x0]页内偏移是0但注意这条ldr访问的地址是x0 0也就是0x400000。那global_var到底是不是就在0x400000位置需要看链接器怎么布局。在这个例子里global_var正好被放到了页偏移0的位置编译器优化后把add合并掉了直接用ldr的立即数偏移。再看main里访问msg指针的反汇编4005a0: 90000000 adrp x0, 400000 msg 4005a4: f9400000 ldr x0, [x0, #0x0] 4005a8: 90000001 adrp x1, 400000 __printf_main 4005ac: 91000021 add x1, x1, #0x0 ...这里有个容易混淆的点msg本身是一个指针变量存在.data段里里面存的是字符串hello adrp的地址。所以取msg的过程是两层先用adrp ldr取出msg变量在.data段里存的8字节值也就是字符串的地址。再把这个地址作为printf的参数传进去。类似地调用printf时printf的地址在PIE程序里也是通过GOT间接访问的反汇编看起来会是adrp x16, ...; ldr x16, [x16, #...]; blr x16这种形式。3.3 用objdump看重定位彻底理解adrp的“未完成感”上面反汇编有一个问题adrp x0, 400000里的0x400000是谁算出来的是链接器。编译产生的.o文件里adrp的立即数还是空的由重定位记录填充。用这个命令看目标文件aarch64-linux-gnu-gcc -O2 -c -o demo.o demo.c aarch64-linux-gnu-objdump -dr demo.o输出里会有类似这样的片段0: 90000000 adrp x0, 0 global_var R_AARCH64_ADR_PREL_PG_HI21 global_var 4: b9400000 ldr w0, [x0, #0x0] R_AARCH64_LDST_ABS_LO12_NC global_var这里的R_AARCH64_ADR_PREL_PG_HI21就是专门为adrp设计的重定位类型。名字很长拆开看ADR_PRELPC相对寻址PG页(page)粒度HI21立即数是高21位也就是说链接器会拿到global_var的最终地址算出目标页和当前指令页的差值填到这个adrp指令的21位立即数里。同理R_AARCH64_LDST_ABS_LO12_NC对应的是ldr指令里的低12位页内偏移。提示在GDB或IDA里看到adrp后面显示的是一个看起来很整的地址不要以为它已经拿到了符号的最终地址。它拿到的永远是页基址完整地址大概率在后面会有一条带#0x...偏移的指令来补全。3.4 用qemu实际跑一下感受指令行为在x86上运行ARM64程序qemu-aarch64 -L /usr/aarch64-linux-gnu ./demo正常输出hello adrp 305419896。如果配合gdb-multiarch单步你会看到adrp执行前后寄存器的变化。比如断点停在0x400580执行前x0可能是任意值单步之后x0立即变成0x400000这就是页基址。下一步执行完ldr w0, [x0, #0x0]w0才变成0x12345678。我在实际调试时习惯用display/xi $pc每步都看反汇编和寄存器几次下来就对adrp的行为模式非常敏感了。4. 新手最容易踩的4个坑4.1 把adrp当成“取完整地址”来用这是最常见的问题。看到adrp x0, 0x412000就直接以为x0等于0x412000然后执行ldr x1, [x0]结果发现读出来的数据不对。原因就是忘了adrp只给页基址低12位永远是0。正确姿势是adrp x0, sym add x0, x0, :lo12:sym ldr x1, [x0]或者把偏移合并到访存指令里adrp x0, sym ldr x1, [x0, :lo12:sym]只有当你明确知道目标符号的页内偏移是0时才可以不做add。4.2 目标寄存器写成w寄存器adrp的目标寄存器必须是x寄存器不能是w寄存器。因为adrp算出的是64位地址写入32位寄存器会截断。写adrp w0, sym汇编器直接报错。这一点和adr一样都是按64位处理。4.3 在超大二进制或特殊链接脚本中超出±4GB范围±4GB对大多数程序足够但并不是万能。遇到大型共享库、插件系统、或者多个模块被mmap到相距很远的地址时adrp的±4GB范围可能不够。链接器会报类似这样的错误relocation truncated to fit: R_AARCH64_ADR_PREL_PG_HI21 against symbol解决办法有几种改用movzmovk构造绝对地址但会破坏位置无关特性。通过GOT间接访问GOT表通常放在离代码不远的地方。调整链接脚本把相关段放在相近的地址范围内。这种情况平时不常见但一旦遇到会让人摸不着头脑我之前在分析一个超大so文件时就被坑过一次。4.4 混淆“adrp固定4KB页”和“系统当前页大小”ARM64 Linux的页大小可以配置为4KB、16KB或64KB。但adrp指令本身永远是按“4KB页”来计算它清掉的低12位是固定不变的。也就是说adrp并不关心操作系统当前用的是多大的页它只做自己的算术。这个点很容易搞混你在一个16KB页大小的系统上调试反汇编里看到adrp x0, 0x400000如果拿系统页大小去算偏移怎么算都对不上。后来我把指令手册翻出来才想起来这里的4KB是ARM64架构规定的操作系统页大小是另一码事。4.5 伪指令“ldr x0, sym”能不能随便用在GNU汇编器里ldr x0, sym是合法的伪指令汇编器会把它转换成从一个文字池加载。小程序里用起来很爽一步就把完整地址拿到手了。但要注意这种方式需要额外的内存读取还要在代码段里维护一个8字节的文字池条目对于性能敏感的循环或者频繁执行的路径不如adrp add划算。另外在PIE或共享库里ldr x0, sym这种写法会生成一个绝对地址文字池如果不希望引入绝对重定位反而给后续链接带来麻烦。编译器默认不使用这种形式就是因为它不够干净。5. 进阶从adrp快速定位数据和GOT5.1 没有符号表时adrp是“指路明灯”在分析strip过的二进制时没有符号表函数名和变量名都是乱的。但adrp add这个模式可以帮你很快推断代码访问的是什么类型的数据。比如反汇编里出现adrp x0, 0x420000 add x0, x0, #0x14 ldr x1, [x0]那基本可以确认0x420014附近大概率是一个指针变量而且这个变量的初始值很重要。如果是adrp x0, 0x430000 ldr x0, [x0, #0x28] bl x0那就是典型的通过GOT表或者函数指针表间接调用外部函数。这种间接调用的目标地址在运行时才能确定逆向分析时需要结合动态调试或内存dump来分析。5.2 快速判断程序是不是PIE看到adrp出现还有一个额外收获可以判断一个二进制是不是PIE。用readelf -h看文件头readelf -h demo | grep Type如果Type是EXEC大概率是非PIE地址固定如果是DYN就是PIE或共享库。PIE程序里adrp的出现频率非常高因为编译器要为“加载到任意地址都能运行”做好准备。我常用一个小技巧先记住二进制里的某一条adrp指令地址和它后面的偏移然后用gdb运行程序在运行时计算它实际算出来的地址反推加载基址。这在分析ASLR开启后的内存布局时特别好用不用去翻/proc/pid/maps。5.3 内核启动代码里的adrpadrp不只是用户态程序的常客ARM64内核镜像里也到处都是。因为内核需要支持KASLR内核地址空间布局随机化镜像在启动时可能被解压到不同的物理地址所以所有全局访问都必须编译成位置无关代码这也就意味着大量的adrp add。如果你调试过ARM64内核启动流程会在stext到start_kernel的这一段看到无数adrp。这也是理解内核启动早期为什么不能随便用绝对地址的原因——在MMU还没完全配置好、不知道最终映射地址之前PC相对寻址是唯一安全的方式。一些调试心得从我个人的经验来说理解adrp最有效的方法就是把它当作“两步走”的第一步来看。反汇编里只要出现adrp我第一反应是找它后面的那条add或带偏移的ldr/str两两配对。缺了add就说明偏移是0或者链接器把偏移合并到了访存指令里多了一个add就老老实实把页基址和页内偏移加起来。还有一点不同反汇编工具的显示风格不太一样。objdump喜欢显示成adrp x0, 400000这种页基址形式IDA里则可能显示成ADRP X0, #0x400000GDB的x/i输出又可能是adrp x0, 0x400000。不管怎么显示计算规则不变。最后分享一个我常用的手工计算技巧在GDB里看到adrp x0, 0x550000直接在下一行执行p/x $x0如果等于0x550000说明低12位确实是0。然后结合后面的add #0x14快速心算出目标地址是0x550014。这个习惯帮我避免了好几次低级错误尤其在调试带优化选项的程序时编译器的指令调度经常让人眼花缭乱。