以太坊源码学习-RLP编码
2025-12-22 # 区块链

数据编码格式

  • RLP (Recursive Length Prefix),ETH1.0、执行层采用的编码方式
  • SSZ (Simple Serialize),ETH2.0、共识层采用的编码方式

RLP 适合简单、浅层的数据结构

SSZ 适合大型、结构化、可验证的树形数据

RLP编码

简介

递归长度前缀,RLP (Recursive Length Prefix) ,一种以太坊中采用的编码方式,作用是对对象进行序列化和反序列化

定义

RLP 编码只支持两种基本类型:

  1. 字符串(string)
    • 本质为字节组 (bytes)
  2. 列表(list)
    • RLP 基本类型的有序集合
    • list 可以包含 string
    • list 可以包含 list (递归)

前缀空间划分:

RLP 使用 1个字节 (8 bit) 来区分类型与长度信息

[0x00, 0x7f]     单字节(值即编码)    // "c"
[0x80, 0xbf]     字符串
    [0x80, 0xb7] 短字符串(长度0-55)  // "cat"
    [0xb8, 0xbf] 长字符串(长度>=56)
[0xc0, 0xff]     列表
    [0xc0, 0xf7] 短列表(0-55)       // ["cat", "dog"]
    [0xf8, 0xff] 长列表(>=56)

RLP 编码规则如下:

// ========================================================
// 1.对于 [0x00, 0x7f]范围内的单个字节, RLP编码内容就是字节内容本身
// bytedata

"c"
b'c'
63

// ========================================================
// 2.如果是一个 0-55 字节长的字符串,则 RLP 编码由 0x80 加上字符串长度,再拼接上字符串二进制内容
// 0x80+len, bytedata

"cat"
b'\x83cat'
83 63 61 74

// ========================================================
// 3.如果字符串超过 55 字节,则由 0xb7 加上字符串长度字节数,再拼接上字符串长度编码,再拼接上字符串二进制内容
// 0xb7+len(len), len, bytedata

"0"*1024
b'\xb9' + b'\x04\x00' + b'00'*1024
b9 0400 303030303030...

// ========================================================
// 4.如果列表所有项组合长度是 0-55 字节内,则由 0xc0 加上所有项的 RLP 编码串联长度字节,再拼接所有项的 RLP 编码
// 0xc0+len(rlp_all)

["cat", "dog"]
b'\xc8\x83cat\x83dog'
c8 83636174 83646f67

["cat", "dog", ["ab","cd"], "ef"]
b'\xd2 \x83cat\x83dog \xc6\x82ab\x82cd \x82ef'


// ========================================================
// 5.如果列表内容超过 55 字节,则由 0xf7 加上所有项的 RLP 编码串联长度字节,再拼接所有项的 RLP 编码
// 0xf7+len(len(rlp_all)), len, bytedata
...

测试 python 规范 & 深入理解编码设计

以太坊对于 RLP 编码的规范在:ethereum-rlp

相比 go-ethereum 的实现,python 规范的实现更加简洁易懂

理解代码后,可自己实现写个小demo(这里只写了 encode 编码对字符串和列表类型的部分)

#coding: utf-8
"""
@Author: 0xhunya
@Date: 2025-12-22
@Description: test poc for ethereum-rlp
"""

TEST_CASES = {
    "TEST_INT_SHORT": 64,
    "TEST_INT_LONG": 256,
    "TEST_STRING_NULL": "",
    "TEST_STRING_ONE": "c",
    "TEST_STRING_SHORT": "cat",
    "TEST_STRING_LONG": "0"*1024,
    "TEST_LIST_NULL": [],
    "TEST_LIST_SHORT": ["cat", "dog"],
    "TEST_LIST_SHORT_NEST": ["cat", "dog", ["ab", "cd"], "ef"]
}

def encodeBytes(raw):
    len_raw = len(raw)
    if len_raw == 1:
        return raw
    elif len_raw < 56:
        return bytes([0x80+len_raw]) + raw
    else:
        len_len_val = (len_raw.bit_length() + 7) // 8  # 字节长度
        return (
            bytes([0xb7 + len_len_val]) +
            len_raw.to_bytes(len_len_val) +
            raw
        )

def encodeList(raw):
    join_enc_raw = b"".join(encode(i) for i in raw)
    len_join_enc_raw = len(join_enc_raw)
    print(len_join_enc_raw)
    if len_join_enc_raw < 56:
        return bytes([0xc0 + len_join_enc_raw]) + join_enc_raw
    else:
        len_len_join_enc_raw = (len_join_enc_raw.bit_length() + 7) // 8  # 字节长度
        return (
            bytes([0xf7 + len_len_join_enc_raw]) +
            len_join_enc_raw.to_bytes(len_len_join_enc_raw) +
            join_enc_raw
        )

def encode(data):
    if type(data) == int:
        return encodeBytes(data.to_bytes((data.bit_length() + 7) // 8))
    elif type(data) == str:
        return encodeBytes(data.encode())
    elif type(data) == list:
        return encodeList(data)
    else:
        return "Not Support Now"

def main():
    for testType,testData in TEST_CASES.items():
        print("\n==================== testing %s ====================" % testType)
        print("data: %s" % testData)
        res = encode(testData)
        print("encode: %s" % res)

main()

可以通过将自己实现的函数逻辑替换 src/rlp.py 中的对应函数进行测试,看是否能通过测试规范

测试 encodeList 函数,替换 encode_sequence

测试 encodeBytes 函数,替换 encode_bytes

发现有 15 项测试未通过,仔细对比函数逻辑可以发现,在对单字符的判断里缺少了校验该单字符需在 128(0x80) 以内

完善校验 and row[0] < 128 ,再跑一遍,测试通过

细节思考 & 深入理解

为什么会有 < 0x80 的校验?为什么超过 128 的整型数值也会需要额外前缀标识?

因为 RLP 编码设计中基本类型只有 字符串 (string)列表 (list),对于整型并没有单独的规则,那么设计上对于整型的编码是当作字符串来处理的(转为大端序字节串)

我们回到前缀空间的划分部分,可以看到最前面的 0x00-0x80 部分是直接表示的单字符值本身,这也对应标准的 ASCII 码表,可以完美覆盖。那么如果整型超过 128 (0x80) 就超过了单字节可表示的范围了,就需要增加前缀来表示,以确保编码的唯一性

这样的话,那不是整型和字符串的编码结果几乎是重合的,也就是每一个字符串都能找到一个整型使他们的编码结果完全一致

比如 c99,编码结果都是 0x63cat6513012 ,编码结果都是 0x83636174

这也还是回到了 RLP 编码的设计初衷,是一个无类型的序列化格式,不管字符串还是整型,都是针对数据本质的字节序列进行编码,RLP 编码的唯一性是指同一个“字节序列”只有一个合法的 RLP 编码形式,而解码时需要考虑这些数据的类型信息的是上层协议需要做的,0x63 如果需要解码为字符串就是 c,如果需要解码为整型就是 99

go-ethereum 实现

go-ethereum 中的 RLP 实现在 go-ethereum/rlp 目录下 ,相比 python 规范多了非常多的工程优化

核心实现集中在 encode.goencodeBuffer.godecode.go

先看 encode.go 文件,首先全局定义了最特殊的两个数据 空字符串空列表 的编码结果,然后定义了 Encoder 接口类型

Encoder 接口主要用于 包外其他模块实现接口 和 包内通过反射实现对各种不同数据类型进行高效率编码

接着是 EncodeEncodeToBytesEncodeToReader 三个主要编码入口函数

主要逻辑都是通过 getEncBuffer() 函数从 encBufferPool 编码缓存池中获取 encBuffer 编码缓存数据,再调用它的 encode 函数进行编码,最后按需输出到 io.Writer[]byteio.Reader ,所以编码的核心逻辑是在 encBuffer.go 中的 encBuffer 数据结构中,encode.go 文件中剩下的内容就主要是些反射类型处理和辅助工具类的函数

那么我们来看 encBuffer.go 文件,首先定义了 encBuffer 结构体和 encBufferPool 缓存池

encBuffer 结构体拆分了这样几个字段

/// file: go-ethereum/rlp/encbuffer.go

type encBuffer struct {
    str     []byte     // 字符串数据, 包括除列表头以外的所有内容
    lheads  []listhead // 列表头数组,包含所有的列表头
    lhsize  int        // 所有编码后的列表头长度总和
    sizebuf [9]byte    // 整型编码的辅助缓存,主要存放 前缀头(1 byte) + 长度的长度(8 byte)
}

其中 listheadencode.go 中定义

encBuffer 会通过 listhead 记录编码数据中每一个 list 的起始位置总长度 ,这样通过一次遍历就能完成数据的编码

采用 sync.Pool 缓存池以及 encBuffer 的结构设计,均是为了提高编码效率的工程优化,因为 geth 执行层的底层数据结构都会采用 RLP 编码,调用频率极高

紧接着是 makeBytescopyTowriteTo 函数

前面能看到在 encode.go 文件中的 EncodeToBytes 函数最后就是调用 encBuffermakeBytes 函数输出 []byte,这里可以看到 makeBytes 函数是调用的 copyTo 函数,而 writeTo 函数和 copyTo 逻辑一致,只是最后输出写入 io.Writer,而 copyTo 输出返回 []byte

copyTowriteTo 的核心逻辑都是:

  1. 遍历 buf.lheads 列表头数组,先写入第一个 list 前的字符串数据编码(如果有);
  2. 记录位置,循环写入列表头数组中记录的每个列表数据编码;
  3. 写入最后一个 list 后的字符串数据编码(如果有)

再后面就是 encBuffer 的编码核心

  • 编码单一数据类型写入的一系列 write 类函数

  • 处理 list 类型写入的 listlistEnd 函数

  • 通过反射获取类型相应 writer 写入的 encode 函数

  • 编码字符串前缀头的 encodeStringHeader 函数

这里就能看到熟悉的 RLP 编码规则

总结

ethereum 的 RLP 编码,在 python 规范中,通过函数递归实现,简洁高效地展示了 RLP 编码的规则;而在 go-ethereum 中,则通过 encBuffer 缓存结构实现,以类似流式编码的方式实现了高效率高性能的 RLP 编码工程。