追梦人物❤️包子 博主
一直走在追梦的路上。

0x01:RLP 编码

2026-01-2017 阅读0 评论

作为专栏正文第一篇,我们从一个简单但在以太坊系统内广泛使用的工具——RLP(Recursive-Length prefix,递归长度前缀)编码开始。

对人类而言,一般使用的都是描述性很强的数据结构表示形式,比如下面这样的数据:

{
  "to": "abcdefg",
  "value": 100
}

但对以太坊系统而言,其磁盘存储和网络传输的,都是 0101 这样的二进制序列。所以,需要将描述性很强的数据结构表示形式转为二进制序列,这就是编码。

在以太坊设计初期,已有不少成熟的编码方案,但这些方案包含了大量以太坊并不需要的功能模块,显得较为 “臃肿”。以太坊需要的是:

  • 确定性。区块链依赖于数据的哈希值来唯一标识和验证数据,对同一数据,其描述性的结构可能不一样,但编码后的结果必须完全一致。
  • 紧凑性。区块链需要存储整个历史数据,数据也需要在网络中传播,越紧凑越节约系统资源。
  • 简洁性。设计越简洁,编码规则越易于理解和实现,也越不易出错。在区块链这样对安全性要求极高的系统中,简单即是美——代码越简单,漏洞越少,审计越容易。

阅读提示

如果对编码细节不感兴趣,可将 RLP 视为一个 “黑盒工具”:只需了解它能把数据编码成一串二进制数字,且通过解码能将这串二进制数字还原为人类可读懂的描述性数据即可,后续相关细节内容可直接跳过。待有实际需求(比如某些场景下需要自行解码交易数据)时,再回头研究具体原理即可。

编码规则概要

RLP 编码的核心思想是用"长度前缀"来标记数据的类型和长度。它将数据分为三类:单字节、字符串、列表,每类采用不同的编码规则。

单字节编码

对于单个字节,且值在 [0x00, 0x7f] 范围内,编码即原字节本身(不加任何前缀)。

示例:

  • 0x000x00
  • 0x7f0x7f

字符串编码

字符串编码分为三种情况:

空字符串

空字符串编码为 0x80

短字符串(0-55 字节)

字符串长度在 0-55 字节时:

  • 前缀 = 0x80 + 字符串长度
  • 后跟字符串内容

示例:

  • "dog" (长度 3) → 0x83 + 0x64 + 0x6F + 0x67

其中:0x83 是 RLP 前缀,0x64/0x6F/0x67 分别是字符 d/o/g 本身的 16 进制值。

长字符串(56 字节以上)

字符串长度 ≥ 56 字节时:

  • 前缀 = 0xb7 + 长度本身的字节数
  • 后跟长度的编码(大端序)
  • 最后跟字符串内容

示例:

  • 字符串长度为 1024(需要 2 字节表示)
  • 前缀 = 0xb7 + 2 = 0xb9
  • 后跟长度 1024 的编码:0x04 + 0x00
  • 最后跟 1024 字节的字符串内容

列表编码

列表编码是指将列表中所有元素递归编码后拼接,再对总长度加前缀。

空列表

空列表编码为 0xc0

短列表(总载荷 0-55 字节)

列表所有元素编码后的总长度在 0-55 字节时:

  • 前缀 = 0xc0 + 总长度
  • 后跟各元素的编码

示例:

  • ["cat", "dog"] → 编码后总长度 8(cat 和 dog 均为短字符串,RLP 编码后都占 4 字节)
  • "cat"0x83 + 0x63 + 0x61+ 0x74
  • "dog"0x83 + 0x64 + 0x6f + 0x67
  • 列表编码 → 0xc8 + 0x83636174 + 0x83646f67

长列表(总载荷 56 字节以上)

列表所有元素编码后的总长度 ≥ 56 字节时:

  • 前缀 = 0xf7 + 长度本身的字节数
  • 后跟长度的编码(大端序)
  • 最后跟各元素的编码

关键边界情况

  • 长度恰好 55:使用短编码规则(前缀 0x80 + 55 = 0xb7,此时前缀等于长字符串前缀的起始值,但由于长度本身不占用额外字节,仍属短编码)
  • 长度恰好 56:使用长编码规则(前缀 0xb7 + 1 = 0xb8,后跟 1 字节的长度 0x38
  • 空字符串 vs 单字节 0x00:空字符串编码为 0x80,单字节 0x00 编码为 0x00

整数类型的处理

以太坊中的整数需要先转换为字节串,再进行 RLP 编码:

  • 使用大端序(Big-Endian)编码
  • 去掉前导零(除了数字 0 本身编码为空字符串)。注意这里所说的前导零是指有效位以外的 0,例如 0x000418,去除前导 0 后是 0x0418,而不是 0x418,04 是一个整体有效位,占 1 个字节。

示例:

  • 整数 0 → 空字符串 → 0x80
  • 整数 1024 → 字节串 [0x04, 0x00]0x82 + 0x04 + 0x00
  • 整数 0x80 (128) → 字节串 [0x80]0x81 + 0x80

编码规则总结表

数据类型 长度范围 前缀计算方式 前缀范围
单字节 [0x00, 0x7f] 无前缀 N/A
空字符串 0 0x80 0x80
短字符串 1-55 0x80 + 长度 [0x81, 0xb7]
长字符串 ≥56 0xb7 + 长度字节数 [0xb8, 0xbf]
空列表 0 0xc0 0xc0
短列表 总载荷 0-55 0xc0 + 总长度 [0xc1, 0xf7]
长列表 总载荷 ≥56 0xf7 + 长度字节数 [0xf8, 0xff]

编码示例

我们通过一个完整的以太坊交易(Transaction)示例,展示 RLP 编码的过程。现阶段我们无需知道以太坊交易各个字段的具体含义,后续的专栏文章会进行详细讲解。

Transaction 结构

最简单的交易结构,包含以下字段:

[nonce, gasPrice, gasLimit, to, value, v, r, s]

示例交易

假设 Alice 向 Bob 转账 1 ETH,交易数据如下:

  • nonce: 0 (交易序号)
  • gasPrice: 0x4a817c800 (20 Gwei,即 20 × 10^9 wei,16 进制表示的整数)
  • gasLimit: 0x5208 (21000,16 进制表示的整数,标准 ETH 转账的 gas 限制)
  • to: 0x3535353535353535353535353535353535353535 (Bob 的地址,20 字节)
  • value: 0xde0b6b3a7640000 (10^18 wei,即 1ETH,16 进制表示的整数)
  • v: 0x1c (签名恢复标识,16 进制表示的整数)
  • r: 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef (签名 r 值,32 字节)
  • s: 0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba (签名 s 值,32 字节)

编码步骤

说明:编码后的结果都以 16 进制表示,但为了简洁,省略掉了前缀 0x

步骤 1:对每个字段进行 RLP 编码

1. nonce = 0

  • 整数 0 转为字节串,去除前导 0 → 空字符串
  • 编码 → 80

2. gasPrice = 0x4a817c800

  • 整数,转为大端序字节串为:04 a8 17 c8 00 (5 字节)
  • 编码 → 85 + 04a817c800 = 8504a817c800

3. gasLimit = 0x5208

  • 整数,转为大端序字节串为:52 08 (2 字节)
  • 编码 → 82 + 5208 = 825208

4. to = 0x353535...3535 (20 字节)

  • 字节串:3535353535353535353535353535353535353535
  • 编码 → 94 + 地址字节串 = 943535353535353535353535353535353535353535

5. value = 0xde0b6b3a7640000

  • 整数,转为字节串:0d e0 b6 b3 a7 64 00 00] (8 字节)
  • 编码 → 88 + 0de0b6b3a7640000 = 880de0b6b3a7640000

6. v = 0x1c

  • 单字节 0x1c[0x00, 0x7f] 范围内
  • 编码 → 0x1c

7. r = 0x1234567890abcdef... (32 字节)

  • 字节串:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
  • 编码 → a0 + 32 字节 r 值 = a01234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef

8. s = 0x9876543210fedcba... (32 字节)

  • 字节串:9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba
  • 编码 → a0 + 32 字节 s 值 = a09876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba

步骤 2:计算总长度

所有字段编码后的总长度 = 1 + 6 + 3 + 21 + 9 + 1 + 33 + 33 = 107 字节

步骤 3:对列表整体编码

总长度 107 字节 ≥ 56,使用长列表编码:

  • 长度 107 需要用 1 字节表示(6b
  • 前缀 = f7 + 1 = f8
  • 后跟长度编码:6b
  • 最后拼接所有字段的编码

最终编码结果

注意:为了方便阅读,每个元素间用了空格隔开,实际结果中是没有空格的!

f86b 80 8504a817c800 825208 943535353535353535353535353535353535353535 880de0b6b3a7640000 1c a01234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef a09876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba

编码前后对比

字段 原始值 RLP 编码
nonce 0 80
gasPrice 0x4a817c800 (20 Gwei) 8504a817c800
gasLimit 0x5208 (21000) 825208
to 0x3535...3535 (20 字节) 943535...3535
value 0xde0b6b3a7640000 (1 ETH) 880de0b6b3a7640000
v 0x1c 1c
r 0x1234...cdef (32 字节) a01234...cdef
s 0x9876...dcba (32 字节) a09876...dcba

通过这个示例可以看到,RLP 将复杂的交易结构编码为一串紧凑的字节序列,每个部分都可以通过解析前缀长度来正确解码。

go-ethereum 中的实现

go-ethereum 的 RLP 编码位于 rlp package,本节深入分析其核心编码实现逻辑。

编码逻辑的实现思路很直接:对于简单的数据类型,例如整数或字符串,应用前文所述的编码规则进行编码;对于复合数据类型,逐一拆解为简单类型后按规则进行编码。

编码入口

编码的入口函数是 EncodeToBytes,它将任意值编码为 RLP 字节序列:

// rlp/encode.go

func EncodeToBytes(val interface{}) ([]byte, error) {
    buf := getEncBuffer()
    defer encBufferPool.Put(buf)

    if err := buf.encode(val); err != nil {
        return nil, err
    }
    return buf.makeBytes(), nil
}

入口函数十分简单,因为所有的编码的逻辑都封装在 buf.encode 中,buf 的类型是 encBuffer,负责存储编码全过程中编码值的中间结果,并在最后将这些中间结果转换为最终编码结果。

核心数据结构:encBuffer

encBuffer,定义如下:

// rlp/encbuffer.go

type encBuffer struct {
    str     []byte     // string data, contains everything except list headers
    lheads  []listhead // all list headers
    lhsize  int        // sum of sizes of all encoded list headers
    sizebuf [9]byte    // auxiliary buffer for uint encoding
}

各字段的作用:

  • str:存储所有简单类型(不含列表结构的前缀,但包含列表中的元素)编码后的结果
  • lheads:存储所有列表结构的信息,用于处理嵌套列表(下方的说明和后续给出的例子会帮助大家更好地理解其用途)
  • lhsize:所有列表结构编码前缀的总大小
  • sizebuf:辅助缓冲区,用于临时存储长度的编码

listhead 结构体的定义:

// rlp/encode.go

type listhead struct {
    offset int // index of this header in string data
    size   int // total size of encoded data (including list headers)
}

lheads 的存在是为了处理列表的嵌套。根据前文所述的列表编码规则:对列表所有元素编码后,根据结果长度生成前缀,前缀 + 所有元素编码结果作为列表的编码结果。如果包含嵌套列表,这就是一个递归过程。当遇到一个列表时,无法立即知道其前缀值,只有将内部元素全部编码完毕后才可计算。lheads 用来暂存当前列表在 str 中的位置以及内部元素编码后的总长度(内部元素全部编码完后进行更新),生成最终结果时,就能够根据这些信息,将列表的前缀值插入正确位置。

😣听着稍显复杂,不过不用着急,后面的例子会帮助大家更好地理解其用途。

字符串编码

encBuffer 定义了 encodeStringHeader 方法,实现了字符串编码的前缀计算逻辑,对应前文所述的字符串编码规则:

// rlp/encbuffer.go

func (buf *encBuffer) encodeStringHeader(size int) {
    if size < 56 {
        // 短字符串:前缀 = 0x80 + size
        buf.str = append(buf.str, 0x80+byte(size))
    } else {
        // 长字符串:前缀 = 0xB7 + 长度字节数
        sizesize := putint(buf.sizebuf[1:], uint64(size))
        buf.sizebuf[0] = 0xB7 + byte(sizesize)
        buf.str = append(buf.str, buf.sizebuf[:sizesize+1]...)
    }
}

这个函数精确实现了前文描述的规则:

  • 长度 < 56:使用 0x80 + size 作为单字节前缀
  • 长度 ≥ 56:使用 0xB7 + 长度字节数,后跟长度的编码

整数编码

writeUint64 方法负责将整数编码为 RLP 格式。整数编码首先将整数转为字节串,然后调用字符串编码逻辑:

// rlp/encbuffer.go

func (buf *encBuffer) writeUint64(i uint64) {
    if i == 0 {
        // 去除前导 0 后,等价于空字符串
        buf.str = append(buf.str, 0x80)
    } else if i < 128 {
        // fits single byte, no string header
        buf.str = append(buf.str, byte(i))
    } else {
        s := putint(buf.sizebuf[1:], i)
        buf.sizebuf[0] = 0x80 + byte(s)
        buf.str = append(buf.str, buf.sizebuf[:s+1]...)
    }
}

关键点:

  • 整数 0 编码为空字符串 → 0x80
  • 整数 < 128(0x80)编码为单字节本身,符合单字节规则
  • 整数 ≥ 128:先编码长度,再加上前缀 0x80 + 长度字节数

对于大整数,writeBigInt 方法使用类似的逻辑:

// rlp/encbuffer.go

func (buf *encBuffer) writeBigInt(i *big.Int) {
    bitlen := i.BitLen()
    if bitlen <= 64 {
        buf.writeUint64(i.Uint64())
        return
    }
    // 计算最小字节长度
    length := ((bitlen + 7) & -8) >> 3
    buf.encodeStringHeader(length)
    buf.str = append(buf.str, make([]byte, length)...)
    bytesBuf := buf.str[len(buf.str)-length:]
    math.ReadBits(i, bytesBuf)
}

使用 (bitlen + 7) & -8) >> 3 计算最小字节长度,math.ReadBits 将大整数按大端序写入字节切片。

列表编码

列表编码通过 listlistEnd 方法协作完成:

// rlp/encbuffer.go

// list adds a new list header to the header stack. It returns the index of the header.
func (buf *encBuffer) list() int {
    buf.lheads = append(buf.lheads, listhead{offset: len(buf.str), size: buf.lhsize})
    return len(buf.lheads) - 1
}

func (buf *encBuffer) listEnd(index int) {
    lh := &buf.lheads[index]
    lh.size = buf.size() - lh.offset - lh.size
    if lh.size < 56 {
        // 短列表规则,RLP 前缀仅占用 1 个字节
        buf.lhsize++ // length encoded into kind tag
    } else {
        // RLP 前缀占用 1 + intsize(lh.size) 个字节
        buf.lhsize += 1 + intsize(uint64(lh.size))
    }
}

list() 方法创建一个新的列表头,并将其添加到 lheads 栈中,返回该列表头的索引。listEnd(index) 方法在列表的所有元素编码完毕后调用,计算该列表的总大小(size),并更新 lhsize

编码示例

结合上面的数据结构和方法,我们以下方的 Person 结构体为例,详细追踪整个编码过程中代码的调用逻辑,理解这些数据结构和方法的实际作用。初始数据:

Person{
    Name:    "hello",
    Age:     33,
    Hobbies: []string{"basketball", "fishing"},
}

步骤 1:初始化编码缓冲区

buf := getEncBuffer()
// buf.str = []
// buf.lheads = []
// buf.lhsize = 0

步骤 2:编码结构体(视为列表)

通过反射得知 Personstruct,在 RLP 中被编码为列表。首先调用 list() 方法,表示开始进行列表结构的编码:

lh := buf.list()  // 返回 0
// buf.lheads = [{offset: 0, size: 0}]
// buf.lhsize = 0

由于列表在起始位置,所以其偏移(offset)为 0,size 目前未知。

步骤 3:编码 Name 字段

Name 字段值为 "hello",长度为 5,是短字符串。调用 encodeStringHeaderwriteString

buf.writeString("hello")
// buf.str = [0x85, 'h', 'e', 'l', 'l', 'o']

编码结果:0x85 (前缀 0x80 + 5) + "hello" = 8568656c6c6f

步骤 4:编码 Age 字段

Age 字段值为 33,是单字节整数且 < 128。调用 writeUint64(33)

buf.writeUint64(33)
// buf.str = [0x85, 'h', 'e', 'l', 'l', 'o', 0x21]

编码结果:0x21 (33 的十六进制)

步骤 5:编码 Hobbies 字段(嵌套列表)

Hobbies[]string 类型,需要编码为列表。

步骤 5.1:开始内层列表

lh_inner := buf.list()  // 返回 1
// buf.lheads = [{offset: 0, size: 0}, {offset: 7, size: 0}]

buf.str 已经存储了 Name 和 Age 编码的值,当前长度为 7,因此 offset = 7,表示内层列表是在 7 这个位置开始的,size 目前未知。

步骤 5.2:编码 "basketball"

buf.writeString("basketball")
// buf.str = [..., 0x8a, 'b', 'a', 's', 'k', 'e', 't', 'b', 'a', 'l', 'l']

"basketball" 长度为 10,前缀 = 0x80 + 10 = 0x8a

步骤 5.3:编码 "fishing"

buf.writeString("fishing")
// buf.str = [..., 0x87, 'f', 'i', 's', 'h', 'i', 'n', 'g']

"fishing" 长度为 7,前缀 = 0x80 + 7 = 0x87

此时 buf.str 的完整内容:

[0x85, 'h', 'e', 'l', 'l', 'o', 0x21, 0x8a, 'b', 'a', 's', 'k', 'e', 't', 'b', 'a', 'l', 'l', 0x87, 'f', 'i', 's', 'h', 'i', 'n', 'g']

总长度为 25 字节。

步骤 5.4:结束内层列表

buf.listEnd(1)
// lh_inner.size = buf.size() - lh_inner.offset - lh_inner.size
//               = 25 - 7 - 0 = 18
// buf.lheads = [{offset: 0, size: 0}, {offset: 7, size: 18}]
// buf.lhsize = 1 (因为 18 < 56,列表头只占 1 字节)

内层列表的总长度(包括所有元素)为 18 字节,使用短列表编码:前缀 = 0xC0 + 18 = 0xD2

步骤 6:结束外层列表

buf.listEnd(0)
// lh.size = buf.size() - lh.offset - lh.size
//        = 25 - 0 - 1 = 24
// buf.lheads = [{offset: 0, size: 24}, {offset: 7, size: 18}]
// buf.lhsize = 2 (两个列表头各占 1 字节)

外层列表的总长度为 24 字节,使用短列表编码:前缀 = 0xC0 + 24 = 0xD8

步骤 7:生成最终结果

调用 buf.makeBytes() 生成最终编码结果:

// rlp/encbuffer.go

func (buf *encBuffer) makeBytes() []byte {
    out := make([]byte, buf.size())
    buf.copyTo(out)
    return out
}

func (buf *encBuffer) copyTo(dst []byte) {
    strpos := 0
    pos := 0
    for _, head := range buf.lheads {
        // write string data before header
        n := copy(dst[pos:], buf.str[strpos:head.offset])
        pos += n
        strpos += n
        // write the header
        enc := head.encode(dst[pos:])
        pos += len(enc)
    }
    // copy string data after the last list header
    copy(dst[pos:], buf.str[strpos:])
}

copyTo 方法的逻辑是遍历所有列表头,按照正确的顺序将字符串数据和列表头写入输出缓冲区。对于每个列表头:

  1. 将该列表头之前的字符串数据复制到输出
  2. 编码并写入该列表头
  3. 更新位置指针

listhead.encode 方法:

// rlp/encode.go

func (head *listhead) encode(buf []byte) []byte {
    return buf[:puthead(buf, 0xC0, 0xF7, uint64(head.size))]
}

func puthead(buf []byte, smalltag, largetag byte, size uint64) int {
    if size < 56 {
        buf[0] = smalltag + byte(size)
        return 1
    }
    sizesize := putint(buf[1:], size)
    buf[0] = largetag + byte(sizesize)
    return sizesize + 1
}

对于外层列表,head.size = 24puthead 生成前缀 0xC0 + 24 = 0xD8

对于内层列表,head.size = 18puthead 生成前缀 0xC0 + 18 = 0xD2

最终编码结果

D8                    → 外层列表前缀 (0xC0 + 24)
────────────────────────────────────────
85                    → "hello" 的前缀 (0x80 + 5)
68 65 6C 6C 6F        → "hello"
21                    → 33 (Age)
D2                    → 内层列表前缀 (0xC0 + 18)
────────────────────────────────────────
8A                    → "basketball" 的前缀 (0x80 + 10)
62 61 73 6B 65 74 62 61 6C 6C → "basketball"
87                    → "fishing" 的前缀 (0x80 + 7)
66 69 73 68 69 6E 67  → "fishing"

完整编码(十六进制):

D8 8568656C6C6F21 D2 8A6261736B657462616C6C87 66697368696E67

通过这个示例可以看到,go-ethereum 的 RLP 实现将复杂的嵌套结构编码为一串紧凑的字节序列。encBuffer 通过 str 存储所有字符串内容,lheads 存储列表头部信息,在 listEnd 时计算每个列表的总大小,最后通过 copyTo 将所有部分按正确顺序组装成最终结果。

解码是编码的逆过程,实现思路类似,关键是从字节序列的前缀中解析得到元素的类型和值的长度,具体可参考 decode.go 等文件中有关的代码,篇幅所限,这里不再赘述。

总结

RLP 编码规则相对简单,但在以太坊系统中应用极为广泛。系统内大量数据(如交易信息、区块数据等)的存储与网络传输,均需经过 RLP 编码处理,后续学习或开发中,我们会在多个核心模块中频繁接触到它。

在实际开发场景中,各主流编程语言都有成熟的 RLP 开源库,开发者基本无需手动编写编码逻辑,直接调用库中封装好的 API 即可完成操作,无需深入关注底层实现细节;仅在极少数特殊场景(如定制化编码需求、底层库适配问题)下,才需参考官方 RLP 规范手动实现编解码逻辑。

-- EOF --

0 评论
登录后回复