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字节,地址0
  • int 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的倍数。Outerchar 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 知识体系总览

下面这张图,把内存对齐的核心逻辑串起来了。你一看就明白。

结构体内存对齐知识体系 对齐规则 • 成员对齐到自身大小倍数 • 结构体总大小是最大成员 对齐值的整数倍 • 嵌套结构体取内部最大 为什么需要 • CPU按字读取,效率更高 • 避免未对齐访问异常 • 硬件设计决定 • 跨平台兼容性 #pragma pack • pack(1):完全紧凑 • pack(2/4/8):按值对齐 • 用于协议/文件解析 • 注意平台兼容性 核心原则:用空间换时间,保证CPU访问效率 默认对齐 → 效率高但占内存多 | pack(1) → 省内存但可能降低效率 实践建议 1. 结构体成员按对齐值从大到小排列,减少填充 2. 跨平台传输数据时,用 #pragma pack(1) 并手动处理对齐

2.6 避坑指南

最后分享几个我踩过的坑,你遇到了能少走弯路。

技巧1:调整成员顺序减少填充

把大的成员放在前面,小的放在后面。比如 int, short, charchar, int, short 省空间。前者8字节,后者12字节。

技巧2:用 offsetof 宏检查偏移

#include <stddef.h> 里的 offsetof(struct, member) 可以查看成员的偏移量。调试时特别好用。

我曾经犯过的错:在一个嵌入式项目里,我定义了一个结构体用于和FPGA通信。FPGA那边是紧凑排列的,我这边默认对齐。结果通信数据全乱了。查了两天才发现是结构体大小对不上。从那以后,凡是和硬件对接的结构体,我都用 #pragma pack(1) 并加上静态断言检查大小。

嗯,内存对齐这块,说白了就是「理解规则,善用工具」。默认对齐保效率,#pragma pack 保精确。用哪个,取决于你的场景。


公众号:蓝海资料掘金营,微信deep3321