通信协议接口:序列化与反序列化,字节序转换

说实话,做嵌入式通信这么多年,我见过太多因为字节序没处理好而导致的“灵异事件”。两台设备明明协议对得好好的,数据就是不对。查了半天,最后发现是大小端没转。嗯,今天我们就来聊聊这个绕不开的话题——序列化与反序列化接口,以及网络字节序和主机字节序的转换。

为什么需要序列化?

你想想看,结构体在内存里是紧凑排列的,但不同编译器、不同平台,结构体的对齐方式可能不一样。直接 memcpy 发出去?那基本等于埋雷。我在项目中遇到过两次这样的坑,一次是 ARM 和 x86 通信,一次是 32 位和 64 位系统对接。两次都是结构体对齐搞的鬼。

序列化的本质,就是把内存中的数据结构,转换成一种与平台无关的、线性的字节流。反序列化就是逆过程。说白了,就是给数据“打包”和“拆包”。

核心原则: 序列化后的数据,在任何平台上反序列化,结果必须完全一致。

网络字节序 vs 主机字节序

字节序,也叫端序。大端模式(Big-Endian)是高位字节在低地址,小端模式(Little-Endian)是低位字节在低地址。x86 是典型的小端,而网络协议规定使用大端,也就是网络字节序。

所以,当你发送一个 uint32_t 时,必须调用 htonl() 转换。收到时用 ntohl()。uint16_t 对应 htons() 和 ntohs()。这个没什么好说的,是基本功。

函数 含义 典型场景
htonl() Host to Network Long (32位) 发送32位整数
ntohl() Network to Host Long (32位) 接收32位整数
htons() Host to Network Short (16位) 发送16位端口号
ntohs() Network to Host Short (16位) 接收16位端口号
我的习惯: 在定义通信协议结构体时,所有多字节字段都明确标注为“网络字节序”。这样代码审查时一眼就能看出问题。

设计序列化接口

我个人习惯把序列化和反序列化封装成对称的接口。一个 pack 函数,一个 unpack 函数。这样调用方不用关心内部细节。

来看一个实际例子。假设我们要传输一个传感器数据包:

// 协议定义(主机字节序)
typedef struct {
    uint8_t  type;       // 数据类型
    uint16_t length;     // 数据长度(网络字节序)
    uint32_t timestamp;  // 时间戳(网络字节序)
    uint8_t  data[256];  // 实际数据
} sensor_packet_t;

// 序列化:将主机结构体打包成网络字节序的字节流
int sensor_pack(const sensor_packet_t *pkt, uint8_t *buf, size_t buf_size) {
    if (!pkt || !buf || buf_size < sizeof(sensor_packet_t)) {
        return -1;
    }
    size_t offset = 0;
    buf[offset++] = pkt->type;
    uint16_t net_len = htons(pkt->length);
    memcpy(buf + offset, &net_len, sizeof(net_len));
    offset += sizeof(net_len);
    uint32_t net_ts = htonl(pkt->timestamp);
    memcpy(buf + offset, &net_ts, sizeof(net_ts));
    offset += sizeof(net_ts);
    memcpy(buf + offset, pkt->data, pkt->length);
    offset += pkt->length;
    return (int)offset;
}

// 反序列化:从网络字节流恢复成主机结构体
int sensor_unpack(const uint8_t *buf, size_t len, sensor_packet_t *pkt) {
    if (!buf || !pkt || len < 7) { // 至少 type + length + timestamp
        return -1;
    }
    size_t offset = 0;
    pkt->type = buf[offset++];
    uint16_t net_len;
    memcpy(&net_len, buf + offset, sizeof(net_len));
    pkt->length = ntohs(net_len);
    offset += sizeof(net_len);
    if (offset + pkt->length > len) {
        return -1; // 长度校验
    }
    uint32_t net_ts;
    memcpy(&net_ts, buf + offset, sizeof(net_ts));
    pkt->timestamp = ntohl(net_ts);
    offset += sizeof(net_ts);
    memcpy(pkt->data, buf + offset, pkt->length);
    offset += pkt->length;
    return (int)offset;
}
我曾经踩过的坑: 在反序列化时,没有做长度校验就直接 memcpy。结果对方发了一个错误长度,直接导致缓冲区溢出。从那以后,我所有 unpack 函数的第一件事就是校验输入长度。

序列化流程示意图

下面这张图展示了从主机结构体到网络字节流的完整流程。你可以看到,每个多字节字段都经过了字节序转换,最终拼装成连续的字节流。

序列化与反序列化流程 主机结构体 type: uint8_t length: uint16_t (小端) timestamp: uint32_t (小端) data: uint8_t[] pack 字节序转换 type: 不变 length: htons() timestamp: htonl() data: 不变 拼装 网络字节流 [type] [length 大端] [timestamp 大端] [data...] 连续字节流 unpack 逆转换 length: ntohs() timestamp: ntohl() 恢复 恢复后的结构体 所有字段恢复为小端 与原始结构体完全一致

更灵活的序列化方案

如果字段很多,手动一个个转换确实麻烦。我一般会写一个宏或者辅助函数来批量处理。比如:

// 批量序列化辅助宏
#define SERIALIZE_U16(buf, offset, val) \
    do { \
        uint16_t net_val = htons(val); \
        memcpy((buf) + (offset), &net_val, sizeof(net_val)); \
        (offset) += sizeof(net_val); \
    } while(0)

#define SERIALIZE_U32(buf, offset, val) \
    do { \
        uint32_t net_val = htonl(val); \
        memcpy((buf) + (offset), &net_val, sizeof(net_val)); \
        (offset) += sizeof(net_val); \
    } while(0)

// 反序列化同理
#define DESERIALIZE_U16(buf, offset, val) \
    do { \
        uint16_t net_val; \
        memcpy(&net_val, (buf) + (offset), sizeof(net_val)); \
        (val) = ntohs(net_val); \
        (offset) += sizeof(net_val); \
    } while(0)

用宏的好处是代码简洁,而且不容易漏掉某个字段的转换。不过要注意,宏里的 offset 必须是左值,否则会出问题。

我的建议: 如果项目中有多种协议包,可以考虑用函数指针表来统一管理序列化接口。每个协议包注册自己的 pack/unpack 函数,调度器根据 type 字段自动调用。这样新增协议时,不需要修改核心代码。

关于浮点数的序列化

浮点数是个麻烦事。不同平台的浮点数格式可能不同(虽然 IEEE 754 已经统一了大部分),但字节序问题依然存在。我个人的做法是:把浮点数先转成定点数再传输。比如乘以 1000 变成整数,接收端再除以 1000。这样既避免了浮点精度问题,也简化了字节序处理。

如果非要传浮点数,那就用 memcpy 把 float 转成 uint32_t,然后 htonl 发送。接收端反过来。但要注意,这要求双方浮点数格式完全一致。

// 浮点数序列化示例
void serialize_float(uint8_t *buf, size_t *offset, float val) {
    uint32_t tmp;
    memcpy(&tmp, &val, sizeof(tmp));
    tmp = htonl(tmp);
    memcpy(buf + *offset, &tmp, sizeof(tmp));
    *offset += sizeof(tmp);
}

float deserialize_float(const uint8_t *buf, size_t *offset) {
    uint32_t tmp;
    memcpy(&tmp, buf + *offset, sizeof(tmp));
    tmp = ntohl(tmp);
    float val;
    memcpy(&val, &tmp, sizeof(val));
    *offset += sizeof(tmp);
    return val;
}
注意: 浮点数的位模式在不同架构下可能不同(比如 NaN 的表示)。如果通信双方使用不同的编译器或硬件平台,建议先做充分测试。

总结一下

序列化和字节序转换,说白了就是让数据在不同平台之间“说同一种语言”。我个人的经验是:

  • 所有多字节字段,在序列化时统一转成网络字节序
  • 反序列化时统一转回主机字节序
  • 永远不要假设结构体在内存中的布局是跨平台一致的
  • 长度校验和边界检查是反序列化的生命线

做到这几点,通信协议这块基本就不会出大问题。剩下的,就是根据实际需求选择更高效的序列化方案了,比如使用 protobuf 或者自定义的紧凑格式。但不管用什么,底层的字节序处理逻辑是一样的。


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