C 语言变量内存对齐的作用与规则

现代计算机体系中 CPU 按照双字、字、字节访问存储内存,并通过总线进行传输,若未经过一定规则的数据对齐,CPU 的访址操作与总线的传输操作将会异常的复杂,所以现代编译器中都会对内存进行自动的对齐。

主要作用

  1. 平台原因(移植原因)

    不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。比如,有些架构的 CPU 在访问一个没有进行对齐的变量的时候会发生错误,那么在这种架构下编程必须保证字节对齐。

  1. 性能原因

    经过内存对齐后,CPU 的内存访问速度大大提升。比如,有些平台每次读都是从偶地址开始,如果一个 int 型(假设为 32 位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这 32 位,而如果存放在奇地址开始的地方,就需要 2 个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该 32 位数据。显然,这在读取效率上下降很多。

    假设 CPU 要读取一个 int 型 4 字节大小的数据到寄存器中,分两种情况讨论(假定内存读取粒度为 4):

    CPU 读取内存示意图

    • 从 0 字节开始:CPU 只需读取内存一次即可把这 4 字节的数据完全读取到寄存器中。

    • 从 1 字节开始:此时该 int 型数据不是位于内存读取边界上,这就是一类内存未对齐的数据,需要读 2 次。

对齐规则

默认情况

在默认情况下,编译器规定各成员变量存放的起始地址相对于结构的起始地址的 偏移量 必须为该变量的类型所占用的字节数的倍数。

类型 对齐倍数(偏移量相对于类型的字节大小)
char 1 倍
short 2 倍
int 4 倍
float 4 倍
double 8 倍

各成员变量在存放的时候根据在结构中出现的顺序依次申请空间,同时按照上面的对齐方式调整位置,空缺的字节 编译器 会自动填充。同时,编译器为了确保结构的大小为结构的字节边界数(即该结构中占用最大空间的类型所占用的字节数)的倍数,所以在为最后一个成员变量申请空间后,还会根据需要自动填充空缺的字节。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct mystruct {
double dda; // 起始偏移量为0,刚好是double(8)的倍数,占用8个字节
char cda; // 起始偏移量为8,是char的倍数(1),占用1个字节
int ida; // 起始偏移量位9,不是int(4)的倍数,要补3个字节(全是0),所以占了 3 + 4 = 7
// 此时占用空间为16,是double的倍数,不用再补
};

struct mystruct2 {
char cda; // 占1字节
double dda; // 偏移1,要补7,7+8=15,占了15字节
int ida; // 偏移16,不用补,占了4字节,此时偏移了20,不是double的倍数,要补4
// 所以总共24字节
};

对齐参数

默认的对齐方式为 8 字节。有时,我们自己可以设定变量的对齐方式,#pragma pack(n) 来设定变量以 n 字节对齐。n 字节对齐就是说变量存放的起始地址的偏移量有两种情况:

默认对齐方式:按照变量的类型大小对齐。

  1. 如果该变量的类型所占用的字节数小于等于 n,那么偏移量必须满足默认的对齐方式(必须为类型大小的整数倍);

  2. 如果该变量的类型所占用的字节数大于 n,那么偏移量必须为 n 的倍数,不满足默认的对齐方式。

此外,结构的总大小也有约束条件,分下面两种情况:

  1. 如果所有成员变量类型所占用(所分配的空间)的字节数小于等于 n,那么结构的总大小必须为占用空间最大的变量占用的空间数的倍数(即默认对齐方式);

  2. 否则,结构的总大小必须为 n 的倍数。

或者,我们也可以这样表述(读者可以自行对比和理解,很重要!):

  1. 对于结构的各个成员,第一个成员位于偏移为 0 的位置,以后每个数据成员的偏移量必须是 min(n, 这个数据成员的自身长度) 的倍数;

  2. 结构(或联合)本身也要进行对齐,对齐将按照 min(n, 结构(或联合)最大数据成员长度) 进行对齐。

总结:类型大小小于等于 n,看类型大小;大于 n,看 n;n 是个阈值。

第一个例子

1
2
3
4
5
6
7
8
9
#pragma pack(push) //保存对齐状态 
#pragma pack(4) //设定为4字节对齐
struct test {
char m1; // 1 <= n, 1 byte
   double m4; // 8 > n, 其偏移量为1,偏移量要为n = 4的倍数,所以补足3个字节(原来默认要补足7个字节),所以m4占 3+8 = 11,原来占了15字节
int m3; // 此时偏移量为12,4 <= n,满足默认的,是4的倍数,所以分配4个字节。
  // 此时分配了16字节,大于等于n,必须是n的倍数。
};
#pragma pack(pop) //恢复对齐状态

以上结构的大小为 16,下面分析其存储情况:

  • 首先为 m1 分配空间,其偏移量为 0,满足我们自己设定的对齐方式(4 字节对齐),m1 占用 1个字节。

  • 然后开始为 m4 分配空间,这时其偏移量为 1,由于 double 占用 8 个字节(大于 4),需要补足 3 个字节以满足 4 字节对齐,而 m4 本身占用 8 个字节,即分配了 11 个字节。(默认情况则需要 15 字节,节省了空间)

  • 接着为 m3 分配空间,这时其偏移量为 12,m3 占用 4 个字节(小于等于 4),偏移量仅需满足 int 类型大小的倍数,因此不用填补。

  • 最后,此时已经为所有成员变量分配了空间,共分配了 4 + 8 + 4 = 16 个字节。因为所有各个变量的类型大小并不满足小于等于 n(double 不满足),所以结构总大小必须满足为 n 的倍数,因此不用填补。

总结:调小对齐参数可以节省存储空间,如给 m4 分配空间时,节省了 4 个字节。

第二个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
#pragma pack(8) // n = 8
struct S1 {
char a; // 1 byte
long b; // 偏1,补7,7 + 8 = 15
// 1 + 15 = 16(64位)(32位是8)
};

struct S2 {
char c; // 1 byte
struct S1 d; // 偏1,占 16,大于 8,按照 8 对齐,补 7,7 + 16 = 23
long long e; // 偏24,占 8,小于等于 8,按照本身 8 对齐,不用填补
// 1 + 23 + 8 = 32,有成员的类型大小大于参数 8,因此结构体大小按照 8 对齐,不用填补。
};

S1 中,成员 a 占 1 字节,默认按 8 字节对齐,指定对齐参数为8,偏移量为 0,不用填补字节;成员 b 占 4 个字节,默认是按 8 字节对齐,偏移量为 1,这时就按 8 字节对齐,需要填补 7 个字节,且本身占 8 个字节,所以 sizeof(struct S1) 应该为 16。(假定 long 为 8 字节,即在 64 位机器下)

S2 中,c 和 S1 中的 a 同理。而 d 是个结构体,它占 16 个字节,大于参数 8,按照参数 n 对齐,即 8 字节对齐,补 7 个字节,所以共分配了 23 个字节。接着,成员 e 占 8 个字节,小于等于 8,按照本身类型大小 8 字节对齐,不用填补。最后,S2 中有成员的类型大小大于参数 8,因此结构体大小按照 8 字节对齐。由于此时偏移量为 32,为 8 的倍数,因此无需再填补,所以 sizeof(struct S2) 应该为 32。

注意:有的地方说 S2 中 S1 的对齐应该按照 S1 中最大的对齐参数进行对齐,即 8。

参考