4. 加载与存储指令详解
好,咱们今天聊一个很实际的话题。很多从ARM32转过来的工程师,刚接触ARM64时最不习惯的就是——LDM和STM怎么没了?
我记得第一次在ARM64上做上下文切换时,习惯性地写了个LDMFD sp!, {r4-r11},结果汇编器直接报错。当时我还愣了一下,心想这指令不是ARM的标配吗?后来翻了手册才明白,ARM64的设计哲学变了。
4.1 为什么ARM64砍掉了LDM/STM?
说白了,LDM/STM在ARM32时代确实好用。一条指令能加载或存储多个寄存器,代码密度高,尤其适合函数入口/出口的现场保存。但它在高性能处理器上有个硬伤——中断响应延迟不可控。
你想想看,一条LDM指令可能正在连续加载8个寄存器,如果此时来了一个中断,处理器必须等这条指令执行完才能响应。8个寄存器的加载,在慢速内存上可能要几十个周期。这对实时系统来说是不能接受的。
ARM64的设计目标之一就是可预测的中断延迟。所以,他们把LDM/STM拆成了更小粒度的指令对——LDP/STP。每条指令最多操作2个寄存器,中断可以在指令边界上精确响应。
核心变化:
- ARM32:LDM/STM — 一次操作1~16个寄存器
- ARM64:LDP/STP — 一次操作2个寄存器
- 补充:LDR/STR — 一次操作1个寄存器
4.2 LDP/STP指令详解
LDP和STP的语法其实很直观。我直接上例子,你看一眼就明白了。
// 存储两个寄存器到内存
STP x29, x30, [sp, #-16]! // 先减sp,再存入x29和x30
// 从内存加载两个寄存器
LDP x29, x30, [sp], #16 // 先加载x29和x30,再增加sp
// 带偏移的寻址
STP x0, x1, [sp, #32] // 存入sp+32位置,不修改sp
LDP x2, x3, [sp, #64] // 从sp+64加载,不修改sp
这里有个细节我特别想强调——寄存器对必须是连续的。比如STP x0, x1可以,但STP x0, x2就不行。这是硬件设计上的限制,也是为了让解码逻辑更简单。
我的习惯:在函数入口处,我通常用STP一次性保存x29(帧指针)和x30(链接寄存器)。这样既简洁,又保持了栈帧的完整性。
4.3 三种寻址模式对比
LDP/STP支持三种寻址模式,和ARM32的LDM/STM类似,但更规整。我整理了一个表格,方便你对照。
| 寻址模式 | 语法示例 | 行为描述 | 典型用途 |
|---|---|---|---|
| 基址变址(无回写) | STP x0, x1, [sp, #16] | 存入sp+16,sp不变 | 访问栈中固定偏移的局部变量 |
| 前变址(先减后存) | STP x0, x1, [sp, #-32]! | sp先减32,再存入 | 函数入口开辟栈帧 |
| 后变址(先存后加) | LDP x0, x1, [sp], #32 | 先加载,sp再加32 | 函数出口恢复栈帧 |
嗯,这里要注意:前变址的感叹号!表示回写基址寄存器。这个语法和ARM32的LDMFD sp!, {r4-r11}很像,但语义更清晰——你明确知道sp是在访问前还是访问后被修改。
4.4 实战:函数入口/出口的栈帧操作
咱们来看一个完整的例子。这是一个典型的ARM64函数序言和结语。
// 函数序言:开辟栈帧,保存被调用者保存的寄存器
func_example:
STP x29, x30, [sp, #-48]! // 保存FP和LR,sp减48
STP x19, x20, [sp, #16] // 保存x19, x20到sp+16
STP x21, x22, [sp, #32] // 保存x21, x22到sp+32
ADD x29, sp, #0 // 设置帧指针
// ... 函数体 ...
// 函数结语:恢复寄存器,释放栈帧
LDP x19, x20, [sp, #16] // 恢复x19, x20
LDP x21, x22, [sp, #32] // 恢复x21, x22
LDP x29, x30, [sp], #48 // 恢复FP和LR,sp加48
RET // 返回
你看,这里用了3对STP和3对LDP,完成了6个寄存器的保存和恢复。如果用ARM32的LDM/STM,一条LDMFD sp!, {r4-r11}就搞定了。但ARM64这么做,换来的是每个指令边界都可以响应中断。
我曾经踩过的坑:在写中断服务程序时,我一开始用了LDP x0, x1, [sp], #16来恢复寄存器。但忘了检查sp是否对齐到16字节。ARM64要求LDP/STP的地址必须16字节对齐,否则会触发对齐异常。那次调试花了我半天时间,最后发现是sp在某个路径上被改成了奇数偏移。
4.5 性能调优要点
LDP/STP虽然比LDM/STM指令数多,但实际性能并不差。原因有三:
- 双发射能力:现代ARM64处理器通常能在一个周期内发射两条LDP/STP,相当于恢复了LDM/STM的吞吐量。
- 更短的指令延迟:LDP/STP的延迟通常只有2-3个周期,而LDM/STM在慢速内存上可能长达几十个周期。
- 更好的缓存行为:LDP/STP每次只操作两个寄存器,缓存行填充更高效,不容易触发缓存颠簸。
我个人建议,在性能敏感的代码中,尽量把LDP/STP的地址对齐到16字节。如果地址不对齐,处理器可能需要额外的内存访问周期。我在一个网络包处理项目中做过测试,对齐后的LDP/STP比未对齐的快了约15%。
4.6 知识结构图
下面这张图总结了LDP/STP在ARM64寻址体系中的位置,以及它与ARM32 LDM/STM的对应关系。
4.7 避坑指南与最佳实践
最后,我总结几个实际项目中容易踩的坑,以及我个人的建议。
避坑1:寄存器对顺序
LDP x0, x1, [sp] 加载时,x0来自低地址,x1来自高地址。这个顺序和ARM32的LDMIA r0!, {r0, r1}是一致的。但如果你写成LDP x1, x0, [sp],x1会加载低地址的值,x0加载高地址的值。我曾经在寄存器重命名时搞反了顺序,导致函数返回地址被加载到了x0里,程序直接跑飞。
避坑2:栈指针对齐
ARM64的栈指针sp必须始终16字节对齐。如果你在函数内部手动修改了sp(比如为了动态分配内存),一定要在调用LDP/STP之前恢复对齐。我习惯在函数入口处用STP保存寄存器后,立即用ADD x29, sp, #0记录对齐后的栈指针,后续所有栈操作都基于x29而不是sp。
最佳实践:
- 函数入口用STP x29, x30, [sp, #-N]!一次性保存FP和LR
- 函数出口用LDP x29, x30, [sp], #N一次性恢复FP和LR
- 中间的被调用者保存寄存器,用多对STP/LDP按顺序保存和恢复
- 尽量保持LDP/STP的地址16字节对齐,避免性能惩罚
好了,关于LDP/STP的内容就聊到这里。这套指令虽然看起来比ARM32的LDM/STM啰嗦,但它在中断响应和性能可预测性上的优势,是ARM64设计的重要进步。你在实际项目中多写几次,就会习惯这种风格了。