简介
这篇文章的目的是带你了解什么是内存对齐,具体的内存对齐的细节、处理方式、不同架构则不会去详细讲解,只做科普文用。
1、什么是内存对齐
内存对齐和数据在内存中的位置有关。内存对齐以字节为单位进行,一个变量的内存地址如果正好等于它的长度的整数倍,则称为自然对齐。比如在 32 位 cpu 下,一个 u32 的内存地址为 0x00000004 ,则属于自然对齐。
内存对齐可简单的分为:硬件上的对齐访问、软件上的对齐访问两部分。
- 硬件上的对齐访问是由硬件本身设计决定的
- 软件上的对齐访问,是为了配合硬件上的对齐访问,而做的优化
2、为什么要对齐
2.1 物理内存结构
我们经常接触物理内存条,如下有一根 DDR 的内存条,我们可以看到这个内存条上面有 8 个黑色的内存颗粒,在高端服务器上面通常会带有 ECC 校验,所以会存在9个黑色的内存颗粒,其中一个的内存颗粒是专门做 ECC 校验的。
从概念的层次结构上面分为:Channel > DIMM > Rank > Chip > Bank > Row/Column
我们可以把 DIMM 作为一个内存条实体,我们知道一个内存条会有两个面,高端的内存条,两个面都有内存颗粒。所以我们把每个面叫做一个 Rank,也就是说一个内存条会存在 Rank0 和 Rank1。
拿 rank0 举例,上面有8个黑色颗粒,我们把每个黑色颗粒叫做 chip。再向微观走,就是一个 chip 里面会有8个 bank。每个 bank 就是数据存储的实体,这些 bank 就相当于一个二维矩阵,只要声明了 column 和 row 就可以从每个 bank 中取出 8 bit 的数据。
我们之前会经常说双通道,说白了就是一个 DIMM 就是一个通道,两个 DIMM 组成双通道,分别由两个 Memory Controller 控制。
我们可以看到两个DIMM0 DIMM0 组成双通道,两个 DIMM1 DIMM1 组成双通道。
下面先来解释 memory controllers 如何从 rank 中取数据,上面说的都是物理结构,下面说内存的逻辑结构。因为每个 rank 下面会有很多 chip,而每个 chip 又包括 bank0、bank1、bank2 等,在 memory controllers 看来每次发数据,都会同时发送给所有 chip 下的某个 bank,并声明 row 和 col。
以从 bank0 为例:
每个 chip 的 bank0 的同一地点(row=i col=j)都会被读出 8 bit,那么 8 个 chip 就会同时读出 64 bit,然后由 memory controllers 传送给 cpu,也就是8byte。
在 memory controllers 看来,每个 bank 存在于每个 chip 中,如上图所示,可以把每个 chip 里面的小 bank 连成一行,b 看作成一个大的 bank。然后从大的 bank 中读取数据。
每个 bank 有一个 row bufffer,作为一个 bank page,所有 bank 共享地址、数据总线,但是每个 channel 有他们自己的地址、数据总线。正因为有 buffer,所以每次 bank 都会预读 64bit 的数据。
上面看到的是分解的操作,事实上,为了加快 memory 的读写,体系结构中引入了流水线,也就意味着 memory controllers 可以同时读 64byte,也就是 8 次这样的操作。写入到 buffer 中,这就是局部性原理。如果我们程序猿不尊重这个规则,也就迫使 bank 的 buffer 每次取值都必须清空当前的缓冲区,重新读数据,降低数据的访问速度。
PS:说到这,就不得不提到两个很容易混淆的概念
内存位宽:内存位宽指的是 内存 与 内存控制器 之间的数据传输位宽。内存位宽影响的是内存数据传输的速度,而非 CPU 的寻址能力或处理能力。对比我们上面提到的,每个 chip 读 8 bit 数据、8 个 chip、内存位宽为 64 bit
内存总线:可以分为地址总线、数据总线、控制总线。
- 数据总线(Data Bus):用于在 CPU 和内存控制器之间传输数据。总线的位数决定了每次传输的数据位宽。
- 地址总线(Address Bus):用于指定内存单元的地址。地址总线的位数决定了CPU可以直接寻址的内存空间的大小。
- 控制总线(Control Bus):用于发送和接收各种控制信号,例如读写信号。
CPU 的内存总线位宽 与 内存的位宽 是两个不同的概念,分别影响 CPU 的处理能力和内存的数据传输速率。
总结:
所以,内存对齐最最底层的原因,是内存的 IO 是以 8 个字节 64bit 为单位进行的。 对于 64 位数据宽度的内存,假如 cpu 也是 64 位的 cpu(现在的计算机基本都是这样的),每次内存 IO 获取数据都是从同行同列的 8 个 bank 中各自读取一个字节拼起来的。从内存的 0 地址开始,0-7 字节的数据可以一次 IO 读取出来,8-15 字节的数据也可以一次读取出来。
2.2 CPU 硬件访问上的对齐与非对其
CPU 硬件访问以字或双字对齐,是由内存位宽、总线设计和缓存架构决定的。
上面只讲解了内存位宽的概念,剩下的目前还没研究过,这里暂略,日后有机会再补齐。
我们一定要有个概念,就是 CPU 硬件去访问一个地址,并不是你想从哪个地址访问就可以从哪个地址访问的。并且,CPU 访问一次内存,也不是只读一个字节的,而是读一块连续的内存数据(4字节、8字节等)。
2.3 软件上非对齐带来的性能问题
有一个概念很重要,务必要理解:
“对齐优化”是软件为了适应硬件的设计而做的,并不是软件本身能够主动优化出性能。
如果硬件不要求对齐访问,软件根本就不需要考虑对齐优化。
以 CPU 硬件 4 字节存取粒度、对齐粒度为例,读取一个 int 变量(32bit 系统), 处理器只能从 4 的倍数的地址开始。
假如软件上没有内存对齐机制,将一个 int 放在地址为 1 的位置。现在读取该 int 时,需要两次内存访问。第一次从 0 地址读取,剔除首个字节,第二次从 4 地址读取,只取首个字节;最后两下的两块数据合并入寄存器,需要大量工作,极大的浪费了 CPU 的时间。
有了软件上严格的内存对齐,int 必须按照对其规则进行存储,起始位置必须是 4 的整数倍,只需要进行一次读取即可。
2.3 内存与 Cache
Cache 其实是一个很复杂的东西,后面会单独出章节去讲解。这里就不作详细讲解,只是涉及到一点
cache 的大小称之为 cache size,代表 cache 可以缓存最大数据的大小。我们将 cache 平均分成相等的很多块,每一个块大小称之为 cache line,其大小是 cache line size
当 CPU 试图从主存中 load/store 数据的时候, CPU 会首先从 cache 中查找对应地址的数据是否缓存在 cache 中。如果其数据缓存在 cache 中,直接从 cache 中拿到数据并返回给 CPU。
当CPU试图 load 一个字节数据的时候,如果 cache 缺失,那么 cache 控制器会从主存中一次性的 load cache line 大小的数据到 cache 中。
为什么会一次性 load cache line 大小呢?因为内存数据的传输需要很多的准备工作(硬件上),如果每次仅仅传输一个字节显然是太浪费了。因为程序局部性原理,所以每次 load 都会一次性 load cache line大小,提高效率。
因此,访问非对齐的数据也意味着多次的 cache 读取,同样会降低效率。
3、软件上的非对齐访问
3.1 定义
An aligned access is one where the address of the access is aligned to the size of each element of the access.
————《ARM® Architecture Reference Manual ARMv8, for ARMv8-A architecture profile》
当你试图从一个不被 N 偶数整除的地址(即addr % N != 0)开始读取 N 字节的数据时,就会发生非对齐内存访问。例如,从地址 0x10004 读取4个字节的数据是可以的,但从地址 0x10005 读取4个字节的数据将是一个非对齐的内存访问。
3.2 指令非对其访问
PC(Program Counter) 寄存器用来存放下一条执行指令地址,对于 AArch64 架构,如果 PC 寄存器低 2 位不为0,则触发 PC alignment fault。类似于 Instruction Aborts 异常,将非对齐地址加载到 PC 寄存器并不会直接触发 PC alignment fault,只有当 CPU 尝试从该地址取指令时才会触发异常。
ARM 架构下,不论是 ARM32 还是 ARM64,指令长度都是4字节
例如,32位的CPU
- ARM状态下,指令是32位的,指令地址都是4字节对齐,所以PC值肯定是4的倍数,所以最低两位(位[1:0])肯定为0,前面的30位[31:2]用于保存PC
- 如果是thumb状态,指令是16位的,指令地址是2字节对齐。
3.3 数据非对其访问
如果被访问的内存地址不按照被访问的数据类型的位宽对齐,称为非对齐访问。比如 int 型占4个字节,则访问 int 型数据的内存地址需要按照4字节对齐。当 ARM 处理器进行对内存的读写操作时,如果所要操作的数据在内存中是非对齐的,则有可能出现以下两种运行结果:
- 执行的结果不可预知
- 当操作的数据是字类型的时,忽略地址中低两位的值,即访问地址为指定的地址与0xFFFFFFFC相与的结果(Address & 0xFFFFFFFC);当操作的数据是半字类型的时,忽略地址中低一位的值,即访问地址为指定的地址与0xFFFFFFFE相与的结果(Address & 0xFFFFFFFE)。这种操作是由存储系统来完成的,地址值并不会在CPU内发生变化。
注:当发生非对其数据访问时,到底采用以上哪种方式去处理,是由当前所使用的指令所决定的。
3.4 硬件支持非对其访问
-
MIPS架构不支持非对齐访问。
-
X86架构支持非对齐访问,其实现机制是将非对齐访问指令拆分成多条指令执行,结合拼接(或者拆分)指令获取数据。缺点是牺牲性能。
-
ARMv5架构不支持非对齐访问。
-
ARMv6架构开始参考 X86 架构实现方式支持非对齐访问,但是是部分内存访问指令支持。
-
ARMv7-M架构中 CCR.UNALIGN_TRP 位控制是否使能对齐检查(Alignment Check),ARMv7-A、ARMv7-R、ARMv8 架构中SCTLR.A 位控制是否使能对齐检查,默认情况下不使能对齐检查。
如果使能对齐检查,则任何指令的非对齐访问均触发非对齐异常。
对于 A32/T32 代码,如果不使能对齐检查,则大部分指令的非对齐访问由 CPU 处理,如 LDR,LDRH,STR,STRH,LDRSH,LDRT,STRT,LDRSHT,LDRHT,STRHT,TBH。其他的数据访问指令的非对齐访问都会触发非对齐异常,如STRD,LDRD。
对于 A64 代码,如果不使能对齐检查,则所有的 load 和 store 指令的非对齐访问均由CPU处理,但是exclusive load/store, load acquire和store release指令的非对齐访问则会触发非对齐异常,包括LDAXR,LDAXRB,LDAXRH,LDAXP,STLXR,STLXRB,STLXRH,STLXP。
3.5 软件非对其访问支持
部分 MIPS 架构,通过在 VxWorks 内核中对非对齐访问异常进行处理,通过多次访存操作和拼接操作来实现非对齐访问,代价是牺牲性能。ARM 架构内核中也有类似的处理方式,可以通过相关的配置来控制其处理方式。
3.6 编译器非对齐访问支持
- GCC编译器
使能非对齐访问:-munaligned-access
禁止非对齐访问:-mno-unaligned-access
默认情况下,ARM都是 aligned-access 的,如果代码中使用__attribute__((packed))定义的结构体,会出现结构体成员是非对齐的,此时如果没有使能非对齐访问会导致触发 abort 异常。
- 编译器优化
编译器一般支持对非对齐访问代码的优化,即在编译阶段通过多次内存访问操作拆分和拼接从而规避非对齐访问。
GCC编译选项 -Ox 用来指定代码优化级别,-O0 表示不优化,其他优化级别下会对非对齐访问代码进行优化,比如将 LDRD 指令的非对齐访问拆分成多条 LDR 指令。
4、以结构体对齐为例
在没有 #pragma pack 这个宏的声明下,结构体对齐遵循下面三个原则:
- 第一个成员的首地址为0
- 每个成员的首地址是自身大小的整数倍
- 结构体的总大小,为其成员中所含最大类型的整数倍
上述第三条,可能不太好理解。结构体的总大小,为什么要为其成员中所含最大类型的整数倍呢?
这时候,就要考虑一种特殊情况,结构体数组。
即使 arr[0] 已经完成了每个成员首地址是自身大小整数倍。那么 arr[1] 呢?它是否又是对齐的呢?
虽然这么绕,但是根本原因还是为了对齐访问,只不过为了程序化而总结出来的规则而已。
struct foo_s {
u16 field1;
u32 field2;
u8 field3;
};
粗看起来,field2 将发生非对齐访问,幸运的是,编译器会根据内存对齐约束在 field1 和 field2 之间加入2字节的填充字节。在不进行类型强转到其它更长类型的情况下,无需担忧发生非对齐访问。
同样,你也可以依靠编译器根据变量类型的大小,将变量和函数参数对齐到一个自然对齐的方案。在这一点上,应该很清楚,访问单个字节(u8或char)永远不会导致非对齐访问,因为所有的内存地址都可以被1均匀地整除。
上述结构体在填充后将占用 12 字节,更优的写法如下,此时编译器只会填充一个字节,结构体大小为 8 字节,减少长驻内存的大小。
struct foo_s {
u32 field2;
u16 field1;
u8 field3;
};
6、什么情况下容易发生非对齐访问
出现 alignment fault 问题,通常是用户编写的代码导致。估计很多程序猿在编写代码(特别是c/c++代码)时,从未考虑过这样的问题,那是因为多数可能都在 X86 架构下的进行代码开发,而且没有考虑过代码的移植性,如前面所说 X86 硬件会自动处理非对齐问题,用户感知不到,但这种情况下,由此带来的性能损耗,用户可能也关注不到了。另一方面,部分情况下,编译器也会自动做padding处理(如对结构体的自动填充对齐),这也进一步让程序猿们减少了对 alignment fault 的关注。
最常见的可能导致 alignment fault 的代码编写方式如:
- 指针转换
将低位宽类型的指针转换为高位宽类型的指针,如:将char * 转为int *,或将void *转为结构体指针。这类操作是导致 alignment fault 的最主要的来源,在分析定位问题时,需要特别关注。对于出现异常却又必须这样使用的场景,对这类转换后的指针进行访问时,如果不能确认其对应的地址是对齐的,则应该使用memcpy访问(memcpy方式不存在对齐问题)。另外,建议转换后立即使用,不要将其传递到其他函数和模块,防止扩展,带来潜在的问题。 - 使用 packed 属性或者编译选项
这样的操作会关闭编译器的自动填充功能,从而使结构体中各个字段紧凑排列,如果排列时未处理好对齐,则可能导致 alignment fault。一些场景下(内核中也较常见)确实需要用户自行紧凑排列结构体,可节省空间(在内存资源稀缺的场景下,很有用),此时需要特别关注对齐问题,建议通过填充的方法尽量对齐,如此可能会导致空间浪费,但是会提升访问性能,典型的“以空间换时间”的思路。如果对空间有强烈要求,而可以接受性能损失,也可以不考虑对齐,不做 padding,但在访问这些结构体的数据时,需要全部使用 memcpy 的方式。