2. 结构体内存对齐:对齐规则详解、为什么要有内存对齐、#pragma pack的使用
内存对齐这个话题,说大不大,说小不小。我见过不少写了三五年C语言的工程师,一提到结构体对齐,还是模棱两可。说白了,这就是个「编译器帮你填空」的游戏。但你要是没搞懂规则,写出来的代码可能多占一半内存,甚至在某些平台上直接崩溃。
今天咱们就把这块硬骨头啃下来。我会把规则掰开揉碎了讲,再结合我实际踩过的坑,让你一次性搞明白。
2.1 什么是内存对齐?先看个例子
先看一段代码。你猜猜这个结构体占多少字节?
struct Test {
char a; // 1字节
int b; // 4字节
short c; // 2字节
};
直觉上,1+4+2 = 7字节。对吧?
但实际运行一下 sizeof(struct Test),你会发现结果是12字节。多出来的5个字节去哪了?
嗯,这就是内存对齐搞的鬼。编译器在成员之间插入了「填充字节」,让每个成员的地址都满足某种对齐要求。
2.2 对齐规则详解
规则其实就三条,我一条条说清楚。
规则一:每个成员的对齐要求
每个数据类型都有它自己的「对齐值」。说白了就是:
char:对齐到1的倍数(任何地址都行)short:对齐到2的倍数int:对齐到4的倍数double:对齐到8的倍数- 指针:32位系统对齐到4,64位系统对齐到8
编译器会保证每个成员的起始地址,都能被它的对齐值整除。
规则二:结构体的总大小
结构体的总大小,必须是「最大成员对齐值」的整数倍。不够就补。
拿刚才的 struct Test 来说:
char a占1字节,地址0int b要对齐到4的倍数,所以从地址4开始,占4字节(地址4-7)short c要对齐到2的倍数,地址8开始,占2字节(地址8-9)- 现在用了10字节,但最大成员是
int(对齐值4),所以总大小必须是4的倍数。补2字节到12。
这就是为什么结果是12,不是7。
规则三:嵌套结构体的对齐
如果结构体里套了另一个结构体,那嵌套结构体的对齐值,取它内部最大成员的对齐值。
struct Inner {
char a;
double b;
};
struct Outer {
char c;
struct Inner d;
};
Inner 的最大成员是 double(对齐值8),所以 Inner 本身也要对齐到8的倍数。Outer 里 char c 占1字节后,要补7个字节,才能让 d 从地址8开始。
我个人习惯在写嵌套结构体时,手动算一下偏移量,避免踩坑。
2.3 为什么要有内存对齐?
你可能会问:搞这么麻烦干嘛?直接紧凑排列不香吗?
原因其实很简单:CPU读内存不是按字节读的,是按「字」读的。
举个例子。32位CPU一次读4个字节。如果 int 从地址1开始,那CPU需要读两次:第一次读地址0-3,第二次读地址4-7,然后拼起来。这效率就低了。
如果 int 从地址0或4开始,一次就读完了。
我在项目中遇到过一个问题:一个结构体在x86上跑得好好的,移植到ARM上就崩了。查了半天,发现是ARM对未对齐访问会触发异常。说白了,就是对齐规则没处理好。
所以内存对齐的本质是:用空间换时间,甚至换稳定性。
核心结论:对齐后的结构体,CPU访问效率更高。在某些平台上,不对齐直接报错。
2.4 #pragma pack:手动控制对齐
有时候我们不想让编译器自动对齐。比如:
- 要精确控制结构体大小,节省内存
- 要和硬件寄存器、网络协议、文件格式对接
- 跨平台传输数据时,保证结构体布局一致
这时候就用 #pragma pack。
基本用法
#pragma pack(1) // 设置对齐值为1字节
struct PackedTest {
char a;
int b;
short c;
};
#pragma pack() // 恢复默认对齐
设置成1字节对齐后,所有成员都紧挨着放,没有填充字节。sizeof(struct PackedTest) 就是7字节。
常用对齐值
| #pragma pack(n) | 效果 | 典型场景 |
|---|---|---|
| pack(1) | 完全紧凑,无填充 | 网络协议、文件格式 |
| pack(2) | 按2字节对齐 | 某些嵌入式外设 |
| pack(4) | 按4字节对齐 | 32位系统默认 |
| pack(8) | 按8字节对齐 | 64位系统默认 |
警告:我曾经在项目里用 #pragma pack(1) 压缩了一个结构体,结果在某个ARM芯片上访问 int 成员时,直接触发硬件异常。因为那个芯片不支持未对齐访问。
所以,用 #pragma pack 之前,一定要确认你的平台是否支持未对齐访问。x86一般没问题,ARM/MIPS/RISC-V 就要小心了。
实际应用:网络协议解析
我记得有一次做网络协议栈,需要解析一个TCP头。TCP头的格式是固定的,每个字段紧挨着。如果不用 #pragma pack(1),结构体大小和实际协议对不上,解析出来的数据全是错的。
#pragma pack(1)
struct TCPHeader {
uint16_t src_port;
uint16_t dst_port;
uint32_t seq_num;
uint32_t ack_num;
uint8_t data_offset;
uint8_t flags;
uint16_t window;
uint16_t checksum;
uint16_t urgent_ptr;
};
#pragma pack()
这样定义后,结构体大小就是20字节,和TCP协议完全一致。直接强转指针就能解析,效率极高。
2.5 知识体系总览
下面这张图,把内存对齐的核心逻辑串起来了。你一看就明白。
2.6 避坑指南
最后分享几个我踩过的坑,你遇到了能少走弯路。
技巧1:调整成员顺序减少填充
把大的成员放在前面,小的放在后面。比如 int, short, char 比 char, int, short 省空间。前者8字节,后者12字节。
技巧2:用 offsetof 宏检查偏移
#include <stddef.h> 里的 offsetof(struct, member) 可以查看成员的偏移量。调试时特别好用。
我曾经犯过的错:在一个嵌入式项目里,我定义了一个结构体用于和FPGA通信。FPGA那边是紧凑排列的,我这边默认对齐。结果通信数据全乱了。查了两天才发现是结构体大小对不上。从那以后,凡是和硬件对接的结构体,我都用 #pragma pack(1) 并加上静态断言检查大小。
嗯,内存对齐这块,说白了就是「理解规则,善用工具」。默认对齐保效率,#pragma pack 保精确。用哪个,取决于你的场景。
公众号:蓝海资料掘金营,微信deep3321