内存对齐

  • 现代计算机中内存空间都是按照 byte 划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定变量的时候经常在特定的内存地址访问,这就需要各类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。
  • 对齐的作用和原因:各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。其他平台可能没有这种情况,但是最常见的是如果不按照适合其平台的要求对数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个 int 型(假设为32位)如果存放在偶地址开始的地方,那么一个读周期就可以读出,而如果存放在奇地址开始的地方,就可能会需要 2 个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该 int 数据。显然在读取效率上下降很多,这也是空间和时间的博弈。
  • “内存对齐”应该是编译器的“管辖范围”。编译器为程序中的每个“数据单元”安排在适当的位置上。 但是C语言的一个特点就是太灵活,太强大,它允许你干预“内存对齐”

对齐规则

每个特定平台上的编译器都有自己默认的“对齐系数”,我们可以通过预处理指令#pragma pack(n), n=1, 2, 4, 8, 16...来改变这一系数,这个 n 就是对齐系数。

  • 数据成员对齐规则:结构(struct)联合(union)的数据成员,第一个数据成员放在 offset 为 0 的地方,以后的每个数据成员的对齐按照#pragma pack(n)指定的 n 值和该数据成员本身的长度 len = sizeof(type) 中,较小的那个进行,如果没有显示指定n值,则以len为准,进行对齐
  • 结构/联合整体对齐规则:在数据成员对齐完成之后,结构/联合本身也要对齐,对齐按照#pragma pack(n)指定的n值和该结构/联合最大数据成员长度max_len_of_members中,较小的那个进行,如果没有显示指定n值,则以max_len_of_members为准,进行对齐
  • 结合1、2可推断:当n值均超过(或等于)所有数据成员的长度时,这个n值的大小将不产生任何效果

内存对齐的例子

#include <stdio.h>
 
struct {
    char a;
    double b;
    int c;
} test;
 
int main() {
    printf("%d\n", sizeof(test));
    return 0;
}

你会想,占用的大小为 sizeof(char) + sizeof(double) + sizeof(int) = 1 + 8 + 4 = 13 字节,然而:

# root @ localhost in ~/test [15:46:05]
$ gcc a.c
 
# root @ localhost in ~/test [15:46:07]
$ ./a.out
24

我们按照上面的对齐规则来分析一下:

首先是成员a,类型为char,长度为1,放在偏移量为 0 的地方,然后偏移量变为了 1

然后是成员b,类型为double,长度为8,要放在偏移量为 8 的整数倍的地方,所以就放在 8 上,然后偏移量变为了 8 + 8 = 16 最后是成员c,类型为int,长度为4,要是 4 的整数倍,16 刚好是它的倍数,偏移量变为了 16 + 4 = 20,然后 ,整个结构体也要对齐,成员中最大的长度为 8,而要它的整数倍,那就是 24 了,所以整个结构体的长度就是 24 个字节

再来看定义了#pragma pack(n)的情况:

#include <stdio.h>
 
#pragma pack(4)
struct {
    char a;
    double b;
    int c;
} test;
 
int main() {
    printf("%d\n", sizeof(test));
    return 0;
}
# root @ localhost in ~/test [15:57:12]
$ gcc a.c
 
# root @ localhost in ~/test [15:57:14]
$ ./a.out
16

根据对齐规则:

对于成员a,对齐数为 1,因为 1 小于 4,放在偏移量为 0 的位置上,然后长度变为 1 对于成员b,对齐数为 4,因为 4 小于 8,放在偏移量为 4 的位置上,长度变为 4 + 8 = 12 对于成员c,对齐数为 4,两个数相等,放在偏移量为 12 的位置上,长度变为 12 + 4 = 16 最后是结构体,最大的成员长度 8,大于 4,所以取 4,刚好整除,所以就是 16 字节。