C/C++内存对齐

下面是网易的一道笔试题:struct { uint32_t m1; char m2; } varray[2];以下哪些判断一定成立?(多选)

  1. sizeof(varray[0]) == 5
  2. sizeof(varray[0]) == 8
  3. (void*)&(varray[0].m1) < (void*)&(varray[0].m2)
  4. (char*)&varray[0] == (char*)&(varray[0].m1)
  5. (char*)&varray[0] + sizeof(varray[0]) == (char*)&varray[1]
  6. (char*)&(varray[0].m2) + 1 == (char*)&varray[1]
  7. (char*)&(varray[0].m2) + 4 == (char*)&varray[1]

这个题目考查的就是内存对齐的知识点,看完这篇文章你就知道这道题应该选什么了。


一、什么是内存对齐

内存对齐(Memory alignment),也叫字节对齐。

现代计算机中内存空间都是按照 byte 划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。

举一个简单的例子,uint32_t所占内存空间为 4 bytes,char为 1 byte。如果把它们放在一个结构体中,则所占的内存空间应该是 4 + 1 = 5 bytes 。而事实上,在VS2012和gcc环境下,sizeof 操作的结果都是 8 bytes:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct
{
uint32_t m1; // #include<stdint.h>
char m2;
}varray;

int main()
{

printf("%d\n",sizeof(varray.m1)); // 输出4
printf("%d\n",sizeof(varray.m2)); // 输出1
printf("%d\n",sizeof(varray)); // 输出8
return 0;
}

示图:

这里是以4个字节为一个对齐单位。


二、为什么要内存对齐

之所以要内存对齐,有两方面的原因:

  • 平台原因:各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。————- 比如,有些架构的CPU在访问 一个没有进行对齐的变量的时候会发生错误,那么在这种架构下编程必须保证字节对齐。

  • 性能原因:内存对齐可以提高存取效率。————- 比如,有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。


三、对齐的规则

每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。你可以通过预编译命令#pragma pack(n),n=1,2,4,8,16 来改变这一系数,其中 n 就是你要指定的“对齐系数”。

1)概念:

有效对齐值:是 #pragma pack指定值 和 结构体中最长数据类型长度 中较小的那个。有效对齐值也叫对齐单位

注意:VS、VC 默认是#pragma pack(8),而 gcc 默认是#pragma pack(4),并且gcc只支持1,2,4对齐。

2)规则:

  1. 结构体变量的首地址是有效对齐值(对齐单位)的整数倍。

  2. 结构体第一个成员的偏移量(offset)为0,以后每个成员相对于结构体首地址的 offset 都是该成员大小与有效对齐值中较小那个的整数倍,如有需要编译器会在成员之间加上填充字节。

  3. 结构体的总大小为 有效对齐值 的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。

  4. 结构体内类型相同的连续元素将在连续的空间内,和数组一样。

下面给出几个例子帮助理解(测试环境为VS2012):

例一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct
{
int i; // 4个字节
char c1; // 1个字节
char c2; // 1个字节
}x1;

struct
{
char c1; // 1个字节
int i; // 4个字节
char c2; // 1个字节
}x2;

struct
{
char c1; // 1个字节
char c2; // 1个字节
int i; // 4个字节
}x3;

int main()
{

printf("%d\n",sizeof(x1)); // 输出8
printf("%d\n",sizeof(x2)); // 输出12
printf("%d\n",sizeof(x3)); // 输出8
return 0;
}

可以看出,上面定义的三个结构体只是交换了成员声明的顺序。由于结构体中最长的数据类型为4个字节,而VS2010默认#pragma pack(8),所以有效对齐值(对齐单位)为 4 bytes。根据前三条规则可以画出以下图:

例二:

1
2
3
4
5
6
7
8
9
10
11
12
struct
{
int a; // 4个字节
char b[6]; // 6个字节
double c; // 8个字节
}st;

int main()
{

printf("%d\n",sizeof(st)); // 输出24
return 0;
}

上面结构体中最长的数据类型 double 为 8 个字节,而VS2012中默认#pragma pack(8),所以有效对齐值(对齐单位)8 bytes。根据前三条规则可以画出以下图:

其中的字符数组 char b[6] 可以看做6个单独的 char 成员。


四、pragma pack(n)

  • 上面说到,不同平台上编译器的 pragma pack 默认值不同。而我们可以通过预编译命令#pragma pack(n),n=1,2,4,8,16 来改变这一对齐系数。

  • #pragma pack(n)是通过改变有效对齐值来改变数据成员在内存中的布局,若你设定的 n 值没有影响或改变有效对齐值,则成员在内存中的布局不会改变。

下面就看看在1、2、4字节对齐的情况下例一、例二的变化:

1字节对齐:#pragma pack(1)

这时的有效对齐值(对齐单位)为 1 字节,则根据对齐规则,可知成员都是连续存储的。

例一中的输出结果会变为 6,6,6,如下图:

例二中输出结果则变为 4 + 6 + 8 = 18:

2字节对齐:#pragma pack(2)

这时的有效对齐值(对齐单位)为 2 字节,则根据对齐规则,可知例一的输出结果会变为 6,8,6,如下图:

例二结构体中最长数据类型 double 为 8 个字节,所以有效对齐值是 2 。此时输出结果还是18,如下图:

4字节对齐:#pragma pack(4)

对于例一,结构体中最长的数据类型 int 是 4 个字节,所以此时的有效对齐值(对齐单位)仍为 4,没有变化,所以输出仍然是 8,12,8。

而在例二中,原来的有效对齐值为 8,现在变成了 4 。所以输出结果变为 20,具体如下图:

对于 8 字节对齐、 16 字节对齐,在这里就不举例了,相信根据对齐规则你可以很容易写出来。需要注意的是,有些编译器,比如gcc,只支持 1,2,4 对齐。





附:答案

相信看到这里,文章开头的那个网易笔试题应该就很容易得出答案了。只需要根据内存对齐把结构体的内存布局图画出来就一目了然了:

所以多选答案应该是 2、4、5、7。

对于这种类型的题目,最好的办法就是根据对齐规则画出对齐后的内存布局图,简单清晰且不容易出错。

(全文完)