序列化与反序列化:protobuf入门、自定义二进制序列化、跨平台字节序处理
序列化这个话题,说白了就是把内存里的对象变成一串字节,方便存盘或者扔到网络上。反序列化就是反过来,把字节再变回对象。我在做跨平台项目时,几乎每个模块都要跟序列化打交道——从配置文件到网络协议,从日志记录到状态持久化,处处都有它的影子。
今天咱们就聊聊三种常见的序列化方案:protobuf、自定义二进制序列化,以及跨平台时最头疼的字节序问题。嗯,这里面的坑我踩过不少,希望能帮你绕过去。
一、protobuf 入门:Google 的序列化利器
protobuf 全称 Protocol Buffers,是 Google 开源的一种结构化数据序列化方案。我最早接触它是在一个游戏服务器项目里,当时团队在 JSON 和 protobuf 之间纠结了很久。后来选 protobuf 的原因很简单:它生成的二进制数据比 JSON 小得多,解析速度也快一个数量级。
1.1 定义消息结构
先写一个 .proto 文件,定义你的数据结构:
syntax = "proto3";
package tutorial;
message Person {
string name = 1;
int32 age = 2;
repeated string phone_numbers = 3;
Address address = 4;
}
message Address {
string city = 1;
string street = 2;
int32 zip_code = 3;
}
每个字段后面的数字是字段编号,1 到 15 用 1 个字节编码,16 以上用 2 个字节。所以频繁出现的字段尽量用小编号——这是我个人的优化习惯。
1.2 编译与使用
用 protoc 编译器生成 C++ 代码:
protoc --cpp_out=. tutorial.proto
会生成 tutorial.pb.h 和 tutorial.pb.cc。然后在代码里这样用:
#include "tutorial.pb.h"
#include <fstream>
// 构造对象
tutorial::Person person;
person.set_name("张三");
person.set_age(28);
person.add_phone_numbers("13800138000");
person.add_phone_numbers("13900139000");
// 序列化到字符串
std::string data;
person.SerializeToString(&data);
// 反序列化
tutorial::Person another;
another.ParseFromString(data);
std::cout << another.name() << std::endl;
protobuf 的序列化结果是纯二进制,不包含字段名,只包含字段编号和值。所以数据体积很小,但可读性差——调试时我一般会同时保留一份 JSON 格式的日志。
二、自定义二进制序列化:轻量级方案
有时候项目很小,引入 protobuf 显得太重了。或者你需要在嵌入式设备上跑,内存和 CPU 都有限。这时候自己写一个简单的序列化函数反而更灵活。
我记得有一次做 IoT 项目,MCU 只有 64KB 的 Flash,protobuf 的库就占了 20KB,实在装不下。最后我们自己写了一套极简的序列化方案,总共不到 200 行代码。
2.1 基本思路
核心就是:把结构体按字段顺序,逐个写入字节缓冲区。反序列化时按相同顺序读出来。
#include <cstdint>
#include <vector>
#include <cstring>
struct SensorData {
uint32_t timestamp;
float temperature;
float humidity;
uint8_t battery_level;
};
// 序列化
std::vector<uint8_t> serialize(const SensorData& data) {
std::vector<uint8_t> buffer;
buffer.resize(sizeof(data));
std::memcpy(buffer.data(), &data, sizeof(data));
return buffer;
}
// 反序列化
SensorData deserialize(const std::vector<uint8_t>& buffer) {
SensorData data;
std::memcpy(&data, buffer.data(), buffer.size());
return data;
}
这种方案简单粗暴,但有个致命问题:字节序。在不同平台上,整型和浮点数的字节排列顺序可能不同。咱们下面专门聊这个。
2.2 更健壮的自定义方案
我建议的做法是:每个字段单独序列化,并明确指定字节序。比如:
void write_uint32_be(std::vector<uint8_t>& buf, uint32_t value) {
buf.push_back((value >> 24) & 0xFF);
buf.push_back((value >> 16) & 0xFF);
buf.push_back((value >> 8) & 0xFF);
buf.push_back(value & 0xFF);
}
uint32_t read_uint32_be(const uint8_t* data, size_t offset) {
return (static_cast<uint32_t>(data[offset]) << 24) |
(static_cast<uint32_t>(data[offset + 1]) << 16) |
(static_cast<uint32_t>(data[offset + 2]) << 8) |
static_cast<uint32_t>(data[offset + 3]);
}
这样写出来的序列化数据,在任何平台上解析结果都一样。代价就是代码量多了点,但换来的是跨平台的安全性。
三、跨平台字节序处理:大端与小端的战争
字节序,也叫端序(Endianness),指的是多字节数据在内存中的排列顺序。大端(Big-Endian)把高位字节放在低地址,小端(Little-Endian)把低位字节放在低地址。
x86 和 ARM 默认是小端,网络协议和某些嵌入式平台用大端。我当年第一次做跨平台项目时,在服务器(x86)上序列化的数据,传到 ARM 开发板上死活解析不对。查了两天才发现是字节序的问题——从那以后,我所有序列化代码都强制指定字节序。
3.1 检测当前平台的字节序
bool is_little_endian() {
uint16_t test = 0x0102;
uint8_t* bytes = reinterpret_cast<uint8_t*>(&test);
return bytes[0] == 0x02;
}
或者用 C++20 的 std::endian:
#include <bit>
if constexpr (std::endian::native == std::endian::little) {
// 小端平台
} else {
// 大端平台
}
3.2 字节序转换函数
我习惯封装一组跨平台的字节序转换函数:
#include <bit>
#include <cstdint>
// 主机字节序转网络字节序(大端)
uint32_t hton32(uint32_t host) {
if constexpr (std::endian::native == std::endian::little) {
return __builtin_bswap32(host); // GCC/Clang 内置函数
}
return host;
}
// 网络字节序转主机字节序
uint32_t ntoh32(uint32_t net) {
return hton32(net); // 对称操作
}
// 浮点数也要处理
float htonf(float host) {
uint32_t bits;
std::memcpy(&bits, &host, sizeof(bits));
bits = hton32(bits);
float result;
std::memcpy(&result, &bits, sizeof(result));
return result;
}
Windows 上有 htons、htonl 等 API,Linux 上也有同样的函数。但为了跨平台一致性,我建议自己封装一套,避免依赖平台特定的头文件。
四、三种方案对比
| 方案 | 数据体积 | 解析速度 | 跨平台性 | 可读性 | 适用场景 |
|---|---|---|---|---|---|
| protobuf | 小 | 快 | 好 | 差(二进制) | 服务端通信、微服务 |
| 自定义二进制 | 最小 | 最快 | 需手动处理 | 差 | 嵌入式、高性能场景 |
| JSON/XML | 大 | 慢 | 好 | 好 | 配置文件、调试 |
我个人习惯是:网络通信用 protobuf,本地持久化用自定义二进制(体积小、速度快),调试和配置文件用 JSON。各取所长,不纠结于一种方案。
五、知识体系总览
下面这张图概括了本章的核心内容,从序列化的目标出发,到三种实现方案,再到跨平台必须处理的字节序问题:
从图中可以看出,无论选择哪种序列化方案,字节序处理都是绕不开的一环。protobuf 内部已经帮你处理了字节序,所以跨平台最省心。自定义方案则需要你手动处理,但换来的是极致的性能和最小的体积。
好了,关于序列化与反序列化,咱们就聊到这里。protobuf 的入门、自定义二进制方案的实现、以及跨平台字节序的处理,这三块内容在实际项目中几乎每天都会用到。希望你能从这些经验里找到适合自己的方案。