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 倍以上。你想想看,一条指令搞定的事,为什么要用两条?

关键点:LDP/STP 要求地址 16 字节对齐。如果地址不对齐,会触发对齐异常。但别急,往下看。

4.5 非对齐访问与注意事项

ARM64 默认支持非对齐访问。也就是说,你可以用 LDR 从地址 0x1003 读 8 字节。但代价是什么?性能损失。

为什么会有损失?因为内存系统是按对齐边界组织的。一个 8 字节的访问,如果地址是 8 字节对齐的,一次总线事务就搞定。如果不对齐,硬件得拆成两次甚至三次事务,再拼起来。这个开销在热路径上非常明显。

访问类型 对齐要求 非对齐代价
LDR/STR(单寄存器) 无强制要求 1-3 个额外周期
LDP/STP(双寄存器) 16 字节对齐(默认) 对齐异常或性能骤降
LDR/STR(原子操作) 必须对齐 触发对齐异常

我曾经在调试一个视频编解码模块时,发现 LDP 频繁触发 SIGBUS。查了半天,发现输入数据是网络包,起始地址没做 16 字节对齐。解决方案很简单:在分配缓冲区时用 posix_memalign 保证对齐,或者在拷贝时先用 LDR 处理头几个字节,对齐后再用 LDP。

避坑指南:非对齐访问在 ARM64 上「能用」不代表「该用」。性能敏感代码里,我建议你始终保证对齐。特别是 LDP/STP,不对齐直接崩给你看。如果你非要处理非对齐数据,用 LDR/STR 单字节或半字访问,别用 LDP。

另外,SCTLR_EL1 寄存器的 A 位(对齐检查使能位)可以控制是否触发对齐异常。默认是关闭的,但有些操作系统会打开。如果你在用户态遇到对齐异常,先查内核配置,再查数据对齐。

4.6 知识结构总览

下面这张图把本章的核心知识点串起来了。你可以把它当作一个快速索引:

ARM64 内存访问指令与寻址模式 内存访问指令 单寄存器 LDR/STR 双寄存器 LDP/STP 寻址模式 基址+偏移 前变址 [!] 后变址 []! 16字节对齐 性能优势 宽度匹配 非对齐访问 核心原则:对齐访问性能最优,LDP/STP 优先于双 LDR/STR 前变址/后变址在遍历和栈操作中非常实用,但要注意异常行为

这张图把 LDR/STR、LDP/STP、以及三种寻址模式的关系理清楚了。你写代码时,先想清楚要访问几个寄存器,再想地址怎么算,最后决定要不要顺手更新基址。顺序对了,代码就不会乱。

嗯,内存访问指令这块,说白了就是「搬数据」和「算地址」的组合。ARM64 给了你足够的灵活性,但灵活性也意味着选择。我的建议是:能用 LDP/STP 就别用 LDR/STR,能用立即数偏移就别用寄存器偏移,能对齐就别非对齐。这些习惯养成了,性能调优就成功了一半。