第二十一章:内存对齐——结构体对齐规则、pragma pack、对齐对性能的影响

说到内存对齐,我脑子里立刻浮现出刚入行时的一个场景。那时候我写了一个通信协议的结构体,信心满满地发给硬件同事。结果对方看了一眼,幽幽地说:“你这个结构体大小不对,中间有空洞。”我当时一脸懵——什么叫空洞?

后来我才明白,这就是内存对齐在搞鬼。说白了,CPU读取内存不是按字节来的,它有自己的“胃口”。比如32位处理器,一次想吃4个字节。如果你把数据放得歪七扭八,它就得吃两口才能凑齐,效率自然就低了。

1. 结构体对齐的基本规则

先看一个简单的例子:

struct Test {
    char a;    // 1字节
    int b;     // 4字节
    char c;    // 1字节
};

你觉得这个结构体多大?直觉告诉我,1+4+1=6字节。但实际运行一下,你会发现它占了12字节。为什么?

这就是对齐规则在起作用。规则其实不复杂,就三条:

  • 每个成员都有自己的对齐值——通常是它自身的大小。比如int是4字节对齐,char是1字节对齐。
  • 结构体的总大小——必须是最大成员对齐值的整数倍。
  • 成员之间可能有填充——编译器会在成员后面塞一些“无用”的字节,让下一个成员对齐到它该在的位置。

拿上面的结构体来说:

  • a占1字节,地址0
  • b需要4字节对齐,所以它不能从地址1开始,得从地址4开始。地址1、2、3就被填充了。
  • c占1字节,地址8
  • 结构体最大对齐值是4,总大小必须是4的倍数。目前用了9字节(0-8),所以补到12字节。

关键点:结构体的大小 ≠ 成员大小的简单相加。编译器会“偷偷”塞填充字节。

2. 对齐对性能的影响

你可能会问:对齐到底能影响多少性能?我直接告诉你答案——差距可能高达数倍。

我在项目中遇到过这样一个案例:一个嵌入式设备需要频繁解析网络包,结构体里混着各种大小的字段。最初没注意对齐,结果同样的数据量,处理时间多了将近一倍。后来重新排了成员顺序,性能就上来了。

为什么会这样?因为CPU访问未对齐的数据时,会发生:

  • 多次内存访问——比如一个int横跨在两个对齐边界上,CPU得读两次再拼起来。
  • 总线带宽浪费——每次读64位,但实际只用了32位。
  • 某些平台直接崩溃——比如ARM Cortex-M系列,访问未对齐地址会触发硬件异常。

警告:在嵌入式系统中,未对齐访问可能导致程序直接死机。这不是性能问题,是功能问题。

3. pragma pack——手动控制对齐

有时候我们不想让编译器自作主张。比如你要把一个结构体直接写入文件,或者通过网络发送给另一个设备。这时候结构体里的填充字节就成了累赘。

C语言提供了#pragma pack指令,可以临时改变对齐规则:

#pragma pack(1)  // 按1字节对齐
struct PackedTest {
    char a;
    int b;
    char c;
};
#pragma pack()   // 恢复默认对齐

// 此时 sizeof(PackedTest) == 6

嗯,这里要注意:#pragma pack(1)相当于告诉编译器“别给我塞填充字节了,所有成员紧挨着放”。

我个人习惯在需要序列化的结构体上使用pack(1),但仅限于此。因为一旦用了pack,性能就会下降——CPU访问未对齐数据的代价是实打实的。

技巧:如果你既想节省空间,又想保持性能,可以手动重排成员顺序。把大的成员放在前面,小的放在后面,往往能减少填充。

4. 结构体成员重排的实战经验

我曾经接手过一个老项目,里面有个结构体定义了二十多个字段,大小是72字节。我一看就知道有问题——按成员大小算下来,实际数据只有52字节,填充占了20字节。

怎么优化?很简单:

  • 把所有8字节的成员(double、long long)放最前面
  • 然后是4字节的(int、float)
  • 接着是2字节的(short)
  • 最后是1字节的(char、bool)

重排之后,结构体变成了56字节,省了16字节。在嵌入式系统里,16字节可能意味着能多存一个数据包。

// 优化前:72字节
struct BadOrder {
    char flag;
    int value;
    short id;
    double data;
    char name[10];
};

// 优化后:56字节
struct GoodOrder {
    double data;     // 8字节对齐
    int value;       // 4字节对齐
    short id;        // 2字节对齐
    char flag;       // 1字节对齐
    char name[10];   // 1字节对齐
};

5. 不同平台的对齐差异

你想想看,x86和ARM的对齐规则其实不完全一样。x86允许未对齐访问(只是慢),但ARM Cortex-M系列直接报错。

我记得有一次调试一个串口通信程序,在PC上跑得好好的,下载到STM32上就死机。查了半天,发现是结构体里有个uint32_t没有对齐到4字节边界。PC上没事,ARM上直接触发HardFault。

从那以后,我养成了一个习惯:在嵌入式代码里,凡是涉及指针强制转换的地方,都先检查对齐。比如:

// 危险写法
uint8_t buffer[100];
uint32_t *p = (uint32_t *)&buffer[1];  // 地址不是4的倍数

// 安全写法
uint32_t value;
memcpy(&value, &buffer[1], sizeof(value));  // memcpy不关心对齐

避坑指南:我曾经因为强制类型转换导致未对齐访问,在产线上烧了上百片芯片才发现问题。从那以后,我坚持用memcpy代替强制转换来读取非对齐数据。

6. 对齐与缓存行的关系

这个话题稍微深入一点。现代CPU都有缓存,缓存是以“行”为单位的,通常是64字节。如果一个结构体跨越了两个缓存行,访问它就需要两次缓存加载。

我优化过一个高频数据采集程序,结构体大小是68字节。每次访问都会跨缓存行,性能一直上不去。后来我把结构体填充到128字节,虽然浪费了空间,但访问速度提升了30%。

这就是典型的“用空间换时间”。在性能敏感的场景下,有时候多花点内存是值得的。

7. 本章知识体系

下面这张图总结了内存对齐的核心知识点:

内存对齐 对齐规则 成员对齐值 = 自身大小 总大小 = 最大对齐值整数倍 成员间可能有填充字节 性能影响 未对齐 → 多次内存访问 ARM平台可能触发异常 缓存行跨越降低效率 pragma pack pack(1) → 取消填充 节省空间,降低性能 适合序列化场景 最佳实践 大成员放前面 memcpy代替强制转换 跨平台注意差异

8. 总结

内存对齐这件事,说白了就是CPU和内存之间的“默契”。你遵守这个默契,程序就跑得快、跑得稳。你不遵守,轻则性能打折,重则直接崩溃。

我个人建议:

  • 默认情况下,让编译器自己处理对齐,别乱动
  • 只有在序列化、网络通信等场景下,才用#pragma pack
  • 结构体成员按大小降序排列,能减少填充
  • 嵌入式开发中,永远假设未对齐访问会出问题

嗯,关于内存对齐就聊到这里。记住一句话:对齐不是玄学,是计算机体系结构的基本要求。


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