23、__attribute__((packed)) 与 ((aligned)):内存对齐控制、网络协议头
说到内存对齐,我估计不少朋友都吃过它的亏。你明明定义了一个结构体,sizeof 算出来就是比预期大一圈。更头疼的是,当你拿着这个结构体去解析网络协议包时,数据全对不上——字节错位了。
嗯,今天我们就来聊聊 GCC 提供的两个利器:__attribute__((packed)) 和 __attribute__((aligned))。说白了,一个让你「压缩」结构体,一个让你「对齐」变量。它们俩在底层开发、协议解析、硬件寄存器映射中,几乎是绕不开的存在。
为什么会有内存对齐?
先问个问题:CPU 读一个 int(4字节),是从任意地址开始读快,还是从 4 的倍数地址开始读快?
答案是后者。大多数 CPU 访问对齐地址时,只需要一次内存访问。如果地址没对齐,可能要读两次,再拼起来。这就是编译器默认帮你对齐的原因——为了性能。
但性能不是一切。有些场景下,我们宁愿牺牲一点速度,也要保证结构体的内存布局完全可控。比如网络协议头、硬件寄存器映射、文件系统元数据。
__attribute__((packed)):让结构体「紧贴」内存
先看一个典型例子。假设我们要解析一个以太网帧头:
struct eth_header {
uint8_t dst_mac[6]; // 目标MAC
uint8_t src_mac[6]; // 源MAC
uint16_t ether_type; // 上层协议类型
};
你算算,这个结构体应该多大?6 + 6 + 2 = 14 字节,对吧?
但如果你用 sizeof 去测,很可能是 16 字节。为什么?因为编译器把 ether_type 对齐到了 2 字节边界,在 src_mac 后面偷偷塞了 2 个填充字节。
这时候 __attribute__((packed)) 就派上用场了:
struct __attribute__((packed)) eth_header {
uint8_t dst_mac[6];
uint8_t src_mac[6];
uint16_t ether_type;
};
加上 packed 之后,sizeof 就是 14 字节。每个成员紧挨着前一个,中间没有任何填充。
核心要点:packed 告诉编译器「别给我加填充字节,所有成员按实际大小排列」。
我在项目中踩过的坑
我曾经在一个物联网网关项目里,用结构体直接映射 CoAP 协议的消息头。一开始没加 packed,结果解析出来的版本号、类型字段全乱了。查了半天才发现,结构体里有个 uint16_t 被自动对齐到了 4 字节边界,整个偏移量全错了。
加了 packed 之后,一切恢复正常。嗯,从那以后,凡是涉及协议解析的结构体,我第一件事就是加上 packed。
__attribute__((aligned)):手动控制对齐
packed 是「压缩」,aligned 则是「对齐」。有时候我们需要把某个变量或结构体放在特定的地址边界上。比如 DMA 缓冲区通常要求 4 字节或 8 字节对齐。
// 让这个缓冲区 16 字节对齐
uint8_t rx_buffer[1024] __attribute__((aligned(16)));
也可以用在结构体上:
struct __attribute__((aligned(64))) cache_line {
uint32_t data[16];
};
这个结构体的起始地址一定是 64 的倍数。对于缓存行对齐、SIMD 指令操作,非常有用。
小技巧:aligned 和 packed 可以同时使用。比如 __attribute__((packed, aligned(4))),先压缩成员,再让整个结构体 4 字节对齐。
两者结合:网络协议头的标准写法
在实际的嵌入式网络协议栈里,你经常能看到这样的代码:
struct __attribute__((packed)) ip_header {
uint8_t ver_ihl; // 版本 + 首部长度
uint8_t tos; // 服务类型
uint16_t total_length; // 总长度
uint16_t id; // 标识
uint16_t flags_frag; // 标志 + 片偏移
uint8_t ttl; // 生存时间
uint8_t protocol; // 协议
uint16_t checksum; // 首部校验和
uint32_t src_addr; // 源IP
uint32_t dst_addr; // 目的IP
};
这个结构体的大小正好是 20 字节,和 IP 协议头完全一致。你可以直接用一个指针指向收到的数据包,然后按字段读取。
// 假设 buf 指向收到的 IP 包
struct ip_header *iph = (struct ip_header *)buf;
// 直接读取协议类型
if (iph->protocol == 6) {
// TCP 协议
}
如果没有 packed,这种「强转读取」的方式就会出问题。因为结构体内部有填充,字段偏移和协议定义对不上。
注意:packed 结构体成员如果地址没对齐,某些 CPU(比如 ARM Cortex-M0)会触发硬件异常。读取非对齐的 uint16_t 或 uint32_t 时,要么用 memcpy 逐字节拷贝,要么确保 CPU 支持非对齐访问。
性能与安全的权衡
packed 不是银弹。它带来的问题是:
- 访问速度变慢:非对齐访问需要多次内存操作
- 原子操作受限:某些平台不支持对非对齐地址的原子操作
- 代码可移植性下降:不同架构对非对齐访问的支持不同
我个人习惯是:只在协议解析、硬件寄存器映射这些「必须精确控制内存布局」的场景下用 packed。普通业务逻辑的结构体,让编译器自动对齐就好。
一张图看懂内存对齐控制
下面这张图展示了默认对齐、packed、aligned 三种情况下的内存布局差异:
实际应用场景总结
| 场景 | 推荐做法 | 原因 |
|---|---|---|
| 网络协议解析 | packed | 协议头是固定字节布局,不能有填充 |
| 硬件寄存器映射 | packed | 寄存器地址是固定的,必须精确对应 |
| DMA缓冲区 | aligned(16) 或 aligned(32) | DMA控制器通常有对齐要求 |
| SIMD/NEON操作 | aligned(16) | 向量指令要求16字节对齐 |
| 普通业务结构体 | 默认对齐 | 性能优先,填充由编译器处理 |
避坑指南:我曾经在 STM32 上用过 packed 结构体直接映射 CAN 报文数据。结果发现,如果结构体里有 uint32_t 成员,且地址不是 4 的倍数,读出来的值就是错的。后来我改用 memcpy 逐字段拷贝,问题解决。记住:packed 只保证布局,不保证安全访问。
总结
内存对齐控制,说白了就是「性能」和「精确」之间的取舍。packed 让你精确控制布局,适合协议解析和硬件映射。aligned 让你强制对齐,适合 DMA 和 SIMD 场景。
嗯,这两个属性用好了,你的底层代码会干净很多。用不好,就是各种诡异的 bug。我的建议是:先理解你的目标平台的对齐规则,再决定用哪个。不要盲目加 packed,也不要完全依赖默认对齐。
公众号:蓝海资料掘金营,微信deep3321