创建并使用 WebAssembly 模块

阅读 526
收藏 26
2017-09-12
原文链接:www.zcfy.cc

这是 “WebAssembly 以及为什么它这么快” 这个系列的第四部分。如果你还没阅读其他的部分,我们建议你 从头开始阅读

WebAssembly 是一种在页面中运行除了以外的编程语言的方法。在过去,如果你想要使你的代码能在浏览器中运行并且和浏览器交互,JavaScript 是你唯一的选择。

所以当人们谈论到 WebAssembly 的运行之快时,对于 JavaScript,好比谈论的是是苹果和苹果之间的较量。但是这并不意味着你只能在 WebAssembly 与 JavaScript 之间二选一。

事实上,我们期望开发者能够在开发同一个应用时结合两种技术。就算你自己不写 WebAssembly,你也可以利用它的优势。

WebAssembly 模块定义的函数可以被 JavaScript 所用。就好比,你从 npm 下载了一个诸如 lodash 这样的模块然后调用了它提供的 API 。在将来你也可以下载 WebAssembly 的模块。

现在,就让我们来看怎样去创建 WebAssembly 模块并在 JavaScript 中使用这些它们。

WebAssembly 要安放在哪呢?

在这篇关于汇编的文章里,我谈论了编译器是如何将高级编程语言转换为机器码的。

Diagram showing an intermediate representation between high level languages and assembly languages, with arrows going from high level programming languages to intermediate representation, and then from intermediate representation to assembly language

对于上图,WebAssembly 要如何融入这个过程中呢?

你可能会认为它不过就是另一个目标汇编语言。也确实是这样,除了每一种语言(x86, ARM)都对应着不同的机器架构。

当你的代码通过互联网传输到用户的机器上执行的时候,你并不知道你的代码要在什么样的机器上执行。

因此 WebAssembly 和其他的汇编有些不同。它是一种概念中的机器的机器语言,而不是实际的机器的机器语言。

出于这个原因,WebAssembly 的指令有时也称作虚拟指令。 这些指令比 JavaScript 源码更加直接地映射到机器码。它们代表了某种交集,可以更加有效地跨越不同的流行硬件。但是它们也并不是直接地映射到特定的硬件的特定机器码。

Same diagram as above with WebAssembly inserted between the intermediate representation and assembly

浏览器下载完 WebAssembly,然后从 WebAssembly 跳转至目标机器的汇编代码。

编译至 .wasm 文件

目前对 WebAssembly 支持最好的编译工具链叫做LLVM。不同的前端和后端可以插入到 LLVM 当中。

注: 大多数的 WebAssembly 模块大多是开发者使用像 C 和 Rust 这样的语言编写的然后编译成WebAssembly。但是也有其他的办法可以创建 WebAssembly 模块。比如,这里一个实验性的工具可以让你使用TypeScript来创建 WebAssembly 模块。或者你也可以直接使用 WebAssembly 的文本表示来编码

假设我们想要使 C 转换成 WebAssembly。我们可以使用 clang 前端(并非传统意义上的前端)将 C 转换为 LLVM 中间表示(IR)。一旦它到 LLVM 的中间表示,LLVM 就能理解它并且执行一些优化操作。

为了从 LLVM’s IR (intermediate representation) 转换到 WebAssembly,我们需要一个后端(并非传统意义上的后端)。 目前 LLVM 中有个一个正在开发中的后端。这个后端将会是主要的解决方案,并且很快就会敲定了。不过,目前使用它还是很困难。

另外一个叫做 Emscripten 的工具目前来说较为简单一些。它有自己的后端来产生将前端语言先编译成另外一种目标(叫做 asm.js) 然后再将这个目标转化成 WebAssembly。它底层使用了 LLVM,因此,你可以在 Emscripten 中切换两种后端。

Diagram of the compiler toolchain

Emscripten 包含很多额外的工具和库,允许移植整个 C/C++ 代码库。所以它更像是一个 SDK 而不是编译器。比如,系统开发者习惯于有个可以读写的文件系统,因此, Emscripten 可以用 IndexedDB 来模拟这个系统。

无论你使用什么工具链,最后都会生成 .wasm 文件。接下来我会解释 .wasm 文件的结构。不过首先我们先来看看怎么在 JS 中使用它。

在 JavaScript 中加载 .wasm 模块

.wasm 文件就是 WebAssembly 模块,它可以在 JavaScript 中加载。就目前而言,它的加载过程有一些复杂。

 function fetchAndInstantiate(url, importObject) {
  return fetch(url).then(response =>
    response.arrayBuffer()
  ).then(bytes =>
    WebAssembly.instantiate(bytes, importObject)
  ).then(results =>
    results.instance
  );
}

想更深入了解,请查看我们的文档.

目前我们正在努力使这个过程变得更加简单。我们期望能够增强工具链并且与现有的像 webpack 或者 System.js 这样的模块打包工具整合。我们相信未来加载模块可以像加载 modules can be as easy as as loading JavaScript 模块那样简单。

虽然目前 WebAssembly 模块和JS 模块有着一个主要的区别:WebAssembly 的函数只能使用数值 (整数 或者浮点数) 作为参数或者返回值。

Diagram showing a JS function calling a C function and passing in an integer, which returns an integer in response

对于其他更复杂的数据类型,比如字符串,你必须使用 WebAssembly 模块的内存。

如果你大多数情况都在和 JavaScript 打交道,那么直接访问内存对你来说可能不那么熟悉。更高性能的语言像 C,C++ 和 Rust,一般都有手动内存管理。WebAssembly 模块的内存模拟了堆,在这些语言中你是可以看到的。

为了达到这个目的,它使用了 JavaScript 中的 ArrayBuffer。数组缓冲就是一个全是字节的数组,数组的索引代表着具体的内存地址。

如果你想在 JavaScript 和 WebAssembly 之间传递一个字符串,你需要将字符转换成它对应的字符编码。然后将他们写进内存数组。由于索引是整数,索引就能够传递给 WebAssembly 的函数。因此,字符串中的第一个字符的索引就可以作为指针。

Diagram showing a JS function calling a C function with an integer that represents a pointer into memory, and then the C function writing into memory

任何开发 WebAssembly 模块给其他 web 开发者使用的开发者很可能会对模块外面进行包装。这样,使用模块的人就不必知道内部的内存管理的细节了。

如果你想学习更多关于这方面的知识,请查看文档中的 这一部分

.wasm 文件的结构

如果你正在使用更高级的语言书写代码并将其编译为 WebAssembly。你不需要知道 WebAssembly 模块是怎样组织结构的。但是这可以有助于理解基础知识。

如果你还没有准备好,我们建议你先阅读 关于汇编的一篇文章 (这个系列的第三部分)。

这里是一个待转换为 WebAssembly 的 一个C 函数:

 int add42(int num) {
  return num + 42;
}

你可以尝试使用 WASM Explorer 来编译该函数。

如果你打开 .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 看起来的样子。

Table showing hexadecimal representation of 3 instructions (20 00 41 2A 6A), their binary representation, and then the text representation (get_local 0, i32.const 42, i32.add)

代码是如何工作的: 堆栈机

如果你好奇的话,这里是这些指令的作用。

Diagram showing that get_local 0 gets value of first param and pushes it on the stack, i32.const 42 pushes a constant value on the stack, and i32.add adds the top two values from the stack and pushes the result

你或许已经注意到了, add 操作并没有说它要操作的值是从哪里来的。这是因为 WebAssembly 是一种以堆栈机为模板的东西。这意味着所有操作所需要的值都在操作执行之前排列在栈上。

add 这样的操作知道它需要多少个值,由于add 操作需要两个值,所以它会从栈顶提取两个值。这意味着 add 指令可以变得很短(单个字节),因为这个指令不需要指定源或者目标寄存器。这样能够减小 .wasm 文件的体积,这也意味着需要更少的时间来下载它。

尽管 WebAssembly 明确按照堆栈机的规定, 但是这并不是它在物理机器上工作的方式。当浏览器将 WebAssembly 翻译成浏览器所在的机器的机器码的时候,它会用到寄存器。由于Since the WebAssembly 代码并不指定寄存器,它给浏览器提供了更大的灵活性来分配最适合机器的寄存器。

模块的区块

除了 add42 函数本身,还有其他的部分在 .wasm 文件当中。这些叫做区块。有些区块是任何的模块都需要的,有些是可选的。

必需的:

  1. Type 包含任何定义在该模块或者导入进来的函数的签名。
  2. Function 给在该模块定义的每一个函数建立一个索引。
  3. Code 该模块的每一个函数的实际函数体。

可选的:

  1. Export 使得其他 WebAssembly 模块和 JavaScript 可以使用该模块内的函数,内存,表以及全局。这允许单独编译的模块能够被动态的链接到一起。这就是 WebAssembly版 的 .dll 。
  2. Import 指定从其他 WebAssembly 模块和 JavaScript 引入的函数,内存,表以及全局。
  3. Start 一个函数,在 WebAssembly 模块加载完成之后自动执行(有点像 main 函数)。
  4. Global 声明模块的全局变量。
  5. Memory 定义该模块将要使用的内存。
  6. Table 使得它可以映射到的 Webassembly 模块以外的值,如JavaScript对象。这对于允许间接函数调用特别有用。
  7. Data 初始化导入或本地内存。
  8. Element 初始化导入或本地表。.

更多关于区块的内容,这里有更深入的文章 解释这些区块是如何工作的.

接下来

现在你知道了如何与 WebAssembly 模块打交道,让我们来看看 为什么 WebAssembly 能这么快.

关于

Lin Clark

Lin 是 Mozilla Developer Relations 团队的一名工程师。 She 专注于 JavaScript, WebAssembly, Rust, 以及 Servo,同时也绘制一些关于编码的漫画。

Lin Clark 的更多文章

评论