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

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

主要作用

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

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

  2. 性能原因

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

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

    • 从 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。

参考

0%