以太坊源码研究之RLP编码

这是以太坊源码研究的第一篇文章。基本上来说,我写什么内容,说明我正好在学习什么内容,并没有固定的顺序。之所以先写RLP编码,是因为在一开始研究以太坊交易结构的时候,就遇上RLP编码了,所谓拣上不如撞上,就从它开始吧。

RLP(Recursive Length Prefix)是以太坊中广泛运用的一种编码方法,用于序列化以太坊中各类对象。RLP可以把任意嵌套的二进制数据数组,都编码成为一个“扁平”的无嵌套的byte数组。

任意嵌套的二进制数据数组,可能是一个空字符串“”,可能是一个整数比如36,也可能是一个非常复杂的数据结构,比如["cat",["puppy","cow"],"horse",[[]],"pig",[""],"sheep"]。对于这些千变万化的数据,RLP到底是怎么进行编码的呢?其主要规则如下:

  1. 如果是单个字节,并且值在[0x00, 0x7f]范围内,则RLP编码就是它自己。这个好理解,不用解释。

  2. 如果是长度不超过55的字符串,那么RLP编码的第一个字节用来指定字符串长度,其值为0x80加上字符串长度,之后再跟整个字符串。因此,第一个字节值范围从0x80到0xb7,空字符串时为0x80,长度55的字符串为0x80+0x37=0xb7。
    例:“dog"编码后为4个字节:[0x83, 'd', 'o', 'g']

  3. 如果是长度超过55的字符串,这时字符串的长度可能会很长,甚至超过一个字节。RLP规定这种情况下,从第二个字节开始存储字符串的长度,将长度所花的字节数再加上0xb7的和作为第一个字节,字符串长度之后再跟字符串的内容。
    例:"Lorem ipsum dolor sit amet, consectetur adipisicing elit"字符串一共56个字节,编码结果为58字节:[ 0xb8, 0x38, 'L', 'o', 'r', 'e', 'm', ' ', ... , 'e', 'l', 'i', 't' ]。其中,0x38即56为字符串长度,0xb8为0xb7+1,1就是0x38这个长度本身的长度也就是1个字节。

  4. 如果是长度不超过55的列表,那么RLP编码的第一个字节是0xc0加上列表长度,后跟列表内容。因此,第一个字节值范围从0xc0到0xf7。
    例: [ "cat", "dog" ]列表,编码后为[ 0xc8, 0x83, 'c', 'a', 't', 0x83, 'd', 'o', 'g' ]。其中0xc8为0xc0+8,8就是两个字符串的总长度。

  5. 如果是长度超过55的列表,那么RLP编码的第一个字节是0xf7加上列表长度的字节长度,后跟列表总长度,再跟列表项目内容的串联。因此,第一个字节的范围[0xf8, 0xff]。

再举个稍微复杂的例子:[ [ ], [ [ ] ], [ [ ], [ [ ] ] ] ] 的编码结果是 [ 0xc7, 0xc0, 0xc1, 0xc0, 0xc3, 0xc0, 0xc1, 0xc0 ]。

编码过程用python语言来描述是下面这样的:

def rlp_encode(input):
    if isinstance(input,str):
        if len(input) == 1 and ord(input) < 0x80: return input
        else: return encode_length(len(input), 0x80) + input
    elif isinstance(input,list):
        output = ''
        for item in input: output += rlp_encode(item)
        return encode_length(len(output), 0xc0) + output

def encode_length(L,offset):
    if L < 56:
         return chr(L + offset)
    elif L < 256**8:
         BL = to_binary(L)
         return chr(len(BL) + offset + 55) + BL
    else:
         raise Exception("input too long")

def to_binary(x):
    if x == 0:
        return ''
    else: 
        return to_binary(int(x / 256)) + chr(x % 256)

下面再看解码。解码其实就是编码的逆过程,每次读入第一个字节时判断其类型,计算出长度和偏移量,再解构具体数字。其具体规则如下:

  1. 如果第一个字节的范围是[0x00,0x7f],则数据是一个字符串,且字符串本身就是第一个字节;

  2. 如果第一个字节的范围是[0x80,0xb7],则数据是一个字符串,其长度等于第一个字节减去0x80,字符串内容在第一个字节之后;

  3. 如果第一个字节的范围是[0xb8,0xbf],则数据是一个字符串,并且字节长度等于第一个字节减去0xb7,字符串长度在第一个字节之后,字符串跟随长度串;

  4. 如果第一个字节的范围是[0xc0,0xf7],则数据是一个列表,其总长度等于第一个字节减去0xc0,列表中各项目的RLP编码的串联在第一个字节之后;

  5. 如果第一个字节的范围是[0xf8,0xff],则数据是一个列表,字节长度等于第一个字节减去0xf7,列表总长度在第一个字节之后,再接下来是所有项目的RLP编码串联。

例子就不再举了。解码过程的python代码如下:

def rlp_decode(input):
    if len(input) == 0: return
    output = ''
    (offset, dataLen, type) = decode_length(input)
    if type is str: output = instantiate_str(substr(input, offset, dataLen))
    elif type is list: output = instantiate_list(substr(input, offset, dataLen))
    output + rlp_decode(substr(input, offset + dataLen))
    return output

def decode_length(input):
    length = len(input)
    if length == 0: raise Exception("input is null")
    prefix = ord(input[0])
    if prefix <= 0x7f: return (0, 1, str)
    elif prefix <= 0xb7 and length > prefix - 0x80:
        strLen = prefix - 0x80
        return (1, strLen, str)
    elif prefix <= 0xbf and length > prefix - 0xb7 and length > prefix - 0xb7 + to_integer(substr(input, 1, prefix - 0xb7)):
        lenOfStrLen = prefix - 0xb7
        strLen = to_integer(substr(input, 1, lenOfStrLen))
        return (1 + lenOfStrLen, strLen, str)
    elif prefix <= 0xf7 and length > prefix - 0xc0:
        listLen = prefix - 0xc0;
        return (1, listLen, list)
    elif prefix <= 0xff and length > prefix - 0xf7 and length > prefix - 0xf7 + to_integer(substr(input, 1, prefix - 0xf7)):
        lenOfListLen = prefix - 0xf7
        listLen = to_integer(substr(input, 1, lenOfListLen))
        return (1 + lenOfListLen, listLen, list)
    else:
        raise Exception("input don't conform RLP encoding form")

def to_integer(b):
    length = len(b)
    if length == 0: raise Exception("input is null")
    elif length == 1: return ord(b[0])
    else: return ord(substr(b, -1)) + to_integer(substr(b, 0, -1)) * 256

好,RLP编码的算法搞清楚了,接下来回头看以太坊源码里,RLP编码和解码分别是怎么实现的。我用的是以太坊的Go语言版本源码。实际的实现过程很全很细,这里只挑主干。先看rlp/encode.go中的编码过程:

func EncodeToBytes(val interface{}) ([]byte, error) {
    eb := encbufPool.Get().(*encbuf) //从encbuf池当中取出一个encbuf
    defer encbufPool.Put(eb) //运算完了要把encbuf放回池中
    eb.reset()
    if err := eb.encode(val); err != nil {  //调用实际的编码过程
        return nil, err
    }
    return eb.toBytes(), nil  //取得编码后的byte数组
}

encbuf是用来进行编码运算的结构,由于它会非常频繁地调用,为了提高效率,避免反复进行new和dispose,对其进行池化(顺便说一下,由此可见Go语言的效率当然远远超过python等脚本语言,直追C/C++):

var encbufPool = sync.Pool{
    New: func() interface{} { return &encbuf{sizebuf: make([]byte, 9)} },
}

下面重点解释encbuf这个结构。以下是我画的UML类图:
encbuf类图

encbuf类有4个数据成员。str是字节数组,包含所有内容但除了列表头;lheads里存放了所有的列表头;lhsize是所有列表头的总大小;sizebuf是编码期间用到的辅助缓存。除此之外还有一大堆成员函数,篇幅原因这里不一一解释了。重点看一下encode函数:

func (w *encbuf) encode(val interface{}) error {
    rval := reflect.ValueOf(val)  //取得val的Value
    ti, err := cachedTypeInfo(rval.Type(), tags{})  //获取val的类型信息
    if err != nil {
        return err
    }
    return ti.writer(rval, w)  //最后写入w,之后可以用toBytes()函数取得字节数组
}

reflect类似于.net中的反射。注意这里调用了cachedTypeInfo函数,它声明在typecache.go文件内,其内部调用了genTypeInfo函数,然后通过makeDecoder、makeWriter,根据不同的数据类型找到不同的编码和解码函数。以下是makeWriter函数的实现:

func makeWriter(typ reflect.Type, ts tags) (writer, error) {
    kind := typ.Kind()
    switch {
    case typ == rawValueType:
        return writeRawValue, nil
    case typ.Implements(encoderInterface):
        return writeEncoder, nil
    case kind != reflect.Ptr && reflect.PtrTo(typ).Implements(encoderInterface):
        return writeEncoderNoPtr, nil
    case kind == reflect.Interface:
        return writeInterface, nil
    case typ.AssignableTo(reflect.PtrTo(bigInt)):
        return writeBigIntPtr, nil
    case typ.AssignableTo(bigInt):
        return writeBigIntNoPtr, nil
    case isUint(kind):
        return writeUint, nil
    case kind == reflect.Bool:
        return writeBool, nil
    case kind == reflect.String:
        return writeString, nil
    case kind == reflect.Slice && isByte(typ.Elem()):
        return writeBytes, nil
    case kind == reflect.Array && isByte(typ.Elem()):
        return writeByteArray, nil
    case kind == reflect.Slice || kind == reflect.Array:
        return makeSliceWriter(typ, ts)
    case kind == reflect.Struct:
        return makeStructWriter(typ)
    case kind == reflect.Ptr:
        return makePtrWriter(typ)
    default:
        return nil, fmt.Errorf("rlp: type %v is not RLP-serializable", typ)
    }
}

我们从中抽选writeString来具体看看编码细节:

func writeString(val reflect.Value, w *encbuf) error {
    s := val.String()
    if len(s) == 1 && s[0] <= 0x7f {
        w.str = append(w.str, s[0])  //直接编,不需要头
    } else {
        w.encodeStringHeader(len(s)) //长度大于1,或者首字符大于0x7f
        w.str = append(w.str, s...)
    }
    return nil
}

Decode里相应的解码过程,其实就是编码过程的逆向工程,其算法过程这里不再详细介绍了。但需要注意的一点是,对于一个struct结构,它的数据成员可以带3个解码标志:"tail", ”nil" 和 "-"。"-"标志代表忽略本字段,"nil"标志代表如果这个字段的size为0则改变其值为nil。看下面的代码:

input := []byte{0xC1, 0x80}
var normalRules struct {
    String *string
}
Decode(bytes.NewReader(input), &normalRules)
fmt.Printf("normal: String = %q\n", *normalRules.String)  //normal: String = ""

var withEmptyOK struct {
    String *string `rlp:"nil"`
}
Decode(bytes.NewReader(input), &withEmptyOK)
fmt.Printf("with nil tag: String = %v\n", withEmptyOK.String) //with nil tag: String = <nil>

“tail"标志一般用在数组字段,表示余下的内容都存入这里。看下面的示例代码:

type structWithTail struct {
    A, B uint
    C    []uint `rlp:"tail"`
}
var val structWithTail

err := Decode(bytes.NewReader([]byte{0xC4, 0x01, 0x02, 0x03, 0x04}), &val)
fmt.Printf("with 4 elements: err=%v val=%v\n", err, val)
// with 4 elements: err=<nil> val={1 2 [3 4]}

err = Decode(bytes.NewReader([]byte{0xC6, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}), &val)
fmt.Printf("with 6 elements: err=%v val=%v\n", err, val)
// with 6 elements: err=<nil> val={1 2 [3 4 5 6]}

err = Decode(bytes.NewReader([]byte{0xC1, 0x01}), &val)
fmt.Printf("with 1 element: err=%q\n", err)
// with 1 element: err="rlp: too few elements for rlp.structWithTail"

全文结束。


说不出来写不出来,那就说明没学到家。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容

  • GitHub上介绍(解码部分为本人编辑): https://github.com/ethereum/wiki/wi...
    AlbertGou阅读 3,034评论 1 3
  • 文章分为2部分, 第一部分是综合整理已有资料而生成的参考文档, 第二部分是python版以太坊代码中的源码实现分析...
    shi_qinfeng阅读 3,532评论 0 3
  • 我是一名C++软件开发程序员,今年的5月份,在一个长沙的区块链活动上认识、了解了初链,这个区块链公链项目。在活动中...
    小绍阅读 661评论 0 0
  • 一、RLP简介 RLP(Recursive Length Prefix,递归的长度前缀)是一种编码规则,可用于编码...
    小绍阅读 1,994评论 0 0
  • 感恩暖男奕鹏的精彩分享,他的每一份工作的获得都是他精准地接受了宇宙给到他的钥匙,然后他环环相扣地应用到实际生活中,...
    莞尔的人生阅读 487评论 1 2