分类:wasm| 发布时间:2024-12-29 22:22:00
原文:Understanding WebAssembly text format
为了使 WebAssembly 能够被人类读取和编辑,存在一种 WebAssembly 二进制格式的文本表示形式。 这是一种中间形式,旨在便于在文本编辑器、浏览器开发者工具等中展示。 本文将解释这种文本格式的工作原理,包括原始语法,并探讨它与所表示的底层字节码之间的关系——以及表示 WebAssembly 在 JavaScript 中的封装对象。
注意:如果你只是一个希望将 WebAssembly 模块加载到页面并在代码中使用的 Web 开发者(参见 使用 WebAssembly JavaScript API),那么这可能有些过于复杂。 但如果你想编写 Wasm 模块以优化 JavaScript 库的性能,或构建自己的 WebAssembly 编译器,那么这将更加有用。
在二进制格式和文本格式中,WebAssembly 中的基本代码单元是模块。 在文本格式中,模块被表示为一个大的 S 表达式。 S 表达式是一种非常古老且非常简单的文本格式,用于表示树形结构,因此我们可以将模块视为一个描述模块结构和代码的节点树。 不过,与编程语言的抽象语法树(AST)不同,WebAssembly 的树结构相当扁平,主要由指令列表组成。
首先,让我们看看 S 表达式是什么样的。树中的每个节点都放在一对括号内——(...)。 括号内的第一个标签告诉你这个节点的类型,之后是一个以空格分隔的列表,列表中可以是属性或子节点。 这意味着 WebAssembly 的 S 表达式如下所示:
(module (memory 1) (func))
表示一个以 “module” 作为根节点的树,根节点有两个子节点,一个是带有属性 “1” 的 “memory” 节点,另一个是 “func” 节点。 我们稍后将看到这些节点实际代表的含义。
让我们从最简单、最短的 Wasm 模块开始。
(module)
这个模块完全为空,但仍然是一个有效的模块。
如果我们现在将模块转换为二进制格式(参见 将 WebAssembly 文本格式转换为 Wasm),我们将看到二进制格式中描述的 8 字节模块头:
0000000: 0061 736d ; WASM_BINARY_MAGIC
0000004: 0100 0000 ; WASM_BINARY_VERSION
好吧,这样看起来不太有趣,让我们往这个模块中添加一些可执行代码。
WebAssembly 模块中的所有代码都被分组为函数,函数具有以下伪代码结构:
(func <signature> <locals> <body>)
所以,这与其他语言中的函数类似,尽管看起来不同,因为它是一个 S 表达式。
signature 是参数类型声明的序列,后跟返回类型声明的列表。值得注意的是:
每个参数都有显式声明的类型;Wasm 数字类型、引用类型和向量类型。
数字类型有:
一个单一参数写作 (param i32),返回类型写作 (result i32),因此,一个接受两个 32 位整数并返回 64 位浮点数的函数将写作:
(func (param i32) (param i32) (result f64) ...)
在 signature 之后,locals 会按类型列出,例如 (local i32)。 参数基本上是 locals 并由调用者传入并初始化。
局部变量/参数可以通过函数体中的 local.get 和 local.set 指令进行读取和写入。
local.get / local.set 命令通过其数字索引引用要获取/设置的项:参数按照声明的顺序首先被引用,随后是按声明顺序的局部变量。因此,给定以下函数:
(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 类型的局部变量。
这里还有另一个问题 —— 使用数字索引来引用项可能会让人感到困惑和麻烦,因此文本格式允许你通过在类型声明前加上一个美元符号($)来为参数、局部变量和其他大多数项命名。
因此,你可以像下面这样重写我们之前的签名:
(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 值压入栈中。
当一个函数被调用时,它从一个空栈开始,随着函数体内指令的执行,栈会逐渐被填充和清空。 因此,举个例子,执行以下函数后:
(func (param $p i32)
(result i32)
local.get $p
local.get $p
i32.add)
栈中包含一个 i32 值——即表达式 ($p + $p) 的结果,这个计算由 i32.add 指令处理。函数的返回值就是栈中最终剩下的值。
WebAssembly 的验证规则确保栈的状态完全匹配:如果你声明了 (result f32),那么栈在函数结束时必须恰好包含一个 f32 值。 如果没有返回类型,栈必须为空。
如前所述,函数体是一系列指令,这些指令在函数被调用时按顺序执行。 结合我们已经学到的内容,我们终于可以定义一个包含我们自己简单函数的模块:
(module
(func (param $lhs i32) (param $rhs i32) (result i32)
local.get $lhs
local.get $rhs
i32.add))
这个函数接受两个参数,将它们相加,并返回结果。
函数体内可以包含很多其他内容,但我们现在从简单的开始,随着学习的深入,你将看到更多的例子。 有关所有可用操作码的完整列表,请参考 webassembly.org 的语义参考。
我们的函数本身不会做太多事情——现在我们需要调用它。我们该怎么做呢? 就像在 ES 模块中一样,Wasm 函数必须通过模块内的 export 语句显式导出。
与局部变量类似,函数默认通过索引来标识,但为了方便,它们可以被命名。 我们先从这一步开始——首先,我们在 func 关键字后面添加一个以美元符号 $ 为前缀的名称:
(func $add …)
现在我们需要添加一个导出声明——它的形式如下:
(export "add" (func $add))
在这里,add 是函数在 JavaScript 中的标识名称,而 $add 则指定了在模块内被导出的 WebAssembly 函数。
因此,我们最终的模块(目前)看起来是这样的:
(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()):
WebAssembly.instantiateStreaming(fetch("add.wasm")).then((obj) => {
console.log(obj.instance.exports.add(1, 2)); // "3"
});
注意:你可以在 GitHub 上找到这个示例,文件名为 add.html(也可以在线查看)。 另请参见 WebAssembly.instantiateStreaming() 以获取更多关于实例化函数的详细信息。
现在我们已经掌握了基本知识,让我们继续深入,看看一些更高级的功能。
call 指令根据函数的索引或名称调用一个函数。 例如,下面的模块包含两个函数 —— 一个函数只是返回值 42,另一个函数返回调用第一个函数的结果加一:
(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)。
在这个例子中,你会注意到在第二个函数的 func 语句后面声明了一个 (export "getAnswerPlus1") 部分——这是一种简写方式,表示我们希望导出这个函数,并定义我们希望导出的名称。
这在功能上等同于在模块的其他地方(与之前一样)包含一个单独的 export 语句,类似于以下形式:
(export "getAnswerPlus1" (func $functionName))
调用我们上述模块的 JavaScript 代码如下所示:
WebAssembly.instantiateStreaming(fetch("call.wasm")).then((obj) => {
console.log(obj.instance.exports.getAnswerPlus1()); // "43"
});
我们已经看到过 JavaScript 调用 WebAssembly 函数,但 WebAssembly 如何调用 JavaScript 函数呢? WebAssembly 实际上并不具备对 JavaScript 的内建知识,但它确实有一种通用方式来导入函数,可以接受 JavaScript 函数或 Wasm 函数。让我们来看一个例子:
(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,而不管导入声明的签名是什么。 一旦模块声明了一个导入,WebAssembly.instantiate() 的调用者必须传入一个包含相应属性的导入对象。
对于上面的例子,我们需要一个对象(我们称之为 importObject),使得 importObject.console.log 是一个 JavaScript 函数。
如下所示:
const importObject = {
console: {
log(arg) {
console.log(arg);
},
},
};
WebAssembly.instantiateStreaming(fetch("logger.wasm"), importObject).then(
(obj) => {
obj.instance.exports.logIt();
},
);
注意:你可以在 GitHub 上找到这个示例,文件名为 logger.html(也可以 在线查看)。
WebAssembly 具有创建全局变量实例的能力,这些变量可以从 JavaScript 访问,并且可以在一个或多个 WebAssembly.Module 实例之间导入/导出。这非常有用,因为它允许多个模块之间进行动态链接。
在 WebAssembly 文本格式中,它看起来像这样(请参见我们 GitHub 仓库中的 global.wat;也可以查看 global.html 以查看在线的 JavaScript 示例):
(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() 构造函数:
const global = new WebAssembly.Global({ value: "i32", mutable: true }, 0);
上面的示例展示了如何在汇编代码中处理数字,将它们压入栈中,进行操作,然后通过JavaScript 调用其中的方法并记录结果。
对于处理字符串和其他更复杂的数据类型,我们使用 memory,它可以在 WebAssembly 或 JavaScript 中创建,并在不同环境之间共享(WebAssembly 的较新版本还可以使用引用类型)。
在 WebAssembly 中,memory 就是一个大的、连续的、可变的原始字节数组,可以随着时间的推移而增长(参见规范中的线性内存)。 WebAssembly 包含像 i32.load 和 i32.store 这样的内存指令,用于在栈和内存中的任意位置之间读取和写入字节。
从 JavaScript 的角度来看,内存就像是一个大的、可增长的 ArrayBuffer。 JavaScript 可以通过 WebAssembly.Memory() 接口创建 WebAssembly 线性内存实例,并将其导出到内存实例中,或者访问在 WebAssembly 代码中创建并导出的内存实例。 JavaScript 的内存实例具有一个 buffer getter,返回一个 ArrayBuffer,指向整个线性内存。
内存实例也可以增长,例如通过 JavaScript 中的 Memory.grow() 方法或 WebAssembly 中的 memory.grow。 由于 ArrayBuffer 对象无法更改大小,当前的 ArrayBuffer 会被分离,并创建一个新的 ArrayBuffer 来指向更新后的、更大的内存。
请注意,当创建内存时,你需要定义初始大小,并且可以选择性地指定内存可以增长到的最大大小。 WebAssembly 会尝试保留最大大小(如果指定),如果能够做到,它可以在未来更高效地增长缓冲区。 即使现在不能分配最大大小,它也可能可以在以后增长。 只有当无法分配初始大小时,该方法才会失败。
注意:最初,WebAssembly 每个模块实例只允许一个 memory。 现在,在浏览器支持的情况下,你可以拥有multiple memories。 没有使用 multiple memories 的代码无需做任何更改!
为了演示这种行为,让我们考虑一下在 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),并传入导入对象:
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 语句写作如下:
(import "js" "mem" (memory 1))
内存必须使用与 importObject 中指定的相同的两级键(js.mem)进行导入。 数字 1 表示导入的内存必须至少有 1 页内存(WebAssembly 当前定义一页为 64KB)。
注意:由于这是导入到 WebAssembly 模块中的第一个内存,它的内存索引是 "0"。 你可以在内存指令中使用索引来引用这个特定的内存,但由于 0 是默认索引,在单内存应用程序中,你不需要这样做。
现在我们有了共享的内存实例,下一步是将一串数据写入其中。 然后,我们将传递有关字符串位置和长度的信息给 JavaScript(我们也可以选择将字符串的长度编码在字符串本身中,但传递长度对我们来说更容易实现)。
首先,让我们将一串数据添加到内存中,这里使用的是字符串 "Hi"。 由于我们拥有整个线性内存,我们可以使用数据节(data section)将字符串内容写入全局内存。 数据节允许在实例化时将一串字节写入指定的偏移位置,类似于本地可执行文件格式中的 .data 节。 在这里,我们将数据写入默认内存(无需指定)并且偏移量为 0:
(module
(import "js" "mem" (memory 1))
;; ...
(data (i32.const 0) "Hi")
;;
)
注意:上面的双分号语法 ;; 用于表示 WebAssembly 文件中的注释。 在这种情况下,我们只是用它们来表示其他代码的占位符。
为了将这些数据与 JavaScript 共享,我们将定义两个函数。 首先,我们从 JavaScript 导入一个函数,用于将字符串日志记录到控制台。 这个函数需要在用于实例化 WebAssembly 模块的 importObject 中映射到 console.log。 在 WebAssembly 中,该函数命名为 $log,并接受 i32 类型的参数,表示字符串在内存中的偏移量和长度。
第二个 WebAssembly 函数 writeHi() 使用内存中字符串的偏移量和长度(分别为 0 和 2)调用导入的 $log 函数。 这个函数从模块中导出,以便从 JavaScript 中调用。
我们最终的 WebAssembly 模块(文本格式)如下所示:
(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() 方法。完整代码如下所示:
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 中使用多个内存对象,同时保持与只支持单一内存的实现兼容。 多个内存可以用于将需要与其他应用数据区分的数据分开处理,例如公共数据与私有数据、需要持久化的数据和需要在线程之间共享的数据。 它也可能对需要超越 WebAssembly 32 位地址空间的非常大规模应用有用,或者用于其他目的。
提供给 WebAssembly 代码的内存,无论是直接声明的还是导入的,都被赋予一个零索引的按顺序分配的内存索引号。 所有内存指令(如 load 或 store)都可以通过内存的索引来引用特定的内存,这样你就可以控制正在使用哪个内存。
内存指令默认的索引是 0,即 WebAssembly 实例中第一个添加的内存的索引。 因此,如果你只添加一个内存,代码中就不需要指定索引。
为了更详细地展示这个工作原理,我们将扩展前面的示例,将字符串写入三个不同的内存并记录结果。 下面的代码展示了我们如何首先导入两个内存实例,使用与前一个示例相同的方法。 为了展示如何在 WebAssembly 模块内创建内存,我们在模块中创建了第三个名为 $mem2 的内存实例并导出了它。
(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)),以选择我们要写入字符串的内存(你可以对所有其他内存指令,如 load 和 grow,使用相同的方法)。 在这里,我们写入一个字符串,表示每种内存类型。
(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)")
这三个内存实例会根据它们的创建顺序自动分配一个实例索引。 下面的代码展示了我们如何在 data 指令中指定这个索引(例如 (memory 1)),以选择我们要写入字符串的内存(你可以对所有其他内存指令,如 load 和 grow,使用相同的方法)。 在这里,我们写入一个字符串,表示每种内存类型。
(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 的日志记录代码几乎与前一个示例完全相同,不同之处在于除了字符串的偏移量和长度,我们还需要传递包含该字符串的内存的索引。我们还会记录所有三个内存实例。
完整的模块如下所示:
(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 对象匹配。
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 中最复杂、也是最容易让人困惑的部分:表(tables)。 表基本上是可以动态调整大小的引用数组,可以通过索引从 WebAssembly 代码中访问。
为了理解为什么需要表,我们首先需要观察到之前提到的 call 指令(参见 调用同一模块中其他函数)是使用静态函数索引的,因此它只能调用一个固定的函数——但是如果被调用的函数是一个运行时值呢?
WebAssembly 需要一种调用指令来实现这种功能,因此我们给它添加了 call_indirect,它接受一个动态的函数操作数。 问题在于,目前 WebAssembly 只允许使用 i32/i64/f32/f64 类型来作为操作数。
WebAssembly 本来可以添加一个 anyfunc 类型("any" 是因为它可以包含任何签名的函数),但不幸的是,出于安全原因,anyfunc 类型不能存储在线性内存中。 线性内存暴露了存储值的原始内容(以字节形式),这将允许 Wasm 内容任意地观察和破坏原始的函数地址,而这种行为在 Web 中是不能允许的。
解决方案是将函数引用存储在一个表中,而是传递表的索引,这些索引只是 i32 值。 因此,call_indirect 的操作数可以是一个 i32 索引值。
那么,我们如何将 Wasm 函数放入表中呢? 就像数据段(data sections)可以用来初始化线性内存的区域并填充字节一样,元素段(elem sections)可以用来初始化表的区域并填充函数:
(module
(table 2 funcref)
(elem (i32.const 0) $f1 $f2)
(func $f1 (result i32)
i32.const 42)
(func $f2 (result i32)
i32.const 13)
...
)
注意:未初始化的元素会被赋予一个默认的调用时抛出异常的值。
在 JavaScript 中,创建这样的表实例的等效调用大致如下:
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);
};
接下来,我们已经定义了表,接下来需要以某种方式使用它。 我们可以使用以下代码来实现:
(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))
你也可以在命令调用时显式声明 call_indirect 参数,而不是在它之前声明,像这样:
(call_indirect (type $return_i32) (local.get $i))
在像 JavaScript 这样更高级、更具表现力的语言中,你可以想象通过包含函数的数组(或更可能是对象)来做相同的事情。 伪代码可能看起来像 tbl[i]()
回到类型检查问题。由于 WebAssembly 会进行类型检查,而 funcref 可以是任何函数签名,因此我们必须在调用点提供被调用者的预期签名,因此我们包含了 $return_i32 类型,以告知程序期望一个返回 i32 的函数。 如果被调用者的签名不匹配(比如返回的是 f32),则会抛出一个 WebAssembly.RuntimeError。
那么,是什么将 call_indirect 与我们要调用的表关联起来呢? 答案是,目前每个模块实例只允许有一个表,而 call_indirect 隐式地调用的是这个表。 未来,当支持多个表时,我们还需要指定某种形式的表标识符,类似于
call_indirect $my_spicy_table (type $i32_to_void)
完整的模块如下所示,可以在我们的 wasm-table.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 代码将其加载到网页中:
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(也可以在线查看)。
注意:与 memory 类似,表(Tables)也可以从 JavaScript 创建(参见 WebAssembly.Table()),并且可以从另一个 Wasm 模块导入或导出。
因为 JavaScript 可以完全访问函数引用,JavaScript 可以通过 grow()、get() 和 set() 方法对 Table 对象进行修改。 同时,WebAssembly 代码也可以使用一部分 Reference 类型指令(如 table.get 和 table.set)对 table 进行修改。
由于表是可变的,它们可以用于实现复杂的加载时和运行时动态链接机制。 当一个程序被动态链接时,多个实例共享相同的内存和表。 这与本地应用程序中的情况相似,其中多个编译后的 .dll 文件共享一个进程的地址空间。
为了看到这一过程的实际操作,我们将创建一个包含 Memory 对象和 Table 对象的单个导入对象,并将这个导入对象传递给多个 instantiate() 调用。
我们的 .wat 示例如下:
shared0.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:
(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))
)
这些工作原理如下:
注意:上述表达式再次隐式地从栈中弹出值,但你也可以在命令调用中显式地声明这些值,例如:
(i32.store (i32.const 0) (i32.const 42))
(call_indirect (type $void_to_i32) (i32.const 0))
在转换为汇编后,我们通过以下代码在 JavaScript 中使用 shared0.wasm 和 shared1.wasm:
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语言的较新功能——提供了七个新的内建操作,用于执行诸如复制和初始化等批量内存操作,以便 WebAssembly能够以更高效、更具性能的方式模拟本地函数,例如 memcpy 和 memmove。
注意:有关浏览器兼容性的信息,参见 webassembly.bulk-memory-operations。
新的操作包括:
注意:您可以在 Bulk Memory Operations and Conditional Segment Initialization 提案中找到更多信息。
WebAssembly 目前有四种可用的数字类型:
引用类型提案提供了两个主要功能:
注意:wasm-bindgen 文档包含了一些有用的信息,讲解如何从 Rust 中利用 externref。
注意:有关浏览器兼容性的信息,请参见 webassembly.reference-types。
WebAssembly 的另一个较新的功能是多值(multi-value),这意味着 WebAssembly 函数现在可以返回多个值,并且指令序列可以同时消费和产生多个栈值。
注意:有关浏览器兼容性的信息,请参见 webassembly.multi-value。
截至撰写本文时(2020 年 6 月),这个功能仍处于早期阶段,唯一可用的多值指令是调用那些本身返回多个值的函数。例如:
(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 中,类似于 JavaScript 中的 SharedArrayBuffer。 这使得 Worker 之间可以进行非常快速的通信,并且在 Web 应用程序中带来显著的性能提升。
线程提案包括两个部分:共享内存和原子内存访问。
注意:有关浏览器兼容性的信息,请参见 webassembly.threads-and-atomics。
如上所述,您可以创建共享的 WebAssembly memory 对象,这些对象可以像 SharedArrayBuffer 一样,在 Window 和 Worker 上下文之间通过 postMessage() 进行传输。
在 JavaScript API 端 WebAssembly.Memory() 构造函数的初始化对象现在有一个 shared 属性,当该属性设置为 true 时,将创建一个共享内存:
const memory = new WebAssembly.Memory({
initial: 10,
maximum: 100,
shared: true,
});
memory 的 buffer 属性现在将返回一个 SharedArrayBuffer,而不是通常的 ArrayBuffer:
memory.buffer; // returns SharedArrayBuffer
在文本格式中,可以使用 shared 关键字创建共享内存,像这样:
(memory 1 2 shared)
与非共享内存不同,共享内存必须在 JavaScript API 构造函数和 Wasm 文本格式中指定一个“最大”大小。
注意:你可以在 WebAssembly 的线程提案中找到更多的细节。
添加了许多新的 WebAssembly 指令,可以用来实现更高级的功能,如互斥锁(mutex)、条件变量等。你可以在这里找到它们的列表。
注意:Emscripten Pthreads 支持页面展示了如何利用这些新功能,来自 Emscripten。
这篇文章完成了对 WebAssembly 文本格式主要组件的高层次介绍,并说明了它们如何反映在 WebAssembly JavaScript API 中。