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 三种情况下的内存布局差异:

内存对齐控制对比图 默认对齐(有填充) MAC0 MAC1 MAC2 MAC3 MAC4 MAC5 Type PAD sizeof = 16 字节(多了2字节填充) packed(无填充) MAC0 MAC1 MAC2 MAC3 MAC4 MAC5 Type sizeof = 14 字节(无填充,紧贴排列) aligned(8)(8字节对齐) MAC0 MAC1 MAC2 MAC3 MAC4 MAC5 Type PAD 对齐填充 sizeof = 24 字节(对齐到8的倍数) 地址增长方向 →

实际应用场景总结

场景 推荐做法 原因
网络协议解析 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