17. 结构体与编译器扩展:GCC的__attribute__((packed))、__attribute__((aligned))

结构体这东西,平时用着挺顺手。但一旦涉及到内存布局、通信协议、硬件寄存器映射,你就会发现——编译器在背后偷偷给你塞了很多“填充字节”。

我早年做嵌入式通信协议栈时,就吃过这个亏。定义了一个结构体,直接memcpy发出去,结果对端怎么都解析不对。查了一整天,最后发现是结构体对齐搞的鬼。从那以后,我对这两个属性就格外上心。

今天咱们就聊聊GCC提供的两个扩展属性:__attribute__((packed))__attribute__((aligned))。说白了,一个让你“压缩”结构体,一个让你“对齐”结构体。

17.1 结构体的默认对齐规则

先看个基础问题。你写个结构体:

struct example {
    char a;    // 1字节
    int  b;    // 4字节
    char c;    // 1字节
};

你觉得它占多少字节?直觉上:1 + 4 + 1 = 6字节。但实际呢?

printf("%zu\n", sizeof(struct example)); // 输出 12

为什么会这样?因为编译器做了内存对齐。规则很简单:

  • 每个成员的起始地址,必须是其自身对齐值的整数倍
  • 结构体的总大小,必须是最大成员对齐值的整数倍

拿上面例子来说:int 对齐值是4,所以 b 不能紧跟在 a 后面(地址偏移1),必须从偏移4开始。于是 a 后面就多了3个填充字节。同理,c 占完1字节后,结构体总大小要补到4的倍数,又多了3个填充。总共12字节。

核心理解:对齐是为了CPU访问效率。很多CPU访问未对齐的数据会触发异常,或者性能骤降。编译器帮你对齐,其实是保护你。

17.2 __attribute__((packed)):压缩布局

但有些场景,我们不需要这种保护。比如:

  • 你要把结构体直接通过串口、网络发出去
  • 你要把结构体映射到硬件寄存器地址
  • 你要节省RAM空间(比如存大量结构体数组)

这时候,__attribute__((packed)) 就派上用场了。

struct __attribute__((packed)) packed_example {
    char a;    // 偏移0
    int  b;    // 偏移1
    char c;    // 偏移5
};  // 总大小 = 6

加了 packed 之后,编译器不再插入任何填充字节。所有成员紧挨着排列。上面这个结构体,大小就是6字节。

我个人习惯在定义通信协议报文时,一定会加 packed。举个例子:

struct __attribute__((packed)) can_frame {
    uint32_t id;       // 4字节
    uint8_t  dlc;      // 1字节
    uint8_t  data[8];  // 8字节
};  // 总大小 = 13

不加 packed 的话,dlc 后面会填充3个字节,结构体变成16字节。你按13字节发出去,对端就解析错了。

注意:packed结构体中的成员可能未对齐。访问未对齐的 intlong 成员,在某些ARM、MIPS平台上会触发硬件异常。x86上虽然能工作,但性能会下降。我曾经在STM32上遇到过——读一个packed结构体里的uint32_t,程序直接跑飞了。后来加了memcpy才解决。

17.3 __attribute__((aligned)):控制对齐

packed 相反,aligned 是让你提高对齐要求。语法有两种用法:

// 用法1:指定结构体整体对齐到16字节
struct __attribute__((aligned(16))) aligned_struct {
    int a;
    char b;
};

// 用法2:指定某个成员对齐到8字节
struct example2 {
    int a;
    char b __attribute__((aligned(8)));
};

什么时候用?我举几个实际场景:

  • DMA缓冲区:很多DMA控制器要求缓冲区地址对齐到缓存行(通常是32或64字节)
  • SIMD指令:SSE/NEON指令要求数据对齐到16字节
  • 原子操作:某些平台要求8字节变量对齐到8字节

举个例子,你要定义一个DMA用的缓冲区:

struct __attribute__((aligned(64))) dma_buffer {
    uint32_t head;
    uint8_t  data[1024];
    uint32_t tail;
};

这样 dma_buffer 的起始地址一定是64的倍数。DMA控制器就能直接操作,不用你手动对齐。

小技巧:你可以用 offsetof 宏检查成员偏移,确认对齐是否符合预期。比如 offsetof(struct example2, b) % 8 == 0 就是对齐到8字节了。

17.4 两者组合使用

你可能会问:能不能同时用 packedaligned?当然可以。语法是:

struct __attribute__((packed, aligned(4))) combo {
    char a;
    int  b;
    char c;
};

这个结构体:

  • 成员之间没有填充(packed效果)
  • 但结构体整体对齐到4字节(aligned效果)

也就是说,a 在偏移0,b 在偏移1,c 在偏移5。总大小6字节。但结构体变量本身的起始地址必须是4的倍数。

这种组合在通信协议中很常见。你既想紧凑排列,又希望结构体本身对齐到字边界,方便CPU访问。

17.5 跨平台注意事项

这两个属性是GCC扩展,不是C标准的一部分。不同编译器支持情况不同:

编译器 packed aligned
GCC 支持 支持
Clang 支持 支持
MSVC #pragma pack(1) __declspec(align(n))
IAR __packed 不支持

如果你写跨平台代码,建议用宏封装一下:

#ifdef __GNUC__
#define PACKED __attribute__((packed))
#define ALIGNED(x) __attribute__((aligned(x)))
#elif defined(_MSC_VER)
#define PACKED __pragma(pack(push, 1))
#define ALIGNED(x) __declspec(align(x))
#else
#define PACKED
#define ALIGNED(x)
#endif

struct PACKED my_packet {
    uint8_t  type;
    uint32_t value;
};

这样换编译器时,改宏定义就行,不用改结构体本身。

17.6 性能与安全的权衡

最后聊点经验之谈。我见过不少新手,一上来就给所有结构体加 packed,觉得“省内存就是好”。其实不然。

packed的代价:

  • 访问未对齐成员,CPU可能产生多次内存访问
  • 某些平台直接崩溃
  • 编译器无法生成最优的加载/存储指令

aligned的代价:

  • 结构体变大(填充字节增多)
  • 数组占用的总内存可能显著增加

我的建议是:

  • 只在需要序列化/反序列化时用 packed
  • 只在DMA、SIMD等硬件要求对齐时用 aligned
  • 普通业务逻辑结构体,交给编译器默认对齐就好

一句话总结:packed让你控制布局,aligned让你控制地址。用对地方是利器,用错地方是坑。

17.7 知识体系图

结构体编译器扩展:packed vs aligned GCC __attribute__ __attribute__((packed)) 取消填充字节 成员紧挨排列 总大小最小化 应用:通信协议 / 寄存器映射 __attribute__((aligned(n))) 指定对齐值 提高对齐要求 可作用于成员 应用:DMA缓冲区 / SIMD数据 权衡:紧凑性 ↔ 访问效率 ↔ 平台兼容性

这张图把两个属性的定位、子特性、应用场景都串起来了。你写代码时,对着这个图想一下:我到底要紧凑,还是要对齐?想清楚再下手。


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