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) }