2. 结构体对齐规则:默认对齐规则、成员顺序对大小的影响、嵌套结构体的对齐
结构体对齐,说白了就是编译器在给结构体成员分配内存时,不是简单地挨个排排坐。它会根据每个成员的类型,在内存地址上做一些「对齐」处理。为什么要这么干?因为CPU读取内存时,从对齐的地址读最快。你想想看,如果CPU每次读4字节,结果你的int放在了一个奇数地址上,那它就得读两次再拼起来——效率直接打对折。
我刚开始学C语言时,总觉得对齐是编译器多管闲事。直到有一次在嵌入式项目里,我手动算结构体大小,算出来是10字节,结果sizeof告诉我12字节。当时我就懵了。嗯,从那以后,我再也不敢忽略对齐规则了。
2.1 默认对齐规则
默认对齐规则其实就三条,记牢了就行:
- 规则一:每个成员的对齐边界 = 自身大小。比如char是1字节对齐,short是2字节对齐,int是4字节对齐,double是8字节对齐。
- 规则二:结构体的总大小,必须是最大成员对齐边界的整数倍。如果最大成员是double(8字节),那结构体总大小必须是8的倍数。
- 规则三:成员按声明顺序依次存放,但会在成员之间插入填充字节(padding),以保证每个成员都落在自己的对齐边界上。
举个例子,你看这个结构体:
struct S1 {
char a; // 1字节
int b; // 4字节
char c; // 1字节
};
你猜sizeof(S1)是多少?直觉上1+4+1=6字节,对吧?但实际是12字节。为什么会这样?
我来给你拆解一下:
- a占1字节,地址0
- b需要4字节对齐,所以从地址4开始,地址1~3被填充了
- c占1字节,地址8
- 结构体总大小必须是最大成员(int,4字节)的倍数,所以填充到12字节
你看,白白浪费了6个字节。我在项目中遇到过类似的情况,一个结构体里就几个字段,结果sizeof出来比预期大了一倍。当时排查了半天,才发现是成员顺序没排好。
核心结论:默认对齐规则下,编译器会在成员之间和结构体末尾插入填充字节,以保证每个成员都落在自然对齐的地址上。
2.2 成员顺序对大小的影响
这一点很有意思——同样的成员,换个顺序,结构体大小可能就变了。说白了,就是通过调整顺序来减少填充字节。
还是刚才那个例子,我们把成员顺序换一下:
struct S2 {
int b; // 4字节
char a; // 1字节
char c; // 1字节
};
现在sizeof(S2)是多少?答案是8字节。你看:
- b占4字节,地址0~3
- a占1字节,地址4
- c占1字节,地址5
- 结构体末尾填充到8字节(4的倍数)
从12字节降到8字节,省了33%的空间。我建议你在写结构体时,养成一个习惯:把大的成员放在前面,小的成员放在后面。这样能最大程度减少填充。
个人经验:我在做网络协议解析时,经常需要定义报文头结构体。如果成员顺序没排好,不仅浪费内存,还会导致解析出来的字段值不对——因为填充字节把数据位置给挤偏了。所以每次定义结构体前,我都会先在纸上画一下内存布局。
再给你看一个更极端的例子:
struct S3 {
char a; // 1字节
double b; // 8字节
char c; // 1字节
};
struct S4 {
double b; // 8字节
char a; // 1字节
char c; // 1字节
};
sizeof(S3) = 24字节,sizeof(S4) = 16字节。同样的成员,只是顺序不同,差了8个字节。你想想看,如果结构体数组有1000个元素,那就是8KB的差距。在嵌入式系统里,这可不是小数目。
2.3 嵌套结构体的对齐
嵌套结构体的对齐规则,说白了就是「递归应用对齐规则」。内层结构体作为一个整体,它的对齐边界取决于它内部最大的成员。
看这个例子:
struct Inner {
char a; // 1字节
int b; // 4字节
}; // sizeof(Inner) = 8
struct Outer {
char c; // 1字节
struct Inner d; // 8字节
char e; // 1字节
};
sizeof(Outer)是多少?我们来算一下:
- c占1字节,地址0
- Inner的对齐边界是4(因为内部有int),所以从地址4开始放d,地址1~3填充
- d占8字节,地址4~11
- e占1字节,地址12
- 结构体总大小必须是最大成员对齐边界的整数倍。最大成员是谁?是Inner里的int(4字节),还是Outer里的成员?答案是取所有成员(包括嵌套结构体内部的成员)中最大的对齐边界。这里最大是4字节,所以总大小填充到16字节
所以sizeof(Outer) = 16字节。
注意:嵌套结构体本身作为一个成员,它的对齐边界不是它自己的大小,而是它内部最大成员的对齐边界。这一点很容易搞错。我曾经在写一个文件系统驱动时,就因为没搞清楚这个规则,导致结构体大小算错,读写文件时数据偏移全乱了。排查了整整一个下午才找到原因。
再给你看一个更复杂的嵌套例子:
struct Inner2 {
char a; // 1字节
double b; // 8字节
}; // sizeof(Inner2) = 16
struct Outer2 {
struct Inner2 d; // 16字节,对齐边界8
char e; // 1字节
int f; // 4字节
};
sizeof(Outer2) = 24字节。为什么?
- d从地址0开始,占16字节(地址0~15)
- e占1字节,地址16
- f需要4字节对齐,所以从地址20开始,地址17~19填充
- f占4字节,地址20~23
- 最大对齐边界是8(Inner2里的double),24是8的倍数,正好
嵌套结构体对齐总结:
- 内层结构体的对齐边界 = 它内部最大成员的对齐边界
- 外层结构体的总对齐边界 = 所有成员(递归展开)中最大的对齐边界
- 内层结构体作为一个整体,它的起始地址必须对齐到它自己的对齐边界
好了,关于结构体对齐的规则,我就讲到这里。说白了就是三件事:成员要对齐到自己的边界、结构体总大小要对齐到最大成员的倍数、嵌套时要递归考虑。记住这些,你在实际项目中就不会被sizeof坑到了。
实用建议:如果你不确定结构体的大小,可以用offsetof宏来查看每个成员的偏移量。比如 offsetof(struct S1, b) 就能告诉你b成员在结构体中的偏移位置。我在调试时经常用这个宏来验证我的对齐计算是否正确。