前言
内存对齐(memory alignment)是 C++ 里"天天在用、却很少被明确写到代码里"的一类规则。它决定了结构体到底占多少字节、sizeof为什么比所有成员之和更大、把一个自定义结构体直接当二进制写进文件为什么会在另一台机器上读崩。
一个极常见的误解是:"结构体大小就是各成员大小之和。" 实际上,编译器会在成员之间和结构体末尾插入填充字节(padding),而且怎么插是由目标平台的 ABI(Application Binary Interface,应用二进制接口)决定的,C++ 标准本身不规定任何具体布局。另一个误解是:"对齐只是性能问题,随便配个#pragma pack(1)就能把结构体压小。" 压小是真的,但打包之后访问那些没有对齐的成员,一旦编译器生成对齐访问指令,就会撞上未定义行为(Undefined Behavior,UB)。
本文从对齐的来源讲起,说清alignof、alignas、填充字节、成员重排这几件事,再给出两个实战场景(二进制协议头和伪共享),最后用对照表列出真正会踩的坑。文中所有具体字节数都标注了平台;C++17 为基准,GCC 13 / Clang 17 / MSVC 19.3x 均可编译。
一、对齐是怎么来的:从硬件到 ABI
对齐要求有两个来源,缺一不可。
硬件层面:主流 CPU 对内存的读写是按字进行的,访问一个 4 字节的int时,如果它的起始地址是 4 的倍数,一条指令就能完成;如果跨了 4 字节边界,某些架构(比如早期的 ARM、SPARC)会直接触发异常,x86 虽然能用多条指令凑出来,但代价是明显的额外周期。所以硬件天然偏好"自然对齐"。
ABI 层面:编译器需要一套统一的规则,让不同的编译单元、不同的库之间互相调用时不至于对同一个结构体的布局产生分歧。这套规则由平台 ABI 定义。x86-64 上的 System V ABI(Linux、macOS 等)和 Microsoft x64 ABI(Windows)在基本类型的对齐上基本一致,但在long的大小等细节上不同。
基本类型的大小和对齐不是标准规定的,标准只给出最小范围(比如int至少 16 位)。下表是主流平台上的实际取值:
| 类型 | LP64(Linux / macOS 64 位) | LLP64(Windows 64 位) | 标准保证 |
|---|---|---|---|
char | 1 字节,对齐 1 | 1 字节,对齐 1 | 恒为 1 |
int | 4 字节,对齐 4 | 4 字节,对齐 4 | 至少 16 位 |
long | 8 字节,对齐 8 | 4 字节,对齐 4 | 至少 32 位 |
long long | 8 字节,对齐 8 | 8 字节,对齐 8 | 至少 64 位 |
| 指针 | 8 字节,对齐 8 | 8 字节,对齐 8 | 依平台 |
double | 8 字节,对齐 8 | 8 字节,对齐 8 | 至少 64 位 |
用标准提供的工具来查询当前平台的实际值,而不是背表格:
#include <cstddef> #include <iostream> struct Empty {}; struct Mixed { char c; int i; double d; }; int main() { std::cout << "alignof(char) = " << alignof(char) << '\n'; std::cout << "alignof(int) = " << alignof(int) << '\n'; std::cout << "alignof(double) = " << alignof(double) << '\n'; std::cout << "alignof(Mixed) = " << alignof(Mixed) << '\n'; std::cout << "sizeof(Mixed) = " << sizeof(Mixed) << '\n'; std::cout << "sizeof(Empty) = " << sizeof(Empty) << '\n'; // 通常为 1 return 0; }alignof是 C++11 起的关键字形式的运算符(用法像函数),返回std::size_t。sizeof(Empty)一定是 1——这是标准规定的,目的是保证"每个对象都有唯一地址",而不是什么对齐要求。
二、结构体的实际布局:内边距、尾部填充与成员顺序
编译器布局结构体时遵循两条规则(来源于平台 ABI 的约定,不是 C++ 标准):
- 每个成员的起始偏移量必须是该成员对齐值的整数倍,不满足时在前面补填充字节;
- 结构体整体的对齐值等于所有成员对齐值的最大值(被
alignas提高时取提高后的值),并且结构体的总大小必须是整体对齐值的整数倍,不够就在末尾补填充字节。
看一个例子:
#include <cstddef> #include <iostream> struct A { char c; // 偏移 0 int i; // 需要 4 对齐,偏移 4,中间补 3 字节 char d; // 偏移 8 }; // 整体对齐 4,总大小要补到 4 的倍数 → 12 struct B { int i; // 偏移 0 char c; // 偏移 4 char d; // 偏移 5 }; // 整体对齐 4,补 2 字节 → 8 int main() { std::cout << "sizeof(A) = " << sizeof(A) << " alignof(A) = " << alignof(A) << '\n'; std::cout << "sizeof(B) = " << sizeof(B) << " alignof(B) = " << alignof(B) << '\n'; std::cout << "offsetof(A, i) = " << offsetof(A, i) << '\n'; std::cout << "offsetof(A, d) = " << offsetof(A, d) << '\n'; return 0; }在 x86-64 的 System V ABI 与 MSVC x64 ABI 下,A是 12 字节、B是 8 字节:成员完全相同,只是顺序变了,就少了 4 字节。这就是"把占用大的成员放前面、把小的成员聚在一起"这条经验的由来——它减少的是填充,不是元素本身。
再看一个更极端的组合:
#include <iostream> struct S { char a; // 偏移 0 char b; // 偏移 1 double d; // 需要 8 对齐,偏移 8,中间补 6 字节 char e; // 偏移 16 }; // 整体对齐 8,补 7 字节 → 24 int main() { std::cout << sizeof(S) << '\n'; // 典型结果:24 return 0; }四个成员的真实数据加起来只有 1+1+8+1 = 11 字节,却占了 24 字节,其中 13 字节是填充。把它们重排成double, char, char, char之后就只要 16 字节。填充字节不存任何数据,但会实打实地占内存、占缓存、占网络带宽,所以大数组里这份浪费会被放大。
需要强调的是:这些数字是 ABI 决定的,不是标准保证的。同一份代码换到某个 32 位平台、换个编译器、或者加上某些编译选项,都可能得到不同结果。所以永远用sizeof和offsetof去查,不要靠心算。
offsetof声明在<cstddef>里,它是一个宏。标准规定:如果类型不是标准布局类型(standard-layout type,大致要求是不含虚函数、所有非静态成员访问权限一致、没有非标准布局的基类等),offsetof的行为是未定义的。对非标准布局的类型,GCC 和 Clang 通常会给出警告。
三、用 alignas / alignof 控制对齐
alignas是 C++11 引入的对齐说明符,用来提高(不能降低,除非用alignas(0)表示"不施加影响")某个变量、类成员或类型的最小对齐要求。
#include <atomic> #include <cstddef> // 整个结构体按 64 字节对齐 struct alignas(64) PaddedCounter { std::atomic<long> value{0}; }; // 只有这个成员按 64 字节对齐,用于把同结构体内的相邻成员分到不同缓存行 struct SplitCounters { alignas(64) std::atomic<long> a{0}; alignas(64) std::atomic<long> b{0}; };规则细节:alignas的参数必须是2 的幂;多个alignas同时出现时取最大值;如果指定的值小于类型的自然对齐,程序是非良构的(ill-formed),编译器会报错——只有alignas(0)是特例,它被忽略。标准规定alignas只能用在变量声明、类成员声明或类型定义上,不能用在函数参数上。
过对齐类型(over-aligned type,指对齐要求大于alignof(std::max_align_t)的类型)在动态分配上有个重要的时间线:C++17 之前,new只保证返回std::max_align_t的对齐,分配一个alignas(64)的类型拿到未对齐的内存,再去访问就是 UB。C++17 起引入了对齐形式的operator new,new Node对过对齐类型会自动走对齐重载:
#include <cstddef> #include <new> struct alignas(64) Node { int x = 0; }; Node* make() { Node* p = new Node; // C++17 起保证按 64 字节对齐;C++14 及以前不保证 delete p; // 对应的 operator delete 会自动匹配对齐重载 return nullptr; }std::max_align_t定义在<cstddef>,alignof(std::max_align_t)在 x86-64 上通常是 16(因为long double需要 16 字节对齐)。这个值是平台相关的:在某些 32 位平台上它是 8。
栈上的对象对齐最省心:alignas(64) Node n;编译器会负责把栈指针调整到满足要求的位置。
四、实战:二进制协议与并发场景
4.1 二进制协议头:打包的代价
网络协议、文件格式里的结构体通常要求"紧凑排布、无填充",这时要用编译器扩展把填充关掉:
#include <cstdint> #pragma pack(push, 1) struct PacketHeader { std::uint8_t version; // 偏移 0 std::uint16_t length; // 偏移 1(打包后不再补到 2) std::uint32_t seq; // 偏移 3(打包后不再补到 4) }; #pragma pack(pop) static_assert(sizeof(PacketHeader) == 7, "打包后应当是 7 字节");#pragma pack是 MSVC 和 GCC/Clang 都支持的编译器扩展;GCC/Clang 还提供__attribute__((packed))。两者都不是标准 C++,但主流编译器都实现了。
打包的代价必须说清楚:结构体整体的对齐被压到 1,length的偏移量是 1、seq的偏移量是 3,都是未对齐的。取p->seq这类表达式,如果编译器生成一条要求 4 字节对齐的读取指令,在要求严格对齐的架构上会直接出错;即便在 x86 上能凑出来,用reinterpret_cast把未对齐的地址当成std::uint32_t*解引用,标准上也是 UB。安全的做法是逐字节拼装,或者用std::memcpy从缓冲区拷进一个对齐的变量——std::memcpy对源地址的对齐没有要求,这是它被允许用于"解包"的原因。
#include <cstdint> #include <cstring> std::uint32_t read_u32_le(const unsigned char* p) { std::uint32_t v = 0; std::memcpy(&v, p, sizeof(v)); // 不要求 p 对齐;字节序需按协议另行处理 return v; }还有一点:static_assert(sizeof(PacketHeader) == 7, ...)里的 7 依赖于编译器对打包扩展的实现。协议里更稳妥的写法是用固定宽度的std::uint8_t数组加显式的偏移常量,或者干脆用序列化函数逐字段写——布局可控比写法简洁更重要。
4.2 并发场景:用对齐消除伪共享
两个线程分别更新两个互不相关的计数器,如果它们落在同一条缓存行上,CPU 会不停地在两个核心之间搬运这条缓存行,这就是伪共享(false sharing)。典型 x86-64 处理器的缓存行是 64 字节:
#include <atomic> #include <thread> struct Counters { alignas(64) std::atomic<long> a{0}; alignas(64) std::atomic<long> b{0}; }; void demo() { Counters c; std::thread t1([&c] { for (int i = 0; i < 100000; ++i) { c.a.fetch_add(1, std::memory_order_relaxed); } }); std::thread t2([&c] { for (int i = 0; i < 100000; ++i) { c.b.fetch_add(1, std::memory_order_relaxed); } }); t1.join(); t2.join(); }这里alignas(64)让a和b落在不同缓存行,避免两者在缓存一致性协议下互相干扰。64 是经验值,只对缓存行恰好为 64 字节的机器有效;严格一点可以用<new>里的std::hardware_destructive_interference_size(C++17 引入,取值由实现定义)。要注意 GCC 12 起对使用该常量默认产生-Winterference-size警告,因为它和 ABI 相关、跨编译单元可能不一致,工程上常配合-Wno-interference-size或直接写alignas(64)。
顺便澄清一个高频错误认知:volatile不能用来做线程同步。它既不保证原子性,也不建立 happens-before 关系,只是一个"禁止编译器把访问优化掉"的标记。上面这个例子里用std::atomic加std::memory_order_relaxed是合适的,因为两个计数器互不依赖,只需要原子性而不需要跨线程的顺序约束。
常见坑点
| 场景 | ❌ 错误写法 | ✅ 正确写法 |
|---|---|---|
| 假设结构体大小 | assert(sizeof(Header) == 1 + 2 + 4); | 用sizeof/offsetof实测,或用static_assert配合打包扩展 |
| 打包结构体取成员地址 | #pragma pack(1)后std::uint32_t* p = &h.seq;再解引用 | 用std::memcpy拷进对齐变量,再按字节序转换 |
| 把结构体直接写文件 | out.write(reinterpret_cast<const char*>(&s), sizeof s)跨平台读 | 逐字段序列化;填充字节的内容是实现定义的垃圾 |
用alignas降低对齐 | struct alignas(2) { double d; };编译错误 | alignas只能提高对齐;要降低得用#pragma pack |
| 对齐参数非 2 的幂 | struct alignas(24) X {};编译错误 | 用 8、16、32、64 这类 2 的幂 |
| C++14 里分配过对齐类型 | alignas(64) Node* p = new Node;不保证对齐 | C++17 起有保证;C++14 需用posix_memalign等平台接口 |
用volatile做同步 | volatile int flag; while (!flag) {} | 用std::atomic<bool>配memory_order_acquire |
依赖offsetof于非标准布局 | 对含虚函数的类用offsetof | 只对标准布局类型用;否则行为未定义 |
关于"填充字节的内容",这里要特别提醒:填充字节里是什么值实现定义的,通常是被复用的栈或堆上的旧数据。把含有填充的结构体整个写进文件或者通过send发出去,这些字节也就一起出去了,这既是跨平台读崩的原因,也可能是信息泄漏的源头。安全敏感的场景里,发送前要显式初始化(比如std::memset清零)或用逐字段序列化——当然,清零之后sizeof仍然是包含填充的大小,这一点不会变。
总结
| 概念 | 含义 | 谁决定 |
|---|---|---|
对齐值alignof(T) | 该类型对象地址必须是这个值的倍数 | ABI(基本类型)+ 标准(规则)+alignas(提高) |
| 内边距 | 成员之间为满足对齐而插入的空字节 | ABI |
| 尾部填充 | 使总大小成为整体对齐值整数倍而补的字节 | ABI |
| 结构体对齐 | 所有成员对齐值的最大值 | ABI,可被alignas提高 |
| 打包 | 把对齐压到 1,取消填充 | 编译器扩展(#pragma pack/packed) |
| 过对齐类型 | 对齐大于alignof(std::max_align_t)的类型 | 由alignas产生,C++17 起new支持 |
内存对齐的三条核心结论:一,结构体大小是 ABI 决定的,标准不规定,永远用sizeof/offsetof实测;二,填充会浪费内存和带宽,大数组里把同类成员聚在一起能显著减少浪费,但重排成员依赖于平台布局,跨 ABI 时不应当依赖具体字节数;三,打包能省内存但会产生未对齐访问,凡是涉及二进制布局的读写,优先用std::memcpy或逐字段序列化,而不是起一个对齐的指针去解引用。