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 寻址模式知识结构图
下面这张图帮你理清这几种寻址模式的关系:
3.9 实战避坑指南
说了这么多,最后分享几个我踩过的坑:
- 别乱用前变址改基址:我曾经在循环里用前变址遍历数组,结果循环变量被改了,数组越界都没发现。调试了一下午才找到原因。
- 字扩展别忘了移位量:如果你访问的是
int数组(4字节),索引是W2,一定要写UXTW #2。少写了#2,访问的就是字节地址,不是元素地址。 - 后变址在异常路径要小心:如果后变址指令触发了页错误,基址寄存器已经被修改了。异常处理里恢复现场时,地址可能已经不对了。我在写一个内存拷贝函数时遇到过这个问题,后来改用前变址+单独比较才解决。
- 立即数范围有限:前变址和后变址的立即数范围是-256到255(按字节算),超出范围要用寄存器偏移。别硬写大偏移量,编译器会报错。
性能红线:在热路径上,尽量用基址寻址或字扩展+移位。前变址和后变址虽然方便,但会修改基址寄存器,可能引入额外的依赖链。如果循环体很小,这种依赖会拖慢流水线。我一般只在循环体较大(10条指令以上)时才用前变址/后变址,小循环里宁可多写一条ADD。
嗯,寻址模式就讲到这里。每种模式都有它的用武之地,关键是你得理解它们的底层行为。选对了,代码既简洁又高效;选错了,调试和优化都够你喝一壶的。下次写内存访问代码时,多想想:我到底需要哪种寻址?