14、内存对齐:什么是内存对齐、对齐规则、为什么要对齐
内存对齐这个话题,说实话,很多C语言开发者写了三五年代码都不一定真正搞明白。我记得刚入行那会儿,我写了一个结构体,里面就几个字段,结果打印sizeof一看,比我想象的大了一圈。我当时还以为是编译器抽风了。后来被老同事点醒,才知道是内存对齐在作怪。
今天我就把这块掰开了讲清楚。你搞懂了内存对齐,不仅写出来的代码更高效,而且排查一些诡异的内存问题也会更有底气。
什么是内存对齐
内存对齐,说白了就是数据在内存中的存放地址,必须满足某种规则。比如一个int类型的数据,它的地址必须是4的倍数;一个double类型,地址必须是8的倍数。
这不是C语言强加的,而是CPU的硬件要求。CPU读取内存时,不是按字节一个一个读的,而是按"字"来读。32位CPU一次读4个字节,64位CPU一次读8个字节。如果数据没有对齐,CPU就得读两次,再把数据拼起来——这效率就下来了。
核心概念:内存对齐是指数据存放的起始地址,必须是该数据大小的整数倍。比如4字节的int,地址必须是4的倍数;8字节的double,地址必须是8的倍数。
对齐规则
规则其实不复杂,我总结成三条,你记牢了就行:
- 基本对齐规则:每个数据类型的对齐值,等于它自身的大小。char对齐到1,short对齐到2,int对齐到4,double对齐到8。
- 结构体对齐规则:结构体的整体大小,必须是其最大成员对齐值的整数倍。不够就填充字节。
- 成员排列规则:结构体中的每个成员,相对于结构体起始地址的偏移量,必须是该成员对齐值的整数倍。编译器会在成员之间插入填充字节来满足这个要求。
举个例子,你看这个结构体:
struct Example {
char a; // 1字节,偏移0
int b; // 4字节,偏移必须是4的倍数 → 实际偏移4
short c; // 2字节,偏移8(4+4=8,是2的倍数)
};
你算算大小:a占1字节,然后填充3个字节让b对齐到4,b占4字节,c占2字节。总共1+3+4+2=10字节。但结构体整体必须是最大成员(int,4字节)的倍数,所以再填充2个字节,最终是12字节。
嗯,这里要注意:填充字节不是浪费,是必要的代价。我曾经在嵌入式项目里,为了省那几字节的填充,手动调整了结构体成员的顺序,结果程序跑起来偶尔崩溃。查了两天才发现是未对齐访问导致的。从那以后,我再也不敢乱调结构体顺序了。
为什么要对齐
这个问题我问过不少新人,答案五花八门。其实原因就两个:
- 性能原因:对齐的数据,CPU一次就能读完。不对齐的数据,CPU可能要读两次甚至三次,然后做移位和拼接操作。这在循环里跑起来,性能差距可能是几倍甚至十几倍。
- 硬件原因:有些CPU压根不支持未对齐访问。比如早期的ARM处理器,你访问一个未对齐的int,直接触发异常。程序就崩了。
我的经验:在x86平台上,未对齐访问通常不会崩溃,只是性能差。但在ARM、MIPS等嵌入式平台上,未对齐访问就是定时炸弹。我建议你写跨平台代码时,永远假设未对齐访问会崩溃——这样写出来的代码才安全。
对齐与sizeof的关系
很多人以为sizeof就是成员大小的简单相加。其实不是。sizeof返回的是对齐后的大小。你想想看,如果只是简单相加,那结构体的大小就不一定是最大成员对齐值的倍数了。
我习惯用这个公式来估算结构体大小:
结构体大小 = 所有成员大小之和 + 填充字节数
填充字节数 = 使每个成员偏移量对齐 + 使总大小对齐
再来看一个更复杂的例子:
struct Complex {
char a; // 1字节,偏移0
double b; // 8字节,偏移必须是8的倍数 → 实际偏移8(填充7字节)
int c; // 4字节,偏移16(8+8=16,是4的倍数)
char d; // 1字节,偏移20
};
算一下:a占1,填充7,b占8,c占4,d占1。总共1+7+8+4+1=21字节。最大成员是double(8字节),所以总大小必须是8的倍数。21不是8的倍数,填充到24字节。
所以sizeof(struct Complex)的结果是24,而不是你直觉中的1+8+4+1=14。
避坑指南:我曾经在序列化结构体到文件时,直接用了sizeof来算写入大小。结果文件里多了一堆填充字节,反序列化时怎么都对不上。后来我改用#pragma pack(1)取消对齐,或者手动逐个成员写入,才解决了问题。记住:结构体在内存中的布局,不等于它在文件或网络中的布局。
如何控制对齐
有些场景下,我们需要手动控制对齐。比如网络协议解析、硬件寄存器映射等。C语言提供了几种方式:
- #pragma pack(n):设置对齐系数为n。比如#pragma pack(1)就是按1字节对齐,相当于取消对齐。
- __attribute__((aligned(n))):GCC扩展,指定变量或类型的对齐值。
- __attribute__((packed)):GCC扩展,取消对齐,所有成员紧密排列。
举个例子:
#pragma pack(1)
struct PackedStruct {
char a;
int b;
short c;
};
#pragma pack()
// 或者用GCC属性
struct __attribute__((packed)) PackedStruct2 {
char a;
int b;
short c;
};
这样sizeof就是1+4+2=7字节,没有填充。但代价是访问b和c时可能未对齐,性能会下降。
我建议:只在必要时才取消对齐。比如你写网络协议解析库,或者操作硬件寄存器。普通业务代码,让编译器自己处理对齐就好。别为了省那几字节,把性能搭进去。
知识体系图
下面这张图帮你理清内存对齐的核心逻辑:
总结
内存对齐不是什么高深的概念,但理解它确实能帮你写出更好的代码。我个人的习惯是:
- 写结构体时,把大的成员放在前面,小的放在后面,减少填充
- 需要序列化时,用#pragma pack(1)或者逐个成员处理
- 嵌入式开发中,永远假设未对齐访问会崩溃
你想想看,搞懂了这些,以后遇到sizeof结果和预期不符的情况,是不是心里就有数了?
公众号:蓝海资料掘金营,微信deep3321