3、序列化与反序列化:数据在线的“打包”与“拆包”
大家好,我是你们的老朋友。今天我们来聊聊嵌入式通信里一个绕不开的话题——序列化与反序列化。
说白了,序列化就是把内存里的结构体、对象,变成一串能塞进网线或者无线信道里的字节流。反序列化就是反过来,把收到的字节流再还原成结构体。你想想看,两个设备要聊天,总得约定好“话怎么说”吧?序列化就是那个约定。
我在项目中遇到过不少因为序列化没选对,导致整个系统推倒重来的案例。嗯,今天咱们就把这块彻底讲透。
3.1 什么是序列化?为什么需要它?
先问个问题:你的MCU里有一个结构体,里面存了温度、湿度、风速三个数据。你想把它发给云端服务器。直接memcpy发过去行不行?
答案是不行。原因有三:
- 字节序不同:ARM Cortex-M一般是小端,x86也是小端,但有些MIPS或PowerPC是大端。直接发内存,对方读出来全是反的。
- 内存对齐:结构体里如果有uint32_t,编译器可能会在中间塞填充字节。不同编译器、不同优化选项,填充方式不一样。
- 数据自描述:你发了一串字节,对方怎么知道哪个字节是温度,哪个是湿度?序列化格式里通常会带上字段名或编号。
所以,序列化的核心目的就三个:跨平台、跨语言、自描述。
核心观点:序列化不是“把数据变个样子”,而是“让数据在异构系统之间能互相理解”。
3.2 常见序列化格式对比
市面上常见的序列化格式,我按使用场景分成了三类:文本型、二进制型、高性能二进制型。咱们一个一个看。
3.2.1 JSON
JSON 应该是大家最熟悉的。它基于文本,可读性好,调试方便。我在调试初期阶段,特别喜欢用JSON,因为用串口打印出来一眼就能看出问题。
{
"temperature": 25.6,
"humidity": 68.2,
"wind_speed": 3.5
}
但JSON在嵌入式里有个致命伤:解析开销大。你需要一个完整的JSON解析器,内存占用动辄几十KB。而且它没有类型约束,你传个字符串"25.6"和传个数字25.6,解析结果完全不同。我曾经在一个只有64KB RAM的STM32上跑cJSON,结果堆栈直接爆了。
避坑指南:在资源受限的MCU上,尽量避免使用JSON。如果非要用,建议用静态内存分配的解析器,比如JSMN,不要用cJSON这种动态分配内存的库。
3.2.2 XML
XML 在工业控制和配置文件中用得比较多。它的优点是自描述性极强,带标签和属性。但缺点也很明显:冗余数据太多。
<sensor>
<temperature unit="celsius">25.6</temperature>
<humidity unit="percent">68.2</humidity>
</sensor>
你想想看,传输同样的数据,XML的包体可能是JSON的2-3倍,是Protobuf的5-10倍。在低带宽的LoRa或NB-IoT场景下,这简直是灾难。我个人习惯,只在配置文件和SOAP协议里用XML,实时通信基本不考虑。
3.2.3 Protobuf
Protobuf 是Google推出的二进制序列化格式。它通过一个.proto文件定义数据结构,然后生成各语言的代码。它的核心优势是:体积小、解析快、强类型。
syntax = "proto3";
message SensorData {
float temperature = 1;
float humidity = 2;
float wind_speed = 3;
}
编码后的二进制数据大概只有JSON的1/5大小。而且它使用Varint编码,对于小整数能进一步压缩。我在一个智能网关项目里,用Protobuf替换了JSON,网络带宽占用直接降了70%。
但Protobuf也有缺点:需要预定义schema。如果字段经常变,你得频繁更新.proto文件并重新生成代码。另外,它的解码库在MCU上大概要占用8-12KB的Flash,比JSON小,但也不是零开销。
3.2.4 CBOR
CBOR 是另一种二进制格式,它的设计目标是“JSON的二进制等价物”。它不需要预定义schema,可以动态编码任意结构。而且它的编码规则非常简洁,适合在极低资源的设备上实现。
// CBOR编码示例(十六进制)
A3 # map(3)
69 # text(9)
74656D7065726174757265 # "temperature"
FA 41CCCCCD # float(25.6)
68 # text(8)
68756D6964697479 # "humidity"
FA 42886666 # float(68.2)
...
CBOR 在物联网领域用得越来越多,比如CoAP协议就推荐使用CBOR。我个人觉得,如果你不想引入Protobuf那种“代码生成”的流程,又想要二进制的高效,CBOR是个很好的折中方案。
3.3 性能对比:一张表看懂
| 格式 | 编码体积 | 解析速度 | 内存占用 | 自描述 | 强类型 | 适用场景 |
|---|---|---|---|---|---|---|
| JSON | 大 | 慢 | 高 | 是 | 否 | 调试、Web API |
| XML | 极大 | 极慢 | 极高 | 是 | 弱 | 配置文件、工业协议 |
| Protobuf | 小 | 快 | 中 | 否 | 强 | 高性能通信、微服务 |
| CBOR | 小 | 快 | 低 | 是 | 弱 | 物联网、低功耗设备 |
我的建议:如果你在做LoRa、ZigBee这类低带宽项目,优先考虑CBOR或Protobuf。如果是Wi-Fi或以太网项目,JSON也够用,但要注意内存。
3.4 自定义序列化实现
有时候,现成的格式都不太合适。比如你只需要传两个uint16_t和一个int8_t,用Protobuf还得引入整个库,太笨重了。这时候,自己手写一个序列化函数反而更高效。
我曾经在一个Bootloader项目里,需要传输固件包的元信息(版本号、长度、校验和)。数据量极小,但要求解析速度极快。我直接定义了一个固定长度的结构体,手动处理字节序。
// 自定义序列化示例
typedef struct {
uint16_t version;
uint32_t length;
uint8_t checksum;
} __attribute__((packed)) FirmwareMeta;
// 序列化:将结构体转为网络字节序(大端)
void serialize_firmware_meta(const FirmwareMeta *meta, uint8_t *buf) {
buf[0] = (meta->version >> 8) & 0xFF;
buf[1] = meta->version & 0xFF;
buf[2] = (meta->length >> 24) & 0xFF;
buf[3] = (meta->length >> 16) & 0xFF;
buf[4] = (meta->length >> 8) & 0xFF;
buf[5] = meta->length & 0xFF;
buf[6] = meta->checksum;
}
// 反序列化:从网络字节序恢复结构体
void deserialize_firmware_meta(const uint8_t *buf, FirmwareMeta *meta) {
meta->version = ((uint16_t)buf[0] << 8) | buf[1];
meta->length = ((uint32_t)buf[2] << 24) |
((uint32_t)buf[3] << 16) |
((uint32_t)buf[4] << 8) | buf[5];
meta->checksum = buf[6];
}
这里有几个关键点:
- 固定字节序:统一使用大端(网络字节序),避免大小端问题。
- 紧凑打包:使用
__attribute__((packed))禁止编译器填充,保证结构体内存布局和字节流完全一致。 - 无动态内存:所有操作都在栈上完成,不会产生碎片。
注意:自定义序列化只适合数据结构非常稳定的场景。如果字段经常增删,或者需要兼容多个版本,还是用Protobuf这类带版本管理的格式更稳妥。
3.5 知识体系图:序列化选型决策
下面这张图,是我自己总结的序列化格式选型流程。你可以把它当作一个决策树来用。
3.6 总结与个人经验
序列化这件事,说白了就是“沟通的语法”。选对了,系统跑得又快又稳;选错了,后期改起来想哭。
我个人的经验是:
- 原型阶段:用JSON,调试方便,改起来快。
- 产品阶段:如果资源够,上Protobuf;资源紧,上CBOR或自定义。
- 永远不要:直接memcpy结构体到网络。你永远不知道对方的编译器会怎么对齐。
我曾经在一个NB-IoT项目里,因为用了JSON,导致每次上报数据包太大,电池续航从3个月掉到1个月。后来换成CBOR,包体缩小了60%,续航直接回到3个月。嗯,这就是选型的力量。
一句话总结:序列化不是技术问题,是工程权衡问题。理解你的资源边界,选择最合适的格式,而不是最流行的格式。
公众号:蓝海资料掘金营,微信deep3321