联合体在通信协议中的应用:协议帧解析与变长数据包处理

通信协议解析,是嵌入式开发里绕不开的硬骨头。我早年做物联网网关项目时,对接过七八种不同的传感器协议,每种协议的帧结构都不一样。那时候我还没用联合体,全靠手动移位、取字节,代码写得又臭又长。后来一位老同事看了我的代码,只说了一句:「你试试 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 知识体系总览

下面这张图总结了联合体在通信协议中的核心应用逻辑:

联合体在通信协议中的应用 联合体 (Union) 协议帧解析 变长数据包处理 位域 + 联合体 零拷贝转换 结构体映射 功能码分发 载荷复用 位域操作 字节压缩 核心思想:同一块内存,不同解读方式 减少内存拷贝,提升解析效率,增强代码可读性

从这张图可以看得很清楚:联合体的核心价值就是「同一块内存,不同解读方式」。无论是固定帧头的零拷贝解析,还是变长载荷的功能码分发,抑或是位域的字节级压缩,联合体都能帮我们写出更简洁、更高效的代码。

最后说一句:联合体不是银弹。它不能解决所有协议解析问题,比如跨字节序、跨平台对齐等问题仍然需要小心处理。但如果你用对了地方,它绝对能让你的代码质量上一个台阶。


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