WebAssembly 的基本组成结构

Wasm 模块的二进制数据是以 Section 的形式被安排和存放的。对于 Section,可以直接把它想象成一个个具有特定功能的一簇二进制数据。通常,为了能够更好地组织模块内的二进制数据,我们需要把具有相同功能,或者相关联的那部分二进制数据摆放到一起组成了一个Section,每一个不同的 Section 都描述了关于这个 Wasm 模块的一部分信息。而模块内的所有 Section 放在一起,便描述了整个模块在二进制层面的组成结构。在一个标准的Wasm 模块内,以现阶段的 MVP 标准为参考,可用的 Section 有如下几种。

要注意的是,除了其中名为“CustomSecton”,也就是“自定义段”这个 Section 之外,其他的 Section 均需要按照每个Section 所专有的 Section ID,按照这个 ID 从小到大的顺序,在模块的低地址位到高地址位方向依次进行“摆放”。

接下来看看Section 在二进制层面的具体组成方式。分别是:所有 Section 都具有的通用“头部”结构,以及各个 Section 所专有的、不同的有效载荷部分。从整体上来看,每一个 Section 都由有着相同结构的“头部”作为起始,在这部分结构中描述了这个 Section 的一些属性字段,比如不同类型 Section 所专有的 ID、Section 的有效载荷长度。除此之外还有一些可选字段,比如当前 Section 的名称与长度信息等等。关于这部分通用头部结构的具体字段组成,参考下面这张表。

表中第二列给出的类型是一些特定的编码方式。“字段”这一列中的“name_len”与“name”两个字段主要用于 Custom Section,用来存放这个 Section 名字的长度,以及名字所对应的字符串数据。

Type Section

Type Section ——> Type 0 -> int (int, int)
复制代码

首先,第一个出现在模块中的 Section 是“Type Section”。顾名思义,这个 Section 用来存放与“类型”相关的东西。而这里的类型,主要是指“函数类型”。与大部分编程语言类似,函数类型一般由函数的参数和返回值两部分组成。而只要知道了这两部分,就能够确定在函数调用前后,栈上数据的变化情况。

对于 Type Section 来说,它的专有 ID 是 1。紧接着排在“头部”后面的便是这个Section 相关的有效载荷信息(payload_data)。Type Section 的有效载荷部分组成如下表所示。

其中要注意的是 entries 字段对应的 func_type 类型,该类型是一个复合类型,其具体的二进制组成结构又通过另外的一些字段来描述,具体参考下面这张表。

Start Section

Start Section ——> Start Function Index —> 1
复制代码

Start Section 的 ID 为 8。通过这个 Section,我们可以为模块指定在其初始化过程完成后,需要首先被宿主环境执行的函数。所谓的“初始化完成后”是指:模块实例内部的线性内存和 Table,已经通过相应的 Data Section 和 Element Section 填充好相应的数据,但导出函数还无法被宿主环境调用的这个时刻。对于 Start Section 来说,有一些限制是需要注意的,比如:一个 Wasm 模块只能拥有一个 Start Section,也就是说只能调用一个函数。并且调用的函数也不能拥有任何参数,同时也不能有任何的返回值。

Global Section

Global Section ——> { type: i32, mutable: true, value: 10 }
复制代码

Global Section 的 ID 为 6。这个 Section 中主要存放了整个模块中使用到的全局数据(变量)信息。这些全局变量信息可以用来控制整个模块的状态,可以直接把它们类比为我们在 C/C++ 代码中使用的全局变量。在这个 Section 中,对于每一个全局数据,都需要标记出它的值类型、可变性以及对应的初始化值。

Import Section 和 Export Section

接下来的这些 Section 被划分到了“互补 Section”这一类别,也就是说,每一组的两个 Section 共同协作,一同描述了整个 Wasm 模块的某方面特征。

首先是 Import Section,它的 ID 为 2。Import Section 主要用于作为 Wasm 模块的“输入接口”。在这个 Section 中,定义了所有从外界宿主环境导入到模块对象中的资源,这些资源将会在模块的内部被使用。允许被导入到 Wasm 模块中的资源包括:函数、全局数据、线性内存对象以及 Table 对象。设计 Import Section 是为了能够在 Wasm 模块之间,以及 Wasm 模块与宿主环境之间共享代码和数据。

与Import Section 类似,我们也可以反向地将资源从当前模块导出到外部宿主环境中。为此,便有了名为“Export Section”的 Section 结构。Export Section 的 ID为 7,通过它,我们可以将一些资源导出到虚拟机所在的宿主环境中。允许被导出的资源类型同 Import Section 的可导入资源一致。而导出的资源应该如何被表达及处理,则需要由宿主环境运行时的具体实现来决定。

Function Section 和 Code Section

Funcion Section ——> Function 0 -> Type 0
Code Section ——> Function 0 -> Definition
复制代码

Function Section 的 ID 为 3,Function Section 中存放了这个模块中所有函数对应的函数类型信息。在 Wasm 标准中,所有模块内使用到的函数都会通过整型的 indicies 来进行索引并调用。可以想象这样一个数组,在这个数组中的每一个单元格内都存放有一个函数指针,

当你需要调用某个函数时,通过“指定数组下标”的方式来进行索引就可以了。而 Function Section 便描述了在这个数组中,从索引 0 开始,一直到数组末尾所有单元格内函数,所分别对应的函数类型信息。这些类型信息是由先前介绍的 Type Section 来描述的。

Code Section 的 ID 为 10。Code Section 的组织结构从宏观上来看,同样可以将它理解成一个数组结构,这个数组中的每个单元格都存放着某个函数的具体定义,也就是函数体对应的一簇 Wasm 指令集合。每个 Code Section 中的单元格都对应着 Function Section 这个“数组”结构在相同索引位置的单元格。

Table Section 和 Element Section

Table Section ——> Table Meta -> { initial: 2, element: 'anyfunc' }Element Section ——> Table Content -> func1 func2 func3 ……
复制代码

Table Section 的 ID 为 4。在 MVP 标准中,Table Section 的作用并不大,只需要知道我们可以在其对应的 Table 结构中存放类型为 “anyfunc”的函数指针,并且还可以通过指令 “call_indirect”来调用这些函数指针所指向的函数就可以了。

值得说的一点是,在实际的 VM 实现中,虚拟机会将模块的 Table 结构初始化在独立于模块线性内存的区域中,这个区域无法被模块本身直接访问。因此在使用 call_indirect 指令时,我们只能通过 indicies,也就是“索引”的方式来指定和访问这些 Table 中的内容。这在某种程度上,保证了 Table 中数据的安全性。

在默认情况下,Table Section 是没有与任何内容相关联的,也就是说从二进制角度来看,在 Table Section 中,只存放了用于描述某个 Table 属性的一些元信息。比如:Table 中可以存放哪种类型的数据?Table 的大小信息?等等。

为了给 Table Section 所描述的 Table 对象填充实际的数据,我们还需要使用名为Element Section 的 Section 结构。Element Section 的 ID 为 9,通过这个 Section,便可以为 Table 内部填充实际的数据。

Memory Section 和 Data Section

Memory Section ——> Memory Meta -> { initial: 2, maximum: 100 }Data Section ——> Memory Content -> 0 1 1 0 ……
复制代码

Memory Section 的 ID 为 5。同 Table Section 的结构类似,借助 Memory Section,我们可以描述一个 Wasm 模块内所使用的线性内存段的基本情况,比如这段内存的初始大小、以及最大可用大小等等。

Wasm 模块内的线性内存结构,主要用来以二进制字节的形式,存放各类模块可能使用到的数据,比如一段字符串、一些数字值等等。通过浏览器等宿主环境提供的比如WebAssembly.Memory 对象,我们可以直接将一个Wasm 模块内部使用的线性内存结构,以“对象”的形式从模块实例中导出。而被导出的内存对象,可以根据宿主环境的要求,做任何形式的变换和处理,或者也可以直接通过Import Section ,再次导入给其他的 Wasm 模块来进行使用。

同样地,在 Memory Section 中,也只是存放了描述模块线性内存属性的一些元信息,如果要为线性内存段填充实际的二进制数据,还需要使用另外的 Data Section。Data Section 的 ID 为 11。

魔数和版本号

我们如何识别一个二进制文件是不是一个合法有效的 Wasm 模块文件呢?其实同 ELF 二进制文件一样,Wasm 也同样使用“魔数”来标记其二进制文件类型。所谓魔数,可以简单地将它

理解为具有特定含义的一串数字。

一个标准 Wasm 二进制模块文件的头部数据是由具有特殊含义的字节组成的。其中开头的前四个字节分别为“(高地址)0x6d 0x73 0x61 0x0(低地址)”,这四个字节对应的ASCII 可见字符为“asm”(第一个为空字符,不可见)。

接下来的四个字节,用来表示当前 Wasm 二进制文件所使用的 Wasm 标准版本号。就目前来说,所有 Wasm 模块该四个字节的值均为“(高地址)0x0 0x0 0x0 0x1(低地址)”,即表示版本 1。在实际解析执行 Wasm 模块文件时,VM 也会通过这几个字节来判断,当前正在解析的二进制文件是否是一个合法的 Wasm 二进制模块文件。

一个栗子

使用以下 C/C++ 代码所对应生成的 Wasm 二进制字节码来作为例子:

int add (int a, int b) {
    return a + b;
}
复制代码

这段代码定义了一个简单的函数 “add”。这个函数接收两个 int 类型的参数,并返回这两个参数的和。将上述代码编译成对应的 Wasm 二进制文件如下:

最开始红色方框内的前八个字节 “0x0 0x61 0x73 0x6d 0x1 0x0 0x0 0x0” 是 Wasm 模块文件开头的“魔数”和版本号。这里需要注意地址增长的方向是从左向右。

接下来的“0x1”是 Section 头部结构中的“id”字段,这里的值为 “0x1”,表明接下来的数据属于模块的 Type Section。

紧接着绿色方框内的五个十六进制数字 “0x870x80 0x80 0x80 0x0”是由 varuint32 编码的“payload_len”字段信息,经过解码,它的值为“0x7”,表明这个 Section 的有效载荷长度为 7 个字节。

接下来的字节 “0x1”代表当前 Section 中接下来存在的“entries”类型实体的个数为 1 个。

根据同样的分析过程,紧接着紫色方框内的六个十六进制数字序列 “0x600x2 0x7f 0x7f 0x1 0x7f”便代表着“一个接受两个 i32 类型参数,并返回一个 i32 类型值的函数类型”。

同样的分析过程,也适用于接下来的其他类型 Section,可以结合官方文档给出的各 Section 的详细组成结构,来将剩下的字节分别对应到模块的不同Section 结构中。

猜你喜欢

转载自juejin.im/post/7032856060415180814