理解 WebAssembly 文本格式

为了使 WebAssembly 能够被人类读取和编辑,Wasm 二进制格式有一个文本表示形式。这是一种中间形式,旨在在文本编辑器、浏览器开发者工具等中公开。本文解释了该文本格式的工作原理,包括原始语法以及它与表示的底层字节码之间的关系——以及在 JavaScript 中表示 Wasm 的包装器对象。

注意:如果您只是希望将 Wasm 模块加载到页面中并在代码中使用它(请参阅使用 WebAssembly JavaScript API),那么这可能是多余的,但如果您想要编写 Wasm 模块以优化 JavaScript 库的性能,或者构建自己的 WebAssembly 编译器,那么它会更有用。

S 表达式

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

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

wasm
(module (memory 1) (func))

表示一棵树,其根节点为“module”,有两个子节点,一个具有属性“1”的“memory”节点和一个“func”节点。我们很快就会看到这些节点的实际含义。

最简单的模块

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

wasm
(module)

此模块完全为空,但仍然是有效的模块。

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

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

向模块添加功能

好的,这没有太多趣味,让我们向此模块添加一些可执行代码。

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

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

因此,这类似于其他语言中的函数,即使它看起来不同,因为它是一个 S 表达式。

签名和参数

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

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

每个参数都明确声明了类型;Wasm数字类型引用类型向量类型。数字类型为

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

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

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

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

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

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

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

wasm
(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 局部变量。

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

因此,您可以像这样重写我们之前的签名

wasm
(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 值。

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

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

栈中恰好包含一个i32值——表达式 ($p + $p) 的结果,该结果由i32.add处理。函数的返回值只是栈上剩下的最后一个值。

WebAssembly 验证规则确保栈完全匹配:如果您声明(result f32),则栈必须在最后包含恰好一个f32。如果没有结果类型,则栈必须为空。

我们的第一个函数体

如前所述,函数体是调用函数时按顺序执行的指令列表。将此与我们已经学到的内容结合起来,我们终于可以定义一个包含我们自己的简单函数的模块了

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

此函数获取两个参数,将它们加在一起,并返回结果。

函数体中还可以包含很多其他内容,但我们现在先从简单的内容开始,随着学习的深入,您会看到更多示例。有关可用操作码的完整列表,请参阅webassembly.org 语义参考

调用函数

我们的函数本身不会做太多事情——现在我们需要调用它。我们该怎么做呢?与 ES 模块一样,Wasm 函数必须通过模块内的export语句显式导出。

与局部变量一样,函数默认情况下由索引标识,但为了方便起见,可以为其命名。让我们从这里开始——首先,我们将在func关键字之后添加一个以美元符号为前缀的名称

wasm
(func $add)

现在我们需要添加一个导出声明——如下所示

wasm
(export "add" (func $add))

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

因此,我们最终的模块(目前)如下所示

wasm
(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,另一个返回第一个函数返回值加 1 的结果。

wasm
(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 语句之后声明——这是一种简写方式,用于声明我们希望导出此函数,并定义我们希望导出的名称。

这在功能上等同于在模块中的其他地方,在函数外部包含一个单独的函数语句,就像我们之前做的那样,例如:

wasm
(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 函数的函数。让我们来看一个例子。

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

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

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

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

对于上述情况,我们需要一个对象(我们称之为 importObject),使得 importObject.console.log 是一个 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 示例)。

wasm
(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 获取器,它返回一个指向整个线性内存的 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 语句写成如下所示:

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

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

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

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

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

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

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

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

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

我们最终的 WebAssembly 模块(文本格式)如下所示。

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

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

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

多个内存

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

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

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

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

wasm
(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)。在这里,我们写入一个指示每种内存类型的字符串。

wasm
  (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 记录代码与前一个示例几乎完全相同,只是除了字符串偏移量和长度之外,我们还需要传递包含字符串的内存的索引。我们还记录了所有三个内存实例。

完整的模块如下所示

wasm
(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
    call $logMemory

    ;; Log memory index 2, offset 0
    i32.const 2  ;; memory index 2
    i32.const 0  ;; memory offset 0
    i32.const 12  ;; 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 内容任意观察和破坏原始函数地址,这在网络上是不允许的。

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

在 Wasm 中定义表格

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

wasm
(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 () {
  // table section
  const tbl = new WebAssembly.Table({initial: 2, element: "anyfunc"});

  // function sections:
  const f1 = ... /* some imported WebAssembly function */
  const f2 = ... /* some imported WebAssembly function */

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

使用表格

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

wasm
(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参数,而不是在它之前声明,如下所示

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

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

因此,回到类型检查。由于 WebAssembly 是类型检查的,并且funcref可能具有任何函数签名,因此我们必须在调用站点提供被调用者的假定签名,因此我们包含$return_i32类型,以告诉程序期望返回i32的函数。如果被调用者没有匹配的签名(例如,返回f32),则会抛出WebAssembly.RuntimeError

那么是什么将call_indirect与我们调用的表格联系起来呢?答案是每个模块实例目前只允许一个表格,这就是call_indirect隐式调用的表格。将来,当允许多个表格时,我们还需要指定某种表格标识符,例如

wasm
call_indirect $my_spicy_table (type $i32_to_void)

整个模块如下所示,您可以在我们的wasm-table.wat示例文件中找到它

wasm
(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:

wasm
(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:

wasm
(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

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

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

转换为汇编后,我们随后通过以下代码在 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 月),这还处于早期阶段,唯一可用的多值指令是调用自身返回多个值的函数。例如

wasm
(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 撰写的 Multi-Value All The Wasm!

WebAssembly 线程

WebAssembly 线程允许 WebAssembly 内存对象在运行在单独 Web Worker 中的多个 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 关键字创建共享内存,如下所示:

wasm
(memory 1 2 shared)

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

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

原子内存访问

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

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

总结

这完成了我们对 WebAssembly 文本格式主要组件及其如何在 WebAssembly JS API 中反映的高级概述。

另请参阅

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