第23章:序列化与网络协议设计

说到网络编程,绕不开的一个话题就是——数据怎么在网络上传输?

你想想看,两个进程要通信,一个在Linux上,一个在Windows上,一个用C语言,一个用Java。它们怎么理解对方发过来的数据?

这就是序列化要解决的问题。说白了,就是把内存里的结构体、对象,变成一串字节流,让对方能看懂、能还原。

我在做嵌入式网关项目时,就吃过这个亏。当时两个模块之间直接传结构体指针,结果换了个编译器,内存对齐方式变了,数据全乱套。从那以后,我再也不敢裸传结构体了。

23.1 TLV格式:最朴素的二进制协议

TLV,全称是Type-Length-Value。结构很简单:

  • Type:数据类型标识,比如0x01表示温度,0x02表示湿度
  • Length:Value部分的长度
  • Value:实际数据

它的好处是自描述、可扩展。你收到一个TLV包,先读Type,再读Length,就知道后面跟了多少字节,不会读错。

我习惯用这种结构体来定义TLV头:

typedef struct {
    uint16_t type;     // 类型
    uint16_t length;   // 数据长度
    uint8_t  value[0]; // 柔性数组,C99特性
} tlv_t;

注意这里的柔性数组,它不占结构体空间,只用来做指针偏移计算。实际使用时,你需要自己管理内存:

tlv_t *create_tlv(uint16_t type, const void *data, uint16_t len) {
    tlv_t *tlv = malloc(sizeof(tlv_t) + len);
    if (!tlv) return NULL;
    tlv->type = htons(type);     // 网络字节序
    tlv->length = htons(len);
    memcpy(tlv->value, data, len);
    return tlv;
}
注意:网络传输一定要考虑字节序!x86是小端,网络协议通常用大端。htons/htonl就是干这个的。

TLV的缺点也很明显——没有类型检查。你发一个int32,对方如果按int16去读,数据就全歪了。所以TLV适合用在封闭系统里,双方约定好协议文档。

23.2 Protocol Buffers:Google的序列化方案

Protobuf是Google搞的,比TLV高级不少。它用.proto文件定义数据结构,然后生成代码,直接序列化/反序列化。

举个例子,定义一个消息:

syntax = "proto3";

message SensorData {
    int32  temperature = 1;
    int32  humidity    = 2;
    string device_id   = 3;
}

然后你用protoc编译,生成C语言的.h和.c文件。在代码里这样用:

SensorData data;
sensor_data_init(&data, NULL);
data.temperature = 25;
data.humidity = 60;
data.device_id = "sensor_01";

uint8_t buffer[1024];
size_t len;
sensor_data_pack(&data, buffer, sizeof(buffer), &len);
// 现在buffer里就是序列化后的数据,可以直接send()

接收端反序列化:

SensorData recv_data;
sensor_data_unpack(&recv_data, buffer, len);
printf("温度: %d\n", recv_data.temperature);

Protobuf的好处是跨语言、跨平台,而且压缩率高。我做过一个测试,同样的数据,JSON要200字节,Protobuf只要60字节左右。

我的经验:Protobuf在嵌入式设备上要注意内存分配。有些生成的C代码会动态malloc,在资源受限的环境里可能出问题。建议用nanopb这个轻量版。

23.3 JSON序列化:人类可读的协议

JSON的好处不用多说——调试方便,人类能直接看懂。但代价是解析开销大,数据冗余多。

在C语言里,我常用cJSON这个库。它只有一个.c和一个.h,集成起来很方便。

序列化示例:

cJSON *root = cJSON_CreateObject();
cJSON_AddNumberToObject(root, "temperature", 25);
cJSON_AddNumberToObject(root, "humidity", 60);
cJSON_AddStringToObject(root, "device_id", "sensor_01");

char *json_str = cJSON_Print(root);
// json_str 就是 "{"temperature":25,"humidity":60,"device_id":"sensor_01"}"
send(sockfd, json_str, strlen(json_str), 0);

cJSON_Delete(root);
free(json_str);

反序列化:

cJSON *root = cJSON_Parse(recv_buffer);
if (!root) {
    printf("JSON解析失败\n");
    return;
}
int temp = cJSON_GetObjectItem(root, "temperature")->valueint;
cJSON_Delete(root);
坑:cJSON_Print返回的字符串需要free,cJSON_Delete只释放JSON对象本身。我刚开始用的时候忘了free,内存泄漏查了好久。

JSON适合用在API接口、配置文件的场景。但如果是高频数据流,比如每秒几千条传感器数据,JSON的解析开销就太大了。

23.4 自定义协议设计:什么时候自己造轮子?

说实话,我不建议轻易自己设计协议。能用Protobuf就用Protobuf,能用JSON就用JSON。但有些场景,你不得不自己动手:

  • 极端资源受限:比如8位单片机,RAM只有2KB,跑不了Protobuf
  • 实时性要求极高:比如工业控制,要求微秒级解析
  • 私有协议:不想让别人轻易解析你的数据

我设计自定义协议时,一般遵循这几个原则:

  1. 固定头部:前几个字节固定格式,比如魔数(2字节)+版本(1字节)+总长度(2字节)
  2. 校验机制:CRC16或CRC32,防止数据损坏
  3. 可扩展性:预留字段,方便以后加功能
  4. 对齐友好:字段按4字节对齐,避免CPU做非对齐访问

一个简单的自定义协议头部:

typedef struct {
    uint16_t magic;      // 魔数,比如0xABCD
    uint8_t  version;    // 协议版本
    uint8_t  flags;      // 标志位
    uint32_t seq;        // 序列号
    uint16_t payload_len;// 负载长度
    uint16_t crc;        // CRC校验
} custom_header_t;

接收端解析时,先检查魔数,再校验CRC,然后根据payload_len读取后续数据。这样即使网络丢包或粘包,也能正确识别帧边界。

核心思路:自定义协议的核心不是「怎么编码」,而是「怎么保证不出错」。魔数、校验、长度字段,这三样缺一不可。

23.5 四种方案对比

方案 数据大小 解析速度 可读性 跨语言 适用场景
TLV 极快 需约定 嵌入式、低功耗
Protobuf 很小 微服务、RPC
JSON API、配置文件
自定义 最小 最快 需实现 极端场景

你看,没有银弹。每种方案都有自己的位置。我个人的建议是:

  • 如果你在做物联网网关,设备端用TLV,云端用JSON
  • 如果你在做微服务,统一用Protobuf
  • 如果你在写一个简单的工具,JSON就够了
  • 如果你在搞工控、军工,老老实实设计自定义协议,加上完善的校验和重传机制

23.6 知识体系结构图

下面这张图,帮你理清这四种方案的关系和选择路径:

序列化方案选择路径 序列化方案 TLV格式 Protocol Buffers JSON序列化 自定义协议 二进制·自描述 适合嵌入式 跨语言·压缩率高 适合微服务 人类可读·调试方便 适合API接口 极致性能·完全控制 适合极端场景 选择建议 • 资源受限 → TLV • 跨语言通信 → Protobuf • 调试/配置 → JSON • 极端性能/安全 → 自定义协议

嗯,这张图基本把四种方案的定位说清楚了。你从左上角开始,根据你的资源、性能、跨语言需求,一路往下走,就能找到合适的方案。

最后说一句:协议设计没有标准答案,但有一条铁律——先保证不出错,再追求效率。我见过太多人为了省几个字节,结果数据解析全乱套,得不偿失。


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