3、结构体对齐与内存布局:为什么我的结构体大小和我想的不一样?
这个问题,我敢说十个C语言开发者里,有八个都踩过坑。
你写了一个结构体,算算成员变量大小,觉得总尺寸应该是A+B+C。结果一打印sizeof,傻眼了——比你想的多出好几个字节。怎么回事?编译器偷偷给你塞东西了?
嗯,没错。编译器确实干了这件事。而且它这么做,是有充分理由的。
3.1 什么是结构体对齐?
说白了,结构体对齐就是编译器为了让CPU能更快地读取数据,在结构体成员之间插入一些“填充字节”。
CPU读取内存,不是按字节一个一个读的。它一次读4个字节(32位系统)或8个字节(64位系统)。如果数据刚好落在一次读取的范围内,那效率最高。如果数据跨了边界,CPU就得读两次,再拼起来——这就慢了。
所以编译器会主动把每个成员变量放到它“该待的位置”上。这个位置,就是成员的对齐边界。
核心规则:
- 每个成员的对齐边界 = min(成员自身大小, 编译器默认对齐数)
- 结构体的总大小,必须是最大对齐边界的整数倍
- 成员按定义顺序依次存放,但可能被填充字节隔开
我在项目中遇到过一件事:一个通信协议的结构体,因为没处理好对齐,导致两个平台解析出来的数据完全对不上。排查了一整天,最后发现是填充字节在作怪。
3.2 一个让你“意外”的例子
来看这段代码。你先猜猜sizeof会输出多少?
#include <stdio.h>
struct Test {
char a; // 1字节
int b; // 4字节
char c; // 1字节
};
int main() {
printf("sizeof(struct Test) = %zu\n", sizeof(struct Test));
return 0;
}
很多人会脱口而出:1 + 4 + 1 = 6字节。
实际输出呢?12字节(32位系统)或16字节(64位系统)。
为什么会这样?我们来画一下内存布局。
3.3 内存布局图解
偏移量: 0 1 2 3 4 5 6 7 8 9 10 11
+---+---+---+---+---+---+---+---+---+---+---+---+
| a | | | | b | c | | | |
+---+---+---+---+---+---+---+---+---+---+---+---+
↑填充字节↑ ↑填充字节↑
你看,a占偏移0,但b需要4字节对齐,所以它必须从偏移4开始。中间偏移1~3就被填充了。c占偏移8,但结构体总大小必须是最大对齐边界(这里是4)的整数倍,所以末尾又补了3个字节,凑到12。
这就是你“想的不一样”的根本原因。
3.4 如何预测结构体大小?
我个人的习惯是,画一张偏移量表。你想想看,只要掌握了规则,预测起来并不难。
| 成员 | 类型 | 自身大小 | 对齐边界 | 起始偏移 | 占用区间 |
|---|---|---|---|---|---|
| a | char | 1 | 1 | 0 | 0~0 |
| b | int | 4 | 4 | 4 | 4~7 |
| c | char | 1 | 1 | 8 | 8~8 |
然后看最大对齐边界是4,总大小需要是4的倍数。当前最后一个成员结束于偏移8,占用9个字节(0~8),向上取整到12。搞定。
3.5 如何优化结构体布局?
既然填充字节会浪费空间,那能不能减少浪费?当然可以。方法就是:重新排列成员顺序。
把大的成员往前放,小的往后放。让它们自然对齐,减少填充。
// 优化后的版本
struct TestOptimized {
int b; // 4字节,放最前面
char a; // 1字节
char c; // 1字节
};
现在算一下:b占0~3,a占4,c占5。最大对齐边界是4,总大小需要是4的倍数。当前占用6个字节(0~5),向上取整到8。
从12字节降到了8字节。省了33%的空间。如果你在嵌入式系统里定义了几百个这样的结构体,省下来的内存相当可观。
小技巧:
把相同类型的成员放在一起。尤其是把大的类型(如int、double)放在结构体开头。这样填充最少,内存利用率最高。
3.6 编译器指令:强制改变对齐
有时候你不想让编译器自作主张。比如你要把一个结构体直接写入文件,或者通过网络发送到另一个平台。这时候填充字节会破坏数据的二进制兼容性。
可以用 #pragma pack 来强制按1字节对齐:
#pragma pack(1)
struct PackedTest {
char a;
int b;
char c;
};
#pragma pack() // 恢复默认对齐
// 现在 sizeof(PackedTest) = 6
警告:
强制1字节对齐会降低内存访问效率。在性能敏感的代码中慎用。我一般只在网络协议、文件格式、硬件寄存器映射这些场景下才用。
3.7 不同平台的对齐差异
32位系统和64位系统,默认对齐数可能不一样。比如double在32位系统上可能按4字节对齐,在64位系统上按8字节对齐。这就导致同一个结构体在不同平台上大小不同。
我曾经在把ARM Cortex-M的代码移植到x86-64时,就因为这个原因踩了坑。结构体大小变了,整个通信协议都得重新对。
所以,如果你写跨平台代码,要么用 #pragma pack 统一对齐方式,要么在代码里显式加上静态断言来检查结构体大小。
// 编译时检查,防止平台差异导致的问题
_Static_assert(sizeof(struct Test) == 12, "结构体大小异常!");
3.8 结构体对齐知识体系
下面这张图,帮你把本章的核心逻辑串起来:
3.9 避坑指南
我曾经在调试一个串口通信程序时,发现接收到的数据总是错位。排查到最后,发现是发送端和接收端用了不同的对齐方式。发送端是默认对齐,接收端用了 #pragma pack(1)。两边结构体定义一模一样,但内存布局完全不同。
从那以后,我养成了一个习惯:凡是涉及跨模块、跨平台的数据交换,一律显式指定对齐方式,并在代码里加上静态断言。
总结一下本章要点:
- 结构体大小 ≠ 成员大小之和,因为有填充字节
- 填充字节是为了让CPU高效读取
- 通过重排成员顺序可以减少填充
- 用
#pragma pack可以强制改变对齐 - 跨平台代码一定要关注对齐差异
嗯,结构体对齐这件事,说难不难,说简单也不简单。关键是理解编译器背后的逻辑。你只要记住:编译器不是故意跟你作对,它只是在帮你优化性能。搞懂了这一点,下次再看到结构体大小“不对劲”,你就知道该往哪个方向查了。
公众号:蓝海资料掘金营,微信deep3321