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 结构体对齐知识体系

下面这张图,帮你把本章的核心逻辑串起来:

结构体对齐与内存布局 CPU读取效率 一次读取对齐的数据 编译器自动填充 插入填充字节 核心规则 对齐边界 = min(自身大小, 默认对齐数) 重排成员顺序 #pragma pack 静态断言检查

3.9 避坑指南

我曾经在调试一个串口通信程序时,发现接收到的数据总是错位。排查到最后,发现是发送端和接收端用了不同的对齐方式。发送端是默认对齐,接收端用了 #pragma pack(1)。两边结构体定义一模一样,但内存布局完全不同。

从那以后,我养成了一个习惯:凡是涉及跨模块、跨平台的数据交换,一律显式指定对齐方式,并在代码里加上静态断言

总结一下本章要点:

  • 结构体大小 ≠ 成员大小之和,因为有填充字节
  • 填充字节是为了让CPU高效读取
  • 通过重排成员顺序可以减少填充
  • #pragma pack 可以强制改变对齐
  • 跨平台代码一定要关注对齐差异

嗯,结构体对齐这件事,说难不难,说简单也不简单。关键是理解编译器背后的逻辑。你只要记住:编译器不是故意跟你作对,它只是在帮你优化性能。搞懂了这一点,下次再看到结构体大小“不对劲”,你就知道该往哪个方向查了。


公众号:蓝海资料掘金营,微信deep3321