内存操作
在高级语言中,操作的基本对象多以通过不同数据结构和数据类型存储的变量和常量为主,而对于汇编而言,各种指令操作大多通过寄存器实现,然而寄存器数量实在有限,远远无法满足程序员对变量的需求,此时你就会发现寄存器与内存之间的交互操作是十分频繁的。
内存操作大体分为load(加载)和store(存储)两大类,两类之间的用法规范和功能一一对应。以load类别进一步说明,最常见的指令就是ldr和ldp了,前者加载一个寄存器的内容,后者加载两个寄存器,基本的指令格式如下:
Ldr (ldp) 寄存器名称,(寄存器名称2), 内存寻址
- 内存寻址方式
无论是load操作,还是store操作,都需要指向一个内存地址。如表*所示,以ldr指令为例,最常见的内存寻址方式有以下3种:
类型
LDR指令
说明
(假设加载的初始地址保存在名称自定义的寄存器reg_addr中,具体地址为addr)
Base Plus offset
ldr x1, [reg_addr, offset]
从地址addr偏移offset字节后读取8个字节(64位寄存器)加载到寄存器x1中。当offset为0时则为地址不偏移加载。
Pre-indexed
ldr x1, [reg_addr, offset]!
同上,但寄存器加载完成后reg_addr保存的addr值偏移offset。
Post-indexed
ldr x1, [reg_addr], offset
从地址addr读取8个字节加载到寄存器x1中,加载完成后reg_addr保存的addr值偏移offset。
从对指令的说明可以了解到,Base Plus Offset模式只有加载功能,而Pre-indexed和Pose-indexed寻址模式都会对初始寻址地址进行改变,所以在CPU真正执行指令时会同时增加数据操作类的指令如ADD、SUB等。
- 连续内存拷贝
那么当程序需要读取一段连续内存的内容,选择合适的load指令寻址方式可能在一定程度上带来性能的优化。如下表所示,同样需要加载32字节,通常方式二的实现会优于方式一。
实现方式一
实现方式二
… …
Ldr x1, [addr], #8
Ldr x2, [addr], #8
Ldr x3, [addr], #8
Ldr x4, [addr], #8
… …
ldr x1, [addr]
ldr x2, [addr, #8]
ldr x3, [addr, #16]
ldr x4, [addr, #24]!
当然如果仅仅是这几条指令,可能并没有理论上的效果,但是当这段逻辑内嵌在一个循环中,那么效果就会慢慢体现出来。值得注意的是,无论使用何种寻址方式,一定要特别注意边界条件下对地址的读取和改变,否则你汇编代码的功能可能就会出现问题。
- 防止内存踩踏的尾部处理
在优化string类函数的时候,经常会处理大量的内存操作。如上述内容所述,对于大部分内存,我们可以通过循环展开和固定的字节处理长度进行处理,但当内存操作处理到了尾部的时候我们如果依然采取循环内的指令进行最后一遍操作,那么通常处理的字节长度会超过剩余字节数而造成内存踩踏,当然这一情况可能在操作系统内存分配时已经尽量避免,但我们依然要注意一下。
首先我们假设一个情景,当最后剩下的字节数量少于16但不确定的字节数值,我们给出如下两种实现方式。
实现方式一
实现方式二
… …
L(Last16Bytes):
… …
ldrb w1, [addr], #1
subs count, count, #1
b.hi L(Last16Bytes)
… …
/* Assume we calculated and labeled addrend from addr and count before*/
add addrend, addr, count
… …
ldr x1, [addrend, -8]
ldr x2, [addrend, -16]
… …
常规的逻辑如方式一,通过循环单字节读取并不断判断是否达到结束条件。而方式二,则先通过相关参数计算出最后的addr地址标记为addrend,随后基于该地址加载两个八字节,如下图所示,中间会有一部分区域会与之前指令完成的区域存在重复操作,但是这部分重合并没有负面及功能影响,反观整体上指令数量大大减少,并且免去了循环判断。
除去上述主要对ldr和ldp指令不同的用法的介绍,实际上load指令中Armv8还实现了许多适用不同场景但相对使用较少的类,如非暂存内存操作、独占内存操作(锁相关场景会依赖),非特权内存操作(模拟EL0系统寄存器操作)等,推荐各位读者查阅ARMv8指令手册自己进行尝试。