同样两个成员变量,仅仅因为声明顺序不同,一个结构体占用12字节,另一个却只有8字节。这种差异并非编译器刻意浪费内存,而是它遵循了CPU的地址访问规则。内存对齐并不是C++语言层面上的可有可无约束,而是直接映射到处理器取指令和数据时的硬件行为。理解这一点,需要从CPU的地址总线和数据总线如何协同工作说起。

CPU读取内存的最小单位与未对齐带来的两次访问
现代CPU并不会一次只读一个字节送进寄存器,而是按照总线宽度与数据通路设计,以固定的字节块为单位访问物理内存。32位处理器通常一次读取4字节,64位处理器一次可以读取8字节甚至16字节。地址对齐的核心约束是:一个N字节的数据,其起始地址必须能被N整除。以4字节的int为例,当地址是0、4、8、12等4的倍数时,CPU可以一次内存事务将该数据完整加载;当地址是1、2、3、5等非对齐地址时,该int可能横跨两个4字节块,CPU必须发出两次读取请求,把两个块拼出有效数据,或者触发硬件异常。对于连续多次未对齐访问,这种额外读取会成倍放大延迟。
具体来看,读取地址0x00的4字节数据,只需要读取0x00-0x03这一个块;读取地址0x01的4字节数据,则需要读取0x00-0x03和0x04-0x07两个块,然后通过移位和按位或操作合并成寄存器需要的值。在x86架构下,处理器会默默完成这些步骤,但指令周期会增加;在一些RISC架构(如老式ARM、MIPS)中,未对齐访问在非特权模式下可能直接抛出总线错误,由操作系统模拟或直接终止程序。因此,编译器对结构体成员进行填充,本质上是把每个成员放到它自然对齐的地址上,避免CPU付出额外代价。
结构体对齐规则与编译器的填充策略
C++标准通过alignof运算符暴露类型的对齐要求。对于基本类型,alignof(char)通常为1,alignof(short)为2,alignof(int)为4,alignof(double)在64位Linux为8。结构体的对齐值等于其所有成员中对齐值的最大值,每个成员的偏移量必须是该成员对齐值的整数倍,结构体总大小也必须是对齐值的整数倍。因此,struct A { char a; int b; }; 中,char a占偏移0,int b需要偏移4,所以编译器在a后填充3个字节,b在偏移4到7,结构体总大小为8,而不是5。如果成员顺序反过来,struct B { int b; char a; }; 则int b在偏移0,char a在偏移4,总大小需要对int对齐,即填充到8,也是8字节,没有区别。但如果还有更多char成员,顺序的影响就会显现出来。
例如以下两个结构体,在64位系统上大小完全不同:
#include <iostream>
#include <cstddef>
using namespace std;
struct A {
char a;
int b;
char c;
};
struct B {
int b;
char a;
char c;
};
int main() {
cout << "sizeof(A) = " << sizeof(A) << endl;
cout << "offsetof(A, a) = " << offsetof(A, a) << endl;
cout << "offsetof(A, b) = " << offsetof(A, b) << endl;
cout << "offsetof(A, c) = " << offsetof(A, c) << endl;
cout << "sizeof(B) = " << sizeof(B) << endl;
return 0;
}
在A中,char a位于偏移0,int b需要对齐到4,因此在a后填充3字节,b占偏移4-7,char c在偏移8,结构体总大小必须为4的倍数,因此在c后继续填充3字节,最终sizeof(A)为12。而B中int b占偏移0-3,两个char依次占偏移4和5,总大小只需要填充到4的倍数,即8字节。同样的成员,仅因为顺序不同就多出4字节填充,这正是对齐规则与成员排列顺序共同作用的结果。
使用自定义对齐时,alignas可以强制改变成员或变量的对齐值。例如alignas(16) char buf[64]; 将数组对齐到16字节边界,便于SIMD指令处理。但过度对齐可能降低缓存行利用率,需要权衡。也可以使用#pragma pack(1)取消填充,但会引发未对齐访问,导致性能下降或某些平台崩溃。
缓存行对齐与并发中的伪共享
除了CPU核心内部的寄存器加载,对齐还影响缓存子系统。现代CPU的缓存以缓存行为单位,常见大小为64字节。当多个变量落在同一个缓存行中,即使逻辑上互不相关,多核CPU的不同核心分别修改其中不同变量时,也会因为缓存一致性协议(如MESI)而频繁传递整行数据,造成伪共享。如果将关键共享变量分别对齐到单独的缓存行,可以避免这种无意义的同步开销。C++17中可以使用alignas(64)来强制变量起始地址在64字节边界上,但更严谨的做法是使用std::hardware_destructive_interference_size来获取建议值和std::hardware_constructive_interference_size,不过这些值在部分编译器实现中只是提示。
例如下面的结构体通过alignas(64)让两个计数器分别占据独立的缓存行:
#include <cstddef>
struct SharedData {
alignas(64) long long counter1;
alignas(64) long long counter2;
};
在并发程序中,如果两个线程分别频繁更新counter1和counter2,放在同一缓存行会导致每次更新都使对方核心的缓存行失效,需要通过总线重新获取,吞吐量可能下降数倍甚至更多。对齐到缓存行后,这两个变量位于不同缓存行,缓存一致性流量大幅减少。这也解释了为什么一些高性能数据结构会把热点字段拆开,并用对齐填充占据整行,主动用空间换时间。
硬件差异与工程实践
不同处理器对未对齐访问的容忍度不同。x86/x64体系历史包袱较重,硬件会自动处理绝大多数未对齐访问,只是可能多花几个周期;但涉及原子指令(如lock cmpxchg)或SIMD(如movaps要求16字节对齐)时,未对齐会导致异常。ARMv6及更早的架构对普通加载存储也可能直接产生对齐异常,ARMv8虽然放宽,但某些指令仍需对齐。因此,编写跨平台C++代码时不能只以x86测试结果为准,依赖未对齐访问省下的几个字节,可能在ARM设备上付出崩溃或极慢的代价。
工程建议是默认让编译器自然对齐,不随意使用#pragma pack收紧结构体,除非在协议解析、文件格式映射或者内存实在紧张且经过验证的场景。如果必须使用packed,应明确知道每个字段的地址是否可能非对齐,并对性能进行基准测试。对于需要极致性能的热点数据,可以使用alignas将数据对齐到缓存行大小或SIMD要求的16/32字节,并结合padding避开伪共享。最终,内存对齐是用少量内存换取CPU访问效率的典型权衡。