opoojkk

Protocol Buffers编码原理

lxx
目次

Protocol Buffers(简称 proto)是一种高效的二进制序列化协议。它的核心编码方式基于 Base-128 Varint(128 进制变长整数)。理解它的编码规则是掌握 proto 的关键。

基于 Base-128 的 Varint 编码 #

Varint 是一种可变长度整数的编码方式。一个整数可以用 1~10 个字节存储,数值越小,占用的字节越少。

举例:

1
2
0000 0001
^ MSB

数字 1 的 MSB 是 0,表示这是最后一个字节。

再看一个稍大的数字 150

1
2
10010110 00000001
^ MSB    ^ MSB

官方计算示例 #

1
2
3
4
5
10010110 00000001        // 原始输入
 0010110  0000001        // 去掉最高位(MSB)
 0000001  0010110        // 按大端序排列
   00000010010110        // 拼接
 128 + 16 + 4 + 2 = 150  // 解释为无符号整数

可以看到,MSB 只用于标识,不参与数值计算,最终拼接得到的二进制 00000010010110 就是 150。

消息结构 #

Protocol Buffers 的二进制消息是一系列键值对。编码时并不存储字段名称,而是使用字段编号(field number)作为键,类型则由一个叫 wire type 的值表示。

wire type #

可以理解为“底层存储格式”,不同类型的字段对应不同的 wire type。

IDName说明 / 对应类型
0VARINTint32, int64, uint32, uint64, sint32, sint64, bool, enum
1I64fixed64, sfixed64, double
2LENstring, bytes, 嵌套 message, packed repeated
3SGROUPgroup start(已弃用)
4EGROUPgroup end(已弃用)
5I32fixed32, sfixed32, float

字段头(tag) 也是一个 Varint,由以下两部分组成:

例如: 一个字节的结构可以粗略理解为:

整数编码 #

bool 和 enum #

有符号整数 #

Varint 本身只能表示无符号整数,如果直接把负数当成普通整数来编码,就会被视为一个很大的无符号数,占用的字节数也会增多。 为了让负数也能紧凑地表示,Proto 在编码前会使用 ZigZag 进行转换,把有符号整数映射到无符号整数空间。

为什么需要这样的转换,可以先看计算机中**补码(Two’s Complement)**的表示方式:

1
2
3
5 (8)  = 00000101
补码(-5) = 2^8 + (-5) = 256 - 5 = 251
二进制    = 11111011

在 8 位系统中,-5 的补码是 11111011。 如果把 5-5 相加:

1
2
3
4
   00000101  (5)
+  11111011  (-5)
-------------
1_00000000   // 忽略溢出 → 结果为 0

负数的补码设计,就是为了让它能与对应的正数直接相加得到 0。 但对 Varint 来说,这个“补码”只是一个极大的无符号值(这里是 251),导致编码冗长。

ZigZag 的作用就是重新排列整数到无符号空间,让靠近 0 的正负数都能获得更短的编码。 映射规律如下:

原始值ZigZag 编码
00
-11
12
-23

计算公式(以 32 位为例):

1
(n << 1) ^ (n >> 31)

其中:

经过 ZigZag 转换后,再用普通 Varint 编码,就能在保持正负数信息的同时,保证数值越接近 0,占用的字节越少

固定长度类型 #

对于 double、fixed64 等需要固定大小的类型,直接占用对应的字节数:

长度限定(Length-delimited) #

stringbytes、嵌套 message、packed repeated 等类型,使用 wire type LEN 编码。 其结构是:

1
[字段头] [长度] [数据字节...]

其中 长度 也是 Varint 编码。

示例:

1
2
3
message Test2 {
  string b = 2;
}

b 的值是 "testing" 时,编码结果为:

1
12 07 74 65 73 74 69 6e 67

逐字节解释:

嵌套 Message #

子消息字段同样使用 LEN 编码。 例如:

1
2
3
message Test3 {
  Test1 c = 3;
}

Test1.a = 150,编码结果为:

1
1a 03 08 96 01

解释:

List(repeated 字段) #

proto 中,repeated 表示一个字段可以重复多次,相当于列表(list)。 数值类型的 repeated 字段在 proto3 中默认使用 packed 编码,这是一种更节省空间的方式。

packed 编码原理 #

packed 会把所有元素打包进一个字段,而不是为每个元素重复写入字段头。 编码格式:

1
[字段头] [总长度] [值1][值2][值3]...

例如:

1
2
3
message TestList {
  repeated int32 nums = 1;
}

nums = [3, 270] 时,编码结果是:

1
0A 03 03 8E 02

逐字节解释:

这样只写一次字段头,比非 packed 模式(写多次字段头)更紧凑。

map #

map 在底层等价于嵌套 message 的 repeated 字段:

1
2
3
message Test6 {
  map<string, int32> g = 7;
}

实际等价于:

1
2
3
4
5
6
7
message Test6 {
  message g_Entry {
    string key = 1;
    int32 value = 2;
  }
  repeated g_Entry g = 7;
}

Proto 消息大小限制 #

参考 #

标签:
Categories: