图说 WebAssembly(四):快速入门
本文是图说 WebAssembly 系列文章的第四篇。如果您还未阅读之前的文章,建议您从第一篇入手。
WebAssembly 是一种使得除 JavaScript 以外的编程语言也能运行在网页上的技术。
在过去,当我们需要通过编程来控制网页内容时,我们的选择只有 JavaScript 。
所以当大家都说 WebAssembly 运行速度很快时,其实它的比较对象就是指 JavaScript 。
不过这并不意味着你只能使用 JavaScript 和 WebAssembly 中的一种。
反而,更推荐的做法是同时使用它们。即便是你不写 WebAssembly ,你也是可以从它身上获得好处的。
WebAssembly 模块定义了可以被 JavaScript 调用的函数。
就像我们现在可以直接从 npm 下载 lodash 模块并调用其接口一样,未来我们也可以下载 WebAssembly 模块并使用它。
所以,今天我们来看看如何创建 WebAssembly 模块,以及如何使用 JavaScript 调用它。
角色
在上一篇文章中,我们介绍了编译器如何把高级语言编译为机器码。
在上图中,WebAssembly 对应哪个角色呢?
聪明的你可能已经想到,它只不过是另一种目标汇编语言而已。
从某种意义上来说,这种想法是对的,只不过图中的 x86、ARM 等其实对应的是一种特定的计算机架构。
对于开发者来说,他所开发的代码是希望能够运行在互联网上所有用户机器上的,但是他其实并不知道运行这些代码的机器属于哪种架构。
所以 WebAssembly 跟汇编相比还是有略微不同之处。
它面向的是一种概念上机器的机器语言,而不是一种真实存在的物理机器。
这也就导致了 WebAssembly 指令是一种虚拟指令。
与 JavaScript 源码相比,虚拟指令跟机器码的映射来得更为直接。
它们表示一种可以在普遍流行机器上高效使用的指令集合。但同时它们也不会直接映射到特定的机器码。
浏览器会下载 WebAssembly,然后把它变成目标机器的汇编。
编译
目前对 WebAssembly 支持最多的编译器工具链称为 LLVM 。有很多不同的编译器前端和后端都在使用 LLVM 。
注意: 大多数的 WebAssembly 模块开发者都会使用 C 和 Rust 这样的语言,然后编译为 WebAssembly,但是也有其他方式创建 WebAssembly 模块。比如,有一个实验工具可以把 TypeScript 编译为 WebAssembly 模块,更有甚者,
可以直接手写 WebAssembly 。
这里,假如我们想把 C 编译为 WebAssembly 。
我们可以使用 C 语言编译器前端把 C 代码编译为 LLVM 中间代码。一旦变成 LLVM 的中间代码,LLVM 就可以理解并分析代码,然后做一些优化。
为了把 LLVM 中间代码变成 WebAssembly,我们还需要一个编译器后端。刚好,LLVM 项目中确实有一个正在开发编译器后端,未来它应该是大部分人的共同选择,而且应该很快就要完成了。不过,现在用它的话还是相当棘手。
不过不用灰心,还有另一个工具称为 Emscripten,目前用起来会更加简单点。
它拥有自己编译器后端,可以把中间代码编译为 asm.js ,进而转化为 WebAssembly 。
不过它也支持 LLVM,因此我们也可以在 Emscripten 和其他后端之间相互切换。
Emscripten 还包含了很多其他工具和库,允许开发者移植整个 C/C++ 代码,因此与其说它是编译器,其实它更像是软件开发套件(SDK)。
不管用什么工具链,最终的结果都是得到一个 .wasm
文件。后面我们会介绍 .wasm
文件的结构,不过首先让我们来看看如何在 JavaScript 中使用它。
加载
.wasm
文件就是 WebAssembly 模块,它可以直接使用 JavaScript 加载。
截止到目前,这种加载方式略微复杂。
function fetchAndInstantiate(url, importObject) { return fetch(url).then(res => res.arrayBuffer()) .then(bytes => WebAssembly.instantiate(bytes, importObject)) .then(results => results.instance); }
想深入的话,可以参考这个MDN 文档
我们正在努力把这个过程变得更加简单。我们也希望能够把工具链变得更加友好,希望能够直接集成到诸如 webpack 或者 SystemJS 等打包器中。相信未来 WebAssembly 模块可以跟加载 JavaScript 模块一样简单好用。
不过,WebAssembly 模块和 JavaScript 模块之间有一个主要的不同之处。
当前,WebAssembly 模块中的函数只能使用数字作为参数或者返回值。
对于其他任何更复杂的数据类型,如字符串,我们必须直接操作 WebAssembly 模块的内存。
如果你大部分的时间都在使用 JavaScript,那么你可能对直接操作内容不太熟悉。
像 C、C++ 和 Rust 这些高性能的语言,它们都必须手动管理内存。
WebAssembly 模块的内存就模拟了这些语言的堆内存。
为了能够操作内存,我们需要使用 JavaScript 中的 ArrayBuffer
。
它是字节数组,所以它的索引当做内存地址来使用。
如果想要在 JavaScript 和 WebAssembly 之间传递字符串,那么必须先把字符串转为等效的字符码,然后写入 ArrayBuffer
。由于数组索引是整数,所以索引可以传递给 WebAssembly 函数。这样,索引就变成了指向字符串首个字符的指针了。
不过大部分情况下,WebAssembly 模块开发者都会把模块做友好地封装。此时,模块的使用者可能就没必要知道其内部是如何管理内存的了。
如果你对内存管理感兴趣,可以查看 MDN 文档
结构
如果你编程使用的是高级语言然后编译为 WebAssembly,那其实你没必要了解 WebAssembly 模块的结构,不过它可以帮你理解基础信息。
下面是一个 C 函数,我们将把它编译为 WebAssembly 。
int add42(int num) { return num + 42; }
你可以使用 WasmExplorer来编译这个函数。
打开编译好的 .wasm
文件后,我们可能会看到类似以下的内容:
00 61 73 6D 0D 00 00 00 01 86 80 80 80 00 01 60 01 7F 01 7F 03 82 80 80 80 00 01 00 04 84 80 80 80 00 01 70 00 00 05 83 80 80 80 00 01 00 01 06 81 80 80 80 00 00 07 96 80 80 80 00 02 06 6D 65 6D 6F 72 79 02 00 09 5F 5A 35 61 64 64 34 32 69 00 00 0A 8D 80 80 80 00 01 87 80 80 80 00 00 20 00 41 2A 6A 0B
这是模块的“二进制”表示。之所以给“二进制”加上引号,是因为二进制表示通常是显示为十六进制的,但是可以很简单的转为二进制,或者人类可读的格式。
举例来说,下面是 num + 42
的模样:
运行
你可能会对上图中的内容感到疑惑,下面我们把这些指令的作用标注出来。
你可能已经注意到 add
操作并没有说要相加的两个数从哪里来。这是因为 WebAssembly 是一种称为堆栈机器。这意味着,操作码在操作之前,它所需的操作数已经在堆栈的队列当中了。
像 add
这样的操作码本身就知道它需要多少个操作数。因为 add
需要两个操作数,所以它会从堆栈的顶部取出两个值来作为操作数。
这样种设计中,add
指令可以变得很短,只占用一字节,因为它并不需要指定源和目标寄存器地址。这样就减小了 .wasm
文件的大小,从而更利于网络传输。
尽管 WebAssembly 是根据堆栈机器来设计的,但是这并不是它在真实物理机器上工作的方式。
当浏览器把 WebAssembly 编译为机器码时,它仍然会用到寄存器。不过,由于 WebAssembly 代码并不指定寄存器,所以浏览器能够更自由的为其指定最高效的寄存器。
组成
除了 add42
函数本身,.wasm
也还包含了其他内容。这些内容称为段(Section)。有些段是任何模块都必须有的,有些则是可选的。
必选的有:
- 类型:包含模块中函数和任何导入函数的函数签名。
- 函数:给模块中的每个函数提供索引。
- 代码:模块中每个函数的函数体。
可选的有:
- 导出:使得函数、内存、表格和全局变量对其他模块和 JS 可访问。这可以使得模块可以单独编译,然后动态链接起来。
- 导入:指定从其他模块或者 JS 中导入的函数、内存、表格和全局变量等。
- 入口:模块加载时自动运行的函数。
- 全局:模块中的全局变量声明。
- 内存:定义模块使用的内存。
- 表格:用于映射不透明值,这些值不能在 WebAssembly 中表示或直接访问,例如 JS 的对象。
- 数据:用于初始化导入的或本地的内存
- 元素:用于初始化导入的或者本地的表格
更多的资料可参考 MDN 文档
结束
经过本文,相信你已经知道该如何使用 WebAssembly 模块了。下一篇文章我们将探索它为何如此快。