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结构体中的成员可能未对齐。访问未对齐的 int 或 long 成员,在某些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 两者组合使用
你可能会问:能不能同时用 packed 和 aligned?当然可以。语法是:
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 知识体系图
这张图把两个属性的定位、子特性、应用场景都串起来了。你写代码时,对着这个图想一下:我到底要紧凑,还是要对齐?想清楚再下手。
公众号:蓝海资料掘金营,微信deep3321