0%

数据结构的内存对齐分析

概念讨论

内存对齐是一个很基础的概念。虽然内存地址的单位是字节,但是CPU访存指令大部分都是以机器字长(64位的CPU机器字长即8字节)为单位,并且由于内存提供的访存电路接口限制,CPU只能从机器字长的倍数的地址开始访问。即CPU能够在一条指令中访存0000-0007,但是不能够从0003-0010,而必须拆分成两条访存指令,分别从0000和0008开始。

CPU按机器字长访存容易理解,但是只能从倍数地址开始访问难以让人理解。据参考,倍数地址的限制更多来源于内存访问器的硬件连线限制。

在没有内存对齐的情况下,假设有两个连续数据INT8,INT64,分别占据了地址0000和0001-0008。此时64位CPU需要几次访存才能拿到INT64数据呢?

  1. 首先访存0000-0007,拿到0001-0007的部分。
  2. 再次访存0008-0015,拿到0008的部分。

即需要访存两次才能取到数据。因此,通常程序会对数据的布局作出内存对齐,以优化访存效率。例如还是INT8和INT64,对齐后地址分别为0000和0008-0015。这样取INT64就只需要访存一次。

据此,业界给出了一个内存对齐的基本原则:将X字节的数据放在X倍数的起始地址上。这样即可在尽可能节约内存空间的情况下,减少访存指令。

代码分析

再来看看实际中的内存对齐规则是什么。如上基本原则,每个数据会希望把自己放在对齐数倍数的起始地址上:

  1. 基本类型,对齐数 = sizeof(TYPE) ,即数据类型大小
  2. struct/class 作为一个类型,对齐数 = 最大的成员对齐数
  3. struct/class 尾部填充。除了保证内部成员的对齐,由于数组的地址连续,我们还必须保证对于 struct[5] 这样的数组,每一个 struct[i] 的起始地址都是对齐的,因此会在struct的尾部填充字节,并且计算在sizeof内,使得sizeof的结果是struct的对齐数的倍数。

按上述原则分析下面的结构体:

  1. short f 起始为 0000,占据0000-0001,第一个元素不用考虑对齐。
  2. double s 对齐数 = 8,因此对齐后占据 0008-0015。
  3. char i 对齐数 = 1,因此对齐后占据 0016。
  4. int c 对齐数 = 4,因此对齐后占据 0020-0023。
  5. char a 对齐数 = 1,因此对齐后占据 0024。
  6. 尾部填充。在没有进行尾部填充时,目前S1的大小为25字节(0000-0024)。S1的对齐数 = 最大的成员对齐数 = 8,根据对齐原则sizeof(S1)应该为8的倍数,所以最终将大小从25扩到32。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct S1
{
S1() { f = 0; s = 0; i = 0; c = 0; a = 0; }
short f;
double s;
char i;
int c;
char a;
};

std::cout << "S1各成员对齐后的起始字节:";
printf("%zu ", offsetof(S1, f));
printf("%zu ", offsetof(S1, s));
printf("%zu ", offsetof(S1, i));
printf("%zu ", offsetof(S1, c));
printf("%zu ", offsetof(S1, a));
std::cout <<std::endl<< "S1 最终大小" << sizeof(S1) << std::endl;
std::cout <<"S1 对齐数" << alignof(S1) << std::endl;

再来看嵌套结构体:

  1. int d 起始为 0000,占据 0000-0003,第一个元素不用考虑对齐。
  2. S1 s1 对齐数为 8,大小为32字节,因此对齐后占据 0008-0039。
  3. char c 对齐数为 1,因此对齐后占据 0040。
  4. int i 对齐数为 4,因此对齐后占据 0044-0047。
  5. 尾部填充,S2 对齐数为 8,目前大小为48字节,满足要求,不需要尾部填充。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct S2
{
S2() { d = 0; c = 0; i = 0; }
int d;
S1 s1;//24
char c;
int i;
};

std::cout << "S2各成员偏移后的起始字节:";
printf("%zu ", offsetof(S2, d));
printf("%zu ", offsetof(S2, s1));
printf("%zu ", offsetof(S2, c));
printf("%zu ", offsetof(S2, i));
std::cout << std::endl << "S2 最终大小" << sizeof(S2) << std::endl;
std::cout <<"S2 对齐数" << alignof(S2) << std::endl;

class和struct的对齐规则是一样的,不过需要注意两点:

  1. 继承了基类成员的class,其会在头部先排基类成员。
  2. 拥有虚函数的class其会在头部添加一个指针成员vfptr,即虚函数表的指针。

餐后甜点

  1. cpp中可以通过#pragma pack(max_size)命令强制设置一个对齐数的上限,例如在#pragma pack(4)的环境下S1中的double的对齐数会变为4。
  2. cpp中空class的大小为1,便于实例化。
  3. 其他更详细的class在继承多态下的成员分析,请参考这位病毒种的Blog