ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ARM64 ADRP指令详解:PC相对寻址、页对齐与PIC实现原理

ARM64 ADRP指令详解:PC相对寻址、页对齐与PIC实现原理 1. 从一次实际反汇编说起ADRP 到底是干什么的如果你刚开始接触 ARM64AArch64汇编或者正在做逆向分析、内核调试、性能优化你一定无数次在反汇编结果里看到这样两条指令挨在一起adrp x0, 0x1000 add x0, x0, #0x38第一次看到这组指令时我一度以为是编译器偷懒没有做常量折叠。后来才明白这两条指令合在一起是在计算一个全局变量或者字符串常量的地址。而这里的核心功臣就是 ADRP。ADRP 的全称是 Form PC-relative address to 4KB page直译过来就是“基于 PC 相对寻址计算一个 4KB 页的基地址”。它是 ARM64 指令集里专门用来做地址加载的指令几乎每一个非平凡的程序里都会出现它的身影。简单说ADRP 做两件事以当前指令地址PC为基准算出一个与当前 PC 在同一个 4KB 对齐区域内的页基址。把这个页基址写入目标寄存器注意是页基址不是最终的完整地址。低 12 位永远为 0因为页大小是 4KB0x1000。这也是为什么 ADRP 后面往往要跟一条 ADD 或者 LDR 指令来补齐低 12 位的偏移。理解了这一点你再看反汇编代码时就能一眼识别出“取地址”的惯用套路。这篇文章我打算从指令编码、实际用法、与 ADR/LDR 的差异、常见坑位、以及配套工具链这几个角度把 ADRP 讲透。无论你是写 ARM64 汇编的、做底层逆向的还是正在移植操作系统的这篇内容都能帮你节省不少翻文档的时间。2. ADRP 与 ADR、LDR 的恩怨情仇为什么偏偏需要它2.1 一条指令的“能力边界”立即数偏移的编码限制要理解 ADRP 为什么存在最好的办法是先看它的大哥 ADR。ADR 的完整语义是ADR Xd, label将 label 的地址基于 PC 相对偏移计算出来存入 Xd。它的工作距离是 ±1MB实际上是 ±1MB 范围内具体数值是 -1MB 到 1MB 减去一条指令的长度但一般我们就按 ±1MB 记。ARM64 指令宽度是固定的 32 位能用来表示立即数的位段非常有限。给 ADR 的偏移字段只有 21 位再加上必须是 4 字节对齐所以它能表达的范围就是 21 位有符号数乘以 4也就是约 ±1MB。如果目标符号距离当前指令超过 1MBADR 就用不了了。这时候你有两个选择先用 ADRP 拿到目标地址所在的 4KB 页基址再用 ADD 或 LDR 补齐页内偏移。改用 Literal load字面量加载也就是LDR Xd, label这种形式它同样受 ±1MB 范围限制。所以 ADRP 解决的核心问题就是如何在只跳转一次的情况下突破 ±1MB 的寻址范围限制实现更大范围的 PC 相对寻址。2.2 ADRP 的独特之处页对齐寻址ADRP 的厉害之处在于它对立即数的编码方式做了“手脚”。它把 21 位立即数当作一个“页号偏移”来用而不是“字节偏移”。每条指令执行时计算过程是这样的address (PC 0xFFFFFFFFFFFFF000) (SignExtend(immhi:immlo, 21) 12)也就是说ADRP 先把 PC 的低 12 位清零也就是找到当前 PC 所在页的起始地址然后把 21 位立即数左移 12 位得到一个页偏移量两者相加得到目标页的基地址。21 位有符号数左移 12 位之后能表示的偏移范围是多少计算一下最大正值2^20 - 1 1048575再乘以 4096 4,294,967,296 - 4096 ≈ 4GB - 4KB 最大负值-2^20 -1048576再乘以 4096 -4,294,967,296 ≈ -4GB所以 ADRP 的寻址范围大约是当前 PC 所在页的前后各 4GB。这个覆盖范围对绝大多数用户态程序、内核模块来说已经绰绰有余了。但要特别注意ADRP 只能得到“页对齐的地址”也就是低 12 位一定是 0。真正的符号地址如果是0x1234这种非页对齐的值ADRP 只能算出0x1000剩下的0x234必须要用其他指令补上。2.3 ADR 与 ADRP 对比速查表为了让你一眼看清这两个指令的区别我把关键对比整理成表格对比项ADRADRP全称Form PC-relative addressForm PC-relative address to 4KB page寻址范围±1MB±4GB偏移单位字节4 字节对齐4KB 页以页为单位偏移结果地址低 12 位保留真实偏移恒为 0典型应用短距离跳转表、小范围符号全局变量、字符串、大范围地址加载指令格式ADR Xd, labelADRP Xd, label是否需要补充指令通常不需要通常需要 ADD 或 LDR 补齐低 12 位3. 指令编码拆解ADRP 的二进制长什么样3.1 编码格式逐位拆解ARM64 指令集是定长 32 位编码ADRP 的编码布局如下31 28 27 24 23 5 4 0 ----------------------------- | 1 0 0 0 | 0 | immhi | Rd | ----------------------------- | | immlo (2 bits) | -----------------------------具体拆分bit 31-28固定为1000这是 ADRP 和 ADR 共用的主操作码。bit 28-24其中 bit 28 和 bit 23 组合决定是 ADR 还是 ADRP。0 0ADR1 0ADRP这里是简化说法完整判断要看 op 字段bit 23-5immhi19 位立即数高位部分。bit 4-0Rd目标寄存器编号。另外还有 2 位 immlo藏在 bit 30-29 位置编码细节有差异我们按逻辑理解即可。这里有个容易混淆的点21 位立即数被拆成了 immhi 和 immlo 两部分immhi 在编码的高位段immlo 在编码的另一个位置。拼接方式是immhi:immlo其中 immhi 是高位immlo 是低位两者合成一个 21 位有符号立即数。3.2 手动计算示例对 0x1234 取址假设当前 PC 值是0x4000_0000我们想取地址0x4000_1234到 x0 寄存器。第一步计算目标地址所在页基址。目标页基址 0x4000_1234 0xFFFF_FFFF_FFFF_F000 0x4000_1000 当前 PC 所在页基址 0x4000_0000 0xFFFF_FFFF_FFFF_F000 0x4000_0000 页偏移 0x4000_1000 - 0x4000_0000 0x1000 4096第二步计算 21 位立即数。页偏移 / 4096 4096 / 4096 1 即立即数 1第三步指令执行过程。X0 0x4000_0000当前 PC 页基址 (1 12) 0x4000_0000 0x1000 0x4000_1000此时 X0 是 0x4000_1000不是最终地址 0x4000_1234。因此需要补一条add x0, x0, #0x234最终 X0 0x4000_1234正确。这就是 ADRP ADD 组合拳的完整过程。你可能会问为什么不直接一条指令搞定因为 32 位指令的编码空间装不下 21 位有符号立即数加 12 位页内偏移总共需要 33 位远超出可用位段。所以 ARM 架构师选择了用两条指令拼出完整地址。3.3 再看汇编器如何生成 ADRP你写高级语言时编译器会帮你生成这些指令。比如这段 C 代码extern int global_var; int get_global(void) { return global_var; }编译成汇编后未优化版本简化展示你会看到类似这样的结果adrp x0, _global_varPAGE ldr x0, [x0, _global_varPAGEOFF]第一行_global_varPAGE是汇编器语法意思是“取 _global_var 的页基址”第二行_global_varPAGEOFF表示“_global_var 所在页内偏移”。这两行配合正好就是 ADRP 取页基址、LDR 用页内偏移访问内存的完整过程。这里还要注意一个细节LDR的偏移是立即数偏移寻址它的编码同样有限制。但页内偏移最大是 409512 位能表示所以 LDR 从 [X0, #imm] 这种形式完全能覆盖页内偏移。这就是为什么 ADRP 后面是跟 LDR 而不是 ADD 也能完成取数因为 LDR 本身就是“基址 偏移”的访存指令。4. 实战中的 ADRP常见组合与反汇编识别4.1 组合一ADRP ADD取符号地址这是最基础也最常见的组合用于拿到一个变量的地址但不立即访问内存。典型场景是把函数指针、字符串地址放到寄存器里。adrp x1, _stringPAGE add x1, x1, _stringPAGEOFF执行后 x1 就是_string的完整地址。如果你要调用 printf 这类函数通常就是先把格式串地址放进 x0再调用。4.2 组合二ADRP LDR读取全局变量如果目标符号本身是数据而且你想直接读它的值一步到位的方式是adrp x2, _counterPAGE ldr x2, [x2, _counterPAGEOFF]这里 LDR 的偏移是立即数偏移寻址[x2, offset]表示从 x2 加上 offset 得到的内存地址处加载一个字。这种组合比 ADRP ADD LDR 少一条指令效率更高。注意LDR 的立即数偏移范围有限制对于 64 位加载偏移必须是 0 到 32760 且按 8 字节对齐的倍数。页内偏移 0x000 到 0xFFC 都在范围内所以绝大多数情况没问题。但如果遇到页内偏移超出 LDR 编码范围的场景汇编器会自动改用组合一。4.3 组合三ADRP 分支跳转函数调用如果要调用一个距离较远的函数编译器也可能会用 ADRP 先把目标地址算出来再通过寄存器间接跳转adrp x30, _target_funcPAGE add x30, x30, _target_funcPAGEOFF blr x30BLR 是“带返回地址的寄存器间接跳转”跳转前会把下一条指令地址存入 LRx30寄存器。这种方式比直接用 BL 26 位偏移能到达的范围大得多适合大体积二进制里的跨模块调用。4.4 反汇编时如何快速识别 ADRP使用objdump -d反汇编 ARM64 程序时ADRP 的输出格式一般是4004c0: 90000000 adrp x0, 400000 __libc_start_mainplt-0x...注意后面显示的是一个页对齐地址。另外IDA Pro、Ghidra 等逆向工具还会自动帮你把 ADRP ADD/LDR 组合解析成一个完整地址变量引用在做逆向时非常方便。但你一定要记住底层机制不然遇到工具无法自动识别的场景还是会卡住。实际经验里有个小技巧看反汇编代码时如果一个寄存器先被 ADRP 赋值紧接着又被 ADD 或 LDR 用了同一个寄存器基本可以断定这两条指令合在一起是在取某个符号的地址或值。这就是“取址双指令模式”。5. 为什么编译器爱用 ADRPPIC 与位置无关代码5.1 位置无关代码的基础在现代 Linux、Android、iOS 系统里可执行文件和共享库几乎都编译成了位置无关代码PICPosition Independent Code。PIC 的好处是代码可以被加载到任意内存地址不需要链接时固定地址方便实现地址空间布局随机化ASLR和共享库的代码段共享。PIC 的实现基础就是 PC 相对寻址。因为每次运行时PC 的值都不是固定的所以用 PC 相对寻址计算出来的地址天然就是“相对于当前位置”的与加载基址无关。5.2 ADRP 让 PIC 不依赖 GOT 的偏移表也能访问数据在 x86-64 架构上PIC 访问全局变量通常要经过 GOT全局偏移表因为 x86-64 的 RIP 相对寻址范围只有 ±2GB且不支持直接加载 64 位绝对地址。每访问一个全局变量要先从 GOT 里加载真实地址多一次内存访问。而 ARM64 的 ADRP 直接把寻址范围放大到了 ±4GB这让很多全局变量的访问可以直接通过 ADRP ADD/LDR 完成不需要经过 GOT。这减少了访存次数性能上更有优势。当然如果全局变量是动态链接库之间的符号或者需要支持 preemption符号抢占那还是要走 GOT。但至少对于可执行文件内部定义的全局变量编译器倾向于直接用 ADRP。5.3 小例子PIC 与非 PIC 的指令对比考虑同一条代码在两种编译模式下的差异。C 源码int g_val 42; int read_val(void) { return g_val; }非 PIC 编译-fno-pic时可能直接用绝对地址加载adrp x0, 0x30000 ldr x0, [x0, #0x810]PIC 编译默认时同样用 ADRP但符号引用方式在重定位表里会标记为GOT_PAGE或PAGE链接器会做相应处理。从指令层面看差别可能不大但重定位类型不同。对初学者来说不必深究重定位细节只需要明白ADRP 是 ARM64 实现 PIC 的关键指令之一它让数据访问不再依赖绝对地址保证了代码可以在任意地址运行。6. 踩坑实录ADRP 使用中的常见问题6.1 误区一以为 ADRP 能直接得到完整地址这是新手最容易犯的错误。ADRP 得到的是页基址低 12 位永远是 0。如果你在写汇编时直接adrp x0, label然后就用 x0 去访问 label访问的其实是 label 所在页的起始地址而不是 label 本身。正确做法是配合add x0, x0, :lo12:label补齐低 12 位或者用ldr x0, [x0, :lo12:label]直接访存。提示:lo12:是汇编器提供的修饰符表示取 label 的低 12 位偏移。在 GNU 汇编器GAS里写作:lo12:label在别的汇编器里可能有不同写法但语义一致。6.2 误区二搞混 ADR 和 ADRP 的寻址范围有的同学在写启动代码或裸机汇编时直接用 ADR 加载一个稍大的地址结果链接时报错 relocation truncated to fit: R_AARCH64_ADR_PREL_PG_HI21。这个报错的意思是 ADR 的 ±1MB 范围装不下目标地址。解决方案有两种改用 ADRP把寻址范围扩大到 ±4GB。如果地址还是超出 ADRP 范围那就只能手动计算地址并用绝对寻址加载了但那通常意味着你的代码段和数据段距离实在太大需要调整链接脚本。6.3 误区三在 32 位环境下套用 ARM64 指令ARM 的 32 位指令集AArch32可没有 ADRP 这个指令它对应的是 ADR 和基于 PC 的 LDR。只有切换到 AArch64 状态后ARM 处理器才支持 ADRP。如果你是在老旧的 ARMv7 设备上调试却看到反汇编输出 ADRP那大概率是反汇编工具配置错了架构。6.4 误区四忽略页对齐约束ADRP 的页对齐约束不仅体现在结果地址低 12 位为 0还体现在汇编器对 label 地址的处理上。链接器在生成重定位时会要求 ADRP 对应的符号地址做页对齐运算。如果你的链接脚本里数据段对齐设置不合理可能导致链接报错。通常默认的链接脚本不会出问题但如果你自己写链接脚本、把 .data 段放到了奇怪的位置就要检查一下 4KB 对齐是否满足。6.5 常见问题速查表问题现象可能原因解决方法链接报错 relocation truncated to fitADRP 寻址范围超限或符号跨 4GB 边界检查代码段与数据段距离考虑改用绝对地址运行时访问了错误地址忘了补低 12 位偏移用 ADD/LDR 配合 :lo12: 补齐反汇编看到 ADRP 但程序崩溃PC 值与符号地址差超过 ±4GB用 GOT 或绝对加载绕开汇编器不识别PAGE汇编器语法不兼容GNU 汇编用:lo12:其他工具链参考对应文档7. 工具链联动在 QEMU 与交叉编译环境中验证 ADRP7.1 搭建一个 ARM64 实验环境你不需要真的有一块 ARM64 开发板用 QEMU 就能模拟出一个 ARM64 环境来验证 ADRP 的行为。我个人常用的方式是在 x86 的 Linux 机器上装 QEMU 的 user-mode 模拟器直接跑交叉编译出来的 ARM64 可执行文件非常方便。安装工具链和模拟器以 Debian/Ubuntu 为例sudo apt install gcc-aarch64-linux-gnu qemu-user然后写一个简单的测试程序#include stdio.h int global_count 100; int main(void) { printf(global_count address: %p\n, global_count); return 0; }交叉编译并运行aarch64-linux-gnu-gcc -static -o test_adrp test_adrp.c qemu-aarch64 ./test_adrp用 QEMU user-mode 跑静态链接的程序是因为动态链接需要模拟器解析动态库配置起来更繁琐。能静态就静态实验目的只是看 ADRP 的行为。7.2 反汇编验证 ADRP 的实际生成编译完之后反汇编 main 函数那一段aarch64-linux-gnu-objdump -d test_adrp | grep -A20 main你会看到典型的 ADRP 指令序列比如adrp x0, 0x49000 add x0, x0, #0x238 bl printf这里的 0x49000 就是 global_count 所在页的基址0x238 是页内偏移。整个地址加起来正好是 global_count 的运行时地址。你可以试着改动代码把 global_count 换成其他符号或者编译时加上-fno-pic和默认 PIC 模式各编一次对比看看 ADRP 的使用有何不同。我发现这样做一次实验比你读十遍指令手册都管用。7.3 用 GDB 单步验证 ADRP 的取址过程如果只想看指令执行效果可以用 qemu-aarch64 配合 gdb-multiarch 调试qemu-aarch64 -g 1234 ./test_adrp另一个终端启动 gdb-multiarchgdb-multiarch ./test_adrp target remote :1234 break *main continue si info registers x0单步执行到 ADRP 之后你会看到 x0 变成某个页对齐地址。再执行 ADD 后x0 的低 12 位才被填上。这种“看见”寄存器的变化过程对理解 ADRP 的页语义帮助非常大。8. 从指令到系统ADRP 在真实软件中的角色8.1 动态链接器的重定位在动态链接的 ELF 文件里ADRP 对应的重定位类型通常涉及R_AARCH64_ADR_PREL_PG_HI21和R_AARCH64_ADD_ABS_LO12_NC。R_AARCH64_ADR_PREL_PG_HI21的含义是计算符号地址与 PC 所在页之间的页偏移填入 ADRP 的 21 位立即数。这个重定位是在链接或加载时完成的静态链接器或者动态链接器会把正确的立即数写进指令编码里。这也是为什么你反汇编一个动态链接库的.text段时看到的 ADRP 操作数往往是 0 或占位值因为重定位还没发生。等到程序运行时动态链接器拿到最终加载地址才会把这些占位值修正为正确的位移。如果你在分析固件或者其他非标准 ELF 时遇到 ADRP 操作数为 0不要慌那不代表指令无效而是等待重定位填值。8.2 ARM64 相比 x86-64 的地址加载差异做底层开发的朋友经常拿 ARM64 和 x86-64 对比。在 x86-64 的 PIC 代码里访问外部数据通常用lea rax, [rip symbol]RIP 相对寻址范围是 ±2GB对于“当前指令到目标符号”的距离限制更严格。而 ARM64 的 ADRP 是 ±4GB 页对齐范围虽然多了一条补齐指令的开销但整体寻址范围更大。这也算是两种架构各自的设计哲学x86 追求单指令效率ARM 用多条定长指令拼出更大的能力。8.3 在 Linux 内核中的高频使用ARM64 Linux 内核里ADRP 的使用频率极高。内核大部分代码也是 PIC 的因为内核需要被加载到不同的物理地址运行。宏SYM_FUNC_START、CPU_TO_MTT之类的底层汇编宏里到处是 ADRP 的身影。内核经常用 ADRP 加载页表、cpu 寄存器地址、异常向量表基地址等。如果你想深入内核汇编ADRP 是必须掌握的指令。8.4 同时期热词里的“多架构移植”问题最近社区里关于arm64和amd64有何不同、银河麒麟v10sp1 arm64 zerotier、qemu模拟arm64这类话题很热说明越来越多人在做 x86 到 ARM64 的迁移和交叉开发。无论是写业务代码还是移植工具链理解 ADRP 这类指令层面的差异能让你在排障时少走很多弯路。比如你在 ARM64 的国产操作系统上编译一个原本为 x86 写的程序如果里面有内联汇编或者对地址加载方式做了假设可能就会出现各种诡异的问题。这时候第一时间就该想到 PC 相对寻址的差异而 ADRP 就是其中的关键一环。9. 实操心得如何真正掌握 ADRP纸上谈兵终究浅这里分享几个我实际摸索出来的学习方法。第一不要死记硬背编码位段。你需要记住的是“ADRP 计算页基址、低 12 位为零、配合 ADD/LDR 补齐偏移”这个逻辑链条。编码位段上网一查就有但逻辑链条不理解看多少遍手册都白搭。第二用反汇编验证自己的判断。每次写完一个涉及全局变量访问的 C 程序就编译后反汇编看一眼看看编译器在哪里用了 ADRP用了哪种组合方式。看多了你对“什么时候该用 ADRP”会有直觉。第三刻意练习手动计算。随便拿两个地址比如当前 PC 是 0x10000 的倍数目标地址是某个非对齐值自己算一遍 ADRP 的立即数应该填多少。算完再用汇编器生成一下对比结果。这个过程能帮你彻底打通“地址”和“立即数编码”之间的换算关系。第四在调试器里观察实际行为。GDB 单步执行 ADRP看寄存器值的变化。尤其是看到低 12 位清零的那一刻你会对“页基址”这个概念产生肌肉记忆。我在前面提到的 QEMU 实验环境配合 gdb-multiarch 就足够做这些练习了。不需要真实硬件一台普通 Linux 或者 macOS 机器就能搞定。10. 最后再分享一个小技巧快速定位 ADRP 的目标符号如果你在做逆向经常需要根据一条 ADRP 指令反查它到底引用了哪个符号。第一步把 ADRP 的目标地址算出来。假设当前指令地址是0x4004c0反汇编显示adrp x0, 0x400000那目标页基址就是 0x400000。第二步找后续指令里的 ADD 或 LDR 偏移。比如接下来是add x0, x0, #0x38那完整地址就是 0x400038。第三步在二进制文件里查一下 0x400038 对应的符号表条目。IDA Pro 里按 G 输入地址Ghidra 里按 CtrlG都能快速定位到对应符号。如果是静态链接的 ELF符号表信息很丰富基本一查一个准。如果是 stripped 过的二进制没法直接查符号名但至少能看出引用的是哪个地址的数据。这个方法在我分析固件、逆向 App 时帮了大忙。ADRP 在反汇编里出现的频率实在太高掌握这套定位方法阅读反汇编清单的速度能提升一大截。
返回列表