C++内存对齐实战:揭秘结构体大小的计算逻辑
要说内存对齐这玩意儿,那可真是程序界的 “规矩先生”,躲都躲不开。
搞高性能系统开发、玩低延迟交易系统,还有跟硬件打交道的时候,没它可不行。
整明白了内存对齐,能让缓存少出错、SIMD 指令跑得飞起,甚至还能避免程序直接崩掉 —— 有些平台就认对齐访问这一套,不服不行。
C++11 整了个 alignas 关键字,专门用来指定变量或者类型的对齐方式,按理说跟 new 搭配着用挺顺溜,可实际操作起来,哎,这里面门道可不少。
Part1 啥是内存对齐呢?
说白了,就是数据在内存里放的时候,不是挤挤挨挨堆一块儿的。从结构体开始的地址算起,每个元素往内存里搁的时候,都觉得内存是按自己的大小(通常它为4或8)划分好的,所以它放的位置必须是自己宽度的整数倍,这就是内存对齐的规矩。
编译器会给程序里的每个 “数据单元” 找个合适的地儿。C 语言还允许咱们插手 “内存对齐” 这事儿。要是你想摸清底层的猫腻,那 “内存对齐” 可不能再是雾里看花了。
咱从一个例子开始琢磨琢磨。按说 int 占 4 个字节,char 占 1 个字节,把它们塞进一个结构体里,按理说应该是 4+1=5 个字节。可实际上,跑程序出来的结果是 8 个字节,这就是内存对齐所导致的。
#include<stdio.h>
struct{
int x;
char y;
}Test;
int main()
{
printf("%d\n",sizeof(Test)); // 输出8不是5
return 0;
}
Part2 为啥非要内存对齐?
两大核心原因:
1、硬件兼容性(可移植性):
- 并非所有硬件平台都能容忍任意地址上的任意数据访问。
- 某些架构(如早期的 ARM, RISC-V 基础指令集,部分 DSP)严格要求特定数据类型(如 int, float, double, 指针)必须存储在特定边界(如 4 字节边界、8 字节边界)上。
违反后果:尝试在非对齐地址访问这些数据会触发硬件异常(如总线错误 SIGBUS),导致程序崩溃。
2、性能优化:
- 减少内存访问次数:对齐的数据可在单次读取中完成,而未对齐的数据可能需要两次访问(如跨4字节边界)。
- 提高缓存效率:对齐的数据更可能完整占用一个缓存行(通常64字节),减少缓存未命中。
- SIMD指令支持:向量化指令(如SSE/AVX)要求数据严格对齐,否则无法发挥性能优势。
- 原子性保证:对齐访问通常能保证原子性(单次总线事务),而未对齐访问可能需要多次事务,增加开销。
假设没有内存对齐这规矩,数据随便放。现在有个 int 变量,放在从地址 1 开始的连续四个字节里。处理器去拿数据的时候,得先从 0 地址开始读第一个 4 字节块,把不要的字节(0 地址那部分)扔了,再从地址 4 开始读下一个 4 字节块,同样把不要的数据(5、6、7 地址那部分)剔除,最后把剩下的两块数据拼起来放进寄存器。这活儿可够繁琐的。
有了内存对齐就不一样了,int 类型的数据只能放在按规矩对齐的内存里,比如从 0 地址开始。那处理器取数据的时候,一下就能读出来,不用额外瞎折腾,效率这不就上来了嘛。
简单说,就是方便计算机读写数据。对齐的地址一般都是 n(n=2、4、8)的倍数。
- 1 个字节的变量,像 char 类型的,放哪儿都行,随便哪个地址都成。
- 2 个字节的变量,比如 short 类型,得放在 2 的整数倍地址上。
- 4 个字节的变量,像 float、int 类型,就得搁在 4 的整数倍地址上。
- 8 个字节的变量,比如 long long、double 类型,必须放在 8 的整数倍地址上。
Part3 内存对齐规则
3.1、基本规则
成员对齐:每个数据成员的起始地址必须是其对齐值的倍数。
对齐值 = min(成员自身大小, 指定对齐值)(通过#pragma pack或alignas设置)。
结构体整体对齐:
-
- 结构体大小必须是最大成员对齐值的倍数。
- 若指定对齐值小于最大成员对齐值,则以最大成员为准。
3.2、基本变量类型所占大小
注:红色标记部分特别注意!!
|
数据类型 |
ILP32(32位Linux系统) |
LP64(大部分64位Linux系统) |
LLP64(64位Windows系统) |
|
char |
1 字节 |
1 字节 |
1 字节 |
|
short |
2 字节 |
2 字节 |
2 字节 |
|
int |
4 字节 |
4 字节 |
4 字节 |
|
float |
4 字节 |
4 字节 |
4 字节 |
|
long |
4 字节 |
8 字节 |
4 字节 |
|
double |
8 字节 |
8 字节 |
8 字节 |
|
long long |
8 字节 |
8 字节 |
8 字节 |
|
指针 point |
4 字节 |
8 字节 |
8 字节 |
|
枚举 enum |
4 字节 |
4 字节 |
4 字节 |
|
联合体 union |
取 union 中最大一个变量类型大小 |
取 union 中最大一个变量类型大小 |
取 union 中最大一个变量类型大小 |
- ILP32 指 int,long、point 是 32 位。
- LP64 指 long、point 是 64 位。
- LLP64 指 long long、point 是 64 位。
3.3、示例
实例 1:(没指定对齐字节,那 n 就等于最大成员(int i)的大小 4)
class node
{
char c; //放在位置0,位置区间[0]
int i; //4 = n, 那么放置起始位置应该是4的倍数,即4,位置区间为[4~7]
short s; //2 < n,那么放置起始位置应该是2的倍数,即8,位置区间为[8~9]
}
这时候成员一共占了 [0~9] 10 个字节,还得整体对齐,大小得是 4 的倍数,所以就是 12。
实例 2:(假设指定对齐字节为 8,那 n = min (8,4) = 4)
class node
{
int i; //放在位置0,位置区间[0~3]
char c; //1 < n, 那么放置起始位置应该是1的倍数,即4,位置区间为[4]
short s; //2 < n,那么放置起始位置应该是2的倍数,即6,位置区间为[6~7]
}
成员总共占了 [0~7] 8 个字节,刚好是 4 的倍数,所以大小是 8。
实例 3:(假设指定对齐字节是 2,那 n = min (2,4) = 2)
class node
{
char c; //放在位置0,位置区间[0]
int i; //4 > n, 那么放置起始位置应该是2的倍数,即2,位置区间为[2~5]
short s; //2 = n,那么放置起始位置应该是2的倍数,即6,位置区间为[6~7]
}
此时成员共占用 [0~7] 8 个字节,刚好是 4 的倍数,所以大小是 8。
实例 4:(按照默认设置)
class temp
{
char c;
int i;
short s1;
};
从实例 1 能知道,默认对齐情况下,temp 的大小是 12,temp 的对齐字节数是三个成员里最大的,也就是 4。
对于 node 来说,n 等于它三个成员对齐字节数里最大的,也就是 t 的对齐字节数 4。
class node
{
char c; //放在位置0,位置区间[0]
temp t; //4(temp的对齐字节数) = n, 那么放置起始位置应该是4的倍数,即4,位置区间为[4~15]
short s; //2 < n,那么放置起始位置应该是2的倍数,即16,位置区间为[16~17]
}
这时候成员共占用 [0~17] 18 个字节,还得整体对齐,大小得是 4 的倍数,所以是 20。
实例 5:(默认设置)
对于 node,n 等于它三个成员对齐字节数里最大的,也就是 d 的对齐字节数 8。
class node
{
temp t; //放在位置0,位置区间[0~11]
double d; //8(temp的对齐字节数) = n, 那么放置起始位置应该是8的倍数,即16,位置区间为[16~23]
short s; //2 < n,那么放置起始位置应该是2的倍数,即24,位置区间为[24~25]
}
此时成员共占用 [0~25] 26 个字节,整体对齐后大小得是 8 的倍数,所以是 32。
再说说类继承时的内存对齐。
看看下面这些类:
class A
{
int i;
char c1;
}
class B:public A
{
char c2;
}
class C:public B
{
char c3;
}
sizeof (C) 的结果是多少呢?gcc 和 vs 给出的答案不一样,分别是 8 和 16。
在 gcc 里:C 就相当于把所有成员 i、c1、c2、c3 都放一个 class 里,是先继承再对齐。
在 vs 里:对于 A,对齐后大小是 8;对于 B,c2 加上对齐后的 A 的大小是 9,对齐后就是 12;对于 C,c3 加上对齐后的 B 的大小是 13,再对齐就是 16,这是先对齐再继承。
Part4 哪些情况会影响内存对齐?
1、成员声明顺序
如前所示,将大对齐要求的成员声明在前面通常能减少内部填充。
2、编译器指令
- #pragma pack(n)(广泛支持):设置当前编译单元的最大打包对齐值(n 通常是 1, 2, 4, 8, 16)。#pragma pack() 恢复默认。慎用,影响 AB I。
- __attribute__((packed))(GCC/Clang):强制取消结构体/类内部的填充,成员紧密排列(大小=各成员sizeof之和)。对齐值变为 1。
- __attribute__((aligned(n)))(GCC/Clang):显式指定类型或变量的最小对齐值(n)。
- alignas(n) (C++11 标准):显式指定类型或变量的对齐要求(更现代、可移植的方式替代编译器扩展)。
- 目标平台架构 (ABI):x86 相对宽松,ARM、RISC-V 等 RISC 架构通常要求更严格的对齐。
Part5 内存对齐的副作用
内存对齐也不是没副作用。
它的好处是加快内存读取效率,但也有俩缺点。
1、空间浪费。
填充字节的唯一目的就是满足对齐要求,它们不存储有效数据,导致结构体/类的大小大于其成员 sizeof 之和。成员顺序不当会显著增加这种开销(如 上面的例子里,本来只需要 5 个字节,因为对齐就变成 8 个字节了,编译器会在 char 和 int 之间加 3 个字节的 pad,来保证对齐 )。优化变量顺序是节省内存的关键。
// size 12
struct one {
char a;
int b;
short c;
};
// size 8
struct two {
int b;
char a;
short c;
};
所以保持变量的良好声明顺序能省空间,但也有额外问题,类初始化是按变量顺序来的,改了声明顺序,也得注意初始化顺序受不受影响。
2、应用程序二进制接口 (ABI) 兼容性
当使用编译器指令(如 -march=native 启用 SSE/AVX/NEON)或打包指令(如 #pragma pack)编译动态库(.so/.dll) 时,会改变其内部类型的布局和对齐。
问题:
如果调用者(主程序或其他库)使用不同的对齐设置(或不同编译器、不同 -march 设置)编译,它们对所调用库中类型的布局理解就会不一致。
后果:
传递结构体、类对象或在接口中使用它们时,会导致内存解释错误、数据损坏或崩溃。这类问题非常隐蔽且难以调试。
解决方法:
- 明确定义并严格遵循模块间的稳定 ABI(通常规定标准的打包和对齐方式)。
- 避免在模块接口(头文件)中使用需要特殊对齐的编译器指令或属性。如果必须使用,接口双方必须严格一致。
- 重新编译涉及冲突的模块,确保对齐设置统一。
看到这儿,请你回答:(下方评论区说出你的答案)

总结
内存对齐是计算机底层硬件高效、安全访问数据的基石。
深入理解其原理(硬件要求、性能优势)和规则(有效对齐值 n、成员放置、整体补齐),对于编写高性能、可移植且内存高效的 C/C++ 代码至关重要。
可以通过 alignas (C++11)、#pragma pack、__attribute__ 等工具干预对齐,但必须清醒认识其带来的空间开销和潜在的 ABI 兼容性风险,尤其是在跨模块或跨编译器协作时。优化结构体成员顺序是减少填充浪费最直接有效的手段。
往期推荐
更多推荐



所有评论(0)