1. ARM64 寻址模式概述

ARM64 的寻址模式,说白了就是 CPU 怎么找到它要操作的数据。我刚开始从 x86 切到 ARM64 时,最不适应的就是这块——x86 的寻址方式太灵活了,而 ARM64 做了大量精简。但精简不代表简单,反而更讲究套路。

咱们先看一张总览图,把六种寻址模式的关系理清楚。

ARM64 寻址模式知识结构图 ARM64 六大寻址模式 寻址模式 立即数寻址 寄存器寻址 内存寻址 MOV X0, #0x42 ADD X1, X2, X3 基址寻址 前变址 后变址 LDR X0, [X1, #8] LDR X0, [X1, #8]! LDR X0, [X1], #8 PC 相对寻址 ! 后缀表示写回基址寄存器 PC 相对寻址用于位置无关代码

1.1 寄存器寻址

这是最直接的一种。操作数就在寄存器里,CPU 直接拿过来用,不涉及内存访问。

ADD X0, X1, X2    // X0 = X1 + X2
SUB X3, X4, X5    // X3 = X4 - X5
AND X6, X7, X8    // X6 = X7 & X8

寄存器寻址的速度是最快的——没有访存延迟。我优化过一段视频编解码的汇编代码,里面大量使用寄存器寻址,把临时变量全部塞进寄存器,性能直接提升了 30%。

小技巧:ARM64 有 31 个通用寄存器(X0-X30),尽量多用,少往内存里倒腾。但注意 X30 是链接寄存器,别乱用。

1.2 立即数寻址

立即数就是直接写在指令里的常数。ARM64 的立即数编码有点特殊——不是所有 32 位或 64 位数都能直接写进去。

MOV X0, #0x42        // 合法,16 位立即数
MOV X1, #0x12345678  // 非法!超过编码范围

为什么会这样?ARM64 的指令长度固定 32 位,操作码、寄存器已经占了不少位,留给立即数的空间有限。ARM 的设计思路是:常用的立即数(比如 0-65535、移位后的模式)都能编码,不常用的就用多条指令拼出来。

踩坑提醒:我曾经在写启动代码时,直接写了个 MOV X0, #0x10000000,汇编器报错。后来改成 MOVZ X0, #0x1000, LSL #16 才搞定。记住:大常数用 MOVZ/MOVK 组合。

1.3 基址寻址

这是内存访问最常用的模式。格式是 [基址寄存器, #偏移],CPU 把基址寄存器的值加上偏移量,得到有效地址,然后访问内存。

LDR X0, [X1, #8]    // 从 X1+8 地址加载 8 字节到 X0
STR X2, [X3, #16]   // 将 X2 存储到 X3+16 地址

偏移量可以是正数也可以是负数。我调试过一个网络协议栈的 bug,就是偏移量写成了正数,结果访问了错误的内存区域,导致数据包校验失败。

偏移类型 示例 有效地址
正偏移 LDR X0, [X1, #8] X1 + 8
负偏移 LDR X0, [X1, #-8] X1 - 8
寄存器偏移 LDR X0, [X1, X2] X1 + X2
扩展寄存器偏移 LDR X0, [X1, X2, LSL #3] X1 + (X2 << 3)

1.4 前变址(Pre-index)

前变址和基址寻址很像,但多了一个 ! 后缀。这个感叹号的意思是:先计算地址,然后更新基址寄存器。

LDR X0, [X1, #8]!   // 先计算 X1+8,加载到 X0,然后 X1 = X1+8

说白了就是「先改指针,再取值」。这在遍历数组或链表时特别有用。我写过一个内存池分配器,用前变址来管理空闲链表,每次分配节点后指针自动后移,省了一条 ADD 指令。

性能要点:前变址和后变址(下面会讲)都能减少指令数量。在循环中,一次 LDR 加一次 ADD 可以合并成一条前变址 LDR,指令密度更高,流水线更顺畅。

1.5 后变址(Post-index)

后变址和前变址相反:先取值,再改指针。格式是 [基址寄存器], #偏移。

LDR X0, [X1], #8    // 先从 X1 加载到 X0,然后 X1 = X1+8

嗯,这里要注意:后变址的偏移量不能是寄存器,只能是立即数。这是 ARM64 的硬件限制。

我遇到过一种情况:有人写栈操作时用了后变址,结果函数返回时栈指针没恢复对,导致 crash。后来我建议他用前变址加手动调整,问题就解决了。

模式 语法 执行顺序 典型用途
基址寻址 [Xn, #imm] 不修改基址 结构体成员访问
前变址 [Xn, #imm]! 先改基址,再访存 栈帧分配、链表遍历
后变址 [Xn], #imm 先访存,再改基址 栈弹出、数组顺序访问

1.6 PC 相对寻址

PC 相对寻址是 ARM64 里最特殊的一种。它用程序计数器(PC)作为基址,加上一个偏移量来计算目标地址。你想想看,为什么需要这种模式?

因为位置无关代码(PIC)。动态链接库、共享库的代码段可能被加载到任意地址,如果指令里写死绝对地址,换个地方加载就崩了。PC 相对寻址让代码可以在内存中任意移动,只要相对位置不变,就能正常工作。

ADR X0, label       // 将 label 的地址加载到 X0(小范围)
ADRP X0, label      // 将 label 所在页的基址加载到 X0(大范围)
LDR X0, [PC, #offset]  // 从 PC+offset 加载数据

ADR 的寻址范围是 ±1MB,ADRP 可以覆盖 ±4GB(按页对齐)。我调试过的一个 Android 系统服务,启动时总是崩溃,最后发现是 ADR 指令的偏移量超过了 1MB,换成 ADRP 就好了。

注意:PC 相对寻址中的 PC 值,在 ARM64 里是当前指令的地址(不像 ARM32 是当前指令 +8)。这个细节坑过不少人,包括我。

1.7 寻址模式选择建议

说了这么多,到底怎么选?我总结了几条经验:

  • 能放寄存器就别放内存——寄存器寻址最快,没有之一。
  • 小常数用立即数——但注意编码范围,超过 16 位就用 MOVZ/MOVK。
  • 结构体访问用基址寻址——偏移量写清楚,代码可读性高。
  • 循环遍历用后变址——一条指令完成加载和指针移动。
  • 栈操作用前变址/后变址——但一定要配对使用,否则栈会乱。
  • 位置无关代码用 PC 相对寻址——ADR/ADRP 是你的好朋友。
我的习惯:写汇编前先画个数据流图,标清楚每个操作数在哪里(寄存器、立即数、内存),然后选最直接的寻址模式。这样写出来的代码既快又容易调试。