关于内存对齐你需要了解的事

简介

  这篇文章的目的是带你了解什么是内存对齐,具体的内存对齐的细节、处理方式、不同架构则不会去详细讲解,只做科普文用。

1、什么是内存对齐

  内存对齐和数据在内存中的位置有关。内存对齐以字节为单位进行,一个变量的内存地址如果正好等于它的长度的整数倍,则称为自然对齐。比如在32位 cpu 下,一个 u32 的内存地址为0x00000004 ,则属于自然对齐。内存空间按照字节进行划分,理论上可以从任意地址开始读取,实际上会要求读取数据的首地址是某一个值的整数倍。一些系统内存对齐要求较为严格,例如sparc, 如果取未对齐的数据会报错,在x86 cpu上不会报错,但读写效率会下降。

2、为什么要对齐

2.1 底层原理

  说到底层原理,这其实就和内存的物理结构相关了。

  我们经常接触物理内存条,如下有一根 DDR 的内存条,我们可以看到这个内存条上面有8个黑色的内存颗粒,在高端服务器上面通常会带有 ECC 校验,所以会存在9个黑色的内存颗粒,其中一个的内存颗粒是专门做 ECC 校验的。

关于内存对齐你需要了解的事_第1张图片
从概念的层次结构上面分为: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 控制。
关于内存对齐你需要了解的事_第2张图片
我们可以看到两个DIMM0 DIMM0 组成双通道,两个 DIMM1 DIMM1 组成双通道。

下面先来解释 memory controllers 如何从 rank 中取数据,上面说的都是物理结构,下面说内存的逻辑结构。因为每个 rank 下面会有很多 chip,而每个 chip 又包括 bank0、bank1、bank2 等,在 memory controllers 看来每次发数据,都会同时发送给所有 chip 下的某个 bank,并声明 row 和 col。

以从 bank0 为例:
关于内存对齐你需要了解的事_第3张图片
每个 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.2 软件介绍

  尽管内存以字节为单位,现代处理器的内存子系统仅限于以字的大小的粒度和对齐方式访问,处理器按照字节块的方式读取内存。一般按照 2, 4, 8, 16 字节为粒度进行内存读取。合理的内存对齐可以高效的利用硬件性能。

  以4字节存取粒度的处理器为例,读取一个int变量(32bit 系统), 处理器只能从4的倍数的地址开始。假如没有内存对齐机制,将一个 int 放在地址为1的位置。现在读取该int时,需要两次内存访问。第一次从0地址读取,剔除首个字节,第二次从4地址读取,只取首个字节;最后两下的两块数据合并入寄存器,需要大量工作,极大的浪费了 CPU 的时间。
关于内存对齐你需要了解的事_第4张图片

有了严格的内存对齐,int必须按照对其规则进行存储,起始位置必须是4的整数倍,只需要进行一次读取即可。
关于内存对齐你需要了解的事_第5张图片

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大小,提高效率。

3、非对齐访问

3.1 定义

  当你试图从一个不被N偶数整除的地址(即addr % N != 0)开始读取N字节的数据时,就会发生非对齐内存访问。例如,从地址 0x10004 读取4个字节的数据是可以的,但从地址 0x10005 读取4个字节的数据将是一个非对齐的内存访问。

  上述内容可能看起来有点模糊,因为内存访问可以以不同的方式发生。这里的背景是在机器码层面上:某些指令在内存中读取或写入一些字节(例如x86汇编中的movb、movw、movl)。 正如将变得清晰的那样,相对容易发现那些将编译为多字节内存访问指令的C语句,即在处理 u16、u32 和 u64 等类型时。

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
  • 每个成员的首地址是自身大小的整数倍
  • 结构体的总大小,为其成员中所含最大类型的整数倍

虽然这么绕,但是根本原因还是为了对齐访问,只不过为了程序化而总结出来的规则而已。

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 的方式。

你可能感兴趣的:(计算机杂说,缓存,内存对齐,内存,C,Arm)