3. 内存访问指令与寻址模式:LDR/STR指令、基址寻址、前变址寻址、后变址寻址、字扩展寻址、字扩展与移位寻址

内存访问,说白了就是CPU和内存之间的数据搬运。ARM64里,LDR和STR就是干这活的主力。你别看它们名字简单,背后的寻址模式玩起来花样不少。我刚开始接触ARM64时,觉得寻址模式不就是算个地址嘛,后来在调一个网络包处理程序时才发现,选错寻址模式,性能能差出一大截。

嗯,咱们今天就把这些模式掰开揉碎,一个一个说清楚。

3.1 LDR/STR指令基础

LDR(Load Register)从内存读数据到寄存器,STR(Store Register)把寄存器数据写回内存。这是最基础的操作,但细节里藏着魔鬼。

指令格式速览:

LDR  Xt, [Xn]       // 从 Xn 指向的地址加载 8 字节到 Xt
STR  Xt, [Xn]       // 把 Xt 的 8 字节写入 Xn 指向的地址

LDR  Wt, [Xn]       // 加载 4 字节(零扩展到 32 位)
STR  Wt, [Xn]       // 写入 4 字节

注意,ARM64里地址寄存器必须是64位的X系列,但数据寄存器可以是X(64位)或W(32位)。我见过有人用W寄存器当地址,编译直接报错——这个坑,踩一次就记住了。

3.2 基址寻址(Base Register Addressing)

这是最简单的模式。地址就在寄存器里,直接读写。

LDR  X0, [X1]       // X0 = *X1
STR  X2, [X3]       // *X3 = X2

你可能会问:这有什么好讲的?其实,基址寻址是其他所有寻址模式的基础。它不修改地址寄存器,只是单纯地用它指向内存。

我的习惯:在循环体里,如果地址不需要变化,我尽量用基址寻址。它指令编码最短,解码最快。别小看这点,在热路径上,每条指令少一个周期,累积起来就是性能。

3.3 前变址寻址(Pre-indexed Addressing)

前变址,就是先算地址,再访问内存,最后把新地址写回基址寄存器。格式是 [Xn, #imm]!,那个感叹号是关键。

LDR  X0, [X1, #8]!   // X1 = X1 + 8; X0 = *X1
STR  X2, [X3, #16]!  // X3 = X3 + 16; *X3 = X2

这个模式在遍历数组时特别有用。我记得有一次优化一个音频解码器,里面有个循环要连续访问结构体数组。用前变址,一条指令就完成了地址更新和数据加载,比分开写ADD和LDR少了一条指令,循环体小了,指令缓存命中率也上去了。

注意:前变址会修改基址寄存器。如果你后续还需要原来的地址,千万别用这个模式。我曾经在调试一个栈回溯代码时,用了前变址把栈指针改了,结果函数返回时直接崩溃——血的教训。

3.4 后变址寻址(Post-indexed Addressing)

后变址正好反过来:先访问内存,再更新地址寄存器。格式是 [Xn], #imm,没有感叹号。

LDR  X0, [X1], #8   // X0 = *X1; X1 = X1 + 8
STR  X2, [X3], #16  // *X3 = X2; X3 = X3 + 16

这个模式在栈操作里很常见。比如函数返回时,从栈上弹出数据,同时调整栈指针:

LDP  X29, X30, [SP], #16   // 恢复帧指针和链接寄存器,同时 SP += 16

后变址的好处是,你访问的是当前地址,然后指针自动前进。这特别适合FIFO队列、流式数据处理。我在写一个网络驱动时,用后变址处理描述符环,代码简洁,性能也干净。

3.5 字扩展寻址(Register Offset with Extension)

有时候,偏移量不是立即数,而是存在另一个寄存器里。ARM64支持把寄存器里的值做符号扩展或零扩展后,再加到基址上。

LDR  X0, [X1, X2]          // X0 = *(X1 + X2)
LDR  X0, [X1, W2, SXTW]    // X0 = *(X1 + sign_extend(W2))
LDR  X0, [X1, W2, UXTW]    // X0 = *(X1 + zero_extend(W2))

为什么要扩展?因为W2是32位的,但地址是64位的。如果不扩展,高位不确定,地址就错了。我见过一个bug,有人用W寄存器做偏移,没加扩展,结果高32位是垃圾值,程序随机崩溃——查了两天才找到。

实用场景:数组索引。假设你有一个int数组,索引存在W2里,你想访问第W2个元素:

LDR  W0, [X1, W2, UXTW #2]   // X0 = *(X1 + W2 * 4)

这里#2是移位量,表示乘以4(因为int是4字节)。

3.6 字扩展与移位寻址(Extended Register with Shift)

这是字扩展的进阶版。你可以在扩展的同时,对偏移量做移位(左移),用来乘以元素大小。

LDR  X0, [X1, W2, UXTW #3]   // X0 = *(X1 + zero_extend(W2) * 8)
LDR  X0, [X1, X2, LSL #3]    // X0 = *(X1 + X2 * 8)

这个模式在遍历结构体数组或指针数组时特别有用。比如你有一个long数组,每个元素8字节,索引在X2里:

// 循环遍历 long 数组
// X1 = 数组基址, X2 = 索引
loop:
  LDR  X0, [X1, X2, LSL #3]   // 加载第 X2 个元素
  // 处理 X0 ...
  ADD  X2, X2, #1
  CMP  X2, X5                 // X5 = 数组长度
  B.LT loop

你看,一条LDR就完成了索引计算、移位、加载。不需要单独的ADD或LSL指令。指令数少了,循环体小了,性能自然就上去了。

性能调优建议:在热循环里,尽量用这种带移位的寻址模式。它把地址计算和内存访问合并成一条指令,减少了指令发射压力。我在优化一个矩阵乘法时,把索引计算从循环体里提到寻址模式里,性能提升了约12%。

3.7 寻址模式对比与选择

为了让你看得更清楚,我把这几种模式整理成一个表格:

寻址模式 语法示例 地址计算 是否修改基址 典型场景
基址寻址 [Xn] Xn 否 简单变量访问
前变址 [Xn, #imm]! Xn + imm 是 数组遍历、结构体访问
后变址 [Xn], #imm Xn 是 栈操作、FIFO队列
字扩展 [Xn, Wm, UXTW] Xn + ext(Wm) 否 32位索引数组
字扩展+移位 [Xn, Wm, UXTW #n] Xn + ext(Wm) * 2^n 否 元素大小非1的数组

3.8 寻址模式知识结构图

下面这张图帮你理清这几种寻址模式的关系:

ARM64 内存访问寻址模式 LDR / STR 指令 基址寻址 [Xn] 前变址 [Xn,#imm]! 后变址 [Xn],#imm 寄存器偏移 字扩展 [Xn,Wm,UXTW] 字扩展+移位 [Xn,Wm,UXTW #n] 关键选择原则: • 地址不变 → 基址寻址(指令最短) • 地址需自增 → 前变址(先加后访)或后变址(先访后加) • 索引在寄存器中 → 字扩展+移位(合并计算与访问)

3.9 实战避坑指南

说了这么多,最后分享几个我踩过的坑:

  • 别乱用前变址改基址:我曾经在循环里用前变址遍历数组,结果循环变量被改了,数组越界都没发现。调试了一下午才找到原因。
  • 字扩展别忘了移位量:如果你访问的是int数组(4字节),索引是W2,一定要写UXTW #2。少写了#2,访问的就是字节地址,不是元素地址。
  • 后变址在异常路径要小心:如果后变址指令触发了页错误,基址寄存器已经被修改了。异常处理里恢复现场时,地址可能已经不对了。我在写一个内存拷贝函数时遇到过这个问题,后来改用前变址+单独比较才解决。
  • 立即数范围有限:前变址和后变址的立即数范围是-256到255(按字节算),超出范围要用寄存器偏移。别硬写大偏移量,编译器会报错。

性能红线:在热路径上,尽量用基址寻址或字扩展+移位。前变址和后变址虽然方便,但会修改基址寄存器,可能引入额外的依赖链。如果循环体很小,这种依赖会拖慢流水线。我一般只在循环体较大(10条指令以上)时才用前变址/后变址,小循环里宁可多写一条ADD。

嗯,寻址模式就讲到这里。每种模式都有它的用武之地,关键是你得理解它们的底层行为。选对了,代码既简洁又高效;选错了,调试和优化都够你喝一壶的。下次写内存访问代码时,多想想:我到底需要哪种寻址?