为什么 AArch64 获取符号地址总是采用 ADRP + ADD 的形式

刚接触 AArch64 汇编的人往往会对编译器生成的一类代码感到困惑。

考虑下面这样一个简单的 C 函数:

extern int foo;

int *bar(void) {
return &foo;
}

在开启 PIE(Position Independent Executable)后,不同架构的编译器通常会生成如下代码。

leaq foo(%rip), %rax
adrp    x0, foo
add x0, x0, :lo12:foo

两种架构都采用了 PC-relative(PC 相对)寻址,即结合 PC 寄存器(x86-64 中的 %rip 或 AArch64 中的 pc)与预定义偏移量计算目标符号的最终地址。 但相比 x86-64 一条 LEA 指令即可完成地址计算,AArch64 却需要两条指令。这是为什么?

为什么不能直接构造一个 64 位地址?

首先需要说明的是,这并不是因为 AArch64 无法表示一个 64 位地址。

AArch64 是一种定长指令集,所有指令均为 32 位(4 字节)。受限于固定的指令编码空间,MOVZMOVNMOVK 每次只能操作一个 16 位立即数字段。因此,构造一个任意的 64 位常数通常需要四条指令:

movz    x0, #0xcdef
movk x0, #0x89ab, lsl #16
movk x0, #0x4567, lsl #32
movk x0, #0x0123, lsl #48

既然如此,为什么编译器几乎从不这样加载符号地址,而是宁愿使用 ADRP + ADD

答案并不在于指令本身,而在于现代可执行文件的链接与装载方式。

首先,符号地址在汇编阶段通常尚未确定。汇编器只能生成对应的 relocation,由链接器负责回填最终地址。如果采用上述方式,链接器需要分别修改四条指令中的立即数字段;对于位置无关代码(Position Independent Code,PIC),程序每次被加载到不同的虚拟地址时,动态加载器还需要再次修补这些指令。相比之下,PC 相对寻址通常只需要两个 relocation,链接和装载的开销都更低。

更关键的是,直接编码绝对地址破坏了位置无关代码的基本假设。现代操作系统普遍启用了地址空间布局随机化(Address Space Layout Randomization,ASLR),同一个可执行文件每次运行时,其装载基址都可能不同。如果代码中保存的是绝对地址,那么程序装载时就必须修改代码段本身。这不仅增加了动态加载器的工作量,也意味着代码页无法保持只读,从而失去多个进程共享同一份物理页的能力。

因此,现代 AArch64 ABI 更倾向于采用 PC-relative(PC 相对)寻址

为什么是 ADRP + ADD?

AArch64 获取静态符号地址最常见的形式如下:

adrp    x0, foo
add x0, x0, :lo12:foo

其中,ADRP 并不会直接得到 foo 的地址,而是先定位到 foo 所在的 4 KiB 页面;随后,ADD 再补上页内偏移。

假设

foo = 0x123456789abc

那么上述两条指令实际上完成的是:

page(foo)   = foo & ~0xfff
offset(foo) = foo & 0xfff

x0 = page(foo) + offset(foo)

这种拆分利用了虚拟内存分页的特点。对于同一个程序而言,各个页面之间的相对位置在链接时就已经确定,而页内偏移始终只有 12 位。因此,无需编码完整的 64 位地址,只需记录目标页面相对于当前指令所在页面的位置,再补上页内偏移即可恢复完整地址。

从编码角度来看,ADRP 使用 21 位带符号立即数表示页号差,再左移 12 位恢复为字节地址,因此能够覆盖约 ±4 GiB 的寻址范围,足以覆盖绝大多数用户态程序。相比直接构造一个 64 位绝对地址,这种方案不仅需要更少的重定位信息,也天然适用于 PIE、共享库等位置无关代码。

需要注意的是,真正编码进 ADRP 的并不是 foo 的绝对地址,而是目标页面相对于当前 PC 所在页面的偏移。当程序因 ASLR 而整体移动时,代码段和数据段会一起平移,它们之间的相对位置保持不变,因此这两个立即数字段通常无需修改。这也是 PC 相对寻址能够天然支持位置无关代码的根本原因。

ADR 与 ADRP

到这里,一个问题自然出现了:既然 ADRP + ADD 是获取符号地址的标准方式,那么为什么 AArch64 还提供了一条 ADR 指令?

实际上,两者完成的是同一件事情,只是适用范围不同。

ADR 将 21 位带符号立即数解释为字节偏移,因此能够直接计算:

x0 = PC + offset

由于偏移量只有 21 位,其寻址范围约为 ±1 MiB,因此主要用于距离当前位置较近的目标,例如函数内部标签或邻近的只读数据。

相比之下,ADRP 将相同的 21 位立即数解释为页号差,因此覆盖范围扩大到了约 ±4 GiB。编译器无法保证目标符号始终落在 ±1 MiB 范围内,因此对于全局对象,通常都会选择更加通用的 ADRP + ADD

从概念上讲,可以将 ADR 理解为“一步完成地址计算”,而 ADRP + ADD 则是“先定位到目标页面,再恢复页内偏移”。两者完成的是同一件事情,只是在寻址范围和编码方式上有所不同。

总结

初看之下,ADRP + ADD 像是 AArch64 为了弥补固定长度指令编码能力不足而采用的一种折中方案。然而,真正驱动这一设计的并不是 ISA 本身,而是现代 ABI 对位置无关代码、动态链接以及代码共享的需求。

事实上,这并非 AArch64 独有的设计思想。现代 x86-64 编译器在生成 PIE 时,同样会优先采用 RIP 相对寻址:

leaq foo(%rip), %rax

而不是直接使用 movabs 加载绝对地址。两种架构遵循的是同一个原则:尽可能避免在代码中编码绝对地址,而使用位置无关的相对地址表示程序内部对象。

从这个角度来看,ADRP + ADD 并不是一种特殊技巧,而是现代系统软件设计理念在 AArch64 指令集上的具体体现。


作者:hsfzxjy
链接:
许可:CC BY-NC-ND 4.0.
著作权归作者所有。本文不允许被用作商业用途,非商业转载请注明出处。