WebAssembly原理与核心技术
上QQ阅读APP看书,第一时间看更新

2.3 二进制格式解码

现在我们已经详细了解了Wasm二进制格式,并且定义好了模块的内存表示(Module结构体),有了这些基础,编写模块解码逻辑就不难了。本节将有侧重的介绍一些有代表性的代码。

2.3.1 LEB128介绍

通过前面的学习我们已经看到了,为了节约空间,Wasm二进制格式使用LEB128(Little Endian Base 128)来编码列表长度和索引等整数值。LEB128是一种变长编码格式(variable-length code),对于32位整数来说,编码后可能是1到5个字节。对于64位整数来说,编码后可能是1到10个字节。越小的整数,编码后占用的字节数就越少。由于像列表长度和索引这样的整数通常都比较小,所以采用LEB128编码格式就可以起到节约空间的作用。类似的优化在其他二进制格式里也很常见,最典型的就是Google的Protobuf。

LEB128有两个特点。第一,采用小端编码方式,低位字节在前,高位字节在后;第二,采用128进制,每7个比特为一组,由一个字节的第7位承载,空出来的最高位是标志位,1表示还有后续字节,0表示没有。LEB128有两种变体,分别用来编码无符号整数和有符号整数。下面我们通过两个例子来介绍LEB128,先看无符号整数的例子,如图2-2所示。

图2-2 LEB128无符号整数解码

图2-2上方一共画了3个字节,因为前两个字节的最高位是1,第3个字节的最高位是0,所以我们知道这3个字节构成了一个完整的LEB128整数。将这3个字节的顺序反转,去掉最高位,将剩下的21个比特拼接起来,最后将高位补零就得到了最终的解码结果。我们再来看一个有符号整数的例子,如图2-3所示。

图2-3 LEB128有符号整数解码

判断字节个数、反转字节顺序、去掉最高位字节,这些操作都和无符号整数大体一致,只有最后一个字节的处理方式是不同的。最后一个字节的第二高位(左数第二位)是符号位。如果符号位是1,表示这是一个负数,需要将高位全部补1;如果符号位是0,表示这是一个正数,需要将高位全部补0。

根据以上的介绍,下面给出LEB128无符号整数解码函数。


func decodeVarUint(data []byte, size int) (uint64, int) {
    result := uint64(0)
    for i, b := range data {
        result |= (uint64(b) & 0x7f) << (i * 7)
        if b&0x80 == 0 {
            return result, i + 1
        }
    }
  panic(errors.New("unexpected end of LEB128"))
}

这个函数可以同时处理32位和64位整数,由参数size控制。第一个返回值表示解码后的整数(如果是32位整数,可以强转为uint32类型),第二个返回值表示实际消耗的字节数。LEB128有符号整数解码函数稍微复杂一些,代码如下所示。


func decodeVarInt(data []byte, size int) (int64, int) {
    result := int64(0)
    for i, b := range data {
        result |= (int64(b) & 0x7f) << (i * 7)
        if b&0x80 == 0 {
            if (i*7 < size) && (b&0x40 != 0) {
                result = result | (-1 << ((i + 1) * 7))
            }
            return result, i + 1
        }
    }
  panic(errors.New("unexpected end of LEB128"))
}

为了突出重点,上面两个函数都省略了一些错误处理逻辑,读者可以从leb128.go文件里找到这两个函数的完整代码。后文出现的代码也做了类似处理,不再赘述。在第11章将介绍Wasm二进制格式解码过程中可能会遇到的各种错误。

2.3.2 解码基本类型

如前文所述,Wasm在语义上只支持4种基本类型:32位整数、64位整数、32位浮点数、64位浮点数。Wasm规范要求整数使用2的补码表示,浮点数满足IEEE 754-2019规范。由于这两种格式也被Go语言以及其他很多现代编程语言采纳,所以Wasm基本类型和Go语言基本类型有一个非常直接的映射关系。注意,当我们说到整数的时候,并没有强调符号。符号并不是整数内在的属性,关键看怎么解释它,这一点在下一章讨论指令集时会做进一步说明。

如果把范围放宽到整个Wasm二进制格式,还可以再添加两种基本类型:字节和名字。表2-1对这些基本类型以及它们在Wasm和Go之间的映射关系进行了总结。

表2-1 基本类型对照表

虽然Go语言不是标准的面向对象语言,但却可以在很大程度上模拟面向对象的写法。为了提高代码可读性和可维护性,我们定义一个结构体来封装二进制模块解码逻辑,代码如下所示(在reader.go文件中)。


type wasmReader struct {
    data []byte
}

这个结构体很简单,只有一个字段,存放Wasm二进制模块的数据。Go语言的切片类型用起来很方便,读取一部分数据后,重新切片将这部分数据丢弃,所以没必要再维护其他内部状态。我们先实现字节、32位整数、32位浮点数、64位浮点数这4个方法来读取定长数值:。其中定长32位整数只在读取魔数和版本号时使用,浮点数在下一章解码指令时才会用到。下面是这4个方法的代码。


func (reader *wasmReader) readByte() byte {
    b := reader.data[0]
    reader.data = reader.data[1:]
    return b
}
func (reader *wasmReader) readU32() uint32 {
    n := binary.LittleEndian.Uint32(reader.data)
    reader.data = reader.data[4:]
    return n
}
func (reader *wasmReader) readF32() float32 {
    n := binary.LittleEndian.Uint32(reader.data)
    reader.data = reader.data[4:]
    return math.Float32frombits(n)
}
func (reader *wasmReader) readF64() float64 {
    n := binary.LittleEndian.Uint64(reader.data)
    reader.data = reader.data[8:]
    return math.Float64frombits(n)
}

请注意func关键字和方法名之间的接收器(Receiver),这是Go语言里给结构体定义方法(Method)的特殊写法。如果没有接收器,那么定义的就是普通的函数(Function)。另外请注意我们是如何对数据重新切片,以及如何处理小端编码的。接下来的3个方法用于读取变长整数,代码如下所示。


func (reader *wasmReader) readVarU32() uint32 {
    n, w := decodeVarUint(reader.data, 32)
    reader.data = reader.data[w:]
    return uint32(n)
}
func (reader *wasmReader) readVarS32() int32 {
    n, w := decodeVarInt(reader.data, 32)
    reader.data = reader.data[w:]
    return int32(n)
}
func (reader *wasmReader) readVarS64() int64 {
    n, w := decodeVarInt(reader.data, 64)
    reader.data = reader.data[w:]
    return n
}

无符号变长整数主要是用来编码索引和向量长度的,有符号变长整数在下一章介绍解码指令时才会用到。由于使用了前面准备好的两个LEB128解码函数,这3个方法实现起来都很简单。接下来的两个方法用于读取字节向量和名字,代码如下所示。


func (reader *wasmReader) readBytes() []byte {
    n := reader.readVarU32()
    bytes := reader.data[:n]
    reader.data = reader.data[n:]
    return bytes
}
func (reader *wasmReader) readName() string {
    data := reader.readBytes()
    return string(data)
}

读取字节向量,首先要读取字节长度,然后读取相应数量的字节。由于Go语言字符串本身就采用UTF-8编码后的字节序列作为内部表示,所以直接读取字节向量然后强制转换成字符串即可完成解码。最后我们定义一个辅助方法,用于查看剩余的字节数量,代码如下所示。


func (reader *wasmReader) remaining() int {
    return len(reader.data)
}

有了这些方法,实现Wasm二进制模块解码逻辑就比较轻松了,下面再对一些关键的逻辑进行介绍。

2.3.3 解码向量类型

前面介绍了如何解码字节向量,解码其他类型的向量也是一样的:先解码向量数量,然后解码相应数量的向量。为了加深理解,我们再来看一下类型段是如何解码的。


func (reader *wasmReader) readTypeSec() []FuncType {
    vec := make([]FuncType, reader.readVarU32())
    for i := range vec {
        vec[i] = reader.readFuncType()
    }
    return vec
}
func (reader *wasmReader) readFuncType() FuncType {
    return FuncType{
        Tag:         reader.readByte(),
        ParamTypes:  reader.readValTypes(),
        ResultTypes: reader.readValTypes(),
    }
}

2.3.4 处理tag

诸如导入和导出描述,以及限制(Limit),需要先解码,然后再根据tag值解码其他内容。以导入描述为例,其解码方法如下所示。


func (reader *wasmReader) readImportDesc() ImportDesc {
    desc := ImportDesc{Tag: reader.readByte()}
    switch desc.Tag {
    case ImportTagFunc:   desc.FuncType = reader.readVarU32()
    case ImportTagTable:  desc.Table    = reader.readTableType()
    case ImportTagMem:    desc.Mem      = reader.readLimits()
    case ImportTagGlobal: desc.Global   = reader.readGlobalType()
    default: panic(fmt.Errorf("invalid import desc tag: %d", desc.Tag))
    }
    return desc
}

2.3.5 解码代码项和表达式

由于我们还没有介绍指令和表达式的编码格式,所以无法解码方法的字节码,只能先解码出局部变量信息,代码如下所示。


func (reader *wasmReader) readCode() Code {
    codeReader := &wasmReader{data: reader.readBytes()}
    code := Code{
        Locals: codeReader.readLocalsVec(),
    }
    return code
}

同理,我们暂时还无法解码表达式,只能跳过全部字节。这两个问题会在下一章进行处理,下面是临时的表达式解码方法。


func (reader *wasmReader) readExpr() Expr {
    for reader.readByte() != 0x0B {}
    return nil
}

2.3.6 解码整体结构

模块的整体结构处理起来并不难:先解码并检查魔数和版本号,然后根据段ID依次解码各个段即可,代码如下所示。


func (reader *wasmReader) readModule(module *Module) {
    module.Magic = reader.readU32()
    module.Version = reader.readU32()
    reader.readSections(module)
}

段的解码稍微有点复杂:第一,要处理好随时可能出现的自定义段;第二,要保证非自定义段是按照ID递增的顺序出现的,且最多只能出现一次;第三,要确认跟在段ID后面的字节数和段内容实际占用的字节数真的一致。下面给出readSections()方法的代码。


func (reader *wasmReader) readSections(module *Module) {
    prevSecID := byte(0)
    for reader.remaining() > 0 {
        secID := reader.readByte()
        if secID == SecCustomID {
            module.CustomSecs = append(module.CustomSecs,
                reader.readCustomSec())
            continue
        }

        if secID > SecDataID || secID <= prevSecID {
            panic(fmt.Errorf("malformed section id: %d", secID))
        }
        prevSecID = secID

        n := reader.readVarU32()
        remainingBeforeRead := reader.remaining()
        reader.readNonCustomSec(secID, module)
        if reader.remaining()+int(n) != remainingBeforeRead {
            panic(fmt.Errorf("section size mismatch, id: %d", secID))
        }
    }
}

本书的Wasm实现完全忽略自定义段,自定义段的解码逻辑非常简单,代码如下所示。


func (reader *wasmReader) readCustomSec() CustomSec {
    secReader := &wasmReader{data: reader.readBytes()}
    return CustomSec{
        Name:  secReader.readName(),
        Bytes: secReader.data,
    }
}

非自定义段的解码逻辑是一个switch-case语句,代码如下所示。


func (reader *wasmReader) readNonCustomSec(secID byte, module *Module) {
    switch secID {
    case SecTypeID:   module.TypeSec   = reader.readTypeSec()
    case SecImportID: module.ImportSec = reader.readImportSec()
    case SecFuncID:   module.FuncSec   = reader.readIndices()
    case SecTableID:  module.TableSec  = reader.readTableSec()
    case SecMemID:    module.MemSec    = reader.readMemSec()
    case SecGlobalID: module.GlobalSec = reader.readGlobalSec()
    case SecExportID: module.ExportSec = reader.readExportSec()
    case SecStartID:  module.StartSec  = reader.readStartSec()
    case SecElemID:   module.ElemSec   = reader.readElemSec()
    case SecCodeID:   module.CodeSec   = reader.readCodeSec()
    case SecDataID:   module.DataSec   = reader.readDataSec()
    }
}

2.3.7 处理错误情况

Go语言不支持异常处理,只有功能类似的panic-recover机制。为了让逻辑更加清晰,上面的方法在遇到错误时都是直接调用Go语言内置的panic()函数试图终止程序,但这并不是Go语言的惯用法。Go语言支持函数多返回值,推荐的做法是利用最后一个返回值返回错误信息(nil表示没有出错),函数的调用方在调用完函数后应该先检查并处理错误。

我们可以把readModule()方法包装一下,提供一个对外使用的Decode()函数,这样就起到了一举两得的效果(既让大部分代码逻辑清晰,又让对外API满足惯例)。下面是Decode()函数的代码。


func Decode(data []byte) (module Module, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = r.(error)

        }
    }()
    reader := &wasmReader{data: data}
    reader.readModule(&module)
    return
}

还可以再包装一层,直接对Wasm二进制文件进行解码。由于Go语言不支持函数或方法的重载,所以只能起不同的函数名。下面是DecodeFile()函数的代码。


func DecodeFile(filename string) (Module, error) {
    data, err := ioutil.ReadFile(filename)
    if err != nil { return Module{}, err }
    return Decode(data)
}