创建和使用 WebAssembly 模块

这是关于 WebAssembly 及其速度原因的系列文章的第四部分。如果您还没有阅读其他部分,我们建议您从开头开始阅读

WebAssembly 是一种在网页上运行除 JavaScript 以外的编程语言的方法。在过去,当您想要在浏览器中运行代码以与网页的不同部分交互时,您唯一的选项是 JavaScript。

因此,当人们谈论 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)中的每一个都对应于特定的机器架构。

当您将代码传递给用户机器上的 Web 执行时,您不知道代码将在其上运行的目标架构。

因此,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 中间表示。一旦它进入 LLVM 的 IR,LLVM 就可以理解它,因此 LLVM 可以执行一些优化。

要从 LLVM 的 IR(中间表示)转换为 WebAssembly,我们需要一个后端。LLVM 项目中有一个正在进行的后端。该后端已经接近完成,应该很快完成。但是,今天让它工作起来可能很棘手。

还有一个名为 Emscripten 的工具,它目前更容易使用。它有自己的后端,可以通过编译到另一个目标(称为 asm.js)然后将其转换为 WebAssembly 来生成 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)或加载器(如 SystemJS)集成。我们相信加载 WebAssembly 模块可以像加载 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

任何为 Web 开发人员开发 WebAssembly 模块的人可能都会在其周围创建一个包装器。这样,作为模块的消费者,您无需了解内存管理。

如果您想了解更多信息,请查看我们关于使用 WebAssembly 内存的文档。

.wasm 文件的结构

如果您正在使用高级语言编写代码,然后将其编译为 WebAssembly,那么您无需了解 WebAssembly 模块的结构。但了解基本知识会有所帮助。

如果您还没有,我们建议您阅读关于汇编的文章(系列文章的第 3 部分)。

这是一个我们将转换为 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 转换为浏览器运行的机器的机器代码时,它将使用寄存器。由于 WebAssembly 代码没有指定寄存器,因此它为浏览器提供了更多灵活性,可以为该机器使用最佳寄存器分配。

模块的节

除了 `add42` 函数本身之外,.wasm 文件中还有其他部分。这些被称为节。某些节是所有模块所必需的,而某些节是可选的。

必需的

  1. 类型。包含此模块中定义的函数以及任何导入函数的函数签名。
  2. 功能. 为本模块中定义的每个函数提供索引。
  3. 代码. 本模块中每个函数的实际函数体。

可选

  1. 导出. 使函数、内存、表格和全局变量可供其他 WebAssembly 模块和 JavaScript 使用。这允许将分别编译的模块动态链接在一起。这是 WebAssembly 中的 .dll 版本。
  2. 导入. 指定要从其他 WebAssembly 模块或 JavaScript 导入的函数、内存、表格和全局变量。
  3. 启动. 当 WebAssembly 模块加载时自动运行的函数(基本上就像一个主函数)。
  4. 全局. 声明模块的全局变量。
  5. 内存. 定义该模块将使用的内存。
  6. 表格. 使得可以映射到 WebAssembly 模块之外的值,例如 JavaScript 对象。这对于允许间接函数调用特别有用。
  7. 数据. 初始化导入的或本地的内存。
  8. 元素. 初始化导入的或本地的表格。

有关节的更多信息,请参阅此深入的解释这些节是如何工作的

接下来

既然您已经了解了如何使用 WebAssembly 模块,让我们看看为什么 WebAssembly 速度快

关于 Lin Clark

Lin 在 Mozilla 的高级开发部门工作,专注于 Rust 和 WebAssembly。

更多 Lin Clark 的文章……


4 条评论

  1. Nitin Pasumarthy

    Lin 的文章系列很棒。我一直期待着这样的东西。

    您能解释一下您是如何确定顺序的吗?

    C —> IR —-> WASM —-> 机器码

    为什么不是

    C —> WASM —-> IR —-> 机器码?

    2017 年 3 月 4 日 下午 2:25

    1. Lin Clark

      在您的第二个图表中,LLVM 的 IR 将作为位代码发送到浏览器。该WebAssembly 常见问题解答解释了他们为什么决定不这样做。

      2017 年 3 月 4 日 下午 3:16

    2. Jochen Wiedmann

      从 C 到 IR 的桥梁已经存在(CLANG),并且实现从 IR 到 WASM 的桥梁以及从 WASM 到机器码的桥梁都比较简单。

      2017 年 3 月 8 日 上午 3:01

  2. Christian Wenz

    感谢您对 WebAssembly 在幕后工作原理的出色解释。我一直在问自己是否可以从 WebAssembly 模块与本地硬件(如读卡器)交互。您能告诉我一些关于离开浏览器领域时的限制吗?

    2017 年 3 月 23 日 上午 3:06

本文的评论已关闭。