通信协议接口:序列化与反序列化,字节序转换
说实话,做嵌入式通信这么多年,我见过太多因为字节序没处理好而导致的“灵异事件”。两台设备明明协议对得好好的,数据就是不对。查了半天,最后发现是大小端没转。嗯,今天我们就来聊聊这个绕不开的话题——序列化与反序列化接口,以及网络字节序和主机字节序的转换。
为什么需要序列化?
你想想看,结构体在内存里是紧凑排列的,但不同编译器、不同平台,结构体的对齐方式可能不一样。直接 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;
}
序列化流程示意图
下面这张图展示了从主机结构体到网络字节流的完整流程。你可以看到,每个多字节字段都经过了字节序转换,最终拼装成连续的字节流。
更灵活的序列化方案
如果字段很多,手动一个个转换确实麻烦。我一般会写一个宏或者辅助函数来批量处理。比如:
// 批量序列化辅助宏
#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 必须是左值,否则会出问题。
关于浮点数的序列化
浮点数是个麻烦事。不同平台的浮点数格式可能不同(虽然 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;
}
总结一下
序列化和字节序转换,说白了就是让数据在不同平台之间“说同一种语言”。我个人的经验是:
- 所有多字节字段,在序列化时统一转成网络字节序
- 反序列化时统一转回主机字节序
- 永远不要假设结构体在内存中的布局是跨平台一致的
- 长度校验和边界检查是反序列化的生命线
做到这几点,通信协议这块基本就不会出大问题。剩下的,就是根据实际需求选择更高效的序列化方案了,比如使用 protobuf 或者自定义的紧凑格式。但不管用什么,底层的字节序处理逻辑是一样的。
公众号:蓝海资料掘金营,微信deep3321