4、内存访问指令与寻址模式:加载存储指令(LDR/STR)、前变址与后变址寻址、基址加偏移寻址、多字节加载存储(LDP/STP)、非对齐访问与注意事项
内存访问,说白了就是 CPU 和内存之间倒腾数据。ARM64 是个典型的加载-存储架构——你要操作数据,得先把它从内存搬到寄存器里,算完了再存回去。不能像 x86 那样直接在内存上做运算。这个设计哲学贯穿了整个指令集。
我个人习惯把内存访问指令分成三类:单字节、多字节、以及带地址更新的。咱们一个一个来拆。
4.1 单寄存器加载/存储:LDR 与 STR
这是最基础的指令。LDR 从内存读数据到寄存器,STR 把寄存器数据写回内存。格式很简单:
LDR X0, [X1] // 从 X1 指向的地址读 8 字节到 X0
STR W0, [X1] // 把 W0 的 4 字节写到 X1 指向的地址
注意寄存器宽度决定了访问大小:
| 寄存器 | 访问大小 | 典型用途 |
|---|---|---|
| X(64位) | 8 字节 | 指针、长整数、结构体字段 |
| W(32位) | 4 字节 | int、unsigned int |
| B(8位) | 1 字节 | char、byte 操作 |
| H(16位) | 2 字节 | short、半字 |
我在项目中遇到过一个问题:有人用 LDR 加载一个 4 字节的 int,但寄存器用了 X。结果读进来的数据把高 32 位也带上了,后面做比较时死活不对。嗯,宽度匹配是基本功,但最容易翻车。
4.2 寻址模式:基址加偏移
ARM64 的寻址模式比 ARM32 清爽多了。最常用的是「基址 + 偏移」:
LDR X0, [X1, #8] // 地址 = X1 + 8
LDR X0, [X1, X2] // 地址 = X1 + X2
LDR X0, [X1, X2, LSL #3] // 地址 = X1 + (X2 << 3)
偏移可以是立即数、寄存器、或者带移位的寄存器。这里有个坑:立即数偏移必须是访问大小的倍数。比如 LDR X0, [X1, #7] 是非法的,因为 7 不是 8 的倍数。为什么?硬件设计上地址必须对齐,否则要额外处理。
4.3 前变址与后变址寻址
这两个模式是 ARM64 的特色,也是很多人一开始觉得绕的地方。说白了,它们在做内存访问的同时,顺手更新基址寄存器。
前变址(Pre-indexed):先更新地址,再访问。
LDR X0, [X1, #8]! // 先 X1 += 8,再读 [X1] 到 X0
那个感叹号就是关键。它告诉 CPU:把计算后的新地址写回 X1。我经常在遍历数组时用这个,省一条 ADD 指令。
后变址(Post-indexed):先访问,再更新地址。
LDR X0, [X1], #8 // 先读 [X1] 到 X0,再 X1 += 8
这个模式在栈操作里特别常见。比如函数返回时,从栈上弹出数据,同时把栈指针往上抬:
LDP X29, X30, [SP], #16 // 弹出 X29 和 X30,然后 SP += 16
我曾经在写一个网络协议栈时,用后变址遍历环形缓冲区。每次读一个包,指针自动前进,代码简洁到不行。但要注意:后变址的更新是在访问之后,如果访问触发了异常,地址已经更新了。调试时容易懵。
4.4 多字节加载存储:LDP 与 STP
LDP 和 STP 是 ARM64 的明星指令。一条指令能加载或存储一对寄存器,效率比两条 LDR 高得多。
LDP X0, X1, [X2] // 从 X2 读 16 字节到 X0 和 X1
STP X0, X1, [X2] // 把 X0 和 X1 的 16 字节写到 X2
为什么效率高?因为 LDP/STP 在微架构层面是单条微操作,而两条 LDR 是两条微操作。指令解码、发射、提交的开销都省了。我在做性能调优时,只要看到连续的两个 LDR 或 STR,第一反应就是能不能合并成 LDP/STP。
举个例子,结构体拷贝:
// 低效写法
LDR X0, [X1]
STR X0, [X2]
LDR X0, [X1, #8]
STR X0, [X2, #8]
// 高效写法
LDP X0, X3, [X1]
STP X0, X3, [X2]
性能差距在热路径上能到 2 倍以上。你想想看,一条指令搞定的事,为什么要用两条?
4.5 非对齐访问与注意事项
ARM64 默认支持非对齐访问。也就是说,你可以用 LDR 从地址 0x1003 读 8 字节。但代价是什么?性能损失。
为什么会有损失?因为内存系统是按对齐边界组织的。一个 8 字节的访问,如果地址是 8 字节对齐的,一次总线事务就搞定。如果不对齐,硬件得拆成两次甚至三次事务,再拼起来。这个开销在热路径上非常明显。
| 访问类型 | 对齐要求 | 非对齐代价 |
|---|---|---|
| LDR/STR(单寄存器) | 无强制要求 | 1-3 个额外周期 |
| LDP/STP(双寄存器) | 16 字节对齐(默认) | 对齐异常或性能骤降 |
| LDR/STR(原子操作) | 必须对齐 | 触发对齐异常 |
我曾经在调试一个视频编解码模块时,发现 LDP 频繁触发 SIGBUS。查了半天,发现输入数据是网络包,起始地址没做 16 字节对齐。解决方案很简单:在分配缓冲区时用 posix_memalign 保证对齐,或者在拷贝时先用 LDR 处理头几个字节,对齐后再用 LDP。
另外,SCTLR_EL1 寄存器的 A 位(对齐检查使能位)可以控制是否触发对齐异常。默认是关闭的,但有些操作系统会打开。如果你在用户态遇到对齐异常,先查内核配置,再查数据对齐。
4.6 知识结构总览
下面这张图把本章的核心知识点串起来了。你可以把它当作一个快速索引:
这张图把 LDR/STR、LDP/STP、以及三种寻址模式的关系理清楚了。你写代码时,先想清楚要访问几个寄存器,再想地址怎么算,最后决定要不要顺手更新基址。顺序对了,代码就不会乱。
嗯,内存访问指令这块,说白了就是「搬数据」和「算地址」的组合。ARM64 给了你足够的灵活性,但灵活性也意味着选择。我的建议是:能用 LDP/STP 就别用 LDR/STR,能用立即数偏移就别用寄存器偏移,能对齐就别非对齐。这些习惯养成了,性能调优就成功了一半。