这篇文章的目的是带你了解什么是内存对齐,具体的内存对齐的细节、处理方式、不同架构则不会去详细讲解,只做科普文用。
内存对齐和数据在内存中的位置有关。内存对齐以字节为单位进行,一个变量的内存地址如果正好等于它的长度的整数倍,则称为自然对齐。比如在32位 cpu 下,一个 u32 的内存地址为0x00000004 ,则属于自然对齐。内存空间按照字节进行划分,理论上可以从任意地址开始读取,实际上会要求读取数据的首地址是某一个值的整数倍。一些系统内存对齐要求较为严格,例如sparc, 如果取未对齐的数据会报错,在x86 cpu上不会报错,但读写效率会下降。
说到底层原理,这其实就和内存的物理结构相关了。
我们经常接触物理内存条,如下有一根 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 中取出8bit的数据。
我们之前会经常说双通道,说白了就是一个 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)都会被读出8bit,那么8个chip就会同时读出64bit,然后由 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 每次取值都必须清空当前的缓冲区,重新读数据,降低数据的访问速度。
总结:
所以,内存对齐最最底层的原因是内存的 IO 是以8个字节 64bit 为单位进行的。 对于64位数据宽度的内存,假如 cpu 也是64位的 cpu(现在的计算机基本都是这样的),每次内存 IO 获取数据都是从同行同列的8个 bank 中各自读取一个字节拼起来的。从内存的0地址开始,0-7字节的数据可以一次 IO 读取出来,8-15字节的数据也可以一次读取出来。
尽管内存以字节为单位,现代处理器的内存子系统仅限于以字的大小的粒度和对齐方式访问,处理器按照字节块的方式读取内存。一般按照 2, 4, 8, 16 字节为粒度进行内存读取。合理的内存对齐可以高效的利用硬件性能。
以4字节存取粒度的处理器为例,读取一个int变量(32bit 系统), 处理器只能从4的倍数的地址开始。假如没有内存对齐机制,将一个 int 放在地址为1的位置。现在读取该int时,需要两次内存访问。第一次从0地址读取,剔除首个字节,第二次从4地址读取,只取首个字节;最后两下的两块数据合并入寄存器,需要大量工作,极大的浪费了 CPU 的时间。
有了严格的内存对齐,int必须按照对其规则进行存储,起始位置必须是4的整数倍,只需要进行一次读取即可。
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大小,提高效率。
当你试图从一个不被N偶数整除的地址(即addr % N != 0)开始读取N字节的数据时,就会发生非对齐内存访问。例如,从地址 0x10004 读取4个字节的数据是可以的,但从地址 0x10005 读取4个字节的数据将是一个非对齐的内存访问。
上述内容可能看起来有点模糊,因为内存访问可以以不同的方式发生。这里的背景是在机器码层面上:某些指令在内存中读取或写入一些字节(例如x86汇编中的movb、movw、movl)。 正如将变得清晰的那样,相对容易发现那些将编译为多字节内存访问指令的C语句,即在处理 u16、u32 和 u64 等类型时。
PC(Program Counter)寄存器用来存放下一条执行指令地址,对于 AArch64 架构,如果 PC 寄存器低2位不为0,则触发PC alignment fault。类似于 Instruction Aborts 异常,将非对齐地址加载到 PC 寄存器并不会直接触发 PC alignment fault,只有当 CPU 尝试从该地址取指令时才会触发异常。
ARM 架构下,不论是 ARM32 还是 ARM64,指令长度都是4字节
例如,32位的CPU
如果被访问的内存地址不按照被访问的数据类型的位宽对齐,称为非对齐访问。比如 int型占4个字节,则访问 int 型数据的内存地址需要按照4字节对齐。当 ARM 处理器进行对内存的读写操作时,如果所要操作的数据在内存中是非对齐的,则有可能出现以下两种运行结果:
注:当发生非对其数据访问时,到底采用以上哪种方式去处理,是由当前所使用的指令所决定的。
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。
部分 MIPS 架构,通过在 VxWorks 内核中对非对齐访问异常进行处理,通过多次访存操作和拼接操作来实现非对齐访问,代价是牺牲性能。ARM 架构内核中也有类似的处理方式,可以通过相关的配置来控制其处理方式。
禁止非对齐访问:-mno-unaligned-access
默认情况下,ARM都是 aligned-access 的,如果代码中使用__attribute__((packed))定义的结构体,会出现结构体成员是非对齐的,此时如果没有使能非对齐访问会导致触发 abort 异常。
GCC编译选项 -Ox 用来指定代码优化级别,-O0 表示不优化,其他优化级别下会对非对齐访问代码进行优化,比如将 LDRD 指令的非对齐访问拆分成多条 LDR 指令。
在没有 #pragma pack 这个宏的声明下,结构体对齐遵循下面三个原则:
虽然这么绕,但是根本原因还是为了对齐访问,只不过为了程序化而总结出来的规则而已。
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;
};
出现 alignment fault 问题,通常是用户编写的代码导致。估计很多程序猿在编写代码(特别是c/c++代码)时,从未考虑过这样的问题,那是因为多数可能都在 X86 架构下的进行代码开发,而且没有考虑过代码的移植性,如前面所说 X86 硬件会自动处理非对齐问题,用户感知不到,但这种情况下,由此带来的性能损耗,用户可能也关注不到了。另一方面,部分情况下,编译器也会自动做padding处理(如对结构体的自动填充对齐),这也进一步让程序猿们减少了对 alignment fault 的关注。
最常见的可能导致 alignment fault 的代码编写方式如: