把对象存进文件、再原样读回来,是所有「存档 / 配置 / 网络协议」的底层需求。C++ 没有 Java 那样的反射(reflection),编译器不会自动告诉你一个结构体里有哪些字段、叫什么名字,所以序列化必须逐个字段显式声明。这篇讲清楚三件事:手写序列化的最小骨架、跨平台最容易翻车的端序(byte order)问题、以及「存档格式升级后还能读旧档」的版本兼容套路。
1. 引子:没有反射,字段得自己一个个写
别的语言序列化一个对象可能是 JSON.stringify(obj) 一行搞定,因为运行时能反射出字段名和类型。C++ 里这一步必须手动做——你写一个 serialize 函数,把每个字段依次写进字节流:
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
// 手写序列化:没有反射,字段要一个个显式写进字节流
struct Player {
std::int32_t id{};
std::int32_t score{};
std::string name{};
};
// 把一个 32 位整数按小端写进字节流(端序问题见第 3 节)
void put_u32(std::vector<unsigned char>& out, std::uint32_t v) {
out.push_back(static_cast<unsigned char>(v & 0xff));
out.push_back(static_cast<unsigned char>((v >> 8) & 0xff));
out.push_back(static_cast<unsigned char>((v >> 16) & 0xff));
out.push_back(static_cast<unsigned char>((v >> 24) & 0xff));
}
std::vector<unsigned char> serialize(const Player& p) {
std::vector<unsigned char> buf;
put_u32(buf, static_cast<std::uint32_t>(p.id));
put_u32(buf, static_cast<std::uint32_t>(p.score));
put_u32(buf, static_cast<std::uint32_t>(p.name.size())); // 字符串先写长度
buf.insert(buf.end(), p.name.begin(), p.name.end()); // 再写内容
return buf;
}
int main() {
Player p{42, 9999, "neko"};
auto bytes = serialize(p);
std::printf("序列化后共 %zu 字节:\n", bytes.size());
for (unsigned char b : bytes) {
std::printf("%02x ", b);
}
std::printf("\n");
}
序列化后共 16 字节:
2a 00 00 00 0f 27 00 00 04 00 00 00 6e 65 6b 6f
官方文档:std::vector —— 这里用
vector<unsigned char>当字节缓冲区,是序列化最常用的载体
这段输出能读出来的信息:2a 00 00 00 是 id=42 的小端表示,0f 27 00 00 是 score=9999,04 00 00 00 是字符串长度 4,最后 6e 65 6b 6f 是 "neko" 四个字符的 ASCII。手写序列化的核心就是「定好每个字段占几个字节、按什么顺序、什么编码」,读写两端必须严格一致。
2. 二进制 vs JSON 的取舍
序列化有两派:二进制(紧凑、快,人读不了)和文本 JSON(可读、可调试,但体积大、慢、且 C++ 标准库没有内置 JSON)。选型对比:
| 维度 | 二进制 | JSON |
|---|---|---|
| 体积 | 小(整数存 4 字节,不存十进制字符串) | 大(9999 存成 4 个字符,还要加引号逗号) |
| 解析速度 | 快(直接按偏移读) | 慢(要逐字符解析、转义处理) |
| 可读性 / 可调试 | 差,得靠十六进制编辑器 | 好,人眼直接看 |
| 字段顺序与类型 | 必须严格约定,错一位全乱 | 靠字段名,顺序无关 |
| 标准库支持 | 全手工 | 无(要用第三方库如 nlohmann/json) |
| 版本兼容 | 自己管 version 字段 | 加字段天然兼容,删字段要约定 |
结论:对性能敏感、内部存储、跨语言协议用二进制;配置文件、日志、给外部系统看用 JSON。这篇聚焦二进制,因为它最能暴露 C++ 没有反射的痛点。
无论选哪一派,都有一个共同的坑要提前想清楚:字段一旦写进持久化格式,就不能随意改含义。把 int 字段从「分数」改成「等级」会让所有旧数据语义错乱;正确的做法是新增一个字段、老字段保留。这个原则叫「不可变字段」(immutable fields),是版本兼容的地基。
3. 端序:同一份字节,换个 CPU 就反了
「端序(endianness)」指多字节整数在内存里是高位在前(大端 big-endian)还是低位在前(小端 little-endian)。第 1 节我默认了小端,但如果存档在 x86 上写、拿到 ARM(某些是大端)上读,42 会被读成 0x2a000000 = 704643072。C++20 用 std::endian 让代码在编译期就能知道本机端序:
// verify: std=c++20
#include <bit>
#include <cstdio>
int main() {
if constexpr (std::endian::native == std::endian::little) {
std::printf("本机字节序:小端(little-endian)\n");
} else if constexpr (std::endian::native == std::endian::big) {
std::printf("本机字节序:大端(big-endian)\n");
}
// 网络字节序约定为大端:小端机器写大端数据需要翻转
constexpr bool need_swap = (std::endian::native != std::endian::big);
std::printf("写大端网络字节序时%s需要字节翻转\n", need_swap ? "" : "不");
}
本机字节序:小端(little-endian)
写大端网络字节序时需要字节翻转
官方文档:std::endian —— 编译期常量,配合
if constexpr能在不写运行时判断的情况下消除死分支
注意两点:std::endian 是 enum class,它的具体枚举值是实现定义的(GCC 上是 1234/4321,libc++ 上是 1/2),所以判断端序永远写 std::endian::native == std::endian::little 这种比较,不要硬编码数字。std::endian 需要 C++20,且要 #include <bit>。
另一个隐蔽的坑是直接 memcpy 整个结构体:结构体里可能有填充字节(padding),不同编译器、不同平台算出来的布局可能不一样,把「内存里的样子」当成「磁盘上的格式」是危险的。正确做法永远是逐字段序列化,而不是序列化整个对象的内存镜像。
4. 版本兼容:存档升级了,旧档还能读
游戏存档、配置文件会随着版本迭代新增字段。如果读写格式写死,旧档(没有新字段)读进来要么报错要么读错。标准解法是在字节流最前面放一个 version 字段,读取时先看版本号,老版本缺的字段用默认值补上:
#include <cstdint>
#include <cstdio>
#include <vector>
// 版本化二进制格式:
// v1: id(u32) + score(u32)
// v2: id(u32) + score(u32) + level(u32) <- 新增字段
constexpr std::uint32_t kCurrentVersion = 2;
void put_u32(std::vector<unsigned char>& out, std::uint32_t v) {
out.push_back(static_cast<unsigned char>(v & 0xff));
out.push_back(static_cast<unsigned char>((v >> 8) & 0xff));
out.push_back(static_cast<unsigned char>((v >> 16) & 0xff));
out.push_back(static_cast<unsigned char>((v >> 24) & 0xff));
}
std::uint32_t get_u32(const std::vector<unsigned char>& in, std::size_t& pos) {
std::uint32_t v = 0;
v |= static_cast<std::uint32_t>(in[pos + 0]);
v |= static_cast<std::uint32_t>(in[pos + 1]) << 8;
v |= static_cast<std::uint32_t>(in[pos + 2]) << 16;
v |= static_cast<std::uint32_t>(in[pos + 3]) << 24;
pos += 4;
return v;
}
struct Save {
std::uint32_t id{};
std::uint32_t score{};
std::uint32_t level{1}; // v2 新增字段,默认 1
};
// 写 v2 存档(当前版本)
std::vector<unsigned char> serialize(const Save& s) {
std::vector<unsigned char> buf;
put_u32(buf, kCurrentVersion);
put_u32(buf, s.id);
put_u32(buf, s.score);
put_u32(buf, s.level);
return buf;
}
// 模拟一份 v1 旧存档:没有 level 字段
std::vector<unsigned char> make_v1_save(std::uint32_t id, std::uint32_t score) {
std::vector<unsigned char> buf;
put_u32(buf, 1); // version = 1
put_u32(buf, id);
put_u32(buf, score);
return buf;
}
Save deserialize(const std::vector<unsigned char>& in) {
std::size_t pos = 0;
std::uint32_t version = get_u32(in, pos);
Save s;
s.id = get_u32(in, pos);
s.score = get_u32(in, pos);
if (version >= 2) {
s.level = get_u32(in, pos); // 只有 v2 及以后才有 level
}
// version < 2:level 保持默认值 1
return s;
}
int main() {
Save cur{100, 5000, 7};
auto v2 = serialize(cur);
auto v1 = make_v1_save(55, 1200);
Save a = deserialize(v2);
Save b = deserialize(v1);
std::printf("读 v2 存档:id=%u score=%u level=%u\n", a.id, a.score, a.level);
std::printf("读 v1 旧档:id=%u score=%u level=%u(level 用默认值)\n",
b.id, b.score, b.level);
}
读 v2 存档:id=100 score=5000 level=7
读 v1 旧档:id=55 score=1200 level=1(level 用默认值)
这段代码展示了版本兼容的核心套路:读的时候先读 version,再按 version 决定读哪些字段;旧版本缺的字段用默认值补齐。格式布局:
带版本号的二进制存档布局(小端)
偏移 字段 类型 说明
0 version u32 格式版本号,决定后续字段怎么读
4 id u32 玩家 ID
8 score u32 分数
12 level u32 v2 新增;v1 旧档没有这一项,读旧档时用默认值 1
这套「version + 默认值」的兼容策略有个边界要心里有数:它只适合向后兼容:新版能读旧档。如果哪天要删除字段,就不能简单删掉,得保留一个「占位」继续跳过它,否则后面的偏移全乱。所以实际项目里,字段通常只增不删,删除靠「标记废弃」而非物理移除。
真实存档几乎都有变长字段(字符串),把前面的套路串起来,写一个能整体运行的完整示例:
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
constexpr std::uint32_t kVersion = 2;
struct Record {
std::uint32_t id{};
std::string name{};
std::uint32_t gold{0}; // v2 新增,默认 0
};
void put_u32(std::vector<unsigned char>& out, std::uint32_t v) {
out.push_back(static_cast<unsigned char>(v & 0xff));
out.push_back(static_cast<unsigned char>((v >> 8) & 0xff));
out.push_back(static_cast<unsigned char>((v >> 16) & 0xff));
out.push_back(static_cast<unsigned char>((v >> 24) & 0xff));
}
std::uint32_t get_u32(const std::vector<unsigned char>& in, std::size_t& pos) {
std::uint32_t v = 0;
v |= static_cast<std::uint32_t>(in[pos + 0]);
v |= static_cast<std::uint32_t>(in[pos + 1]) << 8;
v |= static_cast<std::uint32_t>(in[pos + 2]) << 16;
v |= static_cast<std::uint32_t>(in[pos + 3]) << 24;
pos += 4;
return v;
}
std::vector<unsigned char> serialize(const Record& r) {
std::vector<unsigned char> buf;
put_u32(buf, kVersion);
put_u32(buf, r.id);
put_u32(buf, static_cast<std::uint32_t>(r.name.size()));
buf.insert(buf.end(), r.name.begin(), r.name.end());
put_u32(buf, r.gold);
return buf;
}
Record deserialize(const std::vector<unsigned char>& in) {
std::size_t pos = 0;
std::uint32_t version = get_u32(in, pos);
Record r;
r.id = get_u32(in, pos);
std::uint32_t name_len = get_u32(in, pos);
r.name.assign(in.begin() + static_cast<std::ptrdiff_t>(pos),
in.begin() + static_cast<std::ptrdiff_t>(pos + name_len));
pos += name_len;
if (version >= 2) {
r.gold = get_u32(in, pos);
}
return r;
}
// 模拟 v1 旧档:只有 id + name,没有 gold
std::vector<unsigned char> make_v1(std::uint32_t id, const std::string& name) {
std::vector<unsigned char> buf;
put_u32(buf, 1);
put_u32(buf, id);
put_u32(buf, static_cast<std::uint32_t>(name.size()));
buf.insert(buf.end(), name.begin(), name.end());
return buf;
}
int main() {
Record cur{1, "miao", 888};
auto v2 = serialize(cur);
auto v1 = make_v1(2, "wang");
Record a = deserialize(v2);
Record b = deserialize(v1);
std::printf("读新档:id=%u name=%s gold=%u\n", a.id, a.name.c_str(), a.gold);
std::printf("读旧档:id=%u name=%s gold=%u(gold 用默认值)\n",
b.id, b.name.c_str(), b.gold);
}
读新档:id=1 name=miao gold=888
读旧档:id=2 name=wang gold=0(gold 用默认值)
官方文档:std::endian 的 C++20 提案背景 与 C++ Core Guidelines —— 前者是端序判断的唯一标准写法,后者提醒用
std::vector而非裸new[]来持有缓冲区
6. 延伸阅读
- std::endian — cppreference:端序判断的标准写法,配合
if constexpr用 - std::byte — cppreference:C++17 引入的「字节」类型,比
unsigned char更表意,适合当字节流元素 - C++ Core Guidelines — 资源管理:序列化缓冲区用 RAII 容器管理,别手写
new[]/delete[]
本知识库内的相关篇目:
- 《C++ 跨平台工程:特性检测与 feature-test 宏实战》 —— 一份代码要在 Windows/Linux/macOS 上都能编,关键是不写死平台和标准。
- 《C ABI 与插件系统:dlopen 导出稳定接口》 —— 为什么插件系统都要用 C ABI?这篇从名字修饰讲起
- 《C++ 日志与单元测试:无第三方库的最小实现》 —— 不引 gtest、spdlog
7. 一句话总结
C++ 没有反射,序列化靠手写字段;二进制格式要管好端序(std::endian,C++20)和版本号——先写 version、读时按版本补默认值,就能让旧存档在新版本里继续读得出来。
319



被折叠的 条评论
为什么被折叠?



