联合体在通信协议中的应用:协议帧解析与变长数据包处理
通信协议解析,是嵌入式开发里绕不开的硬骨头。我早年做物联网网关项目时,对接过七八种不同的传感器协议,每种协议的帧结构都不一样。那时候我还没用联合体,全靠手动移位、取字节,代码写得又臭又长。后来一位老同事看了我的代码,只说了一句:「你试试 union 啊。」——嗯,从那以后,我再也没写过那种笨代码了。
说白了,联合体在协议解析里的核心价值就一句话:同一块内存,不同解读方式。你想想看,协议帧的本质是什么?是一串字节流。而我们要做的,就是把这串字节流「翻译」成结构化的字段。联合体正好能帮我们省掉手动拆包的麻烦。
15.1 协议帧解析:用联合体替代手动拆包
先看一个最简单的场景。假设我们有一个自定义的串口协议,帧头固定为 0xAA 0x55,后面跟着 2 字节的数据长度、4 字节的时间戳、1 字节的校验和。传统做法是定义一个结构体,然后从缓冲区里逐个字节拷贝。但用联合体,我们可以让结构体和字节数组共享同一块内存。
// 协议帧定义(小端模式)
typedef struct {
uint8_t header[2]; // 0xAA, 0x55
uint16_t data_len; // 数据长度
uint32_t timestamp; // 时间戳
uint8_t checksum; // 校验和
} FrameHeader_t;
// 联合体:让结构体和字节数组共享内存
typedef union {
FrameHeader_t header;
uint8_t raw[sizeof(FrameHeader_t)];
} FrameBuffer_t;
收到一帧数据后,直接往联合体的 raw 数组里填字节,然后通过 header 字段就能直接访问各个成员。不需要 memcpy,不需要手动计算偏移量。
FrameBuffer_t rx_buf;
// 假设从串口接收到了 9 个字节,存入 rx_buf.raw
for (int i = 0; i < 9; i++) {
rx_buf.raw[i] = uart_read_byte();
}
// 直接访问结构体字段
if (rx_buf.header.header[0] == 0xAA && rx_buf.header.header[1] == 0x55) {
uint16_t len = rx_buf.header.data_len;
uint32_t ts = rx_buf.header.timestamp;
// 处理数据...
}
核心要点:联合体让「字节流」和「结构化字段」之间实现了零拷贝转换。这在资源受限的 MCU 上尤其重要——省内存、省 CPU 时间。
我的习惯:我一般会在联合体里再加一个 uint8_t 数组,长度等于结构体大小。这样接收数据时直接操作数组,解析时用结构体,清晰又高效。
15.2 变长数据包处理:灵活应对不同载荷
协议帧里最头疼的是什么?变长数据包。比如一个协议帧,前面是固定头部,后面跟着可变长度的载荷。载荷的类型不同,解析方式也不同。这时候联合体就派上大用场了。
我曾经做过一个 Modbus 协议的解析器,它的数据帧里有一个功能码字段,不同的功能码对应不同的数据格式。用联合体可以把所有可能的数据格式放在一起,根据功能码选择解析方式。
// 不同功能码对应的数据结构
typedef struct {
uint16_t start_addr;
uint16_t quantity;
} ReadHoldingRegisters_t;
typedef struct {
uint16_t start_addr;
uint16_t quantity;
uint8_t data[256]; // 实际长度由 quantity 决定
} WriteMultipleRegisters_t;
typedef struct {
uint8_t coil_status[32];
} ReadCoils_t;
// 联合体:包含所有可能的数据格式
typedef union {
ReadHoldingRegisters_t read_holding;
WriteMultipleRegisters_t write_multiple;
ReadCoils_t read_coils;
uint8_t raw[256]; // 最大载荷长度
} Payload_t;
// 完整的协议帧
typedef struct {
uint8_t slave_addr;
uint8_t func_code;
Payload_t payload;
uint16_t crc;
} ModbusFrame_t;
解析时,根据功能码选择对应的联合体成员:
ModbusFrame_t frame;
// 假设 frame 已经填充好数据
switch (frame.func_code) {
case 0x03: // 读保持寄存器
uint16_t addr = frame.payload.read_holding.start_addr;
uint16_t qty = frame.payload.read_holding.quantity;
// 处理...
break;
case 0x10: // 写多个寄存器
uint16_t w_addr = frame.payload.write_multiple.start_addr;
uint16_t w_qty = frame.payload.write_multiple.quantity;
// 注意:data 数组的实际长度 = w_qty * 2
break;
case 0x01: // 读线圈
// 读取 coil_status 数组
break;
}
我曾经踩过的坑:联合体的大小由最大的成员决定。如果你的 WriteMultipleRegisters_t 里 data 数组声明为 256 字节,那么整个 Payload_t 就是 256 字节。但实际收到的数据可能只有 10 字节。如果你不记录实际长度,直接访问 data[200] 就会读到垃圾数据。所以一定要在帧结构里额外保存实际载荷长度。
15.3 联合体 + 位域:极致压缩协议字段
有些协议为了节省带宽,会把多个标志位塞进一个字节里。比如一个控制帧,包含 3 个使能位、2 个模式选择位、3 个保留位。用位域配合联合体,解析起来非常优雅。
typedef struct {
uint8_t enable_a : 1;
uint8_t enable_b : 1;
uint8_t enable_c : 1;
uint8_t mode : 2;
uint8_t reserved : 3;
} ControlBits_t;
typedef union {
ControlBits_t bits;
uint8_t byte;
} ControlByte_t;
收到一个字节后,直接赋值给联合体的 byte 成员,然后通过 bits 成员就能读取每个位域:
ControlByte_t ctrl;
ctrl.byte = uart_read_byte();
if (ctrl.bits.enable_a) {
// 启动通道 A
}
if (ctrl.bits.mode == 2) {
// 切换到模式 2
}
你想想看,如果没有联合体,你得写多少移位和掩码操作?而且代码可读性极差。用联合体位域,代码几乎和协议文档一模一样。
注意:位域的布局是编译器相关的。不同编译器、不同字节序下,位域的顺序可能不同。跨平台项目一定要做单元测试验证。
15.4 实战:一个完整的协议帧解析器
下面是一个完整的例子,展示如何用联合体解析一个自定义的传感器协议。这个协议帧包含:2 字节帧头、1 字节传感器类型、4 字节数据(根据类型不同,可能是温度、湿度或气压)、1 字节校验和。
// 传感器数据类型
typedef enum {
SENSOR_TEMPERATURE = 0x01,
SENSOR_HUMIDITY = 0x02,
SENSOR_PRESSURE = 0x03
} SensorType_t;
// 不同传感器的数据结构
typedef struct {
int16_t integer; // 整数部分
uint16_t decimal; // 小数部分(乘以 100)
} Temperature_t;
typedef struct {
uint16_t value; // 相对湿度百分比,乘以 10
} Humidity_t;
typedef struct {
uint32_t pa; // 气压,单位 Pa
} Pressure_t;
// 联合体:数据载荷
typedef union {
Temperature_t temp;
Humidity_t hum;
Pressure_t press;
uint8_t raw[4];
} SensorData_t;
// 完整帧结构
typedef struct {
uint8_t header[2]; // 0x5A, 0xA5
SensorType_t type;
SensorData_t data;
uint8_t checksum;
} SensorFrame_t;
// 联合体:帧缓冲区
typedef union {
SensorFrame_t frame;
uint8_t raw[sizeof(SensorFrame_t)];
} SensorBuffer_t;
解析函数:
int parse_sensor_frame(uint8_t *buffer, int len) {
if (len < sizeof(SensorFrame_t)) {
return -1; // 数据不完整
}
SensorBuffer_t buf;
memcpy(buf.raw, buffer, sizeof(SensorFrame_t));
// 校验帧头
if (buf.frame.header[0] != 0x5A || buf.frame.header[1] != 0xA5) {
return -2; // 帧头错误
}
// 校验和验证(略)
// 根据传感器类型解析数据
switch (buf.frame.type) {
case SENSOR_TEMPERATURE:
float temp = buf.frame.data.temp.integer +
buf.frame.data.temp.decimal / 100.0f;
printf("温度: %.2f°C\n", temp);
break;
case SENSOR_HUMIDITY:
float hum = buf.frame.data.hum.value / 10.0f;
printf("湿度: %.1f%%\n", hum);
break;
case SENSOR_PRESSURE:
printf("气压: %u Pa\n", buf.frame.data.press.pa);
break;
default:
return -3; // 未知类型
}
return 0;
}
我的建议:实际项目中,我通常会在联合体里再加一个 uint8_t raw[] 数组,长度等于整个帧的大小。这样接收数据时直接操作 raw 数组,解析时用 frame 结构体,既安全又高效。另外,记得用 #pragma pack(1) 或 __attribute__((packed)) 来避免结构体填充,否则联合体的大小可能和实际帧长度不一致。
15.5 知识体系总览
下面这张图总结了联合体在通信协议中的核心应用逻辑:
从这张图可以看得很清楚:联合体的核心价值就是「同一块内存,不同解读方式」。无论是固定帧头的零拷贝解析,还是变长载荷的功能码分发,抑或是位域的字节级压缩,联合体都能帮我们写出更简洁、更高效的代码。
最后说一句:联合体不是银弹。它不能解决所有协议解析问题,比如跨字节序、跨平台对齐等问题仍然需要小心处理。但如果你用对了地方,它绝对能让你的代码质量上一个台阶。
公众号:蓝海资料掘金营,微信deep3321