没有 .proto 也能解 protobuf:wire format 内部机制完整解读

· 约 5 分钟 🧬 Protobuf 解码

Protocol Buffers 设计哲学:向前向后兼容 + 极致紧凑。这两点的实现细节都在 wire format 里——理解 wire format 你就懂了 protobuf 的灵魂。

Wire format 总览

每个 protobuf 消息是一连串”字段记录”。每个记录的格式:

[varint tag][字段值]

tag 包含两部分

tag = (field_number << 3) | wire_type

读 tag varint,右移 3 位拿到 field_number,最低 3 位是 wire_type。

五种 wire type

TypeCode用于字段值格式
varint0int32/int64/uint32/uint64/sint*/bool/enum变长 1-10 字节
i641fixed64/sfixed64/double固定 8 字节
len2string/bytes/message/packed repeatedvarint 长度 + 字节
sgroup3已废弃-
egroup4已废弃-
i325fixed32/sfixed32/float固定 4 字节

只有 5 种活跃的 wire type,3 / 4 是 proto2 老语法的 group 类型,proto3 不再使用

Varint 怎么编码

Varint 把整数拆成 7 位一组,从低位开始放,每字节最高位(continuation bit):

  • 1 = 后面还有
  • 0 = 这是最后一字节

例:编码数字 300。

300 的二进制(去掉前导零): 1 0010 1100

拆成 7 位组(从低位开始,不足补 0):
低组: 010 1100  
高组: 000 0010

加 continuation bit:
低组: 1 010 1100  ← 后面还有
高组: 0 000 0010  ← 最后一字节

字节序: 0xAC 0x02

解码时反向——读到 continuation bit = 0 停止,把每组 7 位从低到高拼起来即可。

优势——小数字非常紧凑:

范围字节数
0-1271
128-163832
16384-20971513
0 - 2^64-1最多 10

业务字段大多在 0-127 范围(如布尔、枚举、小整数 ID),1 字节就够,比 JSON 强很多。

ZigZag:负数的妙招

标准 varint 编码负数会爆炸——-1 是 64 位全 1,要 10 字节。

ZigZag 把符号位和绝对值”交错”映射:

原值ZigZag 编码值
00
-11
12
-23
24
-35

公式:encoded = (n << 1) ^ (n >> 63),反向 decoded = (encoded >> 1) ^ -(encoded & 1)

所以 sint32 / sint64 表面上是 varint,实际编码值是 ZigZag 后的结果。负数小(绝对值小)的字段都能压到 1-2 字节。

经验法则——业务字段可能为负 → sint32/sint64;只可能非负 → int32/uint32。很多人混用,导致负数 ID 占 10 字节,是 protobuf 优化常见 case。

Length-delimited:万能容器

wire type 2 是最灵活的——结构是:

[varint tag][varint length][L 字节内容]

L 字节内容可能是:

  • UTF-8 字符串(string 字段)
  • 任意字节(bytes 字段)
  • 嵌套 message(递归同样的 wire format)
  • packed repeated 标量(多个 varint / fixed 紧凑排列)

wire 上没有任何线索说明 L 字节是哪一种——只有 .proto 能告诉你。

无 schema 解码器的策略:

  1. 尝试递归当 message 解 → 解通就显示嵌套结构
  2. 否则尝试 UTF-8 解码 → 全部字符可读就显示为字符串
  3. 都不行 → 显示为 hex / base64 bytes

有时会猜错——比如某个 string 字段恰好整体是合法的 protobuf 字节序列,工具会优先解成嵌套 message。这种情况贴 .proto 升级即可。

i32 / i64:定长字段

固定字节数,小端序(little-endian):

fixed32 / sfixed32 / float → 4 字节
fixed64 / sfixed64 / double → 8 字节

为什么有 fixed 类型 —— varint 对大数字(接近上界)效率反而低于定长。fixed64 永远 8 字节,int64 最多 10 字节——存高位经常占满的字段(如时间戳纳秒、UUID 高 64 位)用 fixed64 更小。

业务字段很少需要 fixed——绝大多数 int 在低值范围,varint 更划算。fixed 一般用于哈希、UUID、固定宽度标识符。

一个完整例子

.proto

message User {
  int32 id = 1;
  string name = 2;
  repeated int32 tags = 3 [packed = true];
}

填充数据:id=300, name="Alice", tags=[1, 2, 300]

字节序列(hex):

08 AC 02       # field 1, varint, 300
12 05          # field 2, length-delimited, length=5
   41 6C 69 63 65   # "Alice"
1A 04          # field 3, length-delimited, length=4
   01 02 AC 02      # packed varints: 1, 2, 300

手算分析

  • 08 = 0b00001000 = (1 << 3) | 0 = field 1, wire type 0 (varint)
  • AC 02 = varint 300
  • 12 = 0b00010010 = (2 << 3) | 2 = field 2, wire type 2 (len)
  • 05 = length 5
  • 41 6C 69 63 65 = ASCII “Alice”
  • 1A = 0b00011010 = (3 << 3) | 2 = field 3, wire type 2 (len)
  • 04 = length 4
  • 01 02 AC 02 = packed: varint 1, varint 2, varint 300

总长 17 字节——同样的数据 JSON {"id":300,"name":"Alice","tags":[1,2,300]} 是 41 字节,protobuf 节省 58%。

Unknown fields 与兼容性

protobuf 的兼容性核心:老解析器遇到不认识的 field number 不报错,保留为”unknown fields”

这意味着

  • 加新字段(新 number)—— 老服务忽略,新服务能读
  • 删字段 —— 老服务以为是 unknown,不影响
  • 改 field number —— 灾难,等于改了字段身份
  • 改字段类型 —— 部分兼容(int32 ↔ int64 ↔ bool ↔ enum 互通;string ↔ bytes 互通;其它要小心)

proto3 早期默认丢弃 unknown fields,导致代理转发场景下数据被吞——3.5 起改回保留。

本工具的 _unknown_<n> 显示就是把 wire 上没在 .proto 里声明的 tag 都列出来,方便你发现”哎对方加了新字段”。

调试 gRPC 的关键

抓包到 gRPC body 后永远先剥前 5 字节 framing

[1 byte compression flag][4 bytes big-endian length][message bytes]

如果 compression flag = 1,还要按 grpc-encoding header(如 gzipdeflateidentity)解压。

忘记剥 framing 是新手最常见错误——前 5 字节会被当成第一个字段的 tag + length,解出莫名其妙的结构。

Wire format vs Text format

protobuf 有两种序列化格式:

Format用途文件后缀
Wire formatRPC / 存储 / 网络传输二进制(无固定后缀)
Text format调试 / 配置文件.textproto / .pb.txt

Text format 是 field_name: value 的人类可读形式(看起来像 JSON 但语法不同)。两者互相转换需要 .proto——不能直接 wire ↔ text。本工具只处理 wire format。

配套工具

理解 wire format 三件事——tag 编码、varint、length-delimited——你就懂了 protobuf 80% 的二进制内部。剩下 20% 是历史包袱(group / proto2 default values / extensions),新项目大多碰不到。

❓ 常见问题

为什么 protobuf 不像 JSON 那样自带字段名?

为了体积和速度。JSON 把字段名当字符串塞进每条消息——{"user_id":"abc"}{"u":"abc"} 长 6 字节,对网络协议是巨大浪费。Protobuf 把字段名映射成数字(field number),双方靠 .proto 文件约定"1 = user_id",wire 上只发 1 不发名字。代价是必须有 schema 才能完全解读——但 wire format 自身仍带 tag number 和 wire type,没 schema 也能拆出结构(这就是 protoc --decode_raw 和本工具能做的事)。这种取舍让 protobuf 比 JSON 小 50-80%、序列化反序列化快 5-10 倍。

字段顺序不一样还能解吗?

完全能。Protobuf 的 wire format 每个字段独立——读到 tag 就知道是哪个字段,跟前后没关系。所以 .proto 里的字段顺序、wire 上的字段顺序、对方解析的字段顺序可以互不相同——这是 protobuf 兼容性的基石。代码里加新字段、调整顺序、删字段(不删 number),老服务都能继续解码(不认的字段进 unknown fields)。唯一不能动的是 field number——它是字段身份证,改了就是新字段。

varint 编码每字节最高位是干啥的?

continuation bit——告诉解码器"还有下一字节吗"。protobuf varint 把整数拆成 7 位一组,从低位开始放,每字节最高位 1 表示后面还有,0 表示这是最后一字节。例子——300 的二进制是 100101100,拆成 7 位组:0000010 0101100,从低组开始,给低组加 1 高组加 0:10101100 00000010 = 0xAC 0x02优势:小数字(0-127)只占 1 字节,128-16383 占 2 字节,几十亿才到 5 字节——而 int32 在 JSON 里要写成 10 个 ASCII 字符。

为什么 sint32 用 ZigZag 编码,不直接用 varint?

因为负数在 varint 里会爆炸。-1 的二补码是 64 位全 1(int64 等同),varint 编码要 10 字节——比直接发 8 字节定长还浪费。ZigZag 编码做了个数学映射——(n << 1) ^ (n >> 63),把符号位和绝对值"交错"——0 → 0,-1 → 1,1 → 2,-2 → 3,2 → 4,-3 → 5……负数小的也能压到 1-2 字节。结论——业务里大概率出现负数(如温度、利润、坐标偏移)的 int 字段,应当声明为 sint32 / sint64;只可能是非负数(如 ID、计数)的字段用 int32 / uint32 即可。

gRPC 的 5 字节前缀怎么生成的?

gRPC 走 HTTP/2,每个 message 在 HTTP/2 frame 上加 5 字节 framing:1 字节 compression flag + 4 字节 big-endian message length。Compression flag = 0 表示未压缩,= 1 表示按 grpc-encoding header 声明的算法压缩(gzip / deflate)。抓包看到的 body 字节序列——00 00 00 00 17 后面跟 23 字节 protobuf——剥掉前 5 字节才是真 payload。多个 message 在一个 HTTP/2 frame 里通过这个长度依次切片解包。这是 gRPC streaming 的基础。

一个字段在 wire 上重复出现两次会怎样?

取决于字段类型——(1) 普通 scalar(int / string):标准要求实现取最后一次的值,老的服务可能取第一次;(2) repeated 字段:所有出现都被加入数组;(3) embedded message:所有出现的字段被合并(merge)成一个,相同字段嵌套递归合并。实战陷阱——proto3 packed repeated 和 unpacked 在 wire 上长得不一样(packed 是 length-delimited,unpacked 是多个相同 tag),混用可能导致老客户端解码失败——但 protobuf 自 3.5 起两者已自动兼容。

为什么解出的字段类型有时候不准?

varint 类型在 wire 上完全无法区分——int32、int64、uint32、uint64、sint32、sint64、bool、enum 都用 wire type 0 编码。看到一个 varint 字节序列,无 schema 解码器只能给所有可能的解释——unsigned、signed (二补码)、ZigZag——让你按业务知识选。length-delimited 同理——string、bytes、embedded message、packed repeated 都是 wire type 2,本工具按"递归 message → UTF-8 string → 原始 bytes"优先级猜。有 schema 才能精确——这正是 .proto 文件存在的根本原因。

🧬 打开 Protobuf 解码 贴 hex/base64 即解·无 schema 也能拆出 tag/类型·可选 .proto 升级字段名·反向编码 hex/base64·本地不上传