FlatBuffers 二进制格式内部原理全解析从偏移量体系到 FlexBuffers 编码【免费下载链接】flatbuffersFlatBuffers: Memory Efficient Serialization Library项目地址: https://gitcode.com/GitHub_Trending/fl/flatbuffersFlatBuffers 之所以能在不解析、不拆包的情况下直接读取序列化数据秘密全在于它精心设计的二进制内存布局。本篇技术指南基于官方 internals.md 展开逐层拆解 FlatBuffers 的格式组件偏移量、Struct、Table、Vtable、Union、String/Vector、反向构建过程、生成代码的底层含义以及无模式版本 FlexBuffers 的完整编码规范。读完本文你将能从字节层面理解 FlatBuffers 的高效性来源掌握其二进制格式的全部关键细节并能在遇到格式问题时独立排查。说明本文内容属于可选进阶知识。在正常使用 FlatBuffers 时你通常无需了解这些内部细节但理解它们能让你真正明白为什么 FlatBuffers 既高效又便捷。一、总体设计理念用偏移量与邻接性定义格式FlatBuffers 是一种二进制文件与内存格式其主体由各种大小的标量组成且每个标量都按自身大小对齐aligned to their own size。所有标量一律使用**小端序little-endian**表示这与当今主流 CPU 的字节序一致在 big-endian 机器上 FlatBuffers 依然可用只是会因额外的字节交换指令byte-swap intrinsics而略微变慢。为保证跨平台互操作格式假定以下条件成立浮点数使用二进制IEEE-754格式有符号整数使用二进制补码twos complement表示浮点数与整数的字节序保持一致。值得强调的是格式故意不定义对象在内存中的确切位置细节例如 table 中的字段可以任意顺序排列对象在一定程度上也可以多种顺序存储。这是因为格式本身不需要这些信息即可高效工作且这种自由度留出了优化与扩展空间比如字段可以按最紧凑的方式打包。格式只以偏移量offsets和邻接性adjacency来定义。这意味着两个不同的实现针对相同输入值可能产生不同的二进制结果而这些都是完全合法的。二、格式标识与版本设计上刻意缺失格式同样不包含格式标识magic与版本号信息这也是有意为之的设计FlatBuffers 是静态类型系统缓冲区的使用者必须预先知道它是什么类型的缓冲区如有需要FlatBuffers 可以被包裹进其他容器中或者使用其union 特性动态标识其中存储的多个可能的子对象若需要完整的反射能力可配合 schema 解析器parser一起使用。版本控制本质上已内建于格式之中即字段的可选性/可扩展性因此格式本身不需要版本号——从某种意义上说它是一种元格式meta-format。如果未来真的需要破坏格式的变更那将诞生一种新格式而不是仅仅作为一个变体存在。三、偏移量体系uoffset_t / soffset_t / voffset_t偏移量是 FlatBuffers 格式最核心、最通用的类型。在 base.h 中定义了三种偏移量类型底层类型用途uoffset_tuint32_t引用所有 table / union / string / vector这些对象从不内联存储soffset_tint32_t有符号偏移如 table 到 vtable 的偏移voffset_tuint16_tvtable 内元素的类型即字段的 vtable 偏移uoffset_t固定为 32 位是有意为之目的是保持格式在 32 位与 64 位系统之间二进制兼容同时若使用 64 位偏移会显著膨胀几乎所有场景下的体积。需要时很容易衍生出带 64 位或 16 位偏移量的格式版本。uoffset_t是无符号的意味着它只能指向一个方向通常是向前朝向更高内存地址。任何向后的偏移都会被显式标注如soffset_t。整个缓冲区以指向根 table 的uoffset_t开头。格式中有两类对象struct结构体与table表。四、Struct内联存储的最简紧凑对象Struct 是最简单的对象类型适合那些需要极致效率、且不需要版本化/可扩展性的简单数据。它们总是内联存储在父对象struct、table 或 vector中以获得最大紧凑度。Struct 定义了一致的固定内存布局所有成员按自身大小对齐struct 整体按其最大标量成员对齐。这种对齐是独立于底层编译器对齐规则的从而保证跨平台兼容的布局并由生成的代码强制执行。在 table.h 中GetStruct通过 vtable 中的字段偏移直接返回指向内联数据的指针无需任何间接跳转template typename P P GetStruct(voffset_t field) const { auto field_offset GetOptionalFieldOffset(field); auto p const_castuint8_t*(data_ field_offset); return field_offset ? reinterpret_castP(p) : nullptr; }五、Table 与 Vtable可选字段的访问中枢与 struct 不同table不内联存储在父对象中而是通过偏移量引用。每个 table 以指向 vtable 的soffset_t开头——由于 vtable 可能存储在对象相对位置的任意处因此使用有符号版本。该偏移是**从对象起始位置减去而非加上**得到 vtable 起始地址的。紧随其后的是所有字段作为对齐的标量或偏移量。与 struct 不同table 中并非所有字段都必须存在字段也没有固定顺序与布局如果用户显式序列化同一个偏移两次table 中甚至可能出现多个指向同一值的字段偏移。为应对这些不确定性访问字段必须经由 vtable偏移表。vtable 可以在具有相同 vtable 值的任意对象之间共享。vtable 的元素全部是voffset_tuint16_t其布局为元素含义第 1 个元素vtable 自身大小字节数含大小元素本身第 2 个元素对象大小字节数含 vtable 偏移。可用于流式场景得知需读取多少字节才能访问对象的所有内联字段其余 N 个元素各字段的偏移N 为构造该缓冲区时 schema 中声明的字段数因此 vtable 大小为 N 2从 table.h 的实现可以清楚看到访问流程voffset_t GetOptionalFieldOffset(voffset_t field) const { auto vtable GetVTable(); // data_ - ReadScalarsoffset_t(data_) auto vtsize ReadScalarvoffset_t(vtable); // 第一个元素vtable 大小 // 字段超出 vtable 范围 → 正在读取旧数据等价于字段不存在 return field vtsize ? ReadScalarvoffset_t(vtable field) : 0; }生成代码中table 的所有访问器函数都将该字段在 vtable 中的偏移作为编译期常量。访问时先与第一个元素元素个数比较以防止新代码读取旧数据若偏移越界或 vtable 条目为 0说明该字段在此对象中不存在此时返回默认值否则将该条目作为偏移定位并读取字段。这正是 FlatBuffers 前后兼容forwards/backwards compatibility的底层机制。六、Union枚举 偏移的组合编码Union 编码为两个字段的组合一个枚举enum表示 union 的选择一个指向实际元素的偏移量。FlatBuffers 保留枚举常量NONE编码为 0来表示 union 字段未设置。七、String 与 Vector偏移引用的连续数据String本质上是一个字节向量并且总是以 null 结尾null-terminated。Vector存储为连续的、对齐的标量元素前面带一个32 位元素计数不包含任何 null 终止符。两者都不内联存储于父对象而是通过偏移量引用。与 table 类似若用户显式序列化同一个偏移两次vector 中也可能出现多个指向同一值的偏移。八、构建过程从高地址向低地址反向构建当前实现反向构建这些缓冲区从缓冲区的最高内存地址开始向低地址方向写入。这样能显著减少簿记工作bookkeeping并简化构造 API。这种向后写的设计直接体现在 samples/monster.fbs 生成的MonsterBuilder中你可以以任意顺序添加字段最后由Finish()调用确保生成正确的 vtable。九、生成代码逐段解析以 samples/monster.fbs 为例以下是针对 samples/monster.fbs 生成的代码全文按注释分段解析。该 schema 定义了Color枚举、Equipmentunion、Vec3struct 与Monstertablenamespace MyGame.Sample; enum Color:byte { Red 0, Green, Blue 2 } union Equipment { Weapon } struct Vec3 { x:float; y:float; z:float; } table Monster { pos:Vec3; mana:short 150; hp:short 100; name:string; friendly:bool false (deprecated); inventory:[ubyte]; color:Color Blue; weapons:[Weapon]; equipped:Equipment; path:[Vec3]; } root_type Monster;生成的 C 代码开头如下// automatically generated, do not modify #include flatbuffers/flatbuffers.h namespace MyGame { namespace Sample {嵌套命名空间支持。接下来是枚举及其反向查找辅助函数enum { Color_Red 0, Color_Green 1, Color_Blue 2, }; inline const char **EnumNamesColor() { static const char *names[] { Red, Green, Blue, nullptr }; return names; } inline const char *EnumNameColor(int e) { return EnumNamesColor()[e]; }Union 与枚举共享大量生成逻辑enum { Any_NONE 0, Any_Monster 1, }; inline const char **EnumNamesAny() { static const char *names[] { NONE, Monster, nullptr }; return names; } inline const char *EnumNameAny(int e) { return EnumNamesAny()[e]; }由于类型之间允许循环引用对象之间的循环引用则不允许所有数据类型需要预先声明struct Vec3; struct Monster;Vec3 struct 的生成代码使用了两个关键宏FLATBUFFERS_MANUALLY_ALIGNED_STRUCT(4) Vec3 { private: float x_; float y_; float z_; public: Vec3(float x, float y, float z) : x_(flatbuffers::EndianScalar(x)), y_(flatbuffers::EndianScalar(y)), z_(flatbuffers::EndianScalar(z)) {} float x() const { return flatbuffers::EndianScalar(x_); } float y() const { return flatbuffers::EndianScalar(y_); } float z() const { return flatbuffers::EndianScalar(z_); } }; FLATBUFFERS_STRUCT_END(Vec3, 12);这些丑陋的宏做了两件事关闭编译器可能做的任何填充因为填充由代码手动添加本例中没有填充并强制执行 FlatBuffers 选择的对齐。这保证了该 struct 的布局在任何编译器与平台上都一致。注意字段是private的这是因为无论平台如何它们存储的都是小端标量这是序列化数据的一部分EndianScalar负责双向转换——在所有当前移动端与桌面平台上它是空操作在少数 big-endian 平台上则是一条机器指令。Monster table 的访问器struct Monster : private flatbuffers::Table { const Vec3 *pos() const { return GetStructconst Vec3 *(4); } int16_t mana() const { return GetFieldint16_t(6, 150); } int16_t hp() const { return GetFieldint16_t(8, 100); } const flatbuffers::String *name() const { return GetPointerconst flatbuffers::String *(10); } const flatbuffers::Vectoruint8_t *inventory() const { return GetPointerconst flatbuffers::Vectoruint8_t *(14); } int8_t color() const { return GetFieldint8_t(16, 2); } };Table 访问器结构体用于指向 table 的序列化数据其总是以指向 vtable 的偏移开头。它继承自Table包含GetField辅助函数。GetField接收一个 vtable 偏移和一个默认值先在 vtable 中查找该偏移若偏移越界来自旧版本的数据或 vtable 条目为 0则字段不存在并返回默认值否则用该条目作为 table 内的偏移定位字段。注意mana、hp、color的默认值150、100、2正是 schema 中声明的默认值已作为常量嵌入访问器。MonsterBuilder 构造器struct MonsterBuilder { flatbuffers::FlatBufferBuilder fbb_; flatbuffers::uoffset_t start_; void add_pos(const Vec3 *pos) { fbb_.AddStruct(4, pos); } void add_mana(int16_t mana) { fbb_.AddElementint16_t(6, mana, 150); } void add_hp(int16_t hp) { fbb_.AddElementint16_t(8, hp, 100); } void add_name(flatbuffers::Offsetflatbuffers::String name) { fbb_.AddOffset(10, name); } void add_inventory(flatbuffers::Offsetflatbuffers::Vectoruint8_t inventory) { fbb_.AddOffset(14, inventory); } void add_color(int8_t color) { fbb_.AddElementint8_t(8, color, 2); } MonsterBuilder(flatbuffers::FlatBufferBuilder _fbb) : fbb_(_fbb) { start_ fbb_.StartTable(); } flatbuffers::OffsetMonster Finish() { return flatbuffers::OffsetMonster(fbb_.EndTable(start_, 7)); } };MonsterBuilder是使用FlatBufferBuilder构造 table 的基础辅助结构体字段可以任意顺序添加Finish()会确保生成正确的 vtable注意EndTable(start_, 7)中的 7 正是 vtable 中字段条目的数量。构造与写入的核心逻辑见 flatbuffer_builder.h 中的FlatBufferBuilder。便捷函数 CreateMonsterinline flatbuffers::OffsetMonster CreateMonster(flatbuffers::FlatBufferBuilder _fbb, const Vec3 *pos, int16_t mana, int16_t hp, flatbuffers::Offsetflatbuffers::String name, flatbuffers::Offsetflatbuffers::Vectoruint8_t inventory, int8_t color) { MonsterBuilder builder_(_fbb); builder_.add_inventory(inventory); builder_.add_name(name); builder_.add_pos(pos); builder_.add_hp(hp); builder_.add_mana(mana); builder_.add_color(color); return builder_.Finish(); }CreateMonster是替你依次调用上述所有add_*函数的便捷函数。注意如果你传入的值恰好是默认值它实际上不会构造该字段——因此大多数情况下可以直接使用这个函数而非 Builder 类从而让缓冲区更紧凑。根 table 的入口函数inline const Monster *GetMonster(const void *buf) { return flatbuffers::GetRootMonster(buf); }该函数仅为根 table 类型生成用于从原始缓冲区指针开始遍历一个 FlatBuffer。十、编码示例JSON 到二进制的字节级拆解下面是对应于上述 schema 的一段 JSON 的编码示例{ pos: { x: 1, y: 2, z: 3 }, name: fred, hp: 50 }编码得到的二进制缓冲区如下// Start of the buffer: uint32_t 20 // Offset to the root table. // Start of the vtable. Not shared in this example, but could be: uint16_t 16 // Size of table, starting from here. uint16_t 22 // Size of object inline data. uint16_t 4, 0, 20, 16, 0, 0 // Offsets to fields from start of (root) table, 0 for not present. // Start of the root table: int32_t 16 // Offset to vtable used (default negative direction) float 1, 2, 3 // the Vec3 struct, inline. uint32_t 8 // Offset to the name string. int16_t 50 // hp field. int16_t 0 // Padding for alignment. // Start of name string: uint32_t 4 // Length of string. int8_t f, r, e, d, 0, 0, 0, 0 // Text 0 termination padding.逐段解读这个编码缓冲区开头uint32_t 20是指向根 table 的偏移整个格式的统一入口。vtable第一个uint16_t 16是 vtable 自身大小第二个uint16_t 22是对象内联数据大小随后 6 个uint16_t是各字段的偏移4, 0, 20, 16, 0, 0其中 0 表示字段不存在本例中mana、inventory、color未出现或取默认值。根 tableint32_t 16是指向 vtable 的偏移默认负方向即向低地址接着float 1, 2, 3是内联的Vec3structuint32_t 8是指向 name 字符串的偏移int16_t 50是hp字段int16_t 0是对齐填充。name 字符串uint32_t 4是字符串长度随后是f,r,e,d四个字节 0终止符 填充到 8 字节对齐。需要强调的是这不是唯一的可能编码写入者可以灵活决定先写根对象的哪个子对象本例只有一个字符串以及以什么顺序写字段。不同的顺序还可能导致不同的对齐结果。这种灵活性正是第一节所述的格式只定义偏移与邻接性的体现。十一、FlexBuffers无模式版本的编码规范FlexBuffers 是 FlatBuffers 的无 schemaschema-less版本拥有自己独立的编码本文接下来详细阐述。它与 FlatBuffers 共享许多特性所有数据通过偏移量访问、所有标量按自身大小对齐、所有数据一律小端存储。不同之处在于构建方向相反FlexBuffers 从前向后构建子对象先于父对象存储根数据从最后一个字节开始变长位宽标量数据以可变位数8/16/32/64存储当前位宽总是由父对象决定。例如若标量位于 vector 中则由该 vector 为所有元素统一决定位宽。编码器会自动为特定 vector 选择最小位宽通常用户无需关心——但了解这一特性不要把一个double和一堆字节大小的元素放进同一个 vector有助于提升效率单一偏移类型与 FlatBuffers 不同FlexBuffers 只有一种偏移量——无符号整数表示从其自身存储地址向负方向偏移的字节数。11.1 VectorFlexBuffers 的核心表示vector 的表示是理解 FlexBuffers 工作方式的核心因为 map 本质上就是两个 vector 的组合值得从这里开始。如前所述vector 由单一的位宽由其父对象提供支配这包括 size 字段本身。例如存储整数值1, 2, 3的 vector 编码如下uint8_t 3, 1, 2, 3, 4, 4, 4第一个3是 size 字段放在 vector 之前父对象到此 vector 的偏移指向第一个元素而非 size 字段因此 size 字段实际上位于索引 -1 处由于这是无类型 vectorSL_VECTOR/FBT_VECTOR其后跟 3 个类型字节每个元素一个总是跟在 vector 之后且总是uint8_t即使 vector 由更大的标量组成。同样若用户显式序列化同一个偏移两次vector 中可能包含多个指向同一值的偏移。11.2 类型字节Type Bytes一个类型字节由 2 个部分组成精确值参见 flexbuffers.h 中的enum Type低 2 位子对象的位宽8/16/32/64。仅当子对象通过偏移访问如子 vector时使用对内联类型忽略高 6 位实际类型。因此在上例中4表示 8 位子对象值为 0未使用因为值内联、类型SL_INT值为 1。enum Type中的部分关键常量带FBT_前缀FBT_KEY 4、FBT_INDIRECT_INT 6、FBT_INDIRECT_UINT 7、FBT_INDIRECT_FLOAT 8、FBT_MAP 9、FBT_VECTOR_INT 11任意大小的类型化 vector不存类型表、FBT_VECTOR_INT2 16/FBT_VECTOR_INT3 19/FBT_VECTOR_INT4 22定长元组无类型表、无 size 字段。11.3 类型化 VectorTyped Vectors类型化 vector 与上述 vector 类似但省略了类型字节——类型改由父对象提供的 vector 类型决定。类型化 vector 仅对省空间效果显著的部分类型可用内联有符号/无符号整数TYPE_VECTOR_INT/TYPE_VECTOR_UINT浮点数TYPE_VECTOR_FLOAT键TYPE_VECTOR_KEY见下文。此外对于标量还有长度为 2 / 3 / 4 的定长 vectorTYPE_VECTOR_INT2等它们不存储 size 字段在存储常见 vector 或颜色数据时可进一步节省空间。11.4 标量ScalarsFlexBuffers 支持整数TYPE_INT/TYPE_UINT和浮点数TYPE_FLOAT可按上文所述位宽存储既可内联也可通过偏移存储TYPE_INDIRECT_*。偏移版本非常有用它可以将昂贵的 64 位甚至 32 位数值编码进小尺寸的 vector / map 中并支持多次共享/重复同一个值。11.5 布尔值与空值Booleans and Nulls布尔值TYPE_BOOL和空值TYPE_NULL编码为内联的无符号整数。11.6 Blob、String 与 KeyBlobTYPE_BLOB编码方式类似 vector唯一区别是元素总是uint8_t。父位宽只决定 size 字段的宽度这使得 blob 可以很大而元素本身不必很大StringTYPE_STRING与 blob 类似但额外带一个0 终止字节以方便使用且必须为 UTF-8 编码因为不支持 UTF-8 数据指针的语言可能需要在访问器中将其转换为原生字符串类型KeyTYPE_KEY与 string 类似但不存储 size 字段。之所以如此命名是因为它们用于 map——map 不在乎大小因此可以更紧凑。与 string 不同key 的数据中不能包含值为 0 的字节其大小只能通过strlen确定所以虽然你也可以在 map 之外使用 key但通常还是用 string 更合适。11.7 Map两个 vector 的组合MapTYPE_MAP就像一个无类型vector但在 size 字段之前多了2 个前缀索引字段-3指向 keys vector 的偏移可在表之间共享-2keys vector 的字节宽度-1大小从这开始与TYPE_VECTOR兼容0元素Size类型由于 map 其余部分与 vector 相同因此可以像迭代 vector 一样迭代 map这可能比按键查找更快。keys vector 是一个类型化的 key vector。keys 和对应的 values 都必须按排序顺序存储以strcmp判定这样查找才能使用二分搜索binary search。key vector 之所以与 value vector 分离成独立结构是为了可以在多个 value vector 之间共享以及在代码中将其作为独立的 vector 处理。一个示例 map{ foo: 13, bar: 14 }的编码如下0 : uint8_t b, a, r, 0 4 : uint8_t f, o, o, 0 8 : uint8_t 2 // key vector of size 2 // key vector offset points here 9 : uint8_t 9, 6 // offsets to bar_key and foo_key 11: uint8_t 2, 1 // offset to key vector, and its byte width 13: uint8_t 2 // value vector of size // value vector offset points here 14: uint8_t 14, 13 // values 16: uint8_t 4, 4 // types逐字节解读bar\0与foo\0是排序后的 keys字节 8 是 keys vector 的大小 2字节 9 处是两个指向bar_key/foo_key的偏移字节 11 处是指向 keys vector 的偏移及其字节宽度字节 13 是 values vector 的大小 2随后是值14, 13与类型字节4, 4即内联 8 位整数。11.8 根The Root如前所述根从缓冲区末尾开始最后一个uint8_t是根的宽度字节数通常由父对象决定宽度但根没有父对象它前面的uint8_t是根的类型再往前的若干字节是根的值字节数由最后一个字节指定。例如整数值13作为根时编码为uint8_t 13, 4, 1 // Value, type, root byte width.即值13、类型48 位内联整数、根宽度1字节。FlexBuffers 的完整实现与类型常量定义可参考 flexbuffers.h其行为在 flexbuffers_test.cpp 中有大量测试覆盖。十二、进一步阅读无模式版本的整体使用指南flexbuffers.md偏移量与基础类型定义base.hTable 访问器GetVTable/GetOptionalFieldOffset/GetField/GetPointer/GetStruct实现table.h缓冲区反向构建核心逻辑flatbuffer_builder.hFlexBuffers 类型枚举与引用实现flexbuffers.h本文示例对应的 schemasamples/monster.fbs 及其生成代码 monster_generated.h格式验证与测试flexbuffers_test.cpp 以及 tests/ 目录下的其余测试文件。【免费下载链接】flatbuffersFlatBuffers: Memory Efficient Serialization Library项目地址: https://gitcode.com/GitHub_Trending/fl/flatbuffers创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考