结构体对齐规则:默认对齐、#pragma pack 与 __attribute__

说到结构体对齐,我见过太多新手在这上面栽跟头了。说白了,就是编译器在给结构体成员分配地址时,不是挨个儿紧挨着放的,而是会按照某种规则在成员之间插入填充字节。为什么要这么干?因为 CPU 读取对齐的数据更快,有些硬件甚至强制要求对齐访问。

咱们今天就把三种对齐方式掰开揉碎了讲清楚:默认对齐规则、#pragma pack 指令,以及 __attribute__((packed)) 与 __attribute__((aligned))。这三种方式,基本覆盖了你日常开发中 99% 的场景。

一、默认对齐规则:编译器替你做的决定

默认情况下,编译器遵循一个简单原则:每个成员的偏移量必须是其自身大小的整数倍,整个结构体的大小必须是最大成员大小的整数倍。这就是所谓的「自然对齐」。

举个例子你就明白了:

struct Example {
    char   a;   // 1 字节,偏移 0
    int    b;   // 4 字节,偏移 4(不是 1!中间填充了 3 字节)
    short  c;   // 2 字节,偏移 8
    char   d;   // 1 字节,偏移 10
}; // 总大小 12 字节(不是 1+4+2+1=8)

为什么会这样?int b 要求 4 字节对齐,所以它不能放在偏移 1 的位置,编译器在 a 后面塞了 3 个填充字节。最后 d 占偏移 10,但结构体总大小要对齐到最大成员 int 的 4 字节边界,所以末尾又补了 2 个字节,凑成 12。

关键结论:默认对齐下,结构体大小 = 各成员对齐后的总大小 + 尾部填充。你想想看,如果结构体数组连续存放,每个元素都对齐到 4 字节边界,CPU 访问起来就特别顺畅。

我记得有一次做网络协议解析,直接拿结构体指针去强转接收缓冲区,结果在 ARM 平台上直接触发总线错误。原因就是缓冲区地址没对齐,CPU 不认。从那以后,我对默认对齐的「潜规则」就格外上心了。

二、#pragma pack:手动压缩对齐

有些场景下,默认对齐太「浪费」了。比如你要把结构体直接写入文件,或者通过网络发送,填充字节会破坏数据的紧凑性。这时候 #pragma pack 就派上用场了。

#pragma pack(n) 告诉编译器:所有成员的对齐边界不能超过 n 字节。n 通常取 1、2、4、8 等 2 的幂。

#pragma pack(1)  // 按 1 字节对齐,即取消所有填充
struct PackedExample {
    char   a;   // 偏移 0
    int    b;   // 偏移 1(不再需要 4 字节对齐)
    short  c;   // 偏移 5
    char   d;   // 偏移 7
}; // 总大小 8 字节
#pragma pack()   // 恢复默认对齐

你看,#pragma pack(1) 之后,所有成员都紧挨着放,结构体大小从 12 字节降到了 8 字节。这在传输协议、文件头定义中非常常见。

我的建议:#pragma pack 一定要成对使用——开头设置,结尾恢复。我曾经接手过一个项目,某个头文件里 pack 了没恢复,导致后面所有结构体都错位了,排查了整整两天。血的教训。

不过要注意,#pragma pack 是编译器相关的。MSVC、GCC、ARMCC 都支持,但行为细节略有差异。跨平台代码里,我一般会用条件宏来包装:

#ifdef _MSC_VER
    #pragma pack(push, 1)
#else
    #pragma pack(1)
#endif

struct MyProtocolHeader {
    uint16_t length;
    uint32_t sequence;
    uint8_t  flags;
};

#ifdef _MSC_VER
    #pragma pack(pop)
#else
    #pragma pack()
#endif

三、__attribute__((packed)) 与 __attribute__((aligned))

GCC 和 Clang 提供了更精细的控制方式——__attribute__。它可以直接加在结构体或成员上,不用像 #pragma pack 那样影响全局。

__attribute__((packed)):按需压缩

这个属性告诉编译器:这个结构体不要填充,所有成员按最小对齐来。效果等同于 #pragma pack(1),但作用范围更精确。

struct __attribute__((packed)) SensorData {
    uint8_t  id;       // 偏移 0
    uint32_t value;    // 偏移 1
    uint16_t crc;      // 偏移 5
}; // 总大小 7 字节

我个人习惯在定义硬件寄存器映射、通信协议帧时使用 __attribute__((packed))。它让结构体布局和实际数据完全一致,不会出现「定义的和收到的对不上」这种尴尬。

注意:packed 结构体成员的访问可能变慢,因为 CPU 需要做非对齐访问处理。在性能敏感的路径上,比如每秒处理百万级数据包,packed 结构体可能成为瓶颈。我一般只在「存储/传输」和「运行时处理」之间做一次解包,运行时用对齐的结构体。

__attribute__((aligned)):强制对齐

和 packed 相反,__attribute__((aligned(n))) 是让结构体或变量的对齐边界 至少 为 n 字节。n 必须是 2 的幂。

struct __attribute__((aligned(64))) CacheLineData {
    int x;
    int y;
    int z;
}; // 结构体大小 12 字节,但起始地址按 64 字节对齐

这个在缓存优化、DMA 传输中特别有用。比如你要用 DMA 搬运数据,很多 DMA 控制器要求缓冲区地址按 32 字节或 64 字节对齐。加上 __attribute__((aligned(64))),编译器会帮你保证这一点。

你还可以把它用在单个成员上:

struct Mixed {
    char a;
    int  b __attribute__((aligned(16)));  // b 必须 16 字节对齐
}; // 结构体大小至少 20 字节

嗯,这里要注意:aligned 只影响对齐边界,不影响成员之间的填充规则。它和 packed 可以组合使用,但顺序有讲究:

struct __attribute__((packed, aligned(4))) Combo {
    uint8_t a;
    uint32_t b;
}; // 成员紧凑排列,但结构体整体按 4 字节对齐

四、三种对齐方式对比

特性 默认对齐 #pragma pack __attribute__((packed)) __attribute__((aligned))
控制粒度 全局/文件 区域(需成对) 结构体级别 结构体/变量级别
填充行为 自然对齐 限制最大对齐 取消所有填充 不影响填充,只影响首地址
典型用途 通用场景 协议/文件格式 硬件映射/序列化 缓存/DMA 优化
性能影响 最优 可能下降 非对齐访问更慢 对齐访问,性能好
可移植性 好 编译器相关 GCC/Clang 系列 GCC/Clang 系列

五、知识结构图

下面这张图帮你理清三种对齐方式的关系和适用场景:

结构体对齐规则总览 结构体对齐 默认对齐规则 成员偏移 = 自身大小整数倍 总大小 = 最大成员整数倍 #pragma pack(n) 限制最大对齐边界为 n 需成对使用 pack() 恢复 __attribute__ GCC/Clang 专用扩展 packed aligned(n) 取消所有填充字节 强制首地址按 n 对齐 选择建议:协议/存储用 packed,性能敏感用默认对齐,DMA/缓存用 aligned

六、避坑指南

最后分享几个我踩过的坑:

  • 跨平台结构体不要混用对齐方式——不同编译器对 #pragma pack 的默认值可能不同。我一般会在公共头文件里统一用宏定义,确保所有平台行为一致。
  • packed 结构体取成员地址要小心——如果成员没有自然对齐,取地址后赋值给指针,再通过指针访问,可能触发非对齐异常。我习惯用 memcpy 来读写 packed 结构体的成员。
  • 调试时用 offsetof 验证布局——offsetof(struct, member) 宏可以帮你确认每个成员的偏移量,比肉眼靠谱多了。我每次定义完协议结构体,都会写个单元测试打印所有成员的偏移和结构体总大小。
  • 位域和 packed 一起用要谨慎——位域的布局在不同编译器下差异很大,加上 packed 后更不可控。能不用位域就别用,实在要用,记得写测试用例验证。

对齐这件事,说难不难,说简单也不简单。关键是要理解编译器背后的「对齐契约」,然后根据场景选择合适的工具。默认对齐保性能,packed 保紧凑,aligned 保特殊需求——这三板斧用好了,结构体布局这块你就基本拿下了。