mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-19 23:53:47 +08:00
del
This commit is contained in:
161
极客时间专栏/geek/WebAssembly入门课/核心原理篇/03 | WebAssembly 是一门新的编程语言吗?.md
Normal file
161
极客时间专栏/geek/WebAssembly入门课/核心原理篇/03 | WebAssembly 是一门新的编程语言吗?.md
Normal file
@@ -0,0 +1,161 @@
|
||||
<audio id="audio" title="03 | WebAssembly 是一门新的编程语言吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/39/5d/39f989b85876f59d32ffa7bd4ba9e05d.mp3"></audio>
|
||||
|
||||
你好,我是于航。
|
||||
|
||||
“WebAssembly(缩写为 Wasm)是一种基于堆栈式虚拟机的二进制指令集。Wasm 被设计成为一种编程语言的可移植编译目标,并且可以通过将其部署在 Web 平台上,以便为客户端及服务端应用程序提供服务”。这是 Wasm 官网给出的一段,对 “Wasm 是什么?” 这个问题的解答。
|
||||
|
||||
其实,在开设这门课程之前,我曾在国内的各类博客和资讯网站上查阅过很多有关 Wasm 的相关资料。发现大多数文章都会声称 “Wasm 是一种新型的编程语言”。但事实真的是这样的吗?希望本篇文章的内容,能够给你心中的这个问题一个更加明确的答案。要想了解 Wasm 究竟是什么,我们还要先从“堆栈机模型”开始说起。
|
||||
|
||||
## 堆栈机模型
|
||||
|
||||
堆栈机,全称为“堆栈结构机器”,即英文的 “Stack Machine”。堆栈机本身是一种常见的计算模型。换句话说,基于堆栈机模型实现的计算机,无论是虚拟机还是实体计算机,都会使用“栈”这种结构来实现数据的存储和交换过程。栈是一种“后进先出(LIFO)”的数据结构,即最后被放入栈容器中的数据可以被最先取出。
|
||||
|
||||
接下来,我们将尝试模拟堆栈机的实际运行流程。在这个过程中,我们会使用到一些简单的指令,比如 “push”,“pop” 与 “add” 等等。这里你可以把它们想象成一种汇编指令。
|
||||
|
||||
大多数指令在执行时,都会从堆栈机的栈容器中取出若干个所需的操作数,然后根据指令所对应的功能,堆栈机会对取出的操作数进行一定的运算和处理。当这个过程结束后,若指令有需要返回的计算结果,这个值会被重新压入到栈容器中。
|
||||
|
||||
假设此时我们需要计算表达式 “1 + 2” 的值,那么通过栈机,这句表达式会以怎样的方式来执行呢?我们前面提到过,堆栈机中的栈容器,主要是作为程序执行时的数据存储和交换场所。那么对于上述表达式,编译器在实际进行编译时,假设在没有使用任何优化策略的情况下,通常会生成类似如下的这样几条指令。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e3/85/e307e8d6a1b212yy764c031935d29a85.png" alt="">
|
||||
|
||||
如上图所示,这里我们将编译器生成的指令集合,按照指令从上到下的执行顺序放在左侧。堆栈机中栈容器的当前状态放置在右侧。可以看到,此时的栈容器为空,内部没有任何数据。下面,堆栈机开始执行第一条指令 “push 1”。push 指令会将紧随其后出现的操作数直接压入栈中。当该指令执行完毕后,此时栈容器的状态如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/49/d1/496597ac78a86b8088b520cb1e513bd1.png" alt="">
|
||||
|
||||
我们将已经执行完毕的指令用红色进行标记。此时,栈容器的栈底存放着通过第一条 push 指令压入的操作数 “1”。以同样的方式,堆栈机继续执行第二条指令 “push 2”。该条指令执行完毕后,栈容器的状态如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e4/07/e4bbf69cfd1792163c8b0cb8a56d3707.png" alt="">
|
||||
|
||||
可以看到,目前栈容器中存放有通过前两条 push 指令压入的操作数 “1” 和 “2”。接下来,堆栈机继续执行第三条 “add” 指令。
|
||||
|
||||
执行这条指令需要两个操作数,因此在执行指令时,堆栈机会首先检查当前的栈容器,看其中存放的元素数量是否满足“大于或等于 2 个”。如果这个条件成立,堆栈机会直接从栈容器的顶部取出两个操作数,然后将它们直接相加,所得到的结果会被再次压入到栈容器中。当最后一条 add 指令执行完毕后,此时栈容器的状态如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/f3/61b464c3706b42f97765fdb415fed6f3.png" alt="">
|
||||
|
||||
当全部指令执行完毕后,在栈容器中,会存放有表达式 “1 + 2” 在经过堆栈机求值后的结果值。
|
||||
|
||||
## 寄存器机与累加器机
|
||||
|
||||
刚刚我们通过一个简单的例子,来大致了解了堆栈机模型是什么,以及堆栈机中栈容器与指令间的交互关系。但实际上,除了堆栈机模型以外,还有另外两种曾经使用过,或现在也仍然在广泛使用的计算模型,即“寄存器机”与“累加器机”模型。
|
||||
|
||||
### 累加器机
|
||||
|
||||
顾名思义,累加器机是使用“累加器”,来作为指令操作数的交换场所。累加器机实际上是一种较为古老的计算模型,它仅能够使用可存放单一值的累加器寄存器(后简称“累加器”)单元,来作为指令操作数的暂存场所。因此,基于累加器机模型设计的指令一般都仅支持一个操作数。
|
||||
|
||||
不仅如此,由于累加器的存储容量有限,因此对于一些需要进行暂存的中间数据,通常都只能够被存放到机器的线性内存中。又由于访问线性内存的速度,一般远远低于访问寄存器的速度,因此从某种程度上来讲,累加器机的指令整体执行效率会相对较低。
|
||||
|
||||
比如,对同样的表达式 “1 + 2” 进行求值,在累加器机中,对应的指令和执行情况,可以大致用如下图示来进行概括。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/c2/06cf2c4a6ba4a309f2f5fc7396069bc2.png" alt="">
|
||||
|
||||
初始状态时,累加器中没有任何数据。接下来,指令按照类似从上到下的顺序开始执行。第一条指令 “load” 会将其后面跟随的立即数(根据指令设计不同,后面也可能会跟随一个线性内存的地址)放到累加器中。当该条指令执行完毕后,累加器机的整体状态如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/25/06/2525d309ce7273ded5f863706bfd9b06.png" alt="">
|
||||
|
||||
此时,累加器中保存的数值为 1。继续,机器执行第二条指令 “add 2”。该条指令会将其后面跟随的立即数,累加到机器的累加器单元中。当最后一条指令执行完毕后,累加器机的终态将如下图所示。此时,累加器中便存放着表达式 “1 + 2” 的计算终值 “3”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/28/1d820054b9e91cb3e138808a43361728.png" alt="">
|
||||
|
||||
以上呢,便是累加器机模型下的指令设计特征,以及机器的整体运作模式。
|
||||
|
||||
### 寄存器机
|
||||
|
||||
另一种常用的计算模型被称为“寄存器机”。顾名思义,基于这种计算模型的机器,将使用特定的CPU 寄存器组,来作为指令执行过程中数据的存储和交换容器。
|
||||
|
||||
在寄存器机中,由于每一条参与到数据交换和处理的指令,都需要显式地标记操作数所在的寄存器(比如通过别名的方式),因此相较于堆栈机和累加器机,寄存器机模型下的指令相对更长。但相对地,数据的交换过程也变得更加灵活。
|
||||
|
||||
还是拿对表达式 “1 + 2” 进行求值这个例子,我们来看一看寄存器机在执行这句表达式时的具体流程。
|
||||
|
||||
如下图所示,假设在这个机器的 CPU 中,有 ”r0“ 与 ”r1“ 两个通用寄存器。在初始情况下,这两个寄存器中没有存放任何内容。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/c3/13b60b606b8932ba5cc15578097eaac3.png" alt="">
|
||||
|
||||
第一条指令 ”load r0, 1“。load 指令将接受两个操作数。第一个为目标寄存器的别名,第二个为一个立即数。当指令执行时,作为第二个操作数的立即数,将会被存放到由第一个操作数指定的寄存器中。该指令执行完毕时,对应的寄存器机整体状态如下图所示。此时,寄存器 r0 中存放有数值 1,而寄存器 r1 中没有存放任何内容。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/7b/8ae01756f2b7b31021dc20478yyd447b.png" alt="">
|
||||
|
||||
接下来第二条指令。与第一条指令类似,我相信你已经能够猜测出它的作用。这条 “add” 指令会将作为第二个操作数的立即数累加到,由第一个操作数所指定的寄存器中。当指令全部执行完毕后,对应的寄存器机终态将如下图所示。此时,寄存器 r0 中存放有表达式 “1 + 2” 的计算终值 “3”,而寄存器 r1 中仍然没有存放任何内容。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/8a/a7600878c2165801c20e84b9dfd8828a.png" alt="">
|
||||
|
||||
在上述整个指令的执行流程中,我们全程都没有使用到寄存器 r1,这也是寄存器机的一个优点。对于某些复杂的计算流程,寄存器机可以对执行流程进行优化。而优化策略的实施便得益于其拥有的众多数据暂存容器,也就是寄存器。
|
||||
|
||||
## 三种计算模型的比较
|
||||
|
||||
总的来看,包括“堆栈机”、“累加器机”以及“寄存器机”在内的三种计算模型,它们都拥有其各自的特点与使用场景。
|
||||
|
||||
<li>
|
||||
堆栈机使用栈结构作为数据的存储与交换容器,由于其“后进先出”的特性,使得我们无法直接对位于栈底的数据进行操作。因此在某些情况下,机器会使用额外的指令来进行栈数据的交换过程,从而损失了一定的执行效率。但另一方面,堆栈机模型最为简单且易于实现,对应生成的指令代码长短大小适中。
|
||||
</li>
|
||||
<li>
|
||||
累加器机由于其内部只有一个累加器寄存器可用于暂存数据,因此在指令的执行过程中,可能会频繁请求机器的线性内存,从而导致一定的性能损耗。但另一方面,由于累加器模型下的指令最多只能有一个操作数,因此对应的指令较为精简。
|
||||
</li>
|
||||
<li>
|
||||
寄存器机内大多数与数据操作相关的指令,都需要在执行时指定目标寄存器,这无疑增加了指令的长度。过于灵活的数据操作,也意味着寄存器的分配和使用规则变得复杂。但相对的,众多的数据暂存容器,给予了寄存器机更大的优化空间。因此,通常对于同样的一段计算逻辑,基于寄存器机模型,可以生成更为高效的指令执行结构。
|
||||
</li>
|
||||
|
||||
## ISA 与 V-ISA
|
||||
|
||||
我们前面介绍了三种不同的计算模型,总体来看你会发现,对应于每一种计算模型的指令,都有着不同的基本结构。比如指令可以接受的操作数个数、可操作数据所存放的位置,以及指令与指令之间交互方式的细微差别等等。
|
||||
|
||||
通常来说,对于可以应用在诸如 i386、X86-64 等实际存在的物理系统架构上的指令集,我们一般称之为 ISA(Instruction Set Architecture,指令集架构)。而对另外一种使用在虚拟架构体系中的指令集,我们通常称之为 V-ISA,也就是 Virtual(虚拟)的 ISA。
|
||||
|
||||
对这些 V-ISA 的设计,大多都是基于堆栈机模型进行的。而 Wasm 就是这样的一种 V-ISA。
|
||||
|
||||
Wasm 之所以会选择堆栈机模型来进行指令的设计,其主要原因是由于堆栈机本身的设计与实现较为简单。快速的原型实现可以为 Wasm 的未来发展预先试错。
|
||||
|
||||
另一个重要原因是,借助于堆栈机模型的栈容器特征,可以使得 Wasm 模块的指令代码验证过程变得更加简单。
|
||||
|
||||
简单的实现易于 Wasm 引擎与浏览器的集成。基于堆栈机的结构化控制流,通过对 Wasm 指令进行 SSA(Static Single Assignment Form,静态单赋值形式)变换,可以保证即使是在堆栈机模型下,Wasm 代码也能够有着较好的执行性能。而堆栈机模型本身长短适中的指令长度,确保了 Wasm 二进制模块能够在相同体积下,拥有着更高密度的指令代码。
|
||||
|
||||
## Wasm 虚拟指令集
|
||||
|
||||
到这里,我们已经知道了 Wasm 是一种基于堆栈机模型设计的 V-ISA 指令集。那下面就让我们来一起看看它的真实面目。如下所示,是一段标准的 Wasm 指令。这段指令的功能与我们之前在介绍三种计算模型时所使用的例子一样。
|
||||
|
||||
```
|
||||
i32.const 1
|
||||
i32.const 2
|
||||
i32.add
|
||||
|
||||
```
|
||||
|
||||
前两条指令使用了 “i32.const”,这个指令会将紧随其后的立即数作为一个 i32 类型,也就是 32 位整数类型的值,压入到堆栈机的栈容器中。
|
||||
|
||||
最后一条指令 “i32.add”,会取出位于栈容器顶部的两个 i32 类型的值,并相加,然后再将计算结果重新放回到栈容器中。同样的,堆栈机在实际执行这条指令前,也会首先检查当前的栈容器顶部是否含有至少两个 i32 类型的值。
|
||||
|
||||
可以看到,上述这段 Wasm 指令的执行方式,与我们在介绍堆栈机模型时,所采用的那个案例中的指令执行流程完全一样。相信此时的你,一定会对本文开头 “Wasm 是什么?” 这个问题的答案有了新的认识。
|
||||
|
||||
另外要提到的是,类比汇编语言与机器码。这里我们看到的诸如 “i32.const” 与 “i32.add” ,其实都是 Wasm 这个 V-ISA 指令集中,各个指令所对应的文本助记符(mnemonic)。实际当这些助记符被编译到 Wasm 二进制模块中时,会使用助记符所对应的二进制字节码(一般被称为 OpCode,你可以简单地将其理解为一些二进制数字),并配合一些编码算法来压缩整个二进制模块文件的体积。
|
||||
|
||||
最后一点需要你知道的是,Wasm 虽然有着类似汇编语言的这种“助记符”形式,但在大多数情况下,它仅被作为诸如 C/C++ 等高级编程语言的最终编译目标。编译器会自动处理从这些高级语言源代码到 Wasm 二进制指令的转换过程。而这也正如我们在开头所提到的那样,官方声称的 ”Wasm 被设计成为一种编程语言的可移植编译目标“。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
本文开篇我们介绍了三种常见的计算模型,分别是“堆栈机模型”、“累加器机模型”以及“寄存器机模型”。我们把在这三种不同计算模型下,对表达式 “1 + 2” 进行求值时,所使用的对应不同类型的指令与数据存储方式,进行了对比。
|
||||
|
||||
接下来我们讲解了 ISA 与 V-ISA 的区别,即:前者一般指应用在实际物理架构上的指令集,而后者通常指应用于虚拟架构体系的指令集。 Wasm 便是一种基于堆栈机设计的 V-ISA 指令集。包括 Wasm 在内的 ISA 与 V-ISA 指令集,它们都有着指令集所相对应的助记符形式,以及实际用于物理机器,或虚拟机执行的对应二进制字节码形式。
|
||||
|
||||
最后,我们再回到本文的题目。那么你觉得 WebAssembly 是一门新的编程语言吗?对我来说,它不是一门编程语言。因为它完全不同于我们常见的高级程序设计语言,我们通常仅将其用作编译器的一种新的编译目标。但它又可以是一门“编程语言”,因为我们可以通过助记符的形式来直接进行 Wasm 指令集程序的编写。相较于汇编语言来讲,你也可以将 Wasm 看作是一门低级的编程语言。那么对你来说,答案是什么呢?
|
||||
|
||||
## 课后练习
|
||||
|
||||
最后,我们来做一个练习题吧。
|
||||
|
||||
结合我们之前介绍的堆栈机指令的执行规则和流程,你来猜一猜当下面这段 Wasm 指令执行完毕时,堆栈机的栈容器中会剩余几个值?它们的值分别是多少呢?
|
||||
|
||||
关于这些指令的具体执行规则,你可以在[这里](https://webassembly.github.io/spec/core/appendix/index-instructions.html)进行查找。
|
||||
|
||||
```
|
||||
i32.const 1
|
||||
i32.const 1
|
||||
i32.eq
|
||||
i32.const 10
|
||||
i32.const 10
|
||||
i32.add
|
||||
i32.mul
|
||||
|
||||
```
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
@@ -0,0 +1,194 @@
|
||||
<audio id="audio" title="04 | WebAssembly 模块的基本组成结构到底有多简单?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/23/61/23a597f3637c8b8c4b174942b0d60e61.mp3"></audio>
|
||||
|
||||
你好,我是于航。今天我来和你聊一聊 Wasm 模块的基本组成结构与字节码分析。
|
||||
|
||||
在之前的课程中,我们介绍了 Wasm 其实是一种基于“堆栈机模型” 设计的 V-ISA 指令集。在这节课中,我们将深入 Wasm 模块的字节码结构,探究它在二进制层面的基本布局,以及内部各个结构之间的协作方式。
|
||||
|
||||
那为什么要探究 Wasm 在二进制层面的基本布局呢?因为在日常的开发实践中,我们通常只是作为应用者,直接将编译好的 Wasm 二进制模块文件,放到工程中使用就完事了,却很少会去关注 Wasm 在二进制层面的具体组成结构。
|
||||
|
||||
但其实只有在真正了解 Wasm 模块的二进制组成结构之后,你才能够知道浏览器引擎在处理和使用一个 Wasm 模块时究竟发生了什么。所以今天我们就将深入到这一部分内容中,透过现象看本质,为你揭开 Wasm 模块内部组成的真实面目 —— Section。相信通过这一讲,你能够从另一个角度看到 Wasm 的不同面貌。
|
||||
|
||||
## Section 概览
|
||||
|
||||
从整体上来看,同 ELF 二进制文件类似,Wasm 模块的二进制数据也是以 Section 的形式被安排和存放的。Section 翻译成中文是“段”,但为了保证讲解的严谨性,以及你在理解上的准确性,后文我会直接使用它的英文名词 Section。
|
||||
|
||||
对于 Section,你可以直接把它想象成,一个个具有特定功能的一簇二进制数据。通常,为了能够更好地组织模块内的二进制数据,我们需要把具有相同功能,或者相关联的那部分二进制数据摆放到一起。而这些被摆放在一起,具有一定相关性的数据,便组成了一个个 Section。
|
||||
|
||||
换句话说,每一个不同的 Section 都描述了关于这个 Wasm 模块的一部分信息。而模块内的所有 Section 放在一起,便描述了整个模块在二进制层面的组成结构。在一个标准的 Wasm 模块内,以现阶段的 MVP 标准为参考,可用的 Section 有如下几种。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/4e/9560079ae02898d2611b9cdebc77f94e.png" alt="">
|
||||
|
||||
要注意的是,在我们接下来将要讲解的这些 Section 中,除了其中名为 “Custom Secton”,也就是“自定义段”这个 Section 之外,其他的 Section 均需要按照每个 Section 所专有的 Section ID,按照这个 ID 从小到大的顺序,在模块的低地址位到高地址位方向依次进行“摆放”。下面我来分别讲解一下这些基本 Section 的作用和结构。
|
||||
|
||||
## 单体 Section
|
||||
|
||||
首先我们来讲解的这部分 Section 被我划分到了“单体 Section”这一类别。也就是说,这一类 Section 一般可以独自描述整个模块的一部分特征(或者说是功能),同时也可以与其他 Section 一起配合起来使用。
|
||||
|
||||
当然,这里要强调的是,这样的划分规则只是来源于我自己的设计,希望能够给你在理解 Section 如何相互协作这部分内容时提供一些帮助。这种划分规则并非来源于标准或者官方,你对此有一个概念就好。
|
||||
|
||||
### **Type Section**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f7/06/f766e078f63ff4c80f362a2fc37d4f06.png" alt="">
|
||||
|
||||
首先,第一个出现在模块中的 Section 是 “Type Section”。顾名思义,这个 Section 用来存放与“类型”相关的东西。而这里的类型,主要是指“函数类型”。
|
||||
|
||||
“函数”作为编程语言的基本代码封装单位,无论是在 C/C++ 这类高级编程语言,还是汇编语言(一般被称为 routine、例程,但也可以理解为函数或者方法)这类低级语言中,都有它的身影,而 Wasm 也不例外。在后面的课程中,我们将会再次详细讲解,如何在浏览器中使用这些被定义在 Wasm 模块内,同时又被标记导出的函数方法,现在你只要先了解这些就可以了。
|
||||
|
||||
与大部分编程语言类似,函数类型一般由函数的**参数**和**返回值**两部分组成。而只要知道了这两部分,我们就能够确定在函数调用前后,栈上数据的变化情况。因此,对于“函数类型“,你也可以将其直接理解为我们更加常见的一个概念 —— “函数签名”。
|
||||
|
||||
接下来我们试着更进一步,来看看这个 Section 在二进制层面的具体组成方式。我们可以将 Type Section 的组成内容分为如下两个部分,分别是:所有 Section 都具有的通用“头部”结构,以及各个 Section 所专有的、不同的有效载荷部分。
|
||||
|
||||
从整体上来看,每一个 Section 都由有着相同结构的“头部”作为起始,在这部分结构中描述了这个 Section 的一些属性字段,比如不同类型 Section 所专有的 ID、Section 的有效载荷长度。除此之外还有一些可选字段,比如当前 Section 的名称与长度信息等等。关于这部分通用头部结构的具体字段组成,你可以参考下面这张表。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5d/0c/5df9f973ba729a7e17f56e4da4b3a70c.jpg" alt="">
|
||||
|
||||
对于表中第二列给出的一些类型,你目前只需要将它们理解为一种特定的编码方式就可以了,关于这些编码方式和数据类型的具体信息,我会在下一节课中进行讲解。“字段”这一列中的 “name_len” 与 “name” 两个字段主要用于 Custom Section,用来存放这个 Section 名字的长度,以及名字所对应的字符串数据。
|
||||
|
||||
对于 Type Section 来说,它的专有 ID 是 1。紧接着排在“头部”后面的便是这个 Section 相关的有效载荷信息(payload_data)。注意,每个不同类型的 Section 其有效载荷的结构都不相同。比如,Type Section 的有效载荷部分组成如下表所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/39/1b7fb59bfb3eaf2b13a71bdc3d42e039.jpg" alt="">
|
||||
|
||||
可以看到,Type Section 的有效载荷部分是由一个 count 字段和多个 entries 字段数据组合而成的。其中要注意的是 entries 字段对应的 func_type 类型,该类型是一个复合类型,其具体的二进制组成结构又通过另外的一些字段来描述,具体你可以继续参考我下面这张表。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/bc/1e81176f572b41603c67777ae85458bc.jpg" alt="">
|
||||
|
||||
关于表中各个字段的具体说明,你可以参考表格中最后一列的“描述”信息来进行理解。因为其解读方式与上述的 Section 头部十分类似。更详细的信息,你可以按照需求直接参考官方文档来进行查阅。
|
||||
|
||||
### **Start Section**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/fd/982ee4bd2927d293606c19520b431cfd.png" alt="">
|
||||
|
||||
Start Section 的 ID 为 8。通过这个 Section,我们可以为模块指定在其初始化过程完成后,需要首先被宿主环境执行的函数。
|
||||
|
||||
所谓的“初始化完成后”是指:模块实例内部的线性内存和 Table,已经通过相应的 Data Section 和 Element Section 填充好相应的数据,但导出函数还无法被宿主环境调用的这个时刻。关于 Data Section 和 Element Section,我们会在下文给你讲解,这里你只需要对它们有一个大致的概念就可以了。
|
||||
|
||||
对于 Start Section 来说,有一些限制是需要注意的,比如:一个 Wasm 模块只能拥有一个 Start Section,也就是说只能调用一个函数。并且调用的函数也不能拥有任何参数,同时也不能有任何的返回值。
|
||||
|
||||
### **Global Section**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c4/d3/c454c0d023aa8feb72f88c6e22160cd3.png" alt="">
|
||||
|
||||
Global Section 的 ID 为 6。同样地,从名字我们也可以猜到,这个 Section 中主要存放了整个模块中使用到的全局数据(变量)信息。这些全局变量信息可以用来控制整个模块的状态,你可以直接把它们类比为我们在 C/C++ 代码中使用的全局变量。
|
||||
|
||||
在这个 Section 中,对于每一个全局数据,我们都需要标记出它的值类型、可变性(也就是指这个值是否可以被更改)以及值对应的初始化表达式(指定了该全局变量的初始值)。
|
||||
|
||||
### **Custom Section**
|
||||
|
||||
Custom Section 的 ID 为 0。这个 Section 主要用来存放一些与模块本身主体结构无关的数据,比如调试信息、source-map 信息等等。VM(Virtual Machine,虚拟机)在实例化并执行一个 Wasm 二进制模块中的指令时,对于可以识别的 Custom Section,将会以特定的方式为其提供相应的功能。而 VM 对于无法识别的 Custom Section 则会选择直接忽略。
|
||||
|
||||
VM 对于 Custom Section 的识别,主要是通过它 “头部”信息中的 “name” 字段来进行。在目前的 MVP 标准中,有且仅有一个标准中明确定义的 Custom Section,也就是 “Name Section”。这个 Section 对应的头部信息中,“name” 字段的值即为字符串 “name”。在这个 Section 中存放了有关模块定义中“可打印名称”的一些信息。
|
||||
|
||||
## 互补 Section
|
||||
|
||||
接下来要讲解的这些 Section 被划分到了“互补 Section”这一类别,也就是说,每一组的两个 Section 共同协作,一同描述了整个 Wasm 模块的某方面特征。
|
||||
|
||||
### **Import Section 和 Export Section**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/be/78/be82bb1c3f7f15aa6d662e374e265678.png" alt="">
|
||||
|
||||
为了方便理解,我给你画了张图,你可以通过它来直观地了解这两个 Section 的具体功能。
|
||||
|
||||
首先是 Import Section,它的 ID 为 2。Import Section 主要用于作为 Wasm 模块的“输入接口”。在这个 Section 中,定义了所有从外界宿主环境导入到模块对象中的资源,这些资源将会在模块的内部被使用。
|
||||
|
||||
允许被导入到 Wasm 模块中的资源包括:函数(Function)、全局数据(Global)、线性内存对象(Memory)以及 Table 对象(Table)。那为什么要设计 Import Section 呢?其实就是希望能够在 Wasm 模块之间,以及 Wasm 模块与宿主环境之间共享代码和数据。我将在实战篇中给你详细讲解,如何在浏览器内向一个正在实例化中的 Wasm 模块,导入这些外部数据。
|
||||
|
||||
与 Import Section 类似,既然我们可以将资源导入到模块,那么同样地,我们也可以反向地将资源从当前模块导出到外部宿主环境中。
|
||||
|
||||
为此,我们便可以利用名为 “Export Section” 的 Section 结构。Export Section 的 ID 为 7,通过它,我们可以将一些资源导出到虚拟机所在的宿主环境中。允许被导出的资源类型同 Import Section 的可导入资源一致。而导出的资源应该如何被表达及处理,则需要由宿主环境运行时的具体实现来决定。
|
||||
|
||||
### **Function Section 和 Code Section**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/63/762ee2775d53ed76c72678bdd9657463.png" alt="">
|
||||
|
||||
关于 Function Section 与 Code Section 之间的关系,你可以先参考上图,以便有一个直观的印象。Function Section 的 ID 为 3,我想你一定认为,在这个 Section 中存放的是函数体的代码,但事实并非如此。Function Section 中其实存放了这个模块中所有函数对应的函数类型信息。
|
||||
|
||||
在 Wasm 标准中,所有模块内使用到的函数都会通过整型的 indicies 来进行索引并调用。你可以想象这样一个数组,在这个数组中的每一个单元格内都存放有一个函数指针,当你需要调用某个函数时,通过“指定数组下标”的方式来进行索引就可以了。
|
||||
|
||||
而 Function Section 便描述了在这个数组中,从索引 0 开始,一直到数组末尾所有单元格内函数,所分别对应的函数类型信息。这些类型信息是由我们先前介绍的 Type Section 来描述的。
|
||||
|
||||
Type Section 存放了 Wasm 模块使用到的所有函数类型(签名);Function Section 存放了模块内每个函数对应的函数类型,即具体的函数与类型对应关系;而在 Code Section 中存放的则是每个函数的具体定义,也就是实现部分。
|
||||
|
||||
Code Section 的 ID 为 10。Code Section 的组织结构从宏观上来看,你同样可以将它理解成一个数组结构,这个数组中的每个单元格都存放着某个函数的具体定义,也就是函数体对应的一簇 Wasm 指令集合。
|
||||
|
||||
每个 Code Section 中的单元格都对应着 Function Section 这个“数组”结构在相同索引位置的单元格。也就是说举个例子,Code Section 的 0 号单元格中存放着 Function Section 的 0 号单元格中所描述函数类型对应的具体实现。
|
||||
|
||||
当然,上述我们提到的各种“数组”结构,其实并不一定真的是由编程语言中的数组来实现的。只是从各个 Section 概念上的协作和数据引用方式来看,我们可以通过数组来模拟这样的交互流程。具体实现需要依以各个 VM 为准。
|
||||
|
||||
### **Table Section 和 Element Section**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/be/a16513b6696690397c5fa8dc83a283be.png" alt="">
|
||||
|
||||
同样的,Table Section 与 Element Section 之间的关系,你也可以从上图直观地感受到。Table Section 的 ID 为 4。
|
||||
|
||||
在 MVP 标准中,Table Section 的作用并不大,你只需要知道我们可以在其对应的 Table 结构中存放类型为 “anyfunc” 的函数指针,并且还可以通过指令 “call_indirect” 来调用这些函数指针所指向的函数,这就可以了。Table Section 的结构与 Function Section 类似,也都是由“一个个小格子”按顺序排列而成的,你可以用数组的结构来类比着进行理解。
|
||||
|
||||
值得说的一点是,在实际的 VM 实现中,虚拟机会将模块的 Table 结构,初始化在独立于模块线性内存的区域中,这个区域无法被模块本身直接访问。因此 Table 中这些“小格子”内具体存放的值,对于 Wasm 模块本身来说是不可见的。
|
||||
|
||||
所以在使用 call_indirect 指令时,我们只能通过 indicies,也就是“索引”的方式,来指定和访问这些“小格子”中的内容。这在某种程度上,保证了 Table 中数据的安全性。
|
||||
|
||||
在默认情况下,Table Section 是没有与任何内容相关联的,也就是说从二进制角度来看,在Table Section 中,只存放了用于描述某个 Table 属性的一些元信息。比如:Table 中可以存放哪种类型的数据?Table 的大小信息?等等。
|
||||
|
||||
那为了给 Table Section 所描述的 Table 对象填充实际的数据,我们还需要使用名为 Element Section 的 Section 结构。Element Section 的 ID 为 9,通过这个 Section,我们便可以为 Table 内部填充实际的数据。
|
||||
|
||||
### **Memory Section 和 Data Section**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/2d/2d8306e2f86d24ffe84bec7445883e2d.png" alt="">
|
||||
|
||||
Memory Section 的 ID 为 5。同样,从这个 Section 的名字中我们就基本能够猜到它的用途。同 Table Section 的结构类似,借助 Memory Section,我们可以描述一个 Wasm 模块内所使用的线性内存段的基本情况,比如这段内存的初始大小、以及最大可用大小等等。
|
||||
|
||||
Wasm 模块内的线性内存结构,主要用来以二进制字节的形式,存放各类模块可能使用到的数据,比如一段字符串、一些数字值等等。
|
||||
|
||||
通过浏览器等宿主环境提供的比如 WebAssembly.Memory 对象,我们可以直接将一个 Wasm 模块内部使用的线性内存结构,以“对象”的形式从模块实例中导出。而被导出的内存对象,可以根据宿主环境的要求,做任何形式的变换和处理,或者也可以直接通过 Import Section ,再次导入给其他的 Wasm 模块来进行使用。
|
||||
|
||||
同样地,在 Memory Section 中,也只是存放了描述模块线性内存属性的一些元信息,如果要为线性内存段填充实际的二进制数据,我们还需要使用另外的 Data Section。Data Section 的 ID 为 11。
|
||||
|
||||
## 魔数和版本号
|
||||
|
||||
到这里呢,我们就已经大致分析完在 MVP 标准下,Wasm 模块内 Section 的二进制组成结构。但少侠且慢,Section 信息固然十分重要,但另一个更重要的问题是:我们如何识别一个二进制文件是不是一个合法有效的 Wasm 模块文件呢?其实同 ELF 二进制文件一样,Wasm 也同样使用“魔数”来标记其二进制文件类型。所谓魔数,你可以简单地将它理解为具有特定含义/功能的一串数字。
|
||||
|
||||
一个标准 Wasm 二进制模块文件的头部数据是由具有特殊含义的字节组成的。其中开头的前四个字节分别为 “(高地址)0x6d 0x73 0x61 0x0(低地址)”,这四个字节对应的 ASCII 可见字符为 “asm”(第一个为空字符,不可见)。
|
||||
|
||||
接下来的四个字节,用来表示当前 Wasm 二进制文件所使用的 Wasm 标准版本号。就目前来说,所有 Wasm 模块该四个字节的值均为 “(高地址)0x0 0x0 0x0 0x1(低地址)”,即表示版本 1。在实际解析执行 Wasm 模块文件时,VM 也会通过这几个字节来判断,当前正在解析的二进制文件是否是一个合法的 Wasm 二进制模块文件。
|
||||
|
||||
在这节课的最后,我们一起来分析一个简单的 Wasm 模块文件的二进制组成结构。这里为了方便你理解,我简化了一下分析流程。我们将使用以下 C/C++ 代码所对应生成的 Wasm 二进制字节码来作为例子进行讲解:
|
||||
|
||||
```
|
||||
int add (int a, int b) {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我们定义了一个简单的函数 “add”。这个函数接收两个 int 类型的参数,并返回这两个参数的和。我们使用一个线上的名为 WasmFiddle 的在线 Wasm 编译工具,将上述代码编译成对应的 Wasm 二进制文件,并将它下载到本地。然后,我们可以使用 “hexdump” 命令来查看这个二进制文件的字节码内容。对于这个命令的实际运行结果,你可以参考下面的这张图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/83/b9a460247a51c563718ecef01ea70d83.png" alt="">
|
||||
|
||||
你可以看到,最开始红色方框内的前八个字节 “0x0 0x61 0x73 0x6d 0x1 0x0 0x0 0x0” 便是我们之前介绍的, Wasm 模块文件开头的“魔数”和版本号。这里需要注意地址增长的方向是从左向右。
|
||||
|
||||
接下来的 “0x1” 是 Section 头部结构中的 “id” 字段,这里的值为 “0x1”,表明接下来的数据属于模块的 Type Section。紧接着绿色方框内的五个十六进制数字 “0x87 0x80 0x80 0x80 0x0” 是由 varuint32 编码的 “payload_len” 字段信息,经过解码,它的值为 “0x7”,表明这个 Section 的有效载荷长度为 7 个字节(关于编解码的具体过程我们会在下一节课中进行讲解)。
|
||||
|
||||
根据这节课一开始我们对 Type Section 结构的介绍,你可以知道,Type Section 的有效载荷是由一个 “count” 字段和多个 “entries” 类型数据组成的。因此我们可以进一步推断出,接下来的字节 “0x1” 便代表着,当前 Section 中接下来存在的 “entries” 类型实体的个数为 1 个。
|
||||
|
||||
根据同样的分析过程,你可以知道,紧接着紫色方框内的六个十六进制数字序列 “0x60 0x2 0x7f 0x7f 0x1 0x7f” 便代表着“一个接受两个 i32 类型参数,并返回一个 i32 类型值的函数类型”。同样的分析过程,也适用于接下来的其他类型 Section,你可以试着结合官方文档给出的各 Section 的详细组成结构,来将剩下的字节分别对应到模块的不同 Section 结构中。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,讲到这里,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
今天我们主要介绍了一个 Wasm 模块在二进制层面的具体组成结构。每一个 Wasm 模块都是由多个不同种类的 Section 组成的,这些 Section 按照其专有 ID 从小到大的顺序被依次摆放着。
|
||||
|
||||
其中的一些 Section 可以独自描述 Wasm 模块某个方面的特性,而另外的 Section 则需要与其他类型的 Section 一同协作,来完成对模块其他特性的完整定义。
|
||||
|
||||
除了这些专有 Section,模块还可以通过 Custom Section 来支持一些自定义功能。这个 Section 一般可以用于提供一些 VM 专有的、而可能又没有被定义在 Wasm 标准中的功能,比如一些与调试相关的特性等等。
|
||||
|
||||
最后,我们还介绍了整个 Wasm 模块中最为重要的,位于模块二进制代码最开始位置的“魔数”以及“版本号”。这两个字段主要会被 VM 用于对 Wasm 模块的类型进行识别,当 VM 检测到二进制文件中的某一个字段不符合规范时,则会立即终止对该模块的初始化和后续处理。这里我放了一张脑图,你可以通过这张图,对 Wasm 模块的整体结构有个更直观的认识。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/29/c740d7bc9bf4395c06cf61aa83444729.png" alt="">
|
||||
|
||||
## **课后思考**
|
||||
|
||||
本节课最后,我来给你留一个思考题:
|
||||
|
||||
尝试去了解一下 ELF 格式的 Section 结构,并谈谈它与 Wasm Section 在设计上的异同之处?
|
||||
|
||||
好,今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
@@ -0,0 +1,252 @@
|
||||
<audio id="audio" title="05 | 二进制编码:WebAssembly 微观世界的基本数据规则是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/44/03855305c104a64d385a4a2ec08bcc44.mp3"></audio>
|
||||
|
||||
你好,我是于航。
|
||||
|
||||
在上节课的最后,我举了一个简单的例子,来帮助你理解了 Wasm 二进制模块内部字节码的基本结构。在这短短的几十个十六进制数字中,我们看到了组成 Wasm 模块所不可或缺的“魔数”与“版本号”编码,以及组成了各个 Section 结构的专有编码。
|
||||
|
||||
在这些字节码中,Wasm 会使用不同的编码方案来处理不同的字段数据。比如对于 Section 的通用头部结构来说,Wasm 会用名为 “varuint7” 的编码方式,来编码各个 Section 的专有 ID。
|
||||
|
||||
除此之外,对于字符串以及浮点数,Wasm 也会分别通过 UTF-8 以及 IEEE-754 编码来将这些字面量值转换为对应的二进制编码,并存储到最终的 Wasm 二进制模块文件中。
|
||||
|
||||
那么本节课,我们就来一起看看 Wasm 所使用的这些数据编码方式,它们决定了 Wasm 在二进制层面的具体数据存储规则。
|
||||
|
||||
## 字节序
|
||||
|
||||
首先,作为字节码组成方式最为重要的一个特征,我们来聊一聊与具体编码方案无关的另外一个话题 —— 字节序。
|
||||
|
||||
那么什么是“字节序”呢?相信仅从字面上理解,你就能够略知一二。字节序也就是指“字节的排列顺序”。在计算机中,数据是以最原始的二进制 0 和 1 的方式被存储的。在大多数现代计算机体系架构中,计算机的最小可寻址数据为 8 位(bit),即 1 个字节(byte)。
|
||||
|
||||
因此,我们通常将 1 字节定义为一个存储单元的大小。对于连续占用了多个存储单元的数据,我们通常称之为“多字节数据”,组成这段数据的每个字节都会地址连续地进行存放。
|
||||
|
||||
比如,在 C/C++ 中,一个 short 类型的变量便是一个多字节数据。假设我们有一个该类型的变量,其值为 1000。如下图所示,我们将该值在内存中的实际二进制存放形式展示如下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b5/7c/b535c1059yy2bc722a386c9dc1cec67c.png" alt="">
|
||||
|
||||
对于一个多字节数据,我们会将其二进制形式下,用于组成该数字值的最低有效数字位与最高有效数字位,分别称为这个数据的“最低有效位(LSB,Least Significant Bit)”和“最高有效位(MSB,Most Significant Bit)”。如上图我们所标记出的那样。
|
||||
|
||||
而当计算机将这个多字节数据存放到物理内存中时,一个对于存储方式的不同抉择便出现了。
|
||||
|
||||
我们是应该选择将多字节数据的 LSB 位,存放到物理内存的低地址段(也就是相应地把 MSB 位存放到高地址段);还是相反地,应该将多字节数据的 LSB 位,存放到物理内存的高地址段(即将 MSB 位相应地存放到低地址段)呢?实际上这两种方式均有被业界所使用,它们分别被称为“小端模式”与“大端模式”。
|
||||
|
||||
### 小端模式(Little-Endian)
|
||||
|
||||
小端模式即“将多字节数据的 LSB 位存放到内存的低地址位,相应地将 MSB 位存放到内存的高地址位”。
|
||||
|
||||
为了能够让你对这个概念有一个更加直观的理解,你可以参考下面的这张图。这张图是之前我们提到的,那个存储着值 1000 的 short 类型变量,在以“小端模式”进行存放时的内存结构图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/3e/1b0fda3b15838489104aeaf39a15143e.png" alt="">
|
||||
|
||||
你可以看到,这个 short 类型变量值的 LSB 位所对应的低 8 位数据(0xe8),被存放到了内存的低地址位单元(a+1)中。 MSB 位对应的高 8 位数据(0x3)则被存放到了内存的高地址单元(a+2)中。而这便是“小端模式”所独有的特征。
|
||||
|
||||
### 大端模式(Big-Endian)
|
||||
|
||||
相信当你理解了小端模式后,对于“大端模式”便可以举一反三。与小端模式相反,在大端模式下,多字节数据的 LSB 位所对应部分会被存放到内存的高地址位,而 MSB 对应的部分则会被存放到内存的低地址位。也就是说,将上图内存中两个存储单元所存放的数据 0x3 与 0xe8 的位置相互调换后,便是大端模式下的数据存储方式。
|
||||
|
||||
实际上,大端模式与小端模式两者并没有优劣之分,这两种模式均被广泛地应用在基于不同处理器架构的计算机和一些特殊的应用场景中。在本文接下来的内容中,我们将会讲解 Wasm 二进制数据编码与字节序的一些关系。
|
||||
|
||||
## LEB-128 整数编码
|
||||
|
||||
LEB-128 的全称为 “Little Endian Base 128”,是一种用于整数的、基于小端模式的可变长编码。所谓“可变长编码”,是指源数据在经过编码后,所得到的目标编码结果长度并不固定。依据不同的输入数据会得到不同长度的编码结果。
|
||||
|
||||
LEB-128 编码通常可以被分为两种更为具体的形式,即 “Unsigned LEB-128” 与 “Signed LEB-128”。其中前者仅用于编码无符号整数,后者主要用于编码有符号整数。
|
||||
|
||||
在无符号整数中,没有符号位,也就是说在该类型所对应大小范围内的所有比特位,都可以用来保存整数值的一部分。相反,在有符号整数中,类型首位会被用作符号位。
|
||||
|
||||
接下来,我们将分别讲解这两种 LEB-128 编码方式的具体编码规则。
|
||||
|
||||
### Unsigned LEB-128
|
||||
|
||||
假设这里我们使用 Unsigned LEB-128 来编码一个正整数 123456。编码的具体步骤如下所示。
|
||||
|
||||
第一步:首先将该十进制数转换为对应原码(与补码相同)的二进制表示方式。
|
||||
|
||||
```
|
||||
11110001001000000
|
||||
|
||||
```
|
||||
|
||||
第二步:将该二进制数用额外的 “0” 位进行填充,直至其总位数达到最近的一个 7 的倍数。注意这里我们只能够在该数字最高位的左侧进行填充,这样才不会影响数字原本的值。这种为无符号数进行位数扩展的方式我们一般称之为“零扩展”。
|
||||
|
||||
```
|
||||
000011110001001000000
|
||||
|
||||
```
|
||||
|
||||
第三步:将该二进制数以每 7 个二进制位为一组进行分组,每组之间以空格进行区分。
|
||||
|
||||
```
|
||||
0000111 1000100 1000000
|
||||
|
||||
```
|
||||
|
||||
第四步:在最高有效位所在分组的左侧填充一个值为 “0” 的二进制位。而在其他分组的最高位左侧填充一个值为 “1” 的二进制位。
|
||||
|
||||
```
|
||||
00000111 11000100 11000000
|
||||
|
||||
```
|
||||
|
||||
第五步:将上述二进制位分组以每组为单位,转换成对应的十六进制值,即为编码所得结果。
|
||||
|
||||
```
|
||||
0x7 0xc4 0xc0
|
||||
|
||||
```
|
||||
|
||||
到这里,一次对无符号(Unsigned)整数进行的 LEB-128 编码过程便完成了。对于 Unsigned LEB-128 编码的解码过程,实质上与编码过程完全相反,你可以试着自己去推导看看,能不能从 “0x7 0xc4 0xc0” 这三个十六进制数字解码到原先的无符号整数值 123456。
|
||||
|
||||
### Signed LEB-128
|
||||
|
||||
Signed LEB-128 的编码过程,实质上与 Unsigned LEB-128 十分类似。假设我们用它来编码一个有符号的负整数 -123456。编码的具体流程如下所示。
|
||||
|
||||
第一步:首先,我们需要将该数字转换为对应的二进制表示形式。这里需要注意的是,由于 -123456 为一个有符号数,因此在编码时我们需要使用它的补码形式。在下面这段二进制编码中,第一位是符号位,这里的 “1” 表示该二进制序列所对应的十进制数是一个负数。
|
||||
|
||||
```
|
||||
100001110111000000
|
||||
|
||||
```
|
||||
|
||||
第二步:在这一步中,我们需要对这个有符号数进行“符号扩展”操作。所谓“符号扩展”是指对二进制数的最高位,也就是符号位,其左侧填充指定的二进制位来增加整个有符号数的总位数,并同时保证该二进制数本身的值不会被改变。
|
||||
|
||||
因此,对于负整数来说,我们需要为其填充 “1”,而正整数则填充 “0”。与 Unsigned LEB-128 类似,这里我们要对其进行符号扩展,直到这个二进制数的总位数达到最近的一个 7 的倍数。
|
||||
|
||||
```
|
||||
111100001110111000000
|
||||
|
||||
```
|
||||
|
||||
第三步:将这个二进制数以每 7 个二进制位为一组进行分组,每组之间以空格进行区分。
|
||||
|
||||
```
|
||||
1111000 0111011 1000000
|
||||
|
||||
```
|
||||
|
||||
第四步:同样地,在最高有效位所在分组的左侧填充一个值为 “0” 的二进制位。而在其他分组的最高位左侧填充一个值为 “1” 的二进制位。
|
||||
|
||||
```
|
||||
01111000 10111011 11000000
|
||||
|
||||
```
|
||||
|
||||
第五步:将上述二进制分组以每组为单位,转换成对应的十六进制值,即为编码所得结果。
|
||||
|
||||
```
|
||||
0x78 0xbb 0xc0
|
||||
|
||||
```
|
||||
|
||||
你可以看到,Signed LEB-128 与 Unsigned LEB-128 在编码规则上的不同,仅体现在整个编码 流程的前两步。这两步的不同主要是由于无符号数与有符号数在计算机内的实际存储方式不同。
|
||||
|
||||
另外还需要注意的是,我们经过编码计算所得的结果,需要按照“小端模式”的方式存放在内存中,这也是 LEB-128 编码的一个重要特征。不仅如此,当在实际应用 LEB-128 编码时,有时由于所编码数字有着固定的大小(比如 64 位),因此会导致实际的编码结果中可能会含有特殊的“填充字节”,比如 “0x80” 与 “0xff”。
|
||||
|
||||
## IEEE-754 浮点数编码
|
||||
|
||||
IEEE-754 是一种用于进行浮点数编码的行业标准。你几乎可以在任何与浮点数编码有关的应用场景中看到它的存在。在这一节中,我将以 IEEE-754-1985(后面简称为 IEEE-754)标准为例,来给你介绍浮点数编码的具体方式。
|
||||
|
||||
在 IEEE-754 标准中规定,一个浮点数由三个不同的部分组成,即“符号位”、“指数位”与“小数位”。这里我们以 32 位浮点数 “1234.567” 为例,来介绍它在 IEEE-754 下的实际编码结构。
|
||||
|
||||
首先,32 位的最高位,也就是其 MSB 位会被符号位占用,以标记该浮点数的正负性。同整数一样,该位为 “0” 表示正数,为 “1” 则表示负数。因此对于 “1234.567” 来说,该位的值为 0。
|
||||
|
||||
紧接着符号位的是长度为 8 位的“指数位”。该位用来记录的是,当以“科学计数法”形式表示一个浮点数时,表示法中底数所对应的幂次值。这里我们需要将小数编码成对应的二进制形式,因此所使用科学计数法的底数为 “2”。
|
||||
|
||||
指数位采用了一种名为“移码”的值存储方法,以便能支持负数次幂。当我们计算该位的实际值时,会将从上一步中得到的幂次值与 127 相加,以得到该位的最终结果。对于 “1234.567”,我们可以按照如下步骤来计算对应的指数位值。
|
||||
|
||||
第一步,将浮点数按照整数位和小数位,分别转换成对应的二进制表示形式(对于小数部分,这里我们采用“循环乘 2”的方式,来将其展开成二进制形式)。
|
||||
|
||||
```
|
||||
10011010010.10010001001001...
|
||||
|
||||
```
|
||||
|
||||
第二步,将从上一步得到的二进制小数,以“科学计数法”的形式进行表示。
|
||||
|
||||
```
|
||||
1.001101001010010001001001... * 2^10
|
||||
|
||||
```
|
||||
|
||||
第三步,计算指数位对应的十进制数值。即将上述 2 的幂次值 10,再加上 127,得到 137。换算成二进制序列即 “10001001”。
|
||||
|
||||
这样,我们就计算出了浮点数 1234.567 在 IEEE-754 编码下,其组成部分中指数位对应的二进制序列。
|
||||
|
||||
紧接着指数位的是剩下 23 位的“小数位”,该位主要用于存放浮点数在二进制科学计数法形式下,对应的小数部分序列(也就是在上述第二步我们得到的二进制序列中,小数点后面的那部分)。但要注意的是,这部分只有 23 位大小,对于溢出的部分将会被直接截断。
|
||||
|
||||
最后,我们可以得到浮点数 1234.567 在 IEEE-754 编码下的完整组成形式,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/c7/81303df55b40ddcc96e545f7d13d88c7.png" alt="">
|
||||
|
||||
实际上,在 Wasm 模块中,所有以字面量形式出现的浮点数值,都会通过 IEEE-754 进行编码。而经过编码生成的二进制序列,也将成为 Wasm 二进制模块组成的一部分。
|
||||
|
||||
## UTF-8 字符串编码
|
||||
|
||||
对于 UTF-8 编码,你应该是再熟悉不过了。与 LEB-128 类似,UTF-8 也是一种可变长编码,即随着被编码内容的不同,实际产生的编码结果其长度也各不相同。如下图所示,UTF-8 的编码结果值可能会有着从最少 1 个字节到最多 4 个字节不等的长度。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/b2/a33133dc0eb14f5e27a09eb1744b6ab2.png" alt="" title="图片来源于维基百科">
|
||||
|
||||
UTF-8 的编码过程是基于 Unicode 字符集进行的。在 Unicode 字符集中,每一个字符都有其对应的码位值。比如对于汉字 “极”,它在 Unicode 字符集中的码位值为 “26497”,换算为十六进制即 “0x6781”。因此,我们说,汉字“极”对应的 Unicode 码位值便为 “U+6781”。
|
||||
|
||||
Unicode 虽然规定了各个字符对应的码位值,但却没有规定这些值应该以怎样的格式被计算机存储。 UTF-8 作为众多 Unicode 编码方式中的常用一种,通过上面这种方式巧妙地解决了这个问题。下面我们仍以汉字“极”为例,来介绍 UTF-8 编码的具体过程。
|
||||
|
||||
第一步,我们先将该汉字对应的码位值展开成二进制序列的形式。
|
||||
|
||||
```
|
||||
01100111 10000001
|
||||
|
||||
```
|
||||
|
||||
第二步,根据上图中第三行对应的规则(码位值位于 [U+0800, U+FFFF] 之间),替换出 UTF-8 编码对应的三个字节。在替换时,你需要将从上一步获得的二进制序列中的各个二进制位,按照从左到右的顺序依次替换掉 UTF-8 编码中用于占位的 “x”。
|
||||
|
||||
```
|
||||
11100110 10011110 10000001
|
||||
|
||||
```
|
||||
|
||||
第三步,将替换结果转换为对应的十六进制形式,即为 UTF-8 编码的最终结果。
|
||||
|
||||
```
|
||||
0xe6 0x9e 0x81
|
||||
|
||||
```
|
||||
|
||||
## Wasm 数字类型
|
||||
|
||||
到这里,我们已经介绍了在 Wasm 二进制模块中,可能会使用到的所有二进制编码方案。而对于整数的编码,Wasm 并没有“直接使用” LEB-128,而是在其基础之上又做了进一步的约束。
|
||||
|
||||
Wasm 将其模块内部所使用到的数字值分为以下三种类型:
|
||||
|
||||
- **uintN**(N = 8 / 16 / 32)
|
||||
|
||||
该类型表示了一个占用 N 个 bit 的无符号整数。该整数由 N/8 个字节组成,并以小端模式进行存储。N 的可取值为 8、16 或 32。
|
||||
|
||||
- **varuintN**(N = 1 / 7 / 32)
|
||||
|
||||
该类型表示一个使用 Unsigned LEB-128 编码,具有 N 个 bit 长度的可变长无符号整数。N 的可取值为 1、7 或 32,对应各类型的可取值范围为 [0, 2^N-1]。需要注意的是,当使用较大数据类型(比如 N 取 32)来存放较小的值,比如 12 时,在经过 Unsigned LEB-128 编码后的二进制序列中,可能会存在用于占位的字节 “0x80”。
|
||||
|
||||
- **varintN**(N = 7 / 32 / 64)
|
||||
|
||||
该类型与上述的 varuintN 类似,只不过表示的是使用 Signed LEB-128 编码,具有 N 个 bit 长度的可变长有符号整数。N 的可取值为 7、32 或 64,对应各类型的取值范围为 [-2^(N-1), +2^(N-1)-1]。同样地,当在使用一个较大类型(比如 N 取 64)保存较小的整数值时,经过 Signed LEB-128 编码后的二进制序列中,可能会存在用于占位的字节 “0x80” 或 “0xff”。
|
||||
|
||||
还记得我们在上节课介绍 Wasm 模块内部 Section 组成结构时曾提到的,用于组成 Section 通用头部信息的字段中,id 字段对应的数据类型便为 varuint7。其他的还有诸如 payload_len 字段所对应的 varuint32 类型。希望这种联系能够帮助你加深和巩固 Wasm 的知识体系。
|
||||
|
||||
最后需要注意的是,上述类型只是规定了对应类型的字段其可取值范围,但并不代表对应的字段值需要以一个固定的长度来进行编码。比如对于一个类型为 varint32 的字段值,虽然这里的 N 取值为 32,但实际编码时并不需要把数字值先扩展为 32 位。当然,以扩展后的 32 位值来进行编码,结果也是一个有效的编码值。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,讲到这里,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
Wasm 使用了不同的编码方式来编码其内部使用到的各类字面量数据,比如整数值、浮点数值,以及字符串值。这些字面量值可能被使用在包括“指令立即数”、“指令 OpCode” 以及 “Section 组成结构”等组成 Wasm 二进制模块的各个部分中。
|
||||
|
||||
对于整数,Wasm 使用 LEB-128 编码方式来编码具有不同长度(N),以及具有不同符号性(Signed / Unsigned)的字面量整数值;对于浮点数,Wasm 使用了业界最常用的 IEEE-754 标准进行编码;而对于字符串,Wasm 也同样采用了业界的一贯选择 —— UTF8 编码。
|
||||
|
||||
通过编码,我们能够确保各数字值类型按照其最为合适的格式,被“摆放”在 Wasm 的二进制字节码序列中。其中用于字符串的 UTF-8 以及用于浮点数的 IEEE-754 编码标准,是我们在日常开发中最为常见的两种编码方式。
|
||||
|
||||
基于 LEB-128 的可变长编码,也可以对整型数值类型有一个很好的二进制表示方式(一个趣事:事实上,在 MVP 标准正式发布初期,社区也曾讨论过使用 Google 的 PrefixVarint 编码来代替 LEB-128,因为某种程度上 PrefixVarint 编解码速度更快。但事实是,由于 LEB-128 更为人所知,因此成为了 MVP 的最终选择)。
|
||||
|
||||
## **课后练习**
|
||||
|
||||
本节课最后,我来给你留一个练习题:
|
||||
|
||||
请你尝试计算一下,有符号数 “-654321” 在 varint32 类型下的可能编码值是多少呢?
|
||||
|
||||
好,今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
@@ -0,0 +1,197 @@
|
||||
<audio id="audio" title="06 | WAT:如何让一个 WebAssembly 二进制模块的内容易于解读?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b3/97/b33dc3cd5afd051cc1c10444402ee397.mp3"></audio>
|
||||
|
||||
你好,我是于航。
|
||||
|
||||
在前面的两节课中,我们分别讲解了 Wasm 模块在二进制层面的基本组成结构与数据编码方式。在 04 的结尾,我们还通过一个简单的例子,逐个字节地分析了定义在 C/C++ 源代码中的函数,在被编译到 Wasm 之后所对应的字节码组成结构。
|
||||
|
||||
比如字节码 “0x60 0x2 0x7f 0x7f 0x1 0x7f” ,便表示了 Type Section 中定义的一个函数类型(签名)。而该函数类型为 “接受两个 i32 类型参数,并返回一个 i32 类型值”。
|
||||
|
||||
我相信,无论你对 Wasm 的字节码组成结构、V-ISA 指令集中的各种指令使用方式有多么熟悉,在仅通过二进制字节码来分析一个 Wasm 模块时,都会觉得无从入手。那感觉仿佛是在上古时期时,直接面对着机器码来调试应用程序。那么,有没有一种更为简单、更具有可读性的方式来解读一个 Wasm 模块的内容呢?答案,就在 WAT。
|
||||
|
||||
## WAT(WebAssembly Text Format)
|
||||
|
||||
首先,我们来直观地感受一下 WAT 的“样貌”。假设我们有如下这样一段 C/C++ 源代码,在这段代码中,我们定义了一个函数 factorial,该函数接受一个 int 类型的整数 n,然后返回该整数所对应的阶乘。现在,我们来将它编译成对应的 WAT 代码。
|
||||
|
||||
```
|
||||
int factorial(int n) {
|
||||
if (n == 0) {
|
||||
return 1;
|
||||
} else {
|
||||
return n * factorial(n-1);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
经过编译和转换后,该函数对应的 WAT 文本代码如下所示。
|
||||
|
||||
```
|
||||
(func $factorial (; 0 ;) (param $0 i32) (result i32)
|
||||
(local $1 i32)
|
||||
(local $2 i32)
|
||||
(block $label$0
|
||||
(br_if $label$0
|
||||
(i32.eqz
|
||||
(get_local $0)
|
||||
)
|
||||
)
|
||||
(set_local $2
|
||||
(i32.const 1)
|
||||
)
|
||||
(loop $label$1
|
||||
(set_local $2
|
||||
(i32.mul
|
||||
(get_local $0)
|
||||
(get_local $2)
|
||||
)
|
||||
)
|
||||
(set_local $0
|
||||
(tee_local $1
|
||||
(i32.add
|
||||
(get_local $0)
|
||||
(i32.const -1)
|
||||
)
|
||||
)
|
||||
)
|
||||
(br_if $label$1
|
||||
(get_local $1)
|
||||
)
|
||||
)
|
||||
(return
|
||||
(get_local $2)
|
||||
)
|
||||
)
|
||||
(i32.const 1)
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
WAT 的全称 “WebAssembly Text Format”,我们一般称其为 “WebAssembly 可读文本格式”。它是一种与 Wasm 字节码格式完全等价,可用于编码 Wasm 模块及其相关定义的文本格式。
|
||||
|
||||
这种格式使用 “S-表达式” 的形式来表达 Wasm 模块及其定义,将组成模块各部分的字节码用一种更加线性的、可读的方式进行表达。
|
||||
|
||||
这种文本格式可以被 Wasm 相关的编译工具直接使用,比如 WAVM 虚拟机、Binaryen 调试工具等。不仅如此,Web 浏览器还会在 Wasm 模块没有与之对应的 source-map 数据时(即无法显示模块对应的源语言代码,比如 C/C++ 代码),使用对应的 WAT 可读文本格式代码来作为代替,以方便开发者进行调试。
|
||||
|
||||
OK,既然我们之前提到,WAT 使用了 “S-表达式” 的形式来表达 Wasm 模块及其相关定义,那么接下来,我们就来看看这个 “S-表达式” 究竟是什么?
|
||||
|
||||
### S-表达式(S-Expression)
|
||||
|
||||
“S-表达式”,又被称为 “S-Expression”,或者简写为 “sexpr”,它是一种用于表达树形结构化数据的记号方式。最初,S-表达式被用于 Lisp 语言,表达其源代码以及所使用到的字面量数据。比如,在 Common Lisp 这个 Lisp 方言中,我们可以有如下形式的一段代码。
|
||||
|
||||
```
|
||||
(print
|
||||
(* 2 (+ 3 4))
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
不知道你有没有感受到,这段 Lisp 代码与之前我们生成的函数 factorial 所对应 WAT 可读文本代码,在结构上有着些许的相似。在这段代码中,我们调用了名为 print 的方法,将一个简单数学表达式 “2 * (3 + 4)” 的计算结果值,打印到了系统的标准输出流(stdout)中。
|
||||
|
||||
在 “S-表达式” 中,我们使用一对小括号 “()” 来定义每一个表达式的结构。而表达式之间的相互嵌套关系则表达了一定的语义规则。比如在上面的 Lisp 代码中,子表达式 “(* 2 (+ 3 4))” 的值直接作为了 print 函数的输入参数。而对于这个子表达式本身,也通过内部嵌套的括号表达式及运算符,规定了求值的具体顺序和规则。
|
||||
|
||||
不仅如此,每一个表达式在求值时,都会将该表达式将要执行的“操作”,作为括号结构的第一个元素,而对应该操作的具体操作“内容”则紧跟其后。
|
||||
|
||||
这里我将“操作”和“内容”都加上了引号,因为 “S-表达式” 可以被应用于多种不同的场景中,所以这里的操作可能是指一个函数、一个 V-ISA 中的指令,甚至是标识一个结构的标识符。而所对应的“内容”也可以是不同类型的元素或结构。因此,这里你只要了解这种通过括号划分出的所属关系就可以了。
|
||||
|
||||
对一个 “S-表达式” 的求值会从最内层的括号表达式开始。比如对于上述的 Lisp 代码,我们会首先计算其最内层表达式 “(+ 3 4)” 的值。计算完毕后,该括号表达式的位置会由该表达式的计算结果进行替换。以此类推,从内到外,最后计算出整个表达式的值。当然,除了求值,对于诸如 print 函数来说,也会产生一些如“与操作系统 IO 进行交互”之类的副作用(Side Effect)。
|
||||
|
||||
你可以参考下面这张图来理解 “S-表达式” 的组成结构与求值方式(以上述 Lisp 代码为例)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cf/3e/cf30453b05873f51ecb9559bb31a563e.png" alt="">
|
||||
|
||||
我们再把目光移回到 WAT 身上。既然我们说,WAT 具有与 Wasm 字节码完全等价的表达能力,可以完全表达通过 Wasm 字节码定义的 Wasm 模块内容。那么从高级语言源代码,到 Wasm 模块字节码、再到对应的 WAT 可读文本代码,这三者是如何做到一一对应的呢?
|
||||
|
||||
### 源码、字节码与 Flat-WAT
|
||||
|
||||
为了能够让你更加直观地看清楚从源代码、Wasm 字节码再到 WAT 三者之间的对应关系,首先我们要做的第一件事就是将对应的 WAT 代码 “拍平(flatten)”,将其变成 “Flat-WAT”。这里还是以“factorial” 函数对应生成的 WAT 可读文本代码为例。
|
||||
|
||||
“拍平”的过程十分简单。正常在通过 “S-表达式” 形式表达的 WAT 代码中,我们通过“嵌套”与“小括号”的方式指定了各个表达式的求值顺序。而 “拍平” 的过程就是将这些嵌套以及括号结构去掉,以“从上到下”的先后顺序,来表达整个程序的执行流程。
|
||||
|
||||
上述 WAT 代码在被“拍平”之后,我们可以得到如下所示的 Flat-WAT 代码(这里我们只列出函数体所对应的部分)。
|
||||
|
||||
```
|
||||
(func $factorial (param $0 i32) (result i32)
|
||||
block $label$0
|
||||
local.get $0
|
||||
i32.eqz
|
||||
br_if $label$0
|
||||
local.get $0
|
||||
i32.const 255
|
||||
i32.add
|
||||
i32.const 255
|
||||
i32.and
|
||||
call $factorial
|
||||
local.get $0
|
||||
i32.mul
|
||||
i32.const 255
|
||||
i32.and
|
||||
return
|
||||
end
|
||||
i32.const 1)
|
||||
|
||||
```
|
||||
|
||||
然后我们再将对应 “factorial” 函数的 C/C++ 源代码、Wasm 字节码以及上述 WAT 经过转换生成的 Flat-WAT 代码放到一起,相信你会有一个更加直观的感受。如下图所示,你可以看到 Flat-WAT 代码与 Wasm 字节码会有着直观的“一对一”关系。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/ff/d2764cbb73d17fe8afb8bddbbd229dff.png" alt="">
|
||||
|
||||
### 模块结构与 WAT
|
||||
|
||||
除了我们前面看到的,WAT 可以通过“S-表达式”的形式,来描述一个定义在 Wasm 模块内的函数定义以外,WAT 还可以描述与 Wasm 模块定义相关的其他部分,比如模块中各个 Section 的具体结构。如下所示,这是用于构成一个完整 Wasm 模块定义的其他字节码组成部分,所对应的 WAT 可读文本代码。
|
||||
|
||||
```
|
||||
(module
|
||||
(table 0 anyfunc)
|
||||
(memory $0 1)
|
||||
(export "memory" (memory $0))
|
||||
(export "factorial" (func $factorial))
|
||||
...
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
在这里,我们仍然使用 “S-表达式” 的形式,通过为子表达式指定不同的“操作”关键字,进而赋予每个表达式不同的含义。
|
||||
|
||||
比如带有 “table” 关键字的子表达式,定义了 Table Section 的结构。其中的 “0” 表示该 Section 的初始大小为 0,随后紧跟的 “anyfunc” 表示该 Section 可以容纳的元素类型为函数指针类型。其他的诸如 “memory” 表达式定义了 Memory Section,“export” 表达式定义了 Export Section,以此类推。
|
||||
|
||||
### WAT 与 WAST
|
||||
|
||||
在 Wasm 的发展初期,曾出现过一种以 “.wast” 为后缀的文本文件格式,这种文本文件经常被用来存放类似 WAT 的代码内容。
|
||||
|
||||
但实际上,以 “.wast” 为后缀的文本文件通常表示着 “.wat” 的一个超集。也就是说,在该文件中可能会包含有一些,基于 WAT 可读文本格式代码标准扩展而来的其他语法结构。比如一些与“断言”和“测试”有关的代码,而这部分语法结构并不属于 Wasm 标准的一部分。
|
||||
|
||||
相反的,以 “.wat” 为后缀结尾的文本文件,通常只能够包含有 Wasm 标准语法所对应的 WAT 可读文本代码。并且在一个文本文件中,我们也只能够定义单一的 Wasm 模块结构。
|
||||
|
||||
因此,在日常的 Wasm 学习、开发和调试过程中,我更推荐你使用 “.wat” 这个后缀,来作为包含有 WAT 代码的文本文件扩展名。这样可以保障该文件能够具有足够高的兼容性,能够适配大多数的编译工具,甚至是浏览器来进行识别和解析。
|
||||
|
||||
## WAT 相关工具
|
||||
|
||||
在这节课的最后,我们来看看与 WAT 相关的编译工具。为了使用下面这些工具,你需要安装名为 WABT(The WebAssembly Binary Toolkit)的 Wasm 工具集。关于如何进行安装,你可以在[这里](https://github.com/WebAssembly/wabt#building-using-cmake-directly-linux-and-macos)找到答案。安装完毕后,我们便可以使用如下这些工具来进行 WAT 代码的相关处理。
|
||||
|
||||
- **wasm2wat**:该工具主要用于将指定文件内的 Wasm 二进制代码转译为对应的 WAT 可读文本代码。
|
||||
- **wat2wasm**:该工具的作用恰好与 wasm2wat 相反。它可以将输入文件内的 WAT 可读文本代码转译为对应的 Wasm 二进制代码。
|
||||
- **wat-desugar**:该工具主要用于将输入文件内的,基于 “S-表达式” 形式表达的 WAT 可读文本代码“拍平”成对应的 Flat-WAT 代码。
|
||||
|
||||
上述这三个工具的用法十分简单,默认情况下,转译生成的目标代码将被输出到操作系统的标准输出流中。当然,你也可以通过 “-o” 参数来指定输出结果的保存文件。更详细的信息,你可以直接参考该项目在 Github 上的帮助文档。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
本节课我们主要讲解了 WAT,这是一种可以将 Wasm 二进制字节码基于 “S-表达式” 的结构,用“人类可读”的方式展现出来的文本代码格式。
|
||||
|
||||
WAT 使用嵌套的“括号表达式”结构来表达 Wasm 字节码的内容,表达式由“操作”关键字与相应的“内容”两部分组成。Wasm 字节码与 WAT 可读文本代码两者之间是完全等价的。
|
||||
|
||||
WAT 还有与之相对应的 Flat-WAT 形式的代码。在这个类型的代码中,WAT 内部嵌套的表达式结构(主要是指函数定义部分)将由按顺序平铺开的,由上至下的指令执行结构作为代替。
|
||||
|
||||
除此之外,我们还讲解了 “.wast” 与 “.wat” 两种文本文件格式之间的区别。其中,前者为后者的超集,其内部可能会含有与“测试”和“断言”相关的扩展性语法结构;而后者仅包含有与 Wasm 标准相关的可读文本代码结构。因此,在日常编写 WAT 的过程中,建议你以 “.wat” 作为保存 WAT 代码的文本文件后缀。
|
||||
|
||||
最后,我们还介绍了几个可以用来与 WAT 格式打交道的工具。这几个工具均来自于名为 WABT 的 Wasm 二进制格式工具集,它们的用法都十分简单,相信你可以快速上手。
|
||||
|
||||
## **课后练习**
|
||||
|
||||
最后,我们来做一个小练习吧。
|
||||
|
||||
尝试使用 C/C++ 编写一个“计算第 n 项斐波那契数列值”的函数 fibonacci,然后在 [WasmFiddle](https://wasdk.github.io/WasmFiddle/) 上编译你的函数,并查看对应生成的 WAT 可读文本代码。
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
@@ -0,0 +1,148 @@
|
||||
<audio id="audio" title="07 | WASI:你听说过 WebAssembly 操作系统接口吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/f2/c132afe58b6d3832c87a6a31a8b382f2.mp3"></audio>
|
||||
|
||||
你好,我是于航。
|
||||
|
||||
相信你在刚刚接触到 WebAssembly 这门技术的时候一定有所发现,WebAssembly 这个单词实际上是由两部分组成,也就是 “Web” 和 “Assembly”。
|
||||
|
||||
“Web” 表明了 Wasm 的出身,也就是说它发明并最早应用于 Web 浏览器中, “Assembly” 则表明了 Wasm 的本质,这个词翻译过来的意思是 “汇编”,也就是指代它的 V-ISA 属性。
|
||||
|
||||
鉴于 Wasm 所拥有“可移植”、“安全”及“高效”等特性,Wasm 也被逐渐应用在 Web 领域之外的一些其他场景中。今天我们将要讲解的,便是可以用于将 Wasm 应用到 out-of-web 环境中的一项新的标准 —— WASI(WebAssembly System Interface,Wasm 操作系统接口)。通过这项标准,Wasm 将可以直接与操作系统打交道。
|
||||
|
||||
在正式讲解 WASI 之前,我们先来学习几个与它息息相关的重要概念。在了解了这些概念之后,相信甚至不用我过多介绍,你也能够感受到 WASI 是什么,以及它是如何与 Wasm 紧密结合的。
|
||||
|
||||
## Capability-based Security
|
||||
|
||||
第一个我们要讲解的,是一个在“计算机安全”领域中十分重要的概念 —— “Capability-based Security”,翻译过来为“基于能力的安全”。由于业界没有一个相对惯用的中文表达方式,因此我还是保持了原有的英文表达来作为本节的标题,在后面的内容中,我也将直接使用它的英文表达方式,以保证内容的严谨性。
|
||||
|
||||
Capability-based Security 是一种已知的、常用的安全模型。通常来讲,在计算机领域中,我们所提及的 capability 可以指代如 Token、令牌等概念。capability 是一种用于表示某种权限的标记,它可以在用户之间进行传递且无法被伪造。
|
||||
|
||||
在一个使用了 Capability-based Security 安全模型的操作系统中,任何用户对计算机资源的访问,都需要通过一个具体的 capability 来进行。
|
||||
|
||||
Capability-based Security 同时也指代了一种规范用户程序的原则。比如这些用户程序可以根据“最小特权原则”(该原则要求计算环境中的各个模块仅能够访问当下所必需的信息或资源)来彼此直接共享 capability,这样可以使得操作系统仅分配用户程序需要使用的权限,并且可以做到“一次分配,多次使用”。
|
||||
|
||||
Capability-based Security 这个安全模型,通常会跟另外的一种基于“分级保护域”方式实现的安全模型形成对比。
|
||||
|
||||
基于“分级保护域”实现的安全模型,被广泛应用于类 Unix 的各类操作系统中,比如下图所示的操作系统 Ring0 层和 Ring3 层(Ring1 / Ring2 一般不会被使用)便是“分级保护域”的一种具体实现形式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/08/2b/0812cf94f32ed3ce65f4c23ce192b52b.png" alt="">
|
||||
|
||||
在传统意义上,Ring0 层拥有着最高权限,一般用于内核模式;而 Ring3 层的权限则会被稍加限制,一般用于运行用户程序。当一个运行在 Ring3 层的用户程序,试图去调用只有 Ring0 层进程才有权限使用的指令时,操作系统会阻止调用。这就是“分级保护域”的大致概念。
|
||||
|
||||
反观 Capability-based Security,capability 通过替换在分级保护域中使用的“引用”,来达到提升系统安全性的目的。这里的“引用”是指用于访问资源的一类“定位符”,比如用于访问某个文件资源的“文件路径字符串”便是一个引用。
|
||||
|
||||
引用本身并没有指定实际对应资源的权限信息,以及哪些用户程序可以拥有这个引用。因此,每一次尝试通过该引用来访问实际资源的操作,都会经由操作系统来进行基于“分级保护域”的权限验证。比如验证发起访问的用户是否有权限持有该资源,这种方式便十分适合早期计算机系统的“多用户”特征(每个用户有不同的权限)。
|
||||
|
||||
在具有 capability 概念的操作系统中,只要用户程序拥有了这个 capability,那它就拥有足够的权限去访问对应的资源。从理论上来讲,基于 Capability-based Security 的操作系统,甚至不需要如“权限控制列表(ACL)”这类的传统权限控制机制。
|
||||
|
||||
当然,为了实现上述我们提到的 capability 的能力,每一个 capability 不再是单一的由“字符串”组成的简单数据结构。并且我们还需要保障,capability 的内部结构不会被用户程序直接访问和修改,以防止 capability 本身被伪造。
|
||||
|
||||
相对应的,用户程序只能够通过 capability 暴露出的特定“入口”,来访问对应的系统资源。我们可以用操作系统中常见的一个概念 —— “文件描述符(File Descriptor)”来类比 capability 的概念。如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/94/5b2714041dd7c89420cb00407175c494.png" alt="">
|
||||
|
||||
我们可以将文件描述符类比为 capability。举个例子,当应用程序在通过 C 标准库中的 “fopen” 函数去打开一个文件时,函数会返回一个非负整数,来表示一个特定文件资源对应的文件描述符。
|
||||
|
||||
在拥有了这个描述符后,应用程序便可以按照在调用 “fopen” 函数时所指定的操作(比如 “w”),来相应地对这个文件资源进行处理。当函数返回负整数时,则表示无法获得该资源。在这些返回的错误代码中,就包含有与“权限不足”相关的调用错误信息。
|
||||
|
||||
最为重要的一点是,拥有某个 capability 的用户程序,可以“任意地”处理这个 capability。比如,可以访问其对应的系统资源、可以将其传递给其他的应用程序来进行使用,或者也可以选择直接将这个 capability 删除。操作系统有义务确保某个特定的 capability 只能够对应系统中的某个特定的资源或操作,以保证安全策略的完备性。
|
||||
|
||||
## 系统调用(System Call)
|
||||
|
||||
第二个我们要讲解的概念叫做 “System Call”,翻译成中文即“系统调用”(或者也可称为 “操作系统调用”,这里我们使用简称 “系统调用”)。
|
||||
|
||||
还是回到我们在上一小节中曾提到过的一个场景:“使用 C 标准库中的 fopen 函数,来打开一个计算机本地文件”。请试想,当我们在调用这个 fopen 函数打开某个文件时,实际上发生了什么?fopen 函数是如何访问操作系统的文件资源的呢?带着这两个问题,我们一步步来看。
|
||||
|
||||
既然我们说 fopen 函数是 C 标准库中定义的一个函数,那么我们就从某个特定的 C 标准库实现所对应的源代码入手,来看看 fopen 函数的具体实现细节。这里我们以 musl 这个 libc 的实现为例。在它的源代码中,我们可以找到如下这段对 fopen 函数的定义代码(这里只列出了关键的部分)。
|
||||
|
||||
```
|
||||
FILE *fopen(const char *restrict filename, const char *restrict mode) {
|
||||
...
|
||||
/* Compute the flags to pass to open() */
|
||||
flags = __fmodeflags(mode);
|
||||
|
||||
fd = sys_open(filename, flags, 0666);
|
||||
if (fd < 0) return 0;
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码的具体实现流程和细节我们不做深究,唯一你需要注意的,就是这句函数调用语句 “**fd = sys_open(filename, flags, 0666);**”。在这行语句中,musl 调用了一个名为 “**sys_open**” 的函数,而在这个函数的背后,就是我们本节内容的主角 —— “系统调用”。
|
||||
|
||||
实际上,任何其他需要与操作系统资源打交道的 C ,甚至是 C++ 标准库函数(包括 fopen 函数在内),都需要通过 “系统调用” 来间接访问和使用这些系统资源。sys_open 函数其实是对系统调用进行了封装,在函数内部会使用内联的汇编代码,去实际调用某个具体的“系统调用”。这里 sys_open 对应的,便是指“用于打开本地文件资源”的那个系统调用。
|
||||
|
||||
每一个系统调用,都对应着需要与操作系统打交道的某个特定功能,并且有着唯一的“系统调用 ID” 与之相对应。在不同的操作系统中,对应同一系统调用的系统调用 ID 可能会发生变化。
|
||||
|
||||
而 C/C++ 标准库的作用,便是为我们提供了一个统一、稳定的编程接口。让我们的程序可以做到“**一次编写,到处编译**”。从某种程度上来讲,标准库的出现为应用程序源代码提供了“可移植性”。比如让我们不再需要随着操作系统类型的变化,而硬编码不同的系统调用 ID。
|
||||
|
||||
除此之外,标准库还会帮助我们处理系统调用前后需要做的一些事情,比如简化函数参数的传递、对各种异常情况进行处理,以及“关闭文件”之类的“善后”工作。关于用户应用程序与操作系统调用之间的关系,你可以参考我下面绘制的这幅图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/5c/765ac1d6381b511666430ec022137e5c.png" alt="">
|
||||
|
||||
## WebAssembly 操作系统接口(WASI)
|
||||
|
||||
好了,在讲解完 “Capability-based Security” 以及“系统调用”这两个概念之后,我们再把目光移回到今天的主角 —— WASI。其实从 WASI 对应的全称中,我想你能够猜测到,它肯定与我们在上一节中介绍的“系统调用”有着某种联系(System Call 与 System Interface)。没错,那么接下来我们就一起看看 WASI 究竟是什么。
|
||||
|
||||
我们从“如何在 Web 场景之外使用 Wasm?”这个问题开始说起。我们都知道,Wasm 是一套新的 V-ISA(也就是“虚拟指令集架构”),其中的这些虚拟指令便无法被真实的物理 CPU 硬件直接执行。
|
||||
|
||||
所以如果我们想要在浏览器之外使用 Wasm,就需要提供一种基础设施,来解释并执行这些被存放在 Wasm 二进制模块中的虚拟指令。对于这样的基础设施,我们通常称之为“虚拟机(Virtual Machine)”,或者是 “运行时引擎(Runtime Engine)”。
|
||||
|
||||
OK,假设此时我们已经有了这样的一个虚拟机,可以用于执行 Wasm 的虚拟字节码指令。然后我们希望将这样一段 C/C++ 代码经过编译后,以 Wasm 的形式在这个虚拟机中运行。在这段 C/C++ 代码中,我们使用到了之前提到的 fopen 函数。
|
||||
|
||||
但是问题来了。我们知道,在如 musl 这类 C 标准库的实现中,类似 fopen 这样的函数,最后会被编译为对某个特定平台(IA32、X86-64 等)系统调用的调用过程。这对于 Wasm 来说,会使自己丧失“天生自带”的可移植性。
|
||||
|
||||
单纯对于某一个 Wasm 模块来讲,由于我们并不知道这个模块将会被运行在什么类型的操作系统上,因此我们无法将平台相关的具体信息放到 Wasm 模块中。那如何解决这个问题呢?WASI 给了我们答案。
|
||||
|
||||
WASI 在 Wasm 字节码与虚拟机之间,增加了一层“系统调用抽象层”。比如对于在 C/C++ 源码中使用的 fopen 函数,当我们将这部分源代码与专为 WASI 实现的 C 标准库 “wasi-libc” 进行编译时,源码中对 fopen 的函数调用过程,其内部会间接通过调用名为 “__wasi_path_open” 的函数来实现。这个 __wasi_path_open 函数,便是对实际系统调用的一个抽象。
|
||||
|
||||
__wasi_path_open 函数的具体实现细节会交由各个虚拟机自行处理。也就是说,虚拟机需要在其 Runtime 运行时环境中提供,对 Wasm 模块字节码所使用到的 __wasi_path_open 函数的解析和执行能力的支持。而虚拟机在实际实现这些系统调用抽象层接口时,也需要通过实际的系统调用来进行。只不过这些细节上的处理,对于 Wasm 二进制模块来讲,是完全透明的。
|
||||
|
||||
我们可以将上述提到的 wasi-libc、Wasm 二进制模块、WASI 系统调用抽象层,以及虚拟机基础设施之间的关系,通过下图来直观地展示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/2d/c81f8abfd79925f049b3f0622edc3d2d.png" alt="">
|
||||
|
||||
实际上,类似 __wasi_path_open 的这类以 “__wasi” 开头的,用于抽象实际系统调用的函数,便是 WASI 的核心组成部分。WASI 根据不同系统调用所提供的不同功能,将这些系统调用对应的 WASI 抽象函数接口,分别划分到了不同的子集合中。
|
||||
|
||||
如下图所示,一个名为 “wasi-core” 的 WASI 标准子集合,包含有对应于“文件操作”与“网络操作”等相关系统调用的 WASI 抽象函数接口。其他如 “crypto”、“multimedia” 等子集合,甚至可以包含与实际系统调用无关的一系列 WASI 抽象系统调用接口。你可以理解为 WASI 所描述的抽象系统调用,是针对 Wasm V-ISA 描述的抽象机器而言。针对这部分抽象系统的具体实现,则会依赖一部分实际的系统调用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/5a/67d84dc5a31cbaf1f4b914b980c7275a.png" alt="" title="图片来源于 Lin Clark 博客">
|
||||
|
||||
WASI 在设计和实现时,需要遵守 Wasm 的“可移植性”及“安全性”这两个基本原则。那下面我们来分别看一看, WASI 及其相关的运行时/虚拟机基础设施,是如何确保能够在设计和实现时满足这两个基本原则的。
|
||||
|
||||
### 可移植性(Portability)
|
||||
|
||||
对于“可移植性”,其实我们已经在讲解 WASI 时给出了答案。WASI 通过在 Wasm 二进制字节码与虚拟机基础设施之间,提供统一的“系统调用抽象层”来保证 Wasm 模块的可移植性。这样一来,上层的 Wasm 模块可以不用考虑平台相关的调用细节,统一将对实际系统调用的调用过程,转换为对“抽象系统调用”的调用过程。
|
||||
|
||||
而“抽象系统调用”的实现细节,则由下层的相关基础设施来负责处理。基础设施会根据其所在操作系统类型的不同,将对应的抽象系统调用映射到真实的系统调用上。当然,并不是所有的抽象系统调用都需要被映射到真实的系统调用上,因为对于某些抽象系统调用而言,基础设施只是负责提供相应的实现即可。
|
||||
|
||||
这样,一个经过编译生成的 Wasm 二进制模块便可以在浏览器之外也同样保证其可移植性。真正做到“一次编译,到处运行”,“**抽象**”便是解决这个问题的关键。
|
||||
|
||||
### 安全性(Security)
|
||||
|
||||
对于“安全性”,我们需要再次回到开头介绍的 “Capability-based Security”。
|
||||
|
||||
实际上,基础设施在真正实现 WASI 标准时,便会采用 “Capability-based Security” 的方式来控制每一个 Wasm 模块实例所拥有的 capability。
|
||||
|
||||
举个例子,假设一个 Wasm 模块想要打开一个计算机本地文件,而且这个模块还是由使用了 fopen 函数的 C/C++ 源代码编译而来,那对应的虚拟机在实例化该 Wasm 模块时,便会将 fopen 对应的 WASI 系统调用抽象函数 “__wasi_path_open” 以某种方式(比如通过包装后的函数指针),当做一个 capability 从模块的 Import Section 传递给该模块进行使用。
|
||||
|
||||
通过这种方式,基础设施掌握了主动权。它可以决定是否要将某个 capability 提供给 Wasm 模块进行使用。若某个 Wasm 模块偷偷使用了一些不为开发者知情的系统调用,那么当该模块在虚拟机中进行实例化时,便会露出马脚。掌握这样的主动权,正适合如今我们基于众多不知来源的第三方库进行代码开发的现状。
|
||||
|
||||
对于没有经过基础设施授权的 capability 调用过程,将会被基础设施拦截。通过相应的日志系统进行收集,这些“隐藏的小伎俩”便会在第一时间被开发者/用户感知,并进行相应的处理。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
本节课,我们主要讲解了什么是 WASI。WASI 通过增加“抽象层”的方式,解决了 Wasm 抽象机器(V-ISA)与实际操作系统调用之间的可移植性问题,这可以保证我们基于 WASI 编写的 Wasm 应用(模块)真正做到“**一次编译,到处运行**”。抽象出的“Wasm 系统调用层”将交由具体的底层基础设施(虚拟机/运行时)来提供实现和支持。
|
||||
|
||||
不仅如此,基于 Capability-based Security 模型,WASI 得以在最大程度上保证 Wasm 模块的运行时安全。通过配合 Wasm 模块的 Import Section 与 Export Section,运行时便可以细粒度地控制模块实例所能够使用的系统资源,这相较于传统的“分级保护域”模型来说,无疑会更加灵活和安全。每一个 Wasm 模块在运行时都仅能够使用被授权的 capability,而 WASI 中定义的这些系统调用抽象接口便属于众多 capability 中的一种。
|
||||
|
||||
另外你还需要知道的一点是,无论是 Capability-based Security 模型,还是“分级保护域”模型,两者都是如今被广泛使用的安全模型。只不过相对来说,“最小特权原则” 使得 Capability-based Security 模型对权限的控制力度会更加精细,而“分级保护域”模型则是操作系统中广泛使用的一种安全策略。
|
||||
|
||||
## **课后思考**
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
你还能举出哪些场景,是通过增加“抽象层”来解决了某个实际问题的?
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
@@ -0,0 +1,235 @@
|
||||
<audio id="audio" title="08 | API:在 WebAssembly MVP 标准下你能做到哪些事?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/12/d8fa5f16e700cd3a916319d61d6e8a12.mp3"></audio>
|
||||
|
||||
你好,我是于航。
|
||||
|
||||
在目前与 Wasm 相关的一系列标准中,我们可以将这些标准主要分为两个部分:“Wasm 核心标准(Core Interfaces)”以及“嵌入接口标准(Embedding interfaces)”。
|
||||
|
||||
其中,“Wasm 核心标准”主要定义了与 “Wasm 字节码”、“Wasm 模块结构”、“WAT 可读文本格式”以及模块验证与指令执行细节等相关的内容。关于这部分标准中的内容,我在前面几节课中,已经有选择性地为你挑选了部分重点进行解读。
|
||||
|
||||
而另一个标准“嵌入接口标准”,则定义了有关 Wasm 在 Web 平台上,在与浏览器进行交互时所需要使用的相关 Web 接口以及 JavaScript 接口。在本节课里,我们将讨论有关于这些 API 接口的内容。相信在学完本节课后你便会知道,在当前的 MVP 标准下,我们能够使用 Wasm 在 Web 平台上做些什么?哪些又是 Wasm 暂时无法做到的?
|
||||
|
||||
## Wasm 浏览器加载流程
|
||||
|
||||
那在开始真正讲解这些 API 之前,我们先来看一看,一个 Wasm 二进制模块需要经过怎样的流程,才能够最终在 Web 浏览器中被使用。你可以参考一下我画的这张图,这些流程可以被粗略地划分为以下四个阶段。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/19/8f6880ef50727f61c5f1b72039cf5819.png" alt="">
|
||||
|
||||
首先是 “Fetch” 阶段。作为一个客户端 Web 应用,在这个阶段中,我们需要将被使用到的 Wasm 二进制模块,从网络上的某个位置通过 HTTP 请求的方式,加载到浏览器中。
|
||||
|
||||
这个 Wasm 二进制模块的加载过程,同我们日常开发的 Web 应用在浏览器中加载 JavaScript 脚本文件等静态资源的过程,没有任何区别。对于 Wasm 模块,你也可以选择将它放置到 CDN 中,或者经由 Service Worker 缓存,以加速资源的下载和后续使用过程。
|
||||
|
||||
接下来是 “Compile” 阶段。在这个阶段中,浏览器会将从远程位置获取到的 Wasm 模块二进制代码,编译为可执行的平台相关代码和数据结构。这些代码可以通过 “postMessage()” 方法,在各个 Worker 线程中进行分发,以让 Worker 线程来使用这些模块,进而防止主线程被阻塞。此时,浏览器引擎只是将 Wasm 的字节码编译为平台相关的代码,而这些代码还并没有开始执行。
|
||||
|
||||
紧接着便是最为关键的 “Instantiate” 阶段。在这个阶段中,浏览器引擎开始执行在上一步中生成的代码。在前面的几节课中我们曾介绍过,Wasm 模块可以通过定义 “Import Section” 来使用外界宿主环境中的一些资源。
|
||||
|
||||
在这一阶段中,浏览器引擎在执行 Wasm 模块对应的代码时,会将那些 Wasm 模块规定需要从外界宿主环境中导入的资源,导入到正在实例化中的模块,以完成最后的实例化过程。这一阶段完成后,我们便可以得到一个动态的、保存有状态信息的 Wasm 模块实例对象。
|
||||
|
||||
最后一步便是 “Call”。顾名思义,在这一步中,我们便可以直接通过上一阶段生成的动态 Wasm 模块对象,来调用从 Wasm 模块内导出的方法。
|
||||
|
||||
接下来,我们将围绕上述流程中的第二步 “Compile 编译” 与第三步 “Instantiate 实例化”,来分别介绍与这两个阶段相关的一些 JavaScript API 与 Web API。
|
||||
|
||||
## Wasm JavaScript API
|
||||
|
||||
### 模块对象
|
||||
|
||||
映入眼帘的第一个问题就是,我们如何在 JavaScript 环境中表示刚刚说过的 “Compile 编译” 与 “Instantiate 实例化” 这两个阶段的“产物”?为此,Wasm 在 JavaScript API 标准中为我们提供了如下两个对象与之分别对应:
|
||||
|
||||
- **WebAssembly.Module**
|
||||
- **WebAssembly.Instance**
|
||||
|
||||
不仅如此,上面这两个 JavaScript 对象本身也可以被作为类型构造函数使用,以用来直接构造对应类型的对象。也就是说,我们可以通过 “new” 的方式并传入相关参数,来构造这些类型的某个具体对象。比如,可以按照以下方式来生成一个 WebAssembly.Module 对象:
|
||||
|
||||
```
|
||||
// "..." 为有效的 Wasm 字节码数据;
|
||||
bufferSource = new Int8Array([...]);
|
||||
let module = new WebAssembly.Module(bufferSource);
|
||||
|
||||
```
|
||||
|
||||
这里的 WebAssembly.Module 构造函数接受一个包含有效 Wasm 二进制字节码的 ArrayBuffer 或者 TypedArray 对象。
|
||||
|
||||
WebAssembly.Instance 构造函数的用法与 WebAssembly.Module 类似,只不过是构造函数的参数有所区别。更详细的 API 使用信息,你可以点击[这里](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WebAssembly)进行参考。
|
||||
|
||||
### 导入对象
|
||||
|
||||
我们曾在之前的课程中介绍过 Wasm 二进制模块内部 “Import Section” 的作用。通过这个 Section,模块便可以在实例化时接收并使用来自宿主环境中的数据。
|
||||
|
||||
Web 浏览器作为 Wasm 模块运行时的一个宿主环境,通过 JavaScript 的形式提供了可以被导入到 Wasm 模块中使用的数据类型,这些数据类型包括函数(Function)、全局数据(Global)、线性内存对象(Memory)以及 Table 对象(Table)。其中除“函数”类型外,其他数据类型分别对应着以下由 JavaScript 对象表示的包装类型:
|
||||
|
||||
- **WebAssembly.Global**
|
||||
- **WebAssembly.Memory**
|
||||
- **WebAssembly.Table**
|
||||
|
||||
而对于函数类型,我们可以直接使用 JavaScript 语言中的“函数”来作为代替。
|
||||
|
||||
同理,我们也可以通过“直接构造”的方式来创建上述这些 JavaScript 对象。以 “WebAssembly.Memory” 为例,我们可以通过如下方式,来创建一个 WebAssembly.Memory 对象:
|
||||
|
||||
```
|
||||
let memory = new WebAssembly.Memory({
|
||||
initial:10,
|
||||
maximum:100,
|
||||
});
|
||||
|
||||
|
||||
```
|
||||
|
||||
这里我们通过为构造函数传递参数的方式,指定了所生成 WebAssembly.Memory 对象的一些属性。比如该对象所表示的 Wasm 线性内存其初始大小为 10 页,其最大可分配大小为 100 页。
|
||||
|
||||
需要注意的是,Wasm 线性内存的大小必须是 “Wasm 页” 大小的整数倍,而一个 “Wasm 页” 的大小在 MVP 标准中被定义为了 “64KiB”(注意和 64 KB 的区别。KiB 为 1024 字节,而 KB 为 1000 字节)。
|
||||
|
||||
关于另外的 WebAssembly.Global 与 WebAssembly.Table 这两个类型所对应构造函数的具体使用方式,你可以点击[这里](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WebAssembly)进行参考。
|
||||
|
||||
### 错误对象
|
||||
|
||||
除了上述我们介绍的几个比较重要的 JavaScript WebAssembly 对象之外,还有另外几个与 “Error” 有关的表示某种错误的 “错误对象”。这些错误对象用以表示在整个 Wasm 加载、编译、实例化及函数执行流程中,在其各个阶段中所发生的错误。这些错误对象分别是:
|
||||
|
||||
- **WebAssembly.CompileError** 表示在 Wasm 模块编译阶段(Compile)发生的错误,比如模块的字节码编码格式错误、魔数不匹配
|
||||
- **WebAssembly.LinkError** 表示在 Wasm 模块实例化阶段(Instantiate)发生的错误,比如导入到 Wasm 模块实例 Import Section 的内容不正确
|
||||
- **WebAssembly.RuntimeError** 表示在 Wasm 模块运行时阶段(Call)发生的错误,比如常见的“除零异常”
|
||||
|
||||
上面这些错误对象也都有对应的构造函数,可以用来构造对应的错误对象。(同样,如果有需要,你可以点击[这里](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WebAssembly)进入 MDN 网站参考一下)
|
||||
|
||||
### 模块实例化方法
|
||||
|
||||
最后一个需要重点介绍的 JavaScript API 主要用来实例化一个 Wasm 模块对象。该方法的原型如下所示:
|
||||
|
||||
- **WebAssembly.instantiate(bufferSource, importObject)**
|
||||
|
||||
这个方法接受一个包含有效 Wasm 模块二进制字节码的 ArrayBuffer 或 TypedArray 对象,然后返回一个将被解析为 WebAssembly.Module 的 Promise 对象。就像我上面讲的那样,这里返回的 WebAssembly.Module 对象,代表着一个被编译完成的 Wasm 静态模块对象。
|
||||
|
||||
整个方法接受两个参数。除第一个参数对应的 ArrayBuffer 或 TypedArray 类型外,第二个参数为一个 JavaScript 对象,在其中包含有需要被导入到 Wasm 模块实例中的数据,这些数据将通过 Wasm 模块的 “Import Section” 被导入到模块实例中使用。
|
||||
|
||||
方法在调用完成后会返回一个将被解析为 ResultObject 的 Promise 对象。ResultObject 对象包含有两个字段 ,分别是 “module” 以及 “instance”。
|
||||
|
||||
其中 module 表示一个被编译好的 WebAssembly.Module 静态对象;instance 表示一个已经完成实例化的 WebAssembly.Instance 动态对象。所有从 Wasm 模块中导出的方法,都被“挂载”在这个 ResultObject 对象上。
|
||||
|
||||
基于这个方法实现的 Wasm 模块初始化流程如下图所示。你可以看到,整个流程是完全串行的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/48/b7/48663812f94fdd489f1988c71d4cc5b7.png" alt="">
|
||||
|
||||
需要注意的是,WebAssembly.instantiate 方法还有另外的一个重载形式,也就是其第一个参数类型从含有 Wasm 模块字节码数据的 bufferSource,转变为已经编译好的静态 WebAssembly.Module 对象。这种重载形式通常用于 WebAssembly.Module 对象已经被提前编译好的情况。
|
||||
|
||||
### 模块编译方法
|
||||
|
||||
上面讲到的 WebAssembly.instantiate 方法,主要用于从 Wasm 字节码中一次性进行 Wasm 模块的编译和实例化过程,而这通常是我们经常使用的一种形式。当然你也以将编译和实例化两个步骤分开来进行。比如单独对于编译阶段,你可以使用下面这个 JavaScript API:
|
||||
|
||||
- **WebAssembly.compile(bufferSource)**
|
||||
|
||||
该方法接收一个含有有效 Wasm 字节码数据的 bufferSource,也就是 ArrayBuffer 或者 TypedArray 对象。返回的 Promise 对象在 Resolve 后,会返回一个编译好的静态 WebAssembly.Module 对象。
|
||||
|
||||
## Wasm Web API
|
||||
|
||||
Wasm 的 JavaScript API 标准,主要定义了一些与 Wasm 相关的类型和操作,这些类型和操作与具体的平台无关。为了能够在最大程度上利用 Web 平台的一些特性,来加速 Wasm 模块对象的编译和实例化过程,Wasm 标准又通过添加 Wasm Web API 的形式,为 Web 平台上的 Wasm 相关操作提供了新的、高性能的编译和实例化接口。
|
||||
|
||||
### 模块流式实例化方法
|
||||
|
||||
不同于 JavaScript API 中的 WebAssembly.instantiate 方法,Web API 中定义的“流式接口”可以让我们提前开始对 Wasm 模块进行编译和实例化过程,你也可以称此方式为“流式编译”。比如下面这个 API 便对应着 Wasm 模块的“流式实例化”接口:
|
||||
|
||||
- **WebAssembly.instantiateStreaming(source, importObject)**
|
||||
|
||||
为了能够支持“流式编译”,该方法的第一个参数,将不再需要已经从远程加载好的完整 Wasm 模块二进制数据(bufferSource)。取而代之的,是一个尚未 Resolve 的 Response 对象。
|
||||
|
||||
Response 对象(window.fetch 调用后的返回结果)是 Fetch API 的重要组成部分,这个对象代表了某个远程 HTTP 请求的响应数据。而该方法中第二个参数所使用的 Response 对象,则必须代表着对某个位于远程位置上的 Wasm 模块文件的请求响应数据。
|
||||
|
||||
通过这种方式,Web 浏览器可以在从远程位置开始加载 Wasm 模块文件数据的同时,也一并启动对 Wasm 模块的编译和初始化工作。相较于上一个 JavaScript API 需要在完全获取 Wasm 模块文件二进制数据后,才能够开始进行编译和实例化流程的方式,流式编译无疑在某种程度上提升了 Web 端运行 Wasm 应用的整体效率。
|
||||
|
||||
基于流式编译进行的 Wasm 模块初始化流程如下图所示。可以看到,与之前 API 有所不同的是,Wasm 模块的编译和初始化可以提前开始,而不用再等待模块的远程加载完全结束。因此应用的整体初始化时间也会有所减少。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/90/9e9f17af42c58ea7d4f94d4d26b94690.png" alt="">
|
||||
|
||||
### 模块流式编译方法
|
||||
|
||||
那么既然存在着模块的“流式实例化方法”,便也存在着“流式编译方法”。如下所示:
|
||||
|
||||
- **WebAssembly.compileStreaming(source)**
|
||||
|
||||
该 API 的使用方式与 WebAssembly.instantiateStreaming 类似,第一个参数为 Fetch API 中的 Response 对象。API 调用后返回的 Promise 对象在 Resolve 之后,会返回一个编译好的静态 WebAssembly.Module 对象。
|
||||
|
||||
同 Wasm 模块的“流式实例化方法”一样,“流式编译方法”也可以在浏览器加载 Wasm 二进制模块文件的同时,提前开始对模块对象的编译过程。
|
||||
|
||||
## Wasm 运行时(Runtime)
|
||||
|
||||
这里提到的“运行时”呢,主要存在于我们开头流程图中的 “Call” 阶段。在这个阶段中,我们可以调用从 Wasm 模块对象中导出的函数。每一个经过实例化的 Wasm 模块对象,都会在运行时维护自己唯一的“调用栈”。
|
||||
|
||||
所有模块导出函数的实际调用过程,都会影响着栈容器中存放的数据,这些数据代表着每条 Wasm 指令的执行结果。当然,这些结果也同样可以被作为导出函数的返回值。
|
||||
|
||||
调用栈一般是“不透明”的。也就是说,我们无法通过任何 API 或者方法直接接触到栈容器中存放的数据。因此,这也是 Wasm 保证执行安全的众多因素之一。
|
||||
|
||||
除了调用栈,每一个实例化的 Wasm 模块对象都有着自己的(在 MVP 下只能有一个)线性内存段。在这个内存段中,以二进制形式存放着 Wasm 模块可以使用的所有数据资源。
|
||||
|
||||
这些资源可以是来自于对 Wasm 模块导出方法调用后的结果,即通过 Wasm 模块内的相关指令对线性内存中的数据进行读写操作;也可以是在进行模块实例化时,我们将预先填充好的二进制数据资源以 WebAssembly.Memory 导入对象的形式,提前导入到模块实例中进行使用。
|
||||
|
||||
浏览器在为 Wasm 模块对象分配线性内存时,会将这部分内存与 JavaScript 现有的内存区域进行隔离,并单独管理,你可以参考我下面给你画的这张图。在以往的 JavaScript Memory 中,我们可以存放 JavaScript 中的一些数据类型,这些数据同时也可以被相应的 JavaScript / Web API 直接访问。而当数据不再使用时,它们便会被 JavaScript 引擎的 GC 进行垃圾回收。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/83/919d2e8893d0c8fc13f42ea31ce93983.png" alt="">
|
||||
|
||||
相反,图中绿色部分的 WebAssembly Memory 则有所不同。这部分内存可以被 Wasm 模块内部诸如 “i32.load” 与 “i32.store” 等指令直接使用,而外部浏览器宿主中的 JavaScript / Web API 则无法直接进行访问。不仅如此,分配在这部分内存区域中的数据,受限于 MVP 中尚无 GC 相关的标准,因此需要 Wasm 模块自行进行清理和回收。
|
||||
|
||||
Wasm 的内存访问安全性是众多人关心的一个话题。事实上你并不用担心太多,因为当浏览器在执行 “i32.load” 与 “i32.store” 这些内存访问指令时,会首先检查指令所引用的内存地址偏移,是否超出了 Wasm 模块实例所拥有的内存地址范围。若引用地址不在上图中绿色范围以内,则会终止指令的执行,并抛出相应的异常。这个检查过程我们一般称之为 “Bound Check”。
|
||||
|
||||
那么,接下来我们再把目光移到 WebAssembly Memory 身上,来看一看它是如何与“浏览器”这个 Web 宿主环境中的 JavaScript API 进行交互的。
|
||||
|
||||
### Wasm 内存模型
|
||||
|
||||
根据之前课程所讲的内容,我们知道,每一个 Wasm 模块实例都有着自己对应的线性内存段。准确来讲,也就是由 “Memory Section” 和 “Data Section” 共同“描述”的一个线性内存区域。在这个区域中,以二进制形式存放着模块所使用到的各种数据资源。
|
||||
|
||||
事实上,每一个 Wasm 实例所能够合法访问的线性内存范围,仅限于我们上面讲到的这一部分内存段。对于宿主环境中的任何变量数据,如果 Wasm 模块实例想要使用,一般可以通过以下两种常见的方式:
|
||||
|
||||
1. 对于简单(字符 \ 数字值等)数据类型,可以选择将其视为全局数据,通过 “Import Section” 导入到模块中使用;
|
||||
1. 对于复杂数据,需要将其以“字节”的形式,拷贝到模块实例的线性内存段中来使用。
|
||||
|
||||
在 Web 浏览器这个宿主环境中,一个内存实例通常可以由 JavaScript 中的 ArrayBuffer 类型来进行表示。ArrayBuffer 中存放的是原始二进制数据,因此在需要读写这段数据时,我们必须指定一个“操作视图(View)”。你可以把“操作视图”理解为,在对这些二进制数据进行读写操作时,数据的“解读方式”。
|
||||
|
||||
举个例子,假设我们想要将字符串 “Hello, world!” ,按照逐个字符的方式写入到线性内存段中,那么在进行写操作时,我们如何知道一个字符所应该占用的数据大小呢?
|
||||
|
||||
根据实际需要,一个字符可能会占用 1 个字节到多个字节不等的大小。而这个“占用大小”便是我们之前提到的数据“解读方式”。在 JavaScript 中,我们可以使用 TypedArray 以某个具体类型作为视图,来操作 ArrayBuffer 中的数据。
|
||||
|
||||
你可以通过下面这张图,来理解一下我们刚刚说的 Wasm 模块线性内存与 Web 浏览器宿主环境,或者说与 JavaScript 之间的互操作关系。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/aa/c67cd17af060cee591b1d7c69138fcaa.png" alt="">
|
||||
|
||||
当我们拥有了填充好数据的 ArrayBuffer 或 TypedArray 对象时,便可以构造自己的 WebAssembly.Memory 导入对象。然后在 Wasm 模块进行实例化时,将该对象导入到模块中,来作为模块实例的线性内存段进行使用。
|
||||
|
||||
### 局限性
|
||||
|
||||
一切看起来好像都还不错,但我们现在再来回味一下 MVP 的全称。MVP 全称为 “Minimum Viable Product”,翻译过来是“最小可用产品”。那既然是“最小可用”,当然也就意味着它还有很多的不足。我给你总结了一下,目前可以观测到的“局限性”主要集中在以下几个方面:
|
||||
|
||||
- **无法直接引用 DOM**
|
||||
|
||||
在 MVP 标准下,我们无法直接在 Wasm 二进制模块内引用外部宿主环境中的“不透明”(即数据内部的实际结构和组成方式未知)数据类型,比如 DOM 元素。
|
||||
|
||||
因此目前通常的一种间接实现方式是使用 JavaScript 函数来封装相应的 DOM 操作逻辑,然后将该函数作为导入对象,导入到模块中,由模块在特定时机再进行间接调用来使用。但相对来说,这种借助 JavaScript 的间接调用方式,在某种程度上还是会产生无法弥补的性能损耗。
|
||||
|
||||
- **复杂数据类型需要进行编解码**
|
||||
|
||||
还是类似的问题,对于除“数字值”以外的“透明”数据类型(比如字符串、字符),当我们想要将它们传递到 Wasm 模块中进行使用时,需要首先对这些数据进行编码(比如 UTF-8)。然后再将编码后的结果以二进制数据的形式存放到 Wasm 的线性内存段中。模块内部指令在实际使用时,再将这些数据进行解码。
|
||||
|
||||
因此我们说,就目前 MVP 标准而言,Wasm 模块的线性内存段是与外部宿主环境进行直接信息交换的最重要“场所”。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。
|
||||
|
||||
在本节课中,我们主要讲解了 Wasm MVP 相关标准中的 JavaScript API 与 Web API。借助这些 API,我们可以在 Web 平台上通过 JavaScript 代码来与 Wasm 模块进行一系列的交互。
|
||||
|
||||
我们可以用一句话来总结目前 Wasm MVP 标准在 Web 浏览器上的能力:**凡是能够使用 Wasm 来实现的功能,现阶段都可以通过 JavaScript 来实现;而能够使用 JavaScript 来实现的功能,其中部分还无法直接通过 Wasm 实现(比如调用 Web API)**。
|
||||
|
||||
JavaScript API 提供了众多的包装类型,这样便能够在 JavaScript 环境中表示 Wasm 模块的不同组成部分。比如 WebAssembly.Moulde 对应的 Wasm 模块对象、WebAssembly.Memory 对应的 Wasm 线性内存对象等等。
|
||||
|
||||
除此之外,JavaScript API 中还提供了诸如 WebAssembly.Compile 以及 WebAssembly.instantiate 方法,以用于编译及实例化一个 Wasm 模块对象。
|
||||
|
||||
相对的,Web API 则提供了与 Web 平台相关的一些特殊方法。比如 WebAssembly.compileStreaming 与 WebAssembly.instantiateStreaming。借助这两个 API,我们可以更加高效地完成对 Wasm 模块对象的编译和实例化过程。
|
||||
|
||||
除此之外,我们还讲解了 Wasm 模块在运行时的一些特征,比如“内存模型”。以及目前在 MVP 标准下应用 Wasm 时的一些局限性等等。相信学完本次课程,你可以对 “Wasm 目前在 Web 平台上能够做些什么,哪些事情暂时还无法做到?” 这个问题,有着一个更加深刻的认识。
|
||||
|
||||
最后,我绘制一个 Wasm JavaScript API 脑图,可以供你参考以及回顾本节课的内容。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/9b/5afb46e59487e7a6863f46aebcf9409b.png" alt="">
|
||||
|
||||
## **课后思考**
|
||||
|
||||
最后,我们来做一个思考题吧。
|
||||
|
||||
如果你是曾经使用过 Wasm 的同学,那么你觉得在目前的 MVP 标准下,Wasm 还有着哪些局限性亟待解决?如果你还没有使用过 Wasm,那么你最期待 Wasm 能够支持哪些新的特性呢?
|
||||
|
||||
今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。
|
||||
Reference in New Issue
Block a user