10、内存对齐:对齐原理、alignof/alignas、结构体对齐
内存对齐这个话题,说实话,很多C语言开发者一开始都不太在意。我早年写嵌入式程序时也吃过这个亏——明明结构体里就几个字段,sizeof一算,比预期大了不少。当时我还在纳闷,这编译器是不是偷偷给我塞东西了?
后来我才明白,这不是编译器在捣乱,而是CPU在「提要求」。说白了,内存对齐就是让数据存放在「合适」的地址上,方便CPU一次性把数据取走。你想想看,如果CPU每次读4字节,结果一个int横跨在两个内存行上,那它就得读两次,再拼起来——多累啊。
10.1 为什么需要内存对齐?
现代CPU读取内存,不是按字节来的,而是按「字」来的。比如32位CPU,一次读4字节。如果数据刚好对齐,一次搞定。如果没对齐,轻则多读一次,重则直接崩溃(某些ARM平台就是这样)。
我在项目中遇到过这样一个场景:一个传感器数据包,结构体里塞了char、int、short,结果在ARM Cortex-M3上跑,程序直接进了HardFault。查了半天,发现是结构体成员没对齐,CPU读int时地址不是4的倍数。从那以后,我对齐这件事再也不敢马虎了。
举个例子:
char:1字节,任何地址都行short:2字节,地址必须是2的倍数int:4字节,地址必须是4的倍数double:8字节,地址必须是8的倍数
10.2 alignof 和 alignas —— C11 带来的对齐利器
C11标准引入了两个关键字:_Alignof 和 _Alignas。为了方便使用,头文件 <stdalign.h> 提供了 alignof 和 alignas 这两个宏。
10.2.1 alignof:查询对齐要求
alignof 返回一个类型的对齐字节数。说白了,就是告诉你这个类型的数据应该放在什么样的地址上。
#include <stdio.h>
#include <stdalign.h>
int main() {
printf("char 的对齐要求: %zu\n", alignof(char));
printf("int 的对齐要求: %zu\n", alignof(int));
printf("double 的对齐要求: %zu\n", alignof(double));
printf("float 的对齐要求: %zu\n", alignof(float));
return 0;
}
输出结果(常见平台):
char 的对齐要求: 1
int 的对齐要求: 4
double 的对齐要求: 8
float 的对齐要求: 4
嗯,这里要注意:alignof 返回的是「最小对齐要求」,不是「最大」。有些平台可能要求更严格,但不会比这个值小。
10.2.2 alignas:手动设置对齐
alignas 可以让你手动指定一个变量或类型的对齐方式。我个人习惯在需要缓存行对齐、或者与硬件寄存器对接时使用它。
#include <stdio.h>
#include <stdalign.h>
// 让这个结构体按 32 字节对齐(常用于缓存行对齐)
struct alignas(32) CacheLine {
int data[8];
};
int main() {
struct CacheLine cl;
printf("CacheLine 的对齐要求: %zu\n", alignof(struct CacheLine));
printf("CacheLine 的地址: %p\n", (void*)&cl);
return 0;
}
alignas(4096)。我在做内存池分配器时就这么干过,省了不少心。
10.3 结构体对齐 —— 最让人头疼的部分
结构体对齐,说白了就是编译器在结构体成员之间「塞填充字节」,让每个成员都满足自己的对齐要求。同时,结构体整体的大小也要是最大成员对齐要求的整数倍。
为什么会这样?因为如果你定义一个结构体数组,第二个结构体的起始地址也必须对齐。否则第二个结构体里的成员又可能不对齐了。
10.3.1 一个经典例子
#include <stdio.h>
#include <stddef.h>
struct Example1 {
char a; // 1字节
int b; // 4字节
short c; // 2字节
};
struct Example2 {
int b; // 4字节
short c; // 2字节
char a; // 1字节
};
int main() {
printf("Example1 大小: %zu\n", sizeof(struct Example1));
printf("Example2 大小: %zu\n", sizeof(struct Example2));
return 0;
}
输出结果:
Example1 大小: 12
Example2 大小: 8
你看,同样的成员,只是顺序不同,大小差了4个字节。这就是对齐填充搞的鬼。
我来给你拆解一下 Example1 的内存布局:
| 偏移 | 内容 | 说明 |
|---|---|---|
| 0 | char a | 1字节 |
| 1-3 | 填充 | 为了让int b对齐到4的倍数 |
| 4-7 | int b | 4字节 |
| 8-9 | short c | 2字节 |
| 10-11 | 填充 | 为了让结构体大小是4的倍数 |
而 Example2 的布局就紧凑多了:
| 偏移 | 内容 | 说明 |
|---|---|---|
| 0-3 | int b | 4字节 |
| 4-5 | short c | 2字节 |
| 6 | char a | 1字节 |
| 7 | 填充 | 为了让结构体大小是4的倍数 |
#pragma pack(1) 或者手动按字节解析,才解决了问题。
10.4 知识体系总览
下面这张图,我把内存对齐的核心知识点串了起来。你可以把它当作一个快速索引,遇到问题时回来瞄一眼就行。
10.5 实用建议
说了这么多,我总结几条实战经验给你:
- 调整成员顺序:把大的成员(如double、int)放在前面,小的(如char、short)放在后面。这样能减少填充字节,结构体更紧凑。
- 使用
offsetof宏:offsetof(struct, member)可以查看成员在结构体中的偏移量,调试时很有用。 - 慎用
#pragma pack(1):虽然可以取消对齐,节省空间,但会导致访问未对齐成员时性能下降。在跨平台通信时可以用,但平时不建议。 - 用
alignas对齐缓存行:如果你在做高性能计算,把热点数据按64字节对齐,可以避免伪共享问题。
sizeof 和 offsetof 验证一下布局。尤其是在做底层驱动或网络协议时,这一步能省去后面大量的调试时间。
内存对齐这件事,说白了就是「空间换时间」的典型例子。多花几个字节的填充,换来CPU一次读取搞定,这笔账怎么算都划算。你只要记住:对齐是CPU的脾气,不是编译器的任性,后面写代码时心里就有数了。
公众号:蓝海资料掘金营,微信 deep3321