WebAssembly 原理篇

·  阅读 249

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 结构中。

WebAssembly 编码格式

Wasm 使用了不同的编码方式来编码其内部使用到的各类字面量数据,比如整数值、浮点数值,以及字符串值。这些字面量值可能被使用在包括“指令立即数”、“指令OpCode”以及

“Section 组成结构”等组成 Wasm 二进制模块的各个部分中。

对于整数,Wasm 使用 LEB-128 编码方式来编码具有不同长度(N),以及具有不同符号性(Signed / Unsigned)的字面量整数值;对于浮点数,Wasm 使用了业界最常用的 I****EEE-754 标准进行编码;而对于字符串,Wasm 也同样采用了业界的一贯选择 ——UTF8 编码。通过编码,能够确保各数字值类型按照其最为合适的格式,被“摆放”在 Wasm 的二进制字节码序列中。

WebAssembly 可读文本格式

相信无论你对 Wasm 的字节码组成结构、V-ISA 指令集中的各种指令使用方式有多么熟悉,在仅通过二进制字节码来分析一个 Wasm 模块时,都会觉得无从入手。那感觉仿佛是在上古时期时,直接面对着机器码来调试应用程序。那么,有没有一种更为简单、更具有可读性的方式来解读一个 Wasm 模块的内容呢?答案,就在 WAT。

WAT(WebAssembly Text Format)

WAT 的全称 “WebAssembly Text Format”,我们一般称其为 “WebAssembly 可读文本格式”。它是一种与 Wasm 字节码格式完全等价,可用于编码 Wasm 模块及其相关定义的文本格式。

这种格式使用 “S- 表达式” 的形式来表达 Wasm 模块及其定义,将组成模块各部分的字节码用一种更加线性的、可读的方式进行表达。这种文本格式可以被 Wasm 相关的编译工具直接使用,比如 WAVM 虚拟机、Binaryen调试工具等。不仅如此,Web 浏览器还会在 Wasm 模块没有与之对应的 source-map 数据时(即无法显示模块对应的源语言代码,比如 C/C++ 代码),使用对应的 WAT 可读文本格式代码来作为代替,以方便开发者进行调试。

S-表达式(S-Expression)

“S- 表达式”,又被称为 “S-Expression”,或者简写为 “sexpr”,它是一种用于表达树形结构化数据的记号方式。最初,S- 表达式被用于 Lisp 语言,表达其源代码以及所使用到的字面量数据。

在 “S- 表达式” 中,使用一对小括号 “()” 来定义每一个表达式的结构。而表达式之间的相互嵌套关系则表达了一定的语义规则。每一个表达式在求值时,都会将该表达式将要执行的“操作”,作为括号结构的第一个元素,而对应该操作的具体操作“内容”则紧跟其后。这里“操作”和“内容”都加上了引号,因为“S- 表达式”可以被应用于多种不同的场景中,所以这里的操作可能是指一个函数、一个 V-ISA 中的指令,甚至是标识一个结构的标识符。而所对应的“内容”也可以是不同类型的元素或结构。

可以参考下面这张图来理解 “S- 表达式”的组成结构与求值方式。

源码、字节码与Flat-WAT

为了能够更加直观地看清楚从源代码、Wasm 字节码再到 WAT 三者之间的对应关系,首先将对应的 WAT 代码 flatten,将其变成“Flat-WAT”。这里以“factorial”函数对应生成的 WAT 可读文本代码为例。flatten 的过程十分简单。正常在通过 “S- 表达式” 形式表达的 WAT 代码中,我们通过“嵌套”与“小括号”的方式指定了各个表达式的求值顺序。而 flatten 的过程就是将这些嵌套以及括号结构去掉,以“从上到下”的先后顺序,来表达整个程序的执行流程。

然后再将对应 “factorial”函数的 C/C++ 源代码、Wasm 字节码以及上述 WAT 经过转换生成的 Flat-WAT 代码放到一起,如下图所示,可以看到 Flat-WAT 代码与 Wasm 字节码会有着直观的“一对一”关系。

WAT 除了可以通过“S- 表达式”的形式来描述一个定义在 Wasm 模块内的函数定义以外,WAT 还可以描述与 Wasm 模块定义相关的其他部分,比如模块中各个 Section 的具体结构。如下所示,这是用于构成一个完整 Wasm 模块定义的其他字节码组成部分,所对应的 WAT 可读文本代码。

在这里使用 “S- 表达式” 的形式,通过为子表达式指定不同的“操作”关键字,进而赋予每个表达式不同的含义。比如带有 “table” 关键字的子表达式,定义了 Table Section 的结构。其中的“0”

表示该 Section 的初始大小为 0,随后紧跟的 “anyfunc” 表示该 Section 可以容纳的元素类型为函数指针类型。其他的诸如 “memory” 表达式定义了 MemorySection,“export” 表达式定义了 Export Section,以此类推。

WAT与WAST

在 Wasm 的发展初期,曾出现过一种以 “.wast” 为后缀的文本文件格式,这种文本文件经常被用来存放类似 WAT 的代码内容。但实际上,以 “.wast” 为后缀的文本文件通常表示着 “.wat”

的一个超集。也就是说,在该文件中可能会包含有一些,基于 WAT 可读文本格式代码标准扩展而来的其他语法结构。比如一些与“断言”和“测试”有关的代码,而这部分语法结构并不属于 Wasm 标准的一部分。

相反的,以 “.wat” 为后缀结尾的文本文件,通常只能够包含有 Wasm 标准语法所对应的 WAT 可读文本代码。并且在一个文本文件中,我们也只能够定义单一的 Wasm 模块结构。因此,在日常的 Wasm 学习、开发和调试过程中,推荐使用 “.wat” 这个后缀,来作为包含有 WAT 代码的文本文件扩展名。这样可以保障该文件能够具有足够高的兼容性,能够适配大多数的编译工具,甚至是浏览器来进行识别和解析。

WebAssembly 相关工具

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改