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 给了你「用不同视角看同一块内存」的能力。这在解析数据包时特别有用。
实战场景:从串口接收一个自定义协议包
假设我们有一个简单的传感器数据包,格式如下:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 0 | 1 字节 | 帧头 (0xAA) |
| 1 | 1 字节 | 设备 ID |
| 2 | 2 字节 | 温度值 (小端) |
| 4 | 2 字节 | 湿度值 (小端) |
| 6 | 1 字节 | 校验和 |
传统做法是什么?定义一个结构体,然后 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 里的 raw 和 packet 的布局就不一致了,解析出来的数据全是错的。
个人习惯:我一般在定义通信协议结构体时,都会加上 #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 解析数据包,说白了就是「用内存共享来消除数据拷贝」。它让代码更简洁,运行更快,而且更容易维护。
我个人习惯在以下场景优先使用 union:
- 串口、SPI、I2C 等通信协议的解析
- 网络数据包的拆包与组包
- 需要兼容多个协议版本的场合
- 寄存器位段的读写(比如操作 MCU 的外设寄存器)
当然,union 不是万能的。如果你的数据包字段长度不固定,或者需要动态解析,那还是得用更灵活的方式。但在 80% 的固定长度数据包场景里,union 就是最优雅的解法。
下次你写数据包解析代码时,不妨试试 union。你会发现,原来代码可以写得这么干净。
公众号:蓝海资料掘金营,微信deep3321