17、联合体(union)的妙用:如何用 union 解析数据包?

说到联合体(union),很多初学者觉得它就是个「省内存的小技巧」。说实话,我刚学C语言时也这么想。直到我在一个物联网项目里,需要从串口不停地接收数据包——有温度、有湿度、有设备状态,每个字段长度还不一样。那时候我才发现,union 的真正价值,远不止省几个字节。

今天我们就聊聊,怎么用 union 优雅地解析数据包。

union 的本质:同一块内存,多种解读方式

先复习一下基础。union 里的所有成员共享同一块内存空间。什么意思呢?

union Data {
    uint32_t word;
    uint8_t  bytes[4];
};

这个 union 的大小是 4 个字节。你写 word,就能从 bytes[0]bytes[3] 分别读到每个字节。反过来,你逐个填充 bytes,就能拼出一个完整的 32 位整数。

说白了,union 给了你「用不同视角看同一块内存」的能力。这在解析数据包时特别有用。

实战场景:从串口接收一个自定义协议包

假设我们有一个简单的传感器数据包,格式如下:

偏移长度含义
01 字节帧头 (0xAA)
11 字节设备 ID
22 字节温度值 (小端)
42 字节湿度值 (小端)
61 字节校验和

传统做法是什么?定义一个结构体,然后 memcpy 或者逐字节赋值。但这样代码很啰嗦,而且容易出错。

用 union 就清爽多了:

#pragma pack(1)
typedef struct {
    uint8_t  header;
    uint8_t  device_id;
    uint16_t temperature;
    uint16_t humidity;
    uint8_t  checksum;
} SensorPacket;

typedef union {
    uint8_t      raw[7];      // 原始字节流
    SensorPacket packet;      // 结构化访问
} SensorUnion;
#pragma pack()

接收数据时,你只需要:

SensorUnion su;
// 假设从串口读到了 7 个字节,存入 su.raw
uart_read_bytes(su.raw, 7);

// 然后直接访问结构体字段
if (su.packet.header == 0xAA) {
    float temp = su.packet.temperature * 0.1f;
    float humi = su.packet.humidity * 0.1f;
    printf("温度: %.1f°C, 湿度: %.1f%%\n", temp, humi);
}

你看,不用 memcpy,不用逐字节拼装。数据进来,直接解读。代码量减少一半,可读性还提高了。

核心思想:union 让「字节流」和「结构化数据」之间实现了零拷贝转换。这在嵌入式系统里,意味着更少的 RAM 开销和更快的处理速度。

避坑指南:字节序和内存对齐

嗯,这里要注意。union 虽然好用,但有两个坑你必须知道。

第一个坑:字节序(大小端)

我在项目中遇到过一个问题:在 STM32 上跑得好好的代码,换到另一款芯片上,温度值就乱套了。为什么?因为 STM32 是小端模式,而新芯片是大端模式。

小端模式下,uint16_t 的低字节在低地址。如果你从串口收到的数据是「高字节在前」,那直接读 packet.temperature 就会得到错误的值。

解决办法有两个:

  • 在协议设计时就统一用小端(推荐,因为大部分嵌入式 MCU 都是小端)
  • 或者手动做字节序转换:__REV16()htons() 之类的宏

我曾经在一个项目里忽略了字节序问题,结果调试了两天才发现是大小端不匹配。从那以后,我每定义一个通信协议,都会在文档里明确标注字节序。

第二个坑:内存对齐

结构体默认有对齐规则。比如 uint16_t 可能要求 2 字节对齐,uint32_t 要求 4 字节对齐。但你的数据包是紧凑排列的,中间没有填充字节。

这时候必须用 #pragma pack(1) 或者 __attribute__((packed)) 来取消对齐。否则 union 里的 rawpacket 的布局就不一致了,解析出来的数据全是错的。

个人习惯:我一般在定义通信协议结构体时,都会加上 #pragma pack(1)。虽然访问未对齐的字段会慢一点点,但换来的是数据包解析的绝对正确。在嵌入式系统里,正确性永远优先于那点性能损耗。

进阶用法:用 union 实现协议版本兼容

有时候,同一个数据包在不同版本里字段长度不一样。比如 V1 协议里温度是 2 字节,V2 协议里变成了 4 字节。用 union 可以优雅地处理这种兼容性。

typedef struct {
    uint8_t  header;
    uint8_t  version;
    uint8_t  data[8];
} PacketV1;

typedef struct {
    uint8_t  header;
    uint8_t  version;
    uint32_t temperature;
    uint32_t humidity;
} PacketV2;

typedef union {
    uint8_t   raw[10];
    PacketV1  v1;
    PacketV2  v2;
} ProtocolUnion;

收到数据后,先读 version 字段,然后根据版本号选择用 v1 还是 v2 来解读。代码结构清晰,扩展性也好。

一张图看懂 union 解析数据包的流程

下面这张图展示了整个思路:

union 解析数据包流程 原始字节流 uint8_t raw[7] 0xAA 0x01 0x1A 0x2B 0x3C 0x4D 0x5E union 共享内存 同一块 7 字节内存 raw[0..6] packet.header, .device_id, ... 结构化访问 packet.temperature packet.humidity 无需 memcpy,无需逐字节拼装 数据包解析效率提升 50% 以上 ⚠ 注意事项 1. 使用 #pragma pack(1) 取消内存对齐 2. 注意大小端字节序问题

总结一下

union 解析数据包,说白了就是「用内存共享来消除数据拷贝」。它让代码更简洁,运行更快,而且更容易维护。

我个人习惯在以下场景优先使用 union:

  • 串口、SPI、I2C 等通信协议的解析
  • 网络数据包的拆包与组包
  • 需要兼容多个协议版本的场合
  • 寄存器位段的读写(比如操作 MCU 的外设寄存器)

当然,union 不是万能的。如果你的数据包字段长度不固定,或者需要动态解析,那还是得用更灵活的方式。但在 80% 的固定长度数据包场景里,union 就是最优雅的解法。

下次你写数据包解析代码时,不妨试试 union。你会发现,原来代码可以写得这么干净。


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