理解 WebAssembly 文本格式

为了使 WebAssembly 能够被人类阅读和编辑,Wasm 二进制格式有一种文本表示。这是一种中间形式,旨在显示在文本编辑器、浏览器开发者工具和其他类似环境中。本文解释了文本格式的原始语法如何工作,以及它与它所代表的底层字节码以及在 JavaScript 中表示 Wasm 的包装对象之间的关系。

注意: 如果您是一名 Web 开发者,想要将 Wasm 模块加载到页面并在代码中使用它(请参阅使用 WebAssembly JavaScript API),这可能有点矫枉过正。如果您想编写 Wasm 模块来优化 JavaScript 库的性能或构建自己的 WebAssembly 编译器,它会更有用。

S-表达式

在二进制和文本格式中,WebAssembly 中的代码基本单元是模块。在文本格式中,模块表示为一个大的 S-表达式。S-表达式是一种古老、简单的文本格式,用于表示树;因此,我们可以将模块视为描述模块结构及其代码的节点树。但是,与编程语言的抽象语法树不同,WebAssembly 的树相当扁平,主要由指令列表组成。

首先,让我们看看 S-表达式是什么样子。树中的每个节点都包含在一对括号中 — ( ... )。括号内的第一个标签告诉您它是什么类型的节点,之后是属性或子节点的空格分隔列表。这意味着 WebAssembly S-表达式

wat
(module (memory 1) (func))

表示一个以“module”为根节点、两个子节点(一个带有属性“1”的“memory”节点和一个“func”节点)的树。我们很快就会看到这些节点实际上意味着什么。

最简单的模块

让我们从最简单、最短的 Wasm 模块开始。

wat
(module)

这个模块是空的,但它仍然是一个有效的模块。

如果我们将模块转换为二进制格式(请参阅将 WebAssembly 文本格式转换为 Wasm),我们将只看到二进制格式中描述的 8 字节模块头

0000000: 0061 736d              ; WASM_BINARY_MAGIC
0000004: 0100 0000              ; WASM_BINARY_VERSION

向模块添加功能

好的,这没什么意思,让我们向这个模块添加一些可执行代码。

WebAssembly 模块中的所有代码都分组到函数中,这些函数具有以下伪代码结构

wat
( func <signature> <locals> <body> )
  • 签名声明函数接受什么(参数)和返回什么(返回值)。
  • 局部变量类似于 JavaScript 中的 var,但声明了显式类型。
  • 函数体只是低级指令的线性列表。

这与其它语言中的函数相似,尽管它看起来有些不同。

签名和参数

签名是参数类型声明的序列,后跟返回类型声明的列表。值得注意的是

  • 缺少 (result) 意味着函数不返回任何内容。
  • 在当前迭代中,最多可以有 1 个返回类型,但稍后会放宽到任意数量。

每个参数都显式声明了类型;Wasm 数字类型引用类型向量类型。数字类型是

  • i32:32 位整数
  • i64:64 位整数
  • f32:32 位浮点数
  • f64:64 位浮点数

单个参数写为 (param i32),返回类型写为 (result i32),因此一个接受两个 32 位整数并返回一个 64 位浮点数的二进制函数将这样写

wat
(func (param i32) (param i32) (result f64) ...)

在签名之后,列出局部变量及其类型,例如 (local i32)。参数本质上只是用调用者传递的相应参数值初始化的局部变量。

获取和设置局部变量和参数

局部变量/参数可以通过函数的函数体使用 local.getlocal.set 指令进行读取和写入。

local.get/local.set 命令通过其数字索引引用要获取/设置的项目:首先引用参数,按其声明顺序,然后按其声明顺序引用局部变量。因此,给定以下函数

wat
(func (param i32) (param f32) (local f64)
  local.get 0
  local.get 1
  local.get 2
)

指令 local.get 0 将获取 i32 参数,local.get 1 将获取 f32 参数,local.get 2 将获取 f64 局部变量。

这里还有另一个问题 — 使用数字索引引用项目可能会令人困惑和恼火。为了缓解这个问题,您可以通过在类型声明之前包含一个以美元符号 ($) 为前缀的名称来命名参数、局部变量和大多数其他项目。

因此,您可以将我们之前的签名改写为

wat
(func (param $p1 i32) (param $p2 f32) (local $loc f64) …)

然后可以写 local.get $p1 而不是 local.get 0 等等。(请注意,当此文本转换为二进制时,二进制将只包含整数。)

堆栈机器

在我们编写函数体之前,还有一个重要概念需要讨论:堆栈机器。尽管浏览器会将其编译成更高效的东西,但 Wasm 的执行是根据堆栈机器定义的,其基本思想是每种类型的指令都会向堆栈推入和/或弹出一定数量的 i32/i64/f32/f64 值。

例如,local.get 被定义为将其读取的局部变量的值推入堆栈,而 i32.add 弹出两个 i32 值(它隐式地获取之前推入堆栈的两个值),计算它们的和(模 2^32),然后推入结果 i32 值。

当函数被调用时,它从一个空堆栈开始,随着函数体指令的执行,堆栈逐渐填满和清空。因此,例如,在执行以下函数之后

wat
(func (param $p i32)
  (result i32)
  local.get $p
  local.get $p
  i32.add
)

堆栈包含一个 i32 值 — 表达式 ($p + $p) 的结果,由 i32.add 处理。函数的返回值就是堆栈中留下的最终值。

WebAssembly 验证规则确保堆栈完全匹配:如果您声明了 (result f32),那么堆栈末尾必须正好包含一个 f32。如果没有结果类型,堆栈必须为空。

我们的第一个函数体

函数体是函数被调用时遵循的指令列表。将这一点与我们已经学到的知识结合起来,我们终于可以定义一个包含我们自己的基本函数的模块

wat
(module
  (func (param $lhs i32) (param $rhs i32) (result i32)
    local.get $lhs
    local.get $rhs
    i32.add
  )
)

此函数接受两个参数,将它们相加,然后返回结果。

函数体中可以放入更多内容,但我们现在将从一个基本函数开始。您会看到更多示例。有关可用操作码的完整列表,请参阅 webassembly.org 语义参考

调用函数

我们的函数本身不会做太多事情——现在我们需要调用它。我们如何做到这一点?就像在 ES 模块中一样,Wasm 函数必须通过模块内的 export 语句显式导出。

与局部变量一样,函数默认通过索引标识,但为了方便,它们可以被命名。我们先这样做——首先,我们将在 func 关键字之后添加一个以美元符号开头的名称

wat
(func $add …)

现在我们需要添加一个导出声明——它看起来像这样

wat
(export "add" (func $add))

这里,add 是函数在 JavaScript 中被标识的名称,而 $add 选择模块中哪个 WebAssembly 函数正在被导出。

所以我们最终的模块(暂时)看起来像这样

wat
(module
  (func $add (param $lhs i32) (param $rhs i32) (result i32)
    local.get $lhs
    local.get $rhs
    i32.add
  )
  (export "add" (func $add))
)

如果您想跟着示例操作,请将上面的模块保存到名为 add.wat 的文件中,然后使用 wabt 将其转换为名为 add.wasm 的二进制文件(有关详细信息,请参阅将 WebAssembly 文本格式转换为 Wasm)。

接下来,我们将异步实例化我们的二进制文件(请参阅加载并运行 WebAssembly 代码),并在 JavaScript 中执行我们的 add 函数(我们现在可以在实例的exports属性中找到 add()

js
WebAssembly.instantiateStreaming(fetch("add.wasm")).then((obj) => {
  console.log(obj.instance.exports.add(1, 2)); // "3"
});

注意: 您可以在 GitHub 上找到此示例:add.html也可以在线查看)。另请参阅 WebAssembly.instantiateStreaming() 了解有关实例化函数的更多详细信息。

探索基础知识

现在我们已经介绍了基础知识,接下来我们来看看一些更高级的功能。

从同一模块中的其他函数调用函数

call 指令调用一个函数,给定其索引或名称。例如,以下模块包含两个函数——一个返回 42,另一个返回调用第一个函数加一的结果

wat
(module
  (func $getAnswer (result i32)
    i32.const 42
  )
  (func (export "getAnswerPlus1") (result i32)
    call $getAnswer
    i32.const 1
    i32.add
  )
)

注意: i32.const 定义一个 32 位整数并将其推入堆栈。您可以将 i32 替换为任何其他可用类型,并将常量的值更改为您喜欢的任何值(这里我们将值设置为 42)。

在此示例中,您会注意到一个 (export "getAnswerPlus1") 部分,在第二个函数中的 func 语句之后声明 — 这是一种声明我们要导出此函数并定义要将其导出为的名称的简写方式。

这在功能上等同于在函数外部、模块中其他地方以与我们之前相同的方式包含一个单独的函数语句,例如

wat
(export "getAnswerPlus1" (func $functionName))

调用我们上面模块的 JavaScript 代码如下所示

js
WebAssembly.instantiateStreaming(fetch("call.wasm")).then((obj) => {
  console.log(obj.instance.exports.getAnswerPlus1()); // "43"
});

从 JavaScript 导入函数

我们已经看到 JavaScript 调用 WebAssembly 函数,但是 WebAssembly 调用 JavaScript 函数呢?WebAssembly 没有内置的 JavaScript 知识,但它有一种通用的方式来导入可以接受 JavaScript 或 Wasm 函数的函数。让我们看一个例子

wat
(module
  (import "console" "log" (func $log (param i32)))
  (func (export "logIt")
    i32.const 13
    call $log
  )
)

WebAssembly 具有两级命名空间,因此这里的 import 语句从 console 模块导入 log 函数。您还可以看到导出的 logIt 函数使用我们上面介绍的 call 指令调用导入的函数。

导入的函数就像普通函数一样:它们有一个 WebAssembly 静态检查的签名,并且它们被赋予一个索引,可以命名和调用。

JavaScript 函数没有签名的概念,因此可以传递任何 JavaScript 函数,无论导入声明的签名如何。一旦模块声明了导入,WebAssembly.instantiate() 的调用者必须传入具有相应属性的导入对象。

上述导入需要一个对象(我们称之为 importObject),使得 importObject.console.log 是一个 JavaScript 函数。

这在 JavaScript 中看起来像这样

js
const importObject = {
  console: {
    log(arg) {
      console.log(arg);
    },
  },
};

WebAssembly.instantiateStreaming(fetch("logger.wasm"), importObject).then(
  (obj) => {
    obj.instance.exports.logIt();
  },
);

注意: 您可以在 GitHub 上找到此示例:logger.html也可以在线查看)。

在 WebAssembly 中声明全局变量

WebAssembly 可以创建全局变量实例,这些实例可以从 JavaScript 访问,并可以在一个或多个 WebAssembly.Module 实例之间导入/导出。这非常有用,因为它允许动态链接多个模块。

在 WebAssembly 文本格式中,它看起来像这样(请参阅我们 GitHub 仓库中的 global.wat;另请参阅 global.html 上的实时 JavaScript 示例)

wat
(module
  (global $g (import "js" "global") (mut i32))
  (func (export "getGlobal") (result i32)
    (global.get $g)
  )
  (func (export "incGlobal")
    (global.set $g (i32.add (global.get $g) (i32.const 1)))
  )
)

这看起来与我们之前看到的类似,不同之处在于我们使用关键字 global 指定全局值,如果我们希望它是可变的,我们还会指定关键字 mut 和值的数据类型。

要使用 JavaScript 创建等效值,您可以使用 WebAssembly.Global() 构造函数

js
const global = new WebAssembly.Global({ value: "i32", mutable: true }, 0);

WebAssembly 内存

上面的示例展示了如何使用汇编代码处理数字,将它们添加到堆栈,对其执行操作,然后通过调用 JavaScript 中的方法记录结果。

为了处理字符串和其他更复杂的数据类型,我们使用 memory,它可以在 WebAssembly 或 JavaScript 中创建,并在环境之间共享(WebAssembly 的更新版本也可以使用引用类型)。

在 WebAssembly 中,memory 只是一个大的连续的、可变的原始字节数组,它可以随着时间的推移而增长(请参阅规范中的线性内存)。WebAssembly 包含内存指令,例如 i32.loadi32.store,用于在堆栈和内存中的任何位置之间读写字节。

从 JavaScript 的角度来看,内存仿佛都在一个大的可增长的 ArrayBuffer 中。JavaScript 可以通过 WebAssembly.Memory() 接口创建 WebAssembly 线性内存实例并将其导出到内存实例,或者访问在 WebAssembly 代码中创建并导出的内存实例。JavaScript Memory 实例有一个 buffer getter,它返回一个指向整个线性内存的 ArrayBuffer

内存实例也可以增长,例如通过 JavaScript 中的 Memory.grow() 方法或 WebAssembly 中的 memory.grow。由于 ArrayBuffer 对象不能改变大小,当前的 ArrayBuffer 会被分离,并创建一个新的 ArrayBuffer 指向更大更新的内存。

请注意,当您创建内存时,您需要定义初始大小,并且您可以选择性地指定内存可以增长到的最大大小。WebAssembly 将尝试保留最大大小(如果指定),如果它能够做到,它将来可以更有效地增长缓冲区。即使它现在无法分配最大大小,它将来仍然可能能够增长。该方法只会在无法分配初始大小时失败。

注意: 最初,WebAssembly 每个模块实例只允许一个内存。现在,在浏览器支持时,您可以拥有多个内存。不使用多个内存的代码无需更改!

为了演示其中的一些行为,让我们考虑这样一种情况:我们希望在 WebAssembly 代码中处理字符串。字符串只是此线性内存中某个位置的一系列字节。假设我们已将合适的字节字符串写入 WebAssembly 内存,我们可以通过共享内存、字符串在内存中的偏移量以及其长度指示来将该字符串传递给 JavaScript。

首先,让我们创建一些内存并在 WebAssembly 和 JavaScript 之间共享。WebAssembly 在这里为我们提供了很大的灵活性:我们可以在 JavaScript 中创建一个 Memory 对象并让 WebAssembly 模块导入该内存,或者我们可以让 WebAssembly 模块创建内存并将其导出到 JavaScript。

对于这个例子,我们将在 JavaScript 中创建内存,然后将其导入 WebAssembly。首先,我们创建一个包含 1 页的 Memory 对象,并将其添加到 importObject 中,键为 js.mem。然后,我们使用 WebAssembly.instantiateStreaming() 方法并传入导入对象来实例化我们的 WebAssembly 模块,在本例中为 "the_wasm_to_import.wasm"

js
const memory = new WebAssembly.Memory({ initial: 1 });

const importObject = {
  js: { mem: memory },
};

WebAssembly.instantiateStreaming(
  fetch("the_wasm_to_import.wasm"),
  importObject,
).then((obj) => {
  // Call exported functions ...
});

在我们的 WebAssembly 文件中,我们导入此内存。使用 WebAssembly 文本格式,import 语句编写如下

wat
(import "js" "mem" (memory 1))

内存必须使用 importObject 中指定的相同两级键 (js.mem) 导入。1 表示导入的内存必须至少有 1 页内存(WebAssembly 目前将一页定义为 64KB)。

注意: 由于这是导入到 WebAssembly 模块中的第一个内存,因此其内存索引为 0。您可以使用内存指令中的索引引用此特定内存,但由于 0 是默认索引,在单内存应用程序中您不需要这样做。

现在我们有了一个共享内存实例,下一步是将数据字符串写入其中。然后我们将字符串位置和长度的信息传递给 JavaScript(我们也可以将字符串长度编码在字符串本身中,但传递长度对我们来说更容易实现)。

首先,让我们向内存中添加一个数据字符串,本例中为“Hi”。由于我们拥有整个线性内存,我们可以使用 data 部分将字符串内容直接写入全局内存。数据部分允许在实例化时将字节字符串写入给定偏移量,类似于本机可执行格式中的 .data 部分。这里我们将数据写入默认内存(我们不需要指定)的偏移量 0 处

wat
(module
  (import "js" "mem" (memory 1))
  ;; ...
  (data (i32.const 0) "Hi")
  ;;
)

注意: 上面双分号语法 (;;) 用于指示 WebAssembly 文件中的注释。在这种情况下,我们只是用它们来指示其他代码的占位符。

为了与 JavaScript 共享此数据,我们将定义两个函数。首先,我们从 JavaScript 导入一个函数,我们将用它将字符串记录到控制台。这需要映射到用于实例化 WebAssembly 模块的 importObject 中的 console.log。该函数在 WebAssembly 中命名为 $log,并接受 i32 参数作为内存中的字符串偏移量和长度。

第二个 WebAssembly 函数 writeHi(),调用导入的 $log 函数,并传入字符串在内存中的偏移量和长度 (02)。此函数从模块中导出,以便可以从 JavaScript 调用它。

我们最终的 WebAssembly 模块(文本格式)看起来像这样。

wat
(module
  (import "console" "log" (func $log (param i32 i32)))
  (import "js" "mem" (memory 1))
  (data (i32.const 0) "Hi")
  (func (export "writeHi")
    i32.const 0  ;; pass offset 0 to log
    i32.const 2  ;; pass length 2 to log
    call $log
  )
)

在 JavaScript 方面,我们需要定义日志函数,将其传递给 WebAssembly,然后调用导出的 writeHi() 方法。完整的代码如下所示

js
const memory = new WebAssembly.Memory({ initial: 1 });

// Logging function ($log) called from WebAssembly
function consoleLogString(offset, length) {
  const bytes = new Uint8Array(memory.buffer, offset, length);
  const string = new TextDecoder("utf8").decode(bytes);
  console.log(string);
}

const importObject = {
  console: { log: consoleLogString },
  js: { mem: memory },
};

WebAssembly.instantiateStreaming(fetch("logger2.wasm"), importObject).then(
  (obj) => {
    // Call the function exported from logger2.wasm
    obj.instance.exports.writeHi();
  },
);

请注意,日志函数 consoleLogString()console.log 属性传递给 importObject,并由 WebAssembly 模块导入。该函数使用 Uint8Array 在传递的偏移量和给定长度处在共享内存中创建字符串视图。然后使用 TextDecoder API 将字节从 UTF-8 解码为字符串(这里我们指定 utf8,但也支持许多其他编码)。然后使用 console.log() 将字符串记录到控制台。

最后一步是调用导出的 writeHi() 函数,该函数在对象实例化后执行。当您运行代码时,控制台将显示文本“Hi”。

注意: 您可以在 GitHub 上找到完整的源代码:logger2.html也可以在线查看)。

多重内存

较新的实现允许您在 WebAssembly 和 JavaScript 中使用多个内存对象,其方式与仅支持单个内存的实现编写的代码兼容。多个内存对于分离应该与应用程序其他数据(例如公共数据与私有数据、需要持久化的数据和需要在线程之间共享的数据)区别对待的数据非常有用。它也可能对需要扩展到 Wasm 32 位地址空间之外的超大型应用程序以及其他目的有用。

提供给 WebAssembly 代码的内存(无论是直接声明还是导入)都将获得一个从零开始、顺序分配的内存索引号。所有内存指令,例如loadstore,都可以通过其索引引用任何特定内存,以便您可以控制要使用的内存。

内存指令的默认索引为 0,即添加到 WebAssembly 实例的第一个内存的索引。因此,如果只添加一个内存,代码无需指定索引。

为了更详细地解释这一点,我们将扩展前面的示例,将字符串写入三个不同的内存并记录结果。下面的代码展示了我们如何首先导入两个内存实例,使用与前面示例相同的方法。为了展示如何在 WebAssembly 模块中创建内存,我们在模块中创建了第三个内存实例,名为 $mem2,并将其导出

注意: 如果您正在使用 wabt (例如 wat2wasm) 将文本格式转换为 Wasm,您可能需要传递 --enable-multi-memory,因为多内存支持仍然是可选的。

wat
(module
  ;; ...

  (import "js" "mem0" (memory 1))
  (import "js" "mem1" (memory 1))

  ;; Create and export a third memory
  (memory $mem2 1)
  (export "memory2" (memory $mem2))

  ;; ...
)

三个内存实例根据它们的创建顺序自动分配一个内存索引。下面的代码展示了我们如何在 data 指令中指定这个索引(例如,(memory 1))来选择我们要写入字符串的内存(您可以对所有其他内存指令(例如 loadgrow)使用相同的方法)。这里我们写入一个指示每个内存类型的字符串。

wat
  (data (memory 0) (i32.const 0) "Memory 0 data")
  (data (memory 1) (i32.const 0) "Memory 1 data")
  (data (memory 2) (i32.const 0) "Memory 2 data")

  ;; Add text to default (0-index) memory
  (data (i32.const 13) " (Default)")

请注意,(memory 0) 是默认值,因此是可选的。为了演示这一点,我们写入文本 " (Default)" 而不指定内存索引,这应该在记录内存内容时附加到 "Memory 0 data" 之后。

WebAssembly 日志代码与之前的示例类似,只是我们需要传递包含字符串的内存的索引以及字符串偏移量和长度。我们还记录了所有三个内存实例。

完整的模块如下所示

wat
(module
  (import "console" "log" (func $log (param i32 i32 i32)))

  (import "js" "mem0" (memory 1))
  (import "js" "mem1" (memory 1))

  ;; Create and export a third memory
  (memory $mem2 1)
  (export "memory2" (memory $mem2))

  (data (memory 0) (i32.const 0) "Memory 0 data")
  (data (memory 1) (i32.const 0) "Memory 1 data")
  (data (memory 2) (i32.const 0) "Memory 2 data")

  ;; Add text to default (0-index) memory
  (data (i32.const 13) " (Default)")

  (func $logMemory (param $memIndex i32) (param $memOffSet i32) (param $stringLength i32)
    local.get $memIndex
    local.get $memOffSet
    local.get $stringLength
    call $log
  )

  (func (export "logAllMemory")
    ;; Log memory index 0, offset 0
    (i32.const 0)  ;; memory index 0
    (i32.const 0)  ;; memory offset 0
    (i32.const 23)  ;; string length 23
    (call $logMemory)

    ;; Log memory index 1, offset 0
    i32.const 1  ;; memory index 1
    i32.const 0  ;; memory offset 0
    i32.const 20  ;; string length 20 - overruns the length of the data for illustration
    call $logMemory

    ;; Log memory index 2, offset 0
    i32.const 2  ;; memory index 2
    i32.const 0  ;; memory offset 0
    i32.const 13  ;; string length 13
    call $logMemory
  )
)

JavaScript 代码也与之前的示例非常相似,只是我们创建并传递两个内存实例给 importObject(),并且在模块实例实例化后使用已解析的 promise (obj.instance.exports) 访问由模块导出的内存。记录每个字符串的代码也稍微复杂一些,因为我们需要将 WebAssembly 中的内存实例编号与特定的 Memory 对象匹配。

js
const memory0 = new WebAssembly.Memory({ initial: 1 });
const memory1 = new WebAssembly.Memory({ initial: 1 });
let memory2; // Created by module

function consoleLogString(memoryInstance, offset, length) {
  let memory;
  switch (memoryInstance) {
    case 0:
      memory = memory0;
      break;
    case 1:
      memory = memory1;
      break;
    case 2:
      memory = memory2;
      break;
    // code block
  }
  const bytes = new Uint8Array(memory.buffer, offset, length);
  const string = new TextDecoder("utf8").decode(bytes);
  log(string); // implementation not shown - could call console.log()
}

const importObject = {
  console: { log: consoleLogString },
  js: { mem0: memory0, mem1: memory1 },
};

WebAssembly.instantiateStreaming(fetch("multi-memory.wasm"), importObject).then(
  (obj) => {
    // Get exported memory
    memory2 = obj.instance.exports.memory2;
    // Log memory
    obj.instance.exports.logAllMemory();
  },
);

示例的输出应该类似于下面的文本,只是“Memory 1 data”可能有一些尾随的“垃圾字符”,因为文本解码器传递的字节数多于用于编码字符串的字节数。

Memory 0 data (Default)
Memory 1 data
Memory 2 data

您可以在 GitHub 上找到完整的源代码:multi-memory.html也可以在线查看

注意: 有关此功能的浏览器兼容性信息,请参阅主页上的webassembly.multiMemory

WebAssembly 表

为了结束 WebAssembly 文本格式的这次之旅,让我们看看 WebAssembly 最复杂且通常令人困惑的部分:。表本质上是可调整大小的引用数组,可以通过 WebAssembly 代码的索引进行访问。

为了理解为什么需要表,我们需要观察到我们之前看到的 call 指令(请参阅从同一模块中的其他函数调用函数)采用静态函数索引,因此只能调用一个函数——但是如果被调用方是运行时值呢?

  • 在 JavaScript 中,我们经常看到这种情况:函数是第一类值。
  • 在 C/C++ 中,我们看到这种情况与函数指针有关。
  • 在 C++ 中,我们看到这种情况与虚函数有关。

WebAssembly 需要一种调用指令类型来实现这一点,所以我们给它提供了 call_indirect,它接受一个动态函数操作数。问题是 WebAssembly 中可以给操作数的唯一类型(目前)是 i32/i64/f32/f64

WebAssembly 可以添加一个 anyfunc 类型(“any”是因为该类型可以保存任何签名的函数),但不幸的是,出于安全原因,此 anyfunc 类型无法存储在线性内存中。线性内存将存储值的原始内容暴露为字节,因此 Wasm 内容可以任意观察和损坏原始函数地址,这是 Web 上不允许的。

解决方案是将函数引用存储在表中,并传递表索引,这些索引只是 i32 值。因此,call_indirect 的操作数可以是 i32 索引值。

在 Wasm 中定义表

那么,我们如何在表中放置 Wasm 函数呢?就像 data 部分可以用于使用字节初始化线性内存区域一样,elem 部分可以用于使用函数初始化表区域

wat
(module
  (table 2 funcref)
  (elem (i32.const 0) $f1 $f2)
  (func $f1 (result i32)
    i32.const 42)
  (func $f2 (result i32)
    i32.const 13)
  ...
)
  • (table 2 funcref) 中,2 是表的初始大小(表示它将存储两个引用),funcref 声明这些引用的元素类型是函数引用。
  • 函数 (func) 部分就像任何其他声明的 Wasm 函数一样。这些是我们将在表中引用的函数(为了示例目的,每个函数都返回一个常量值)。请注意,这里部分声明的顺序无关紧要——您可以在任何地方声明您的函数,并仍然在 elem 部分中引用它们。
  • elem 部分可以列出模块中函数的任何子集,顺序不限,允许重复。这是一个要由表引用的函数的列表,按它们将被引用的顺序排列。
  • elem 部分中的 (i32.const 0) 值是一个偏移量——这需要在部分的开头声明,并指定在表中函数引用开始填充的索引。这里我们指定了 0,大小为 2(参见上面),所以我们可以在索引 0 和 1 处填充两个引用。如果我们想从偏移量 1 处开始写入我们的引用,我们必须写入 (i32.const 1),并且表大小必须是 3。

注意: 未初始化的元素被赋予默认的调用时抛出值。

在 JavaScript 中,创建此类表实例的等效调用将如下所示

js
function module() {
  // table section
  const tbl = new WebAssembly.Table({ initial: 2, element: "anyfunc" });

  // function sections:
  const f1 = () => 42; /* some imported WebAssembly function */
  const f2 = () => 13; /* some imported WebAssembly function */

  // elem section
  tbl.set(0, f1);
  tbl.set(1, f2);
}

使用表格

接下来,既然我们已经定义了表格,我们需要以某种方式使用它。让我们使用这段代码来实现这一点

wat
...
(type $return_i32 (func (result i32))) ;; if this was f32, type checking would fail
(func (export "callByIndex") (param $i i32) (result i32)
  local.get $i
  call_indirect (type $return_i32)
)
  • (type $return_i32 (func (result i32))) 块指定了一个类型,带有一个引用名称。此类型用于稍后执行表函数引用调用的类型检查。这里我们说这些引用需要是返回 i32 的函数。
  • 接下来,我们定义一个将以名称 callByIndex 导出的函数。这将接受一个 i32 作为参数,并给定参数名称 $i
  • 在函数内部,我们向堆栈添加一个值——作为参数 $i 传入的任何值。
  • 最后,我们使用 call_indirect 从表中调用一个函数——它隐式地从堆栈中弹出 $i 的值。结果是 callByIndex 函数调用表中第 $i 个函数。

您也可以在命令调用期间而不是之前显式声明 call_indirect 参数,如下所示

wat
(call_indirect (type $return_i32) (local.get $i))

在像 JavaScript 这样更高级、更具表现力的语言中,您可以想象使用包含函数的数组(或者更可能是一个对象)来做同样的事情。伪代码看起来像 tbl[i]()

所以,回到类型检查。由于 WebAssembly 是类型检查的,并且 funcref 理论上可以具有任何函数签名,我们必须在调用点提供被调用方的假定签名。因此,我们包含 $return_i32 类型来指定预期返回 i32 的函数。如果被调用方没有匹配的签名(例如返回 f32),则会抛出 WebAssembly.RuntimeError

那么,call_indirect 和我们正在调用的表之间有什么联系呢?答案是目前每个模块实例只允许一个表,这就是 call_indirect 隐式调用的表。将来,当允许多个表时,我们还需要指定某种表标识符,类似于

wat
call_indirect $my_spicy_table (type $i32_to_void)

完整的模块看起来像这样,可以在我们的 wasm-table.wat 示例文件中找到

wat
(module
  (table 2 funcref)
  (func $f1 (result i32)
    i32.const 42
  )
  (func $f2 (result i32)
    i32.const 13
  )
  (elem (i32.const 0) $f1 $f2)
  (type $return_i32 (func (result i32)))
  (func (export "callByIndex") (param $i i32) (result i32)
    local.get $i
    call_indirect (type $return_i32)
  )
)

我们使用以下 JavaScript 将其加载到网页中

js
WebAssembly.instantiateStreaming(fetch("wasm-table.wasm")).then((obj) => {
  console.log(obj.instance.exports.callByIndex(0)); // returns 42
  console.log(obj.instance.exports.callByIndex(1)); // returns 13
  console.log(obj.instance.exports.callByIndex(2)); // returns an error, because there is no index position 2 in the table
});

注意: 您可以在 GitHub 上找到此示例:wasm-table.html也可以在线查看)。

注意: 就像内存一样,表格也可以从 JavaScript 创建(参见 WebAssembly.Table()),并导入/导出到另一个 Wasm 模块。

修改表和动态链接

由于 JavaScript 可以完全访问函数引用,因此可以使用 grow()get()set() 方法从 JavaScript 修改 Table 对象。WebAssembly 代码本身也能够使用作为 引用类型一部分添加的指令(例如 table.gettable.set)来操作表。

由于表是可变的,它们可以用来实现复杂的加载时和运行时动态链接方案。当程序动态链接时,多个实例共享相同的内存和表。这类似于本机应用程序,其中多个编译的 .dll 共享单个进程的地址空间。

为了实际演示这一点,我们将创建一个包含 Memory 对象和 Table 对象的单个导入对象,并将此相同的导入对象传递给多个 instantiate() 调用。

我们的 .wat 示例如下所示

shared0.wat:

wat
(module
  (import "js" "memory" (memory 1))
  (import "js" "table" (table 1 funcref))
  (elem (i32.const 0) $shared0func)
  (func $shared0func (result i32)
    i32.const 0
    i32.load
  )
)

shared1.wat:

wat
(module
  (import "js" "memory" (memory 1))
  (import "js" "table" (table 1 funcref))
  (type $void_to_i32 (func (result i32)))
  (func (export "doIt") (result i32)
   i32.const 0
   i32.const 42
   i32.store  ;; store 42 at address 0
   i32.const 0
   call_indirect (type $void_to_i32)
  )
)

这些工作原理如下

  1. 函数 shared0funcshared0.wat 中定义,并存储在我们导入的表中。
  2. 此函数创建一个包含值 0 的常量,然后使用 i32.load 命令加载提供的内存索引中包含的值。提供的索引是 0 —— 再次,它隐式地从堆栈中弹出之前的值。因此 shared0func 加载并返回存储在内存索引 0 处的值。
  3. shared1.wat 中,我们导出一个名为 doIt 的函数——此函数创建两个包含值 042 的常量,然后调用 i32.store 将提供的值存储在导入内存的提供索引处。同样,它隐式地从堆栈中弹出这些值,因此结果是将值 42 存储在内存索引 0 处,
  4. 在函数的最后一部分,我们创建一个值为 0 的常量,然后调用表中索引 0 处的函数,即 shared0func,该函数之前由 shared0.wat 中的 elem 块存储在那里。
  5. 当被调用时,shared0func 使用 shared1.wat 中的 i32.store 命令加载我们存储在内存中的 42

注意: 上述表达式再次隐式地从堆栈中弹出值,但您可以显式地在命令调用中声明这些值,例如

wat
(i32.store (i32.const 0) (i32.const 42))
(call_indirect (type $void_to_i32) (i32.const 0))

转换为 WebAssembly 二进制 (Wasm) 后,我们通过以下代码在 JavaScript 中使用 shared0.wasmshared1.wasm

js
const importObj = {
  js: {
    memory: new WebAssembly.Memory({ initial: 1 }),
    table: new WebAssembly.Table({ initial: 1, element: "anyfunc" }),
  },
};

Promise.all([
  WebAssembly.instantiateStreaming(fetch("shared0.wasm"), importObj),
  WebAssembly.instantiateStreaming(fetch("shared1.wasm"), importObj),
]).then((results) => {
  console.log(results[1].instance.exports.doIt()); // prints 42
});

每个正在编译的模块都可以导入相同的内存和表对象,从而共享相同的线性内存和表“地址空间”。

注意: 您可以在 GitHub 上找到此示例:shared-address-space.html也可以在线查看)。

批量内存操作

批量内存操作是语言中新增的功能。提供了七个新的内置操作用于批量内存操作,例如复制和初始化,以允许 WebAssembly 以更高效、更高性能的方式模拟 memcpymemmove 等原生函数。

注意: 有关浏览器兼容性信息,请参阅主页上的webassembly.bulk-memory-operations

新操作有

  • data.drop:丢弃数据段中的数据。
  • elem.drop:丢弃元素段中的数据。
  • memory.copy:将线性内存的一个区域复制到另一个区域。
  • memory.fill:用给定的字节值填充线性内存区域。
  • memory.init:从数据段复制一个区域。
  • table.copy:将表的一个区域复制到另一个区域。
  • table.init:从元素段复制一个区域。

注意: 您可以在批量内存操作和条件段初始化提案中找到更多信息。

类型

数字类型

WebAssembly 目前有四种可用的数字类型

  • i32:32 位整数
  • i64:64 位整数
  • f32:32 位浮点数
  • f64:64 位浮点数

向量类型

  • v128:128 位向量,包含打包的整数、浮点数据,或单个 128 位类型。

引用类型

引用类型提案提供了两个主要功能

  • 一个新的类型 externref,它可以保存任何 JavaScript 值,例如字符串、DOM 引用、对象等。从 WebAssembly 的角度来看,externref 是不透明的——Wasm 模块无法访问和操作这些值,而只能接收它们并将它们传回。这对于允许 Wasm 模块调用 JavaScript 函数、DOM API 等,以及通常为与宿主环境更轻松地互操作铺平道路仍然非常有用。externref 可以用于值类型和表元素。
  • 几个新指令允许 Wasm 模块直接操作WebAssembly 表,而无需通过 JavaScript API 进行操作。

注意: wasm-bindgen 文档包含一些关于如何从 Rust 利用 externref 的有用信息。

注意: 有关浏览器兼容性信息,请参阅主页上的webassembly.reference-types

多值 WebAssembly

语言的另一个最新添加是 WebAssembly 多值,这意味着 WebAssembly 函数现在可以返回多个值,并且指令序列可以消耗和生成多个堆栈值。

注意: 有关浏览器兼容性信息,请参阅主页上的webassembly.multi-value

截至撰写本文时(2020 年 6 月),这仍处于早期阶段,唯一可用的多值指令是对本身返回多个值的函数的调用。例如

wat
(module
  (func $get_two_numbers (result i32 i32)
    i32.const 1
    i32.const 2
  )
  (func (export "add_two_numbers") (result i32)
    call $get_two_numbers
    i32.add
  )
)

但这将为更实用的指令类型以及其他功能铺平道路。有关迄今为止的进展以及其工作原理的有用说明,请参阅 Nick Fitzgerald 的多值 WebAssembly!

WebAssembly 线程

WebAssembly 线程允许 WebAssembly 内存对象在单独的 Web Workers 中运行的多个 WebAssembly 实例之间共享,其方式与 JavaScript 中的 SharedArrayBuffer 相同。这使得 Worker 之间能够快速通信,并显著提高 Web 应用程序的性能。

线程提案包括两个部分:共享内存和原子内存访问。

注意: 有关浏览器兼容性信息,请参阅主页上的webassembly.threads-and-atomics

共享内存

如上所述,您可以创建共享的 WebAssembly Memory 对象,这些对象可以使用 postMessage() 在 Window 和 Worker 上下文之间传输,方式与 SharedArrayBuffer 相同。

在 JavaScript API 方面,WebAssembly.Memory() 构造函数的初始化对象现在有一个 shared 属性,当设置为 true 时,将创建共享内存

js
const memory = new WebAssembly.Memory({
  initial: 10,
  maximum: 100,
  shared: true,
});

内存的 buffer 属性现在将返回一个 SharedArrayBuffer,而不是通常的 ArrayBuffer

js
memory.buffer; // returns SharedArrayBuffer

在文本格式中,您可以使用 shared 关键字创建共享内存,如下所示

wat
(memory 1 2 shared)

与非共享内存不同,共享内存必须在 JavaScript API 构造函数和 Wasm 文本格式中指定“最大”大小。

注意: 您可以在WebAssembly 线程提案中找到更多详细信息。

原子内存访问

已添加了一些新的 Wasm 指令,可用于实现更高级别的功能,例如互斥锁、条件变量等。您可以在此处找到它们的列表

注意: Emscripten Pthreads 支持页面展示了如何从 Emscripten 利用此新功能。

总结

至此,我们完成了对 WebAssembly 文本格式主要组件及其在 WebAssembly JS API 中如何体现的高级概述。

另见

  • 未包含的主要内容是函数体中可能出现的所有指令的综合列表。有关每条指令的处理,请参阅 WebAssembly 语义
  • 另请参阅 文本格式的语法,该语法由规范解释器实现。