2.1 二进制格式介绍
如第1章所述,模块是Wasm程序编译、传输和加载的单位,编译后的Wasm模块主要以二进制格式呈现。掌握Wasm模块的二进制格式是理解解码、验证、实例化、执行等阶段的必要条件。本节将先概括介绍Wasm二进制格式,并尝试将模块表达为Go语言结构体。
由于我们刚开始了解Wasm二进制格式,所以这一节理解起来或许会有点难。如果遇到困难,可以先不理会,继续阅读。在2.2节会有实例分析,到时回头再来复习一遍,可能问题就迎刃而解了。
2.1.1 Wasm二进制格式总体结构
和其他很多二进制格式(比如Java类文件、Lua二进制块)一样,Wasm二进制格式也是以魔数(Magic Number)和版本号开头。在魔数和版本号之后是模块的主体内容,这些内容被分门别类放在不同的段(Section,也叫Segment)中。段可能会包含多个项目,Wasm规范一共定义了12种段,并给每种段分配了ID(从0到11)。除了自定义段(详见2.2.12节)以外,其他所有的段都最多只能出现一次,且必须按照段ID递增的顺序出现。图2-1所示是Wasm二进制格式总体结构示意图(没有画自定义段)。
图2-1 Wasm二进制格式总体结构
为了能更好理解Wasm二进制格式,假设我们已经把下面这个Go程序编译成了Wasm二进制模块。注意这只是一个假想的例子,用于帮助我们理解模块的内容,并不是说我们真的把该程序编译成了Wasm模块。由于Go程序需要运行时(比如GC等),所以实际编译后产生的模块会非常大。
package main import "fmt" const PI float32 = 3.14 type type0 = func(a, b int32) int32 type type1 = func() type type2 = func(ptr, len int32) func Add(a, b int32) int32 { return a + b } func Sub(a, b int32) int32 { return a - b } func Mul(a, b int32) int32 { return a * b } func Div(a, b int32) int32 { return a / b } func main() { fmt.Println("Hello, World!") }"
下面简单介绍Wasm二进制模块可能包含的12种段。
1.类型段(ID是1)
该段列出Wasm模块用到的所有函数类型(又叫作函数签名,或者函数原型)。上面的Go代码共出现了3种不同签名的函数(加减乘除、main()、Println()),因此类型段中应该包含3个函数签名。假设函数签名在类型段中的排列顺序和它们在源代码中首次出现的顺序一致,那么前两个签名应该是(int32,int32)→(int32)和()→()。类型段的具体内容详见2.2.1节。
2.导入段和导出段(ID是2和7)
这两个段分别列出模块所有的导入项和导出项,多个模块可以通过导入和导出项链接在一起(详见第10章)。上面的Go代码仅使用了fmt.Println()函数,因此导入段中只有一个项目。有一个全局变量和4个函数被导出(首字母大写),因此导出段中应该包含5个项目:一个全局变量和4个函数。导入和导出段的具体内容详见2.2.2节和2.2.7节。
3.函数段和代码段(ID是3和10)
内部函数信息被分开存储在两个段中,其中函数段实际上是一个索引表,列出内部函数所对应的签名索引;代码段存储内部函数的局部变量信息和字节码。不难看出,函数和代码段中的项目数量必须一致,且一一对应。假设函数在函数段和代码段中的排列顺序也和它们在源代码中定义的顺序一致,那么函数段的内容应该是[0,0,0,0,1],其中前4个0是加减乘除这4个函数的类型索引,1是main()函数的类型索引(注意并不包含导入的函数)。相应的,代码段也应该包含5个项目,依次存放这5个函数的信息。函数和代码段的具体内容详见2.2.3节和2.2.10节。
4.表段和元素段(ID是4和9)
表段列出模块内定义的所有表,元素段列出表初始化数据。Wasm规范规定模块最多只能导入或定义一张表,所以即使模块有表段,里面也只能有一个项目。表主要和间接函数调用有关,将在第9章详细介绍。表和元素段的具体内容详见2.2.4节和2.2.9节。
5.内存段和数据段(ID是5和11)
内存段列出模块内定义的所有内存,数据段列出内存初始化数据。Wasm规范规定模块最多只能导入或定义一块内存,所以即使模块有内存段,里面也只能有一个项目。我们将在第6章详细讨论Wasm内存,内存和数据段的具体内容详见2.2.5节和2.2.11节。
6.全局段(ID是6)
该段列出模块内定义的所有全局变量信息,包括值类型、可变性(Mutability)和初始值。上面的Go代码只定义了一个全局变量(准确地说是一个不可变常量),因此全局段中应该只有一个项目。全局段的具体内容详见2.2.6节。
7.起始段(ID是8)
该段给出模块的起始函数索引。和其他段有所不同,起始段只能有一个项目。起始函数主要起到两个作用,一个是在模块加载后进行一些初始化工作;另一个是把模块变成可执行程序。如果模块有起始段,那么Wasm实现在加载模块后会自动执行起始函数。上面的Go代码有主函数,因此编译后的模块有起始段,里面放着该函数的索引。起始段的具体内容详见2.2.8节。
8.自定义段(ID是0)
该段是给编译器等工具使用的,里面可以存放函数名等调试信息,或者其他任何附加信息。自定义段不参与Wasm语义,所以即便是完全忽略自定义段也不影响模块的执行。自定义段的具体内容详见2.2.12节。
如前文所述,除了自定义段,其他段必须按ID递增的顺序出现。之所以有这样的规定,是因为很多段之间存在信息的依赖关系。例如,由于导入段、函数段、代码段等都需要知道函数类型信息,所以类型段必须在这3个段之前出现;由于导入的函数、表、内存、全局变量在各自索引空间的最前面,所以导入段必须在这4个段之前出现;由于导出函数、表、内存、全局变量时需要知道其索引,所以导出段必须在这4个段之后出现。
Wasm二进制格式的设计原则之一是可以一遍(One-pass)完成模块的解析、验证和编译(指AOT或JIT编译)。换句话说,Wasm实现(比如浏览器)可以在下载模块的同时进行解码、验证和编译。可流式处理(Streamable)是Wasm的特点之一,二进制模块中各个段的排列方式一定程度上是为了满足这一特点。另外,之所以要把函数的签名信息和其他信息分别存放在两个段里,也是为了满足这一特点,读者可以结合后面的实例慢慢体会。
根据前面的介绍,不难把Wasm模块“翻译”成Go语言结构体,代码随后给出。本章的主要源文件集中放在code/go/ch02/wasm.go/binary目录下,与模块内部类型(详见2.1.3节和2.1.4节)相关的常量和结构体在types.go文件中定义,其他模块相关常量和结构体在module.go文件中定义,后文不再赘述。下面是模块的结构体定义。
type Module struct { Magic uint32 // 详见2.2.0节 Version uint32 // 详见2.2.0节 CustomSecs []CustomSec // 详见2.2.12节 TypeSec []FuncType // 详见2.2.1节 ImportSec []Import // 详见2.2.2节 FuncSec []TypeIdx // 详见2.2.3节 TableSec []TableType // 详见2.2.4节 MemSec []MemType // 详见2.2.5节 GlobalSec []Global // 详见2.2.6节 ExportSec []Export // 详见2.2.7节 StartSec *FuncIdx // 详见2.2.8节 ElemSec []Elem // 详见2.2.9节 CodeSec []Code // 详见2.2.10节 DataSec []Data // 详见2.2.11节 }
这些字段在后面会详细介绍,但这里有两点需要说明:第一,如前文所述,Wasm规范规定模块最多只能导入或定义一张表,内存也有同样限制。但这些限制只是暂时的(已经有提案建议放开这两个限制,详见本书第14章),为了将来更容易扩展,也为了更准确地反映二进制格式,我们仍然把表和内存段也定义成切片类型。第二,由于起始段只需要记录一个函数索引,所以我们把它定义成指针类型。如果该指针是nil,则表示没有起始段。
2.1.2 索引空间
函数签名、函数、表、内存、全局变量在模块内有各自的索引空间,局部变量(详见第7章)和跳转标签(详见第8章)在函数内有各自的索引空间。为了提高代码的可读性,我们给这些索引分别定义了类型别名,代码如下所示。
type ( TypeIdx = uint32 FuncIdx = uint32 TableIdx = uint32 MemIdx = uint32 GlobalIdx = uint32 LocalIdx = uint32 LabelIdx = uint32 )
下面对这几种索引进行简单说明。
1.类型索引
不管是外部导入的函数还是内部定义的函数,其签名全都存储在类型段中,因此类型段的有效索引范围就是类型索引空间。比如模块的类型段中包含5个函数签名,那么类型索引空间就是0~4。
2.函数索引
函数索引空间由外部函数和内部函数共同构成。比如模块的导入段中包含3个外部函数,函数和代码段中定义了4个内部函数,那么函数索引空间就是0到6。其中0到2指向外部函数,3到6指向内部函数。当调用某函数时,需要给定该函数的索引,详见第7章。
3.全局变量索引
和函数一样,全局变量索引空间也是由外部全局变量和内部全局变量共同构成的。当读写某全局变量时,需要给定该全局变量的索引,详见第7章。
4.表和内存索引
表和内存也可以从外部导入,所以索引空间的情况和函数索引空间类似。不过,由于Wasm规范的限制,最多只能导入或定义一个表和内存,所以索引空间内的唯一有效索引只能是0。
5.局部变量索引
函数的局部变量索引空间由函数的参数和局部变量构成。比如函数接收3个参数(这一信息可以根据函数段和类型段获取),并且定义了4个局部变量(这一信息可以从代码段获取),那么该函数的局部变量索引空间就是0到6。其中0到2指向参数,3到6指向局部变量。当读写某参数或局部变量时,需要给定该参数或局部变量的索引,详见第7章。
6.跳转标签索引
和局部变量索引一样,每个函数有自己的跳转标签索引空间。由于跳转标签索引比较特殊,推迟到第8章讨论结构化控制指令和跳转指令时再详细介绍。
2.1.3 实体类型
Wasm规范定义了8种实体类型,下面依次介绍这些类型。
1.值类型
Wasm 1.1规范只定义了4种基本的值类型:32位整数(简称i32)、64位整数(简称i64)、32位浮点数(简称f32)和64位浮点数(简称f64)。高级语言所支持的一切类型(比如布尔值、数值、指针、数组、结构体等),都必须由编译器翻译成这4种基本类型或者组合。Wasm规范给4种值类型分配了ID,由于Go语言不支持枚举,所以最好把这4个ID定义成常量,代码如下所示。
type ValType = byte const ( ValTypeI32 ValType = 0x7F // i32 ValTypeI64 ValType = 0x7E // i64 ValTypeF32 ValType = 0x7D // f32 ValTypeF64 ValType = 0x7C // f64 )
注意和高级语言中的整数类型有所不同,Wasm底层的整数类型是不区分符号的,这点在第5章还会做进一步介绍。当需要强调整数的符号时,我们将使用u32和u64表示32和64位无符号(Unsigned)整数、使用s32和s64表示32和64位有符号(Signed)整数。
2.函数类型
函数类型也就是函数的签名或原型,描述函数的参数数量和类型,以及返回值数量和类型。Wasm函数可以有多个返回值(这一点和Go语言一样),下面是函数类型的定义。
type FuncType struct { ParamTypes []ValType ResultTypes []ValType }
这里补充说明一点:Wasm规范最初限制函数最多只能有一个返回值,现在这个限制已经在多返回值提案[1]放开了。
3.限制类型
限制类型用于描述表的元素数量或者内存页数的上下限。我们将在第6章详细讨论内存,在第9章详细讨论表,此处不多做介绍,下面是限制的定义。
type Limits struct { Tag byte // 详见2.2.4小节 Min uint32 Max uint32 }
4.内存类型
内存类型只须描述内存的页数限制,所以定义成限制的别名即可。
type MemType = Limits
5.表类型
表类型需要描述表的元素类型以及元素数量的限制。Wasm规范只定义了一种元素类型,即函数引用,不过已经有提案建议增加其他元素类型,详见第14章。为了反映二进制格式,也为了便于以后扩展,我们还是给元素类型留好位置,下面是表类型的定义。
const FuncRef = 0x70 type TableType struct { ElemType byte // 目前只能是0x70 Limits Limits }
6.全局变量类型
全局变量类型需要描述全局变量的类型以及可变性,下面是全局变量类型的定义。
const ( MutConst byte = 0 MutVar byte = 1 ) type GlobalType struct { ValType ValType Mut byte }
Wasm实体类型除了以上6种,还有结果类型和外部类型。结果类型表示函数或表达式的执行结果,我们在函数类型里已经表示了,就不单独定义了。外部类型是函数类型、表类型、内存类型和全局变量类型的集合,也不再单独定义。到这里,模块的总体结构、各种索引和类型就介绍完毕了,下面我们结合实例来分析Wasm二进制格式。
[1] 参考链接:https://github.com/WebAssembly/multi-value。