序列化与反序列化: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 的字段可以设置默认值。如果某个字段没有赋值,序列化时会被省略,反序列化时返回默认值。这能进一步压缩数据体积。

二、自定义二进制序列化:轻量级方案

有时候项目很小,引入 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]);
}

这样写出来的序列化数据,在任何平台上解析结果都一样。代价就是代码量多了点,但换来的是跨平台的安全性。

注意: 不要直接用 memcpy 序列化含有指针或虚函数表的对象。指针在不同进程或不同机器上没有意义,虚函数表更是运行时才确定的。序列化只适用于纯数据对象(POD 类型)。

三、跨平台字节序处理:大端与小端的战争

字节序,也叫端序(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 上有 htonshtonl 等 API,Linux 上也有同样的函数。但为了跨平台一致性,我建议自己封装一套,避免依赖平台特定的头文件。

核心原则: 序列化时统一用大端(网络字节序),反序列化时再转回主机字节序。这样数据在任何平台上都是一致的。

四、三种方案对比

方案 数据体积 解析速度 跨平台性 可读性 适用场景
protobuf 差(二进制) 服务端通信、微服务
自定义二进制 最小 最快 需手动处理 嵌入式、高性能场景
JSON/XML 配置文件、调试

我个人习惯是:网络通信用 protobuf,本地持久化用自定义二进制(体积小、速度快),调试和配置文件用 JSON。各取所长,不纠结于一种方案。

五、知识体系总览

下面这张图概括了本章的核心内容,从序列化的目标出发,到三种实现方案,再到跨平台必须处理的字节序问题:

序列化与反序列化知识体系 序列化/反序列化 protobuf 自定义二进制 JSON/XML .proto 定义消息 protoc 编译生成代码 Serialize/Parse 接口 逐字段写入缓冲区 指定字节序(大端) memcpy 或逐字节读写 nlohmann/json 等库 文本格式,可读性好 体积大,解析慢 跨平台字节序处理(大端/小端转换)

从图中可以看出,无论选择哪种序列化方案,字节序处理都是绕不开的一环。protobuf 内部已经帮你处理了字节序,所以跨平台最省心。自定义方案则需要你手动处理,但换来的是极致的性能和最小的体积。

我的建议: 如果你刚开始做跨平台项目,先用 protobuf 把业务跑通。等遇到性能瓶颈或者需要移植到嵌入式平台时,再考虑自定义二进制方案。不要一开始就追求极致优化——先让代码正确运行,再谈效率。

好了,关于序列化与反序列化,咱们就聊到这里。protobuf 的入门、自定义二进制方案的实现、以及跨平台字节序的处理,这三块内容在实际项目中几乎每天都会用到。希望你能从这些经验里找到适合自己的方案。


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