7. 内存对齐:为什么需要对齐、alignof/alignas
内存对齐这个话题,说实话,很多C++开发者写了三五年代码都不太在意。我早年也是这样——直到有一次在嵌入式项目里,一个结构体因为对齐问题导致数据读取错误,排查了整整两天。嗯,从那以后,我再也不敢小看这个「小细节」了。
什么是内存对齐?
简单说,内存对齐就是数据在内存中的存放地址,必须是某个值的整数倍。比如一个4字节的int,它的地址必须是4的倍数。你想想看,CPU从内存读数据,不是按字节读的,而是按「字」来读。32位CPU一次读4字节,64位一次读8字节。
如果数据没对齐,会发生什么?
举个例子。假设int放在地址0x01处,CPU要读这个int,得读两次内存:先读0x00-0x03,再读0x04-0x07,然后拼起来。一次能搞定的事,硬生生变成了两次。性能直接腰斩。
核心要点:对齐的本质,是用空间换时间。编译器在结构体里插入填充字节,让每个成员都落在「舒服」的地址上。
对齐规则:编译器是怎么干的?
我习惯把对齐规则总结成三条,你记牢了:
- 每个类型有自己的对齐值——通常是它的大小。int是4,double是8,char是1。
- 结构体的对齐值——取所有成员中最大的那个对齐值。
- 结构体的大小——必须是其对齐值的整数倍,不足就填充。
看个例子就明白了:
struct Example {
char a; // 1字节,偏移0
int b; // 4字节,对齐要求4,偏移4
short c; // 2字节,对齐要求2,偏移8
char d; // 1字节,偏移10
// 结构体对齐值 = 4(int最大)
// 当前大小 = 11,需要填充到12(4的倍数)
};
// sizeof(Example) = 12
你可能会问:为什么b不直接放在偏移1的位置?因为int要求4字节对齐,偏移1不是4的倍数。编译器会在a后面塞3个填充字节,把b推到偏移4。
我的习惯:写结构体时,把大的成员往前放,小的往后放。这样能减少填充,节省内存。比如上面那个例子,如果把a、c、d放一起,b放最后,大小能降到8字节。
alignof:查查类型的对齐要求
C++11提供了alignof运算符,可以查询一个类型的对齐值。用法很简单:
#include <iostream>
#include <cstddef>
struct Small {
char c;
int i;
};
int main() {
std::cout << "int的对齐值: " << alignof(int) << '\n';
std::cout << "double的对齐值: " << alignof(double) << '\n';
std::cout << "Small的对齐值: " << alignof(Small) << '\n';
std::cout << "Small的大小: " << sizeof(Small) << '\n';
return 0;
}
输出结果:
int的对齐值: 4
double的对齐值: 8
Small的对齐值: 4
Small的大小: 8
你看,Small虽然只有5字节的有效数据,但因为int要求4字节对齐,编译器填充到了8字节。alignof告诉你的是「这个类型希望被放在什么地址上」,而不是它实际占了多少空间。
alignas:手动控制对齐
有时候,默认的对齐满足不了需求。比如你要用SIMD指令处理数据,要求16字节对齐。或者你在做内存池,希望对象按缓存行大小(64字节)对齐,避免伪共享。
这时候就用alignas。它可以修饰变量或类型,指定对齐值:
// 让这个变量16字节对齐
alignas(16) float data[1024];
// 让整个结构体64字节对齐(缓存行对齐)
struct alignas(64) CacheLine {
int value;
char padding[60]; // 手动填充到64字节
};
// 也可以修饰类型定义
struct alignas(32) AlignedStruct {
int a;
double b;
};
int main() {
std::cout << "CacheLine的对齐值: " << alignof(CacheLine) << '\n';
std::cout << "CacheLine的大小: " << sizeof(CacheLine) << '\n';
std::cout << "AlignedStruct的对齐值: " << alignof(AlignedStruct) << '\n';
return 0;
}
注意:alignas指定的对齐值不能小于类型的自然对齐值。比如你不能对int用alignas(2),因为int自然对齐就是4。编译器会忽略或报错。
对齐与性能:一个真实案例
我曾经优化过一个网络包解析模块。原始代码里结构体定义很随意,成员按逻辑分组,没考虑对齐。结果在ARM平台上,解析速度只有x86的一半不到。
排查后发现,ARM对未对齐访问的处理比x86更严格——有些ARM架构直接抛异常,有些虽然能处理但性能极差。重新排列成员后,速度提升了将近一倍。
所以我的建议是:
- 跨平台代码——一定要关注对齐,尤其是ARM、RISC-V这类架构
- 高频访问的数据——按对齐要求重新排列成员
- 序列化/反序列化——别直接用memcpy读写结构体,用序列化函数逐字段处理
对齐与动态内存
普通new返回的内存,对齐值通常是__STDCPP_DEFAULT_NEW_ALIGNMENT__,一般是8或16。如果你需要更大的对齐,比如32或64,得用aligned_alloc或者C++17的std::aligned_alloc:
#include <cstdlib>
// C11的aligned_alloc
void* ptr = std::aligned_alloc(64, 1024); // 64字节对齐,1024字节大小
// 用完记得free
std::free(ptr);
// C++17的std::aligned_alloc(注意参数顺序不同)
void* ptr2 = std::aligned_alloc(1024, 64); // 大小在前,对齐在后
std::free(ptr2);
避坑指南:我曾经在项目里直接用reinterpret_cast把普通指针转成对齐要求更高的指针,结果在特定平台上崩溃了。正确的做法是用std::align或者aligned_alloc来获取对齐的内存。
知识体系总览
下面这张图把内存对齐的核心知识点串起来了,你可以对照着回顾:
总结一下
内存对齐不是什么高深理论,说白了就是「让数据待在它该待的地方」。记住三点:
- alignof——查类型的对齐要求
- alignas——手动指定对齐值
- 排列顺序——大成员往前放,减少填充
写代码时多留个心眼,对齐问题就不会成为你的噩梦。我见过太多因为忽略对齐导致的诡异bug,排查起来真是欲哭无泪。嗯,希望你别踩同样的坑。
公众号:蓝海资料掘金营,微信deep3321