本节摘要:每段 Protobuf 字节流的最小单元是"tag + 载荷",tag 用一个 varint 编码同时携带字段编号与 wire type(编号左移三位或上类型值)。本节拆解 tag 的位运算构成、六种 wire type 的完整映射、以及 wire type 赋予解码器的"盲跳"能力——它是全部兼容机制的基石。读完你应当能在十六进制 dump 与 proto 定义之间双向翻译。
本节在知识地图的位置:全章乃至全册的地基。第 1 章悬案里那些"08、0A、12"的字节身份,在读完本节后应当全部可解释;通往 3.2 的 varint 深潜与 3.3 的递归结构。
从最小的例子开始。proto 定义:
message Simple { int32 count = 1; string label = 2; }
赋值 count = 150、label = "ab",序列化结果是:
08 96 01 12 02 61 62
逐颗纽扣拆开。08 是 count 的 tag,96 01 是 150 的 varint 载荷(下一节详解),12 是 label 的 tag,02 是长度前缀(后面跟 2 字节),61 62 是 "ab" 的 UTF-8 字节。每个字段都是"tag 开头,载荷随后"——这就是 TLV(Tag-Length-Value)名字的由来,而 Length 只在 wire type 2 时显式出现,varint 载荷的长度由编码自身决定。
tag 自身也是一个 varint,它的数值由一条位运算公式决定:
tag 数值 = (字段编号 << 3) | wire type
验证两个例子。字段 1、wire type 0(varint):1 左移三位是 8,或上 0 还是 8——十六进制 08,对上了。字段 2、wire type 2(长度前缀):2 左移三位是 16,或上 2 是 18——十六进制 12,也对上了。所以第 2 章说"1–15 号 tag 只占一字节"的来历就完全清晰了:单字节 varint 的 7 个有效位里,低 3 位让给了 wire type,高 4 位装编号,上限 15。字段 16 的 tag 是 16 左移三位 128,或上 0 得 128,而 128 超出单字节 varint 的表示范围(最高位是 continuation bit),必须编码成两个字节 80 01。
tag 低三位的类型值一共有六种合法形态:
| wire type | 值 | 载荷形态 | 对应 proto 类型 |
|---|---|---|---|
| VARINT | 0 | 1–10 字节变长整数 | int32/64、uint32/64、sint32/64、bool、enum |
| I64 | 1 | 恒 8 字节 | fixed64、sfixed64、double |
| LEN | 2 | 长度前缀 + 字节串 | string、bytes、嵌套 message、repeated 载体、map |
| SGROUP | 3 | 分组开始(已废弃) | proto2 groups,禁止使用 |
| EGROUP | 4 | 分组结束(已废弃) | 同上 |
| I32 | 5 | 恒 4 字节 | fixed32、sfixed32、float |
三处值得停留的观察。其一,wire type 是"编码形态"不是"精确类型":int32 与 sint32 的 wire type 都是 0,但载荷的解读规则不同(原码 varint 对 ZigZag varint)——这正是第 2 章说"int 改 sint 是不兼容变更"的完整出处:tag 不变、字节长度甚至都不变,但读出来的数完全错位,且无任何报错。其二,LEN 一家五口:字符串、bytes、嵌套 message、repeated 的每个元素、map 的每个键值对,载荷全部是"长度前缀 + 字节串"形态——3.3 节会展示这五种东西在字节层的惊人同构。其三,类型 3/4 是废弃地层:proto2 的 group 语法留下的遗迹,新代码永不接触,但 decode_raw 老消息时可能撞见,认得出即可。
wire type 最深远的意义不在编码省字节,而在解码器不需要契约就能跳过任何字段。设想一个只认识字段 1 的旧版本解码器,遇到新版本发出的消息:
08 96 01 1A 03 E6 96 87
前 3 字节是它认识的字段 1。接着读到 tag 1A:解码器不知道字段 3 是什么,但它算出 wire type 是 2——载荷是一段长度前缀字节串;读一个 varint 得 3,跳过 3 字节。就这样,不认识字段 3 的解码器安全地越过了它,继续解析后面的内容。未知字段要么存起来回传(现代实现默认保留,第 1.2 节的政策史),要么安静丢弃——无论哪种,都不会错位、不会崩溃、不需要重新协商。
对比一下没有这层设计的世界:固定布局的二进制格式里,新增字段会让所有旧解析器从错位点开始读出彻底的乱码。Protobuf 的全部演进自由——加字段、新版本先上、跨团队独立演进——都从"tag 自描述 + wire type 盲跳"这一颗纽扣上生长出来。第 5 章的兼容法则本质都是对这颗纽扣的保护性约定。
背景:抓包拿到一段 19 字节的 dump,契约已知:
message Event { uint64 id = 1; int32 level = 2; string msg = 3; }
dump 为:
08 96 01 10 05 1A 06 E4 B8 AD E6 96 87
操作:手工逐字段解析。读 tag 08:varint 值 8,右移三位得编号 1、低三位 wire type 0——字段 1 的 varint 载荷。按 varint 规则读 96 01(下一节给手算过程,结果 150)。字段 1 完成,指针在第 3 字节。读 tag 10:十六进制 10 即十进制 16,右移三位得编号 2,低三位得 wire type 0——字段 2 的 varint 载荷,读 05 即 5。指针到第 5 字节。读 tag 1A:十进制 26,右移三位编号 3,wire type 2——长度前缀。读 varint 06 得长度 6,随后 6 字节 E4 B8 AD E6 96 87 按 UTF-8 解码,恰好是"中文"。结果:消息为 id=150、level=5、msg="中文",与 decode_raw 输出互相印证。**解读**:整个解析过程只用了两把尺子——tag 的位运算与"wire type 决定载荷边界";六个 hex 字节的字段 3 无需任何契约就被正确读出。**变式**:把 dump 的第一个字节换成 10(tag 变成字段 2),后面字节流的解析会整体错位——这说明**字节流本身不防篡改**,tag 碰撞时错误是静默的,校验要靠业务层或传输层完成。
⚠️ 高频误读:把 tag 字节直接当字段值。新手在
08 96 01里常把 08 当成 count 的值——记住每个载荷前面必然先有一颗 tag 纽扣,跳过它才是数值。
下一节深挖载荷里最常见的形态:varint 怎么用最少字节装下任意大小的整数,以及负数为什么病态地膨胀到 10 字节。