PAYATHON 2026

C/C++ 中如何可靠地将结构体序列化为原始字节数组?

数据小许

应该优先选择哪种方案?

如果数据需要写入文件、通过网络传输,或由不同架构、编译器和版本的程序读取,通常应明确规定字节格式,再逐字段编码和解码。

逐字段处理不必为每个字段重复编写复杂代码,可以用小型读写函数封装。结构体保持正常对齐,字节数组则按协议规定的偏移量存储数据。

整体 memcpy 只值得在有限场景下考虑:读写双方的内存布局和对象表示都受控,且数据类型适合按字节复制。它不能直接作为通用的持久化或通信格式。

为什么 packed 不能解决全部问题?

结构体打包主要影响成员布局和对齐。即使消除了填充字节,以下问题仍然存在:

  • 字节序:同一个多字节整数在不同平台上的内存字节顺序可能不同。
  • 字段表示:int、long、枚举等类型的大小或表示不能直接当作跨平台协议约定。
  • 成员访问:打包后的成员可能未对齐,编译器可能需要生成额外指令,具体行为和代价取决于编译器及目标平台。
  • 对象语义:指针保存的是地址,std::string 等对象还包含内部状态,整体复制不会自动序列化其指向或管理的数据。
  • 格式演进:增加、删除成员或调整成员顺序,都可能改变整体内存布局。

原问题中的 #pragma 和 attribute 写法只是示意,不能直接视为有效的通用语法。常见扩展形式包括 #pragma pack(push, 1)、#pragma pack(pop) 和 __attribute__((packed)),是否支持及其具体语义需要以使用的编译器为准。

整体复制普通结构体还可能带出填充字节。这些字节不属于字段值,可能使输出不稳定,甚至暴露不应写出的内存内容。

逐字段 memcpy 是否能避免未对齐访问?

在正常对齐的字段对象与字节缓冲区之间复制时,memcpy 不要求缓冲区地址满足该字段类型的对齐要求。例如:

std::uint32_t value;
std::memcpy(&value, buffer + offset, sizeof value);

在源缓冲区范围有效、目标对象有效的前提下,这种方式可以避免通过未对齐的 std::uint32_t* 解引用。

不过,memcpy 只复制字节,不会转换字节序。上面的结果仍然取决于输入字节与本机整数表示之间的关系。逐字段使用 memcpy 时,还需要另外定义和处理协议字节序。

一个明确规定格式的 C++ 示例

假设格式固定为 6 个字节:前 4 字节保存 id,后 2 字节保存 flags,两个字段都采用小端序。下面使用 C++11,要求平台提供 std::uint8_t、std::uint16_t 和 std::uint32_t,且每字节为 8 位。

#include <array>
#include <climits>
#include <cstddef>
#include <cstdint>

static_assert(CHAR_BIT == 8, "Requires 8-bit bytes");

struct A {
    std::uint32_t id;
    std::uint16_t flags;
};

using Bytes = std::array<std::uint8_t, 6>;

Bytes serialize(const A& value) {
    return {{
        static_cast<std::uint8_t>(value.id),
        static_cast<std::uint8_t>(value.id >> 8),
        static_cast<std::uint8_t>(value.id >> 16),
        static_cast<std::uint8_t>(value.id >> 24),
        static_cast<std::uint8_t>(value.flags),
        static_cast<std::uint8_t>(value.flags >> 8)
    }};
}

bool deserialize(const std::uint8_t* data,
                 std::size_t size,
                 A& out) {
    if (data == nullptr || size != 6) {
        return false;
    }

    A value{};
    value.id =
        static_cast<std::uint32_t>(data[0]) |
        (static_cast<std::uint32_t>(data[1]) << 8) |
        (static_cast<std::uint32_t>(data[2]) << 16) |
        (static_cast<std::uint32_t>(data[3]) << 24);

    value.flags = static_cast<std::uint16_t>(
        static_cast<std::uint32_t>(data[4]) |
        (static_cast<std::uint32_t>(data[5]) << 8)
    );

    out = value;
    return true;
}

这个格式的长度始终是 6 字节,与 sizeof(A) 无关。编码和解码按整数值进行,不依赖本机字节序,也不需要对字节数组进行未对齐的多字节访问。

示例将输入视为一条完整记录,因此要求长度恰好为 6。如果需要解析包含多条记录的数据流,可以改成检查剩余长度,并返回实际消费的字节数。C 也可以采用相同思路,使用 <stdint.h> 中的整数类型和普通输出缓冲区实现。

性能应该怎样取舍?

不要仅凭源码中 memcpy 的调用次数判断性能。对于编译期已知的小尺寸复制,优化后的机器码可能只是少量加载和存储指令;逐字段编码也可能得到很紧凑的结果。packed 成员访问的成本同样取决于目标平台。

先确定格式和兼容性要求,再用实际字段数量、记录大小、缓冲区布局及目标编译选项测试。比较时应关注完整的编码和解码过程,而不是只测一次复制。

如果重复代码影响维护,可以封装 write_u32_le、read_u32_le 等函数,集中处理边界检查和字节序。这样既能保持格式明确,也便于后续调整实现。

反序列化还需要注意什么?

不要直接把外部缓冲区强制转换为 A* 后解引用。除了对齐问题,还需要满足对象生命周期、类型访问规则和对象表示方面的要求。

处理外部输入时,应在读取前检查长度,在恢复字段后检查业务约束。例如,枚举值是否允许、标志位是否包含未知位、变长字段的长度是否超过剩余缓冲区或允许上限。对于包含指针、容器或字符串的对象,需要分别编码它们表达的数据,不能复制内部地址。

C++ 中,trivially copyable 类型在标准规定的条件下支持对象表示的字节复制,但这并不保证其内存布局适合作为跨平台协议,也不意味着任意外部字节都能恢复成有效对象。

备注:内容仅供参考。