为了让开发者能看懂、能修改 WebAssembly 代码,Wasm 二进制格式还有一种对应的文本表示形式。这种文本格式是中间形态,专门用来在代码编辑器、浏览器开发者工具这类场景里展示。本文会从原始语法讲起,带你搞懂这种文本格式是怎么工作的,它和背后代表的字节码有什么关系,以及 JavaScript 里那些包装 Wasm 的对象是怎么回事。
提示:如果你的需求只是把 Wasm 模块加载到页面里,在代码中调用它(可以看《使用 WebAssembly JavaScript API》),那本文内容可能有点 “超纲”;但如果你的场景更深入 —— 比如想写 Wasm 模块来优化 JavaScript 库的性能,或者自己开发一个 WebAssembly 编译器 —— 那这些内容就很有用了。
S 表达式(S-expressions)
不管是二进制格式还是文本格式,WebAssembly 里最基础的代码单元都是 “模块”(module)。在文本格式里,一个模块会用一个大的 S 表达式来表示。S 表达式是一种很古老但特别简单的文本格式,专门用来描述树形结构,所以我们可以把 Wasm 模块理解成一棵 “节点树”—— 树上的每个节点都对应模块的结构或者代码。不过和编程语言的抽象语法树(AST)不一样,WebAssembly 的这棵树结构很扁平,大部分节点都是指令列表。
先来看个 S 表达式的样子:树里的每个节点都包在一对括号( ... )里。括号里的第一个 “标签” 代表这个节点的类型,后面跟着的是用空格分隔的内容 —— 可能是节点的属性,也可能是子节点。举个例子,下面这个 WebAssembly S 表达式:
;; 这里是注释:表示一个模块,包含1块内存和1个空函数
(module (memory 1) (func))
它对应的树结构是:根节点是module,下面有两个子节点 —— 一个是memory节点(属性是1),另一个是func节点(空节点)。后面我们会具体讲这些节点的含义。
最简单的模块
咱们从最简单、最短的 Wasm 模块开始看:
;; 空模块(没有任何功能)
(module)
这个模块虽然是空的,但它依然是个合法的 Wasm 模块。
如果现在把这个模块转成二进制(转格式的方法可以看《WebAssembly 文本格式转 Wasm》),得到的只会是二进制格式里规定的 8 字节模块头,具体长这样:
0000000: 0061 736d ; WASM的二进制魔数(用来标识这是Wasm文件)
0000004: 0100 0000 ; WASM的版本号(这里是1版)
给模块加功能
好吧,空模块确实没什么意思,咱们给它加段可执行代码。
WebAssembly 模块里的所有代码都要放在 “函数”(func)里,函数的伪代码结构长这样:
(func <签名> <局部变量> <函数体>)
- 签名(signature):声明函数的参数(输入)和返回值(输出)。
- 局部变量(locals):类似 JavaScript 里的变量,但必须明确声明类型。
- 函数体(body):就是一串线性排列的底层指令。
所以虽然它用 S 表达式写出来看起来不一样,但本质和其他语言的函数是类似的。
签名和参数
函数签名的结构是:先列参数类型声明,再列返回值类型声明。这里有两个点要注意:
- 如果没写
(result),就说明函数没有返回值。 - 目前 Wasm 的函数最多只能有 1 个返回值,不过以后这个限制可能会放宽,支持多返回值。
每个参数都要明确声明类型,Wasm 支持的类型包括数值类型(Number types)、引用类型(Reference types)、向量类型(Vector types)。其中数值类型有四种:
- i32:32 位整数
- i64:64 位整数
- f32:32 位浮点数
- f64:64 位浮点数
举个例子:一个参数是两个 i32、返回值是 f64 的函数,签名要这么写:
;; 函数:两个i32参数,返回f64(...代表函数体,这里省略)
(func (param i32) (param i32) (result f64) ...)
签名后面是局部变量,格式也是(local 类型),比如(local i32)。其实参数本质上也是一种局部变量 —— 只不过参数会被调用者传入的对应值初始化。
读写局部变量和参数
函数体里可以用local.get和local.set这两个指令来读写局部变量 / 参数。
这两个指令是通过 “数字索引” 来定位要操作的变量的:参数会先按声明顺序排,然后局部变量再按声明顺序排。比如下面这个函数:
;; 函数:两个参数(i32和f32),一个局部变量(f64)
(func (param i32) (param f32) (local f64)
local.get 0 ;; 取第一个参数(i32)
local.get 1 ;; 取第二个参数(f32)
local.get 2) ;; 取局部变量(f64)
这里local.get 0就是取 i32 类型的参数,local.get 1取 f32 类型的参数,local.get 2取 f64 类型的局部变量。
不过用数字索引确实容易搞混、也不方便,所以文本格式允许给参数、局部变量(还有其他大部分元素)起名字 —— 在类型前面加个$符号就行。
比如上面的函数可以改成这样:
;; 给参数和局部变量起名:$p1(i32)、$p2(f32)、$loc(f64)
(func (param $p1 i32) (param $p2 f32) (local $loc f64) …)
这样后面就能用local.get $p1代替local.get 0了,是不是清晰多了?(不过要注意:文本格式转成二进制后,这些名字会被丢掉,二进制里只存数字索引。)
栈机器(Stack machines)
在写函数体之前,得先搞懂一个关键概念:栈机器。虽然浏览器会把 Wasm 编译成更高效的代码,但 Wasm 的执行逻辑是基于栈机器定义的 —— 核心逻辑很简单:每种指令都会往栈里压入(push)或者从栈里弹出(pop)若干个 i32/i64/f32/f64 类型的值。
举个例子:
local.get指令会把读取到的变量值压入栈中。i32.add指令会先弹出两个 i32 值(默认取栈顶最近压入的两个),计算它们的和(结果对 2³² 取模),然后把得到的 i32 值压回栈里。
函数调用的时候,栈一开始是空的;随着函数体里的指令一步步执行,栈会不断被填充和清空。比如下面这个函数:
;; 函数:一个i32参数$p,返回i32
(func (param $p i32)
(result i32)
local.get $p ;; 把$p压入栈,栈现在:[$p]
local.get $p ;; 再把$p压入栈,栈现在:[$p, $p]
i32.add) ;; 弹出两个$p,求和后压回栈,栈现在:[$p + $p]
执行完这些指令后,栈里只会剩下一个 i32 值 —— 就是$p + $p的结果(由i32.add计算得出)。而函数的返回值,其实就是函数执行完后栈里剩下的最后那个值。
WebAssembly 有严格的验证规则,会确保栈的状态完全符合预期:比如你声明了(result f32),那函数执行完栈里必须正好剩一个 f32 值;如果没声明返回值,栈就必须是空的。
第一个函数体
前面说过,函数体就是一串指令的列表。结合我们刚才学的知识,现在终于能定义一个带自定义函数的模块了:
;; 模块:包含一个“加法函数”
(module
;; 函数:两个i32参数($lhs左值、$rhs右值),返回i32
(func (param $lhs i32) (param $rhs i32) (result i32)
local.get $lhs ;; 压入$lhs,栈:[$lhs]
local.get $rhs ;; 压入$rhs,栈:[$lhs, $rhs]
i32.add) ;; 弹出求和,栈:[$lhs + $rhs](作为返回值)
)
这个函数的逻辑很简单:取两个参数,相加后返回结果。
函数体里能写的指令其实还有很多,不过咱们先从简单的入手,后面会看到更多例子。如果想查所有可用的操作码(opcode),可以看webassembly.org的《语义参考文档》。
调用函数
光有函数还不够,得能调用它才行。怎么调用呢?和 ES 模块类似,Wasm 里的函数必须通过模块内的export语句明确导出,才能被外部调用。
和局部变量一样,函数默认也是用数字索引标识的,但为了方便,我们也可以给函数起名。步骤很简单:
- 在
func关键字后面加个$开头的名字,比如(func $add …)。 - 加一条
export声明,格式像这样:
wat
;; 导出函数:JavaScript里用"add"这个名字,对应Wasm里的$add函数
(export "add" (func $add))
这里的add是函数在 JavaScript 里的名字,而$add是它在 Wasm 模块内部的名字。
所以最终的模块代码长这样:
;; 带导出函数的加法模块
(module
;; 给函数起名$add,参数$lhs/$rhs,返回i32
(func $add (param $lhs i32) (param $rhs i32) (result i32)
local.get $lhs
local.get $rhs
i32.add)
;; 导出$add为"add",供JavaScript调用
(export "add" (func $add))
)
如果你想跟着试一下,可以把上面的代码保存成add.wat文件,然后用 wabt 工具把它转成二进制文件add.wasm(具体方法看《WebAssembly 文本格式转 Wasm》)。
接下来,我们用异步的方式加载这个二进制模块(加载方法看《加载和运行 WebAssembly 代码》),然后在 JavaScript 里调用add函数 —— 这个函数会在实例的exports属性里:
// 加载add.wasm,实例化后调用add(1,2)
WebAssembly.instantiateStreaming(fetch("add.wasm")).then((obj) => {
console.log(obj.instance.exports.add(1, 2)); // 输出结果是3
});
提示:你可以在 GitHub 上找到这个例子的完整代码(
add.html),也能直接看在线演示。另外,关于WebAssembly.instantiateStreaming()的详细用法,可以看官方文档。
深入基础特性
刚才咱们讲了最核心的内容,现在来看看一些更进阶的特性。
调用模块内的其他函数
用call指令可以调用模块里的其他函数,参数是目标函数的索引或名字。比如下面这个模块里有两个函数:一个返回 42,另一个返回 “42+1” 的结果:
(module
;; 函数$getAnswer:返回i32类型的42
(func $getAnswer (result i32)
i32.const 42) ;; 把42(i32类型)压入栈
;; 函数:导出为"getAnswerPlus1",返回i32
(func (export "getAnswerPlus1") (result i32)
call $getAnswer ;; 调用$getAnswer,栈现在:[42]
i32.const 1 ;; 压入1,栈现在:[42, 1]
i32.add) ;; 求和后压回栈,栈现在:[43](返回值)
)
提示:
i32.const的作用是创建一个 32 位整数并压入栈。你也可以把i32换成其他数值类型,常量值也能随便改(这里我们设为 42)。
注意到第二个函数的(export "getAnswerPlus1")是直接写在func后面的吗?这是一种简写 —— 相当于在模块里单独写一条export语句,效果是一样的。比如:
;; 和上面的简写效果完全相同
(export "getAnswerPlus1" (func $functionName))
调用这个模块的 JavaScript 代码长这样:
// 加载call.wasm,调用getAnswerPlus1()
WebAssembly.instantiateStreaming(fetch("call.wasm")).then((obj) => {
console.log(obj.instance.exports.getAnswerPlus1()); // 输出结果是43
});
从 JavaScript 导入函数
我们已经知道 JavaScript 能调用 Wasm 函数了,但反过来 ——Wasm 能调用 JavaScript 函数吗?
WebAssembly 本身其实并不懂 JavaScript,但它支持一种通用的 “导入函数” 机制 —— 导入的函数既可以是 JavaScript 写的,也可以是其他 Wasm 模块的。来看个例子:
;; 导入JavaScript的console.log,然后调用它
(module
;; 导入:从"console"模块里导入"log",对应Wasm里的$log函数(参数是i32)
(import "console" "log" (func $log (param i32)))
;; 导出函数logIt:调用$log,传入13
(func (export "logIt")
i32.const 13 ;; 压入13(i32)
call $log) ;; 调用导入的$log函数
)
Wasm 的导入用的是 “两级命名空间”—— 上面的代码意思是 “从console这个命名空间里,导入log函数”。另外,导出的logIt函数会用之前讲的call指令调用这个导入的函数。
导入的函数和普通 Wasm 函数没什么区别:Wasm 会静态验证它的签名是否合法,它也有自己的索引,还能起名和调用。
但 JavaScript 函数是没有 “签名” 概念的 —— 不管导入时声明的签名是什么,任何 JavaScript 函数都能传进去。不过要注意:一旦模块声明了导入,调用WebAssembly.instantiate()的时候,必须传一个 “导入对象”(importObject),而且这个对象里得有对应的属性。
比如上面的例子,我们需要一个importObject,让importObject.console.log是一个 JavaScript 函数。完整代码如下:
// 定义导入对象:console.log对应自定义函数
const importObject = {
console: {
log(arg) {
console.log(arg); // 实际执行的是JavaScript的console.log
},
},
};
// 加载logger.wasm,传入importObject,调用logIt()
WebAssembly.instantiateStreaming(fetch("logger.wasm"), importObject).then(
(obj) => {
obj.instance.exports.logIt(); // 会输出13
},
);
提示:你可以在 GitHub 上找到这个例子(
logger.html),也能看在线演示。
在 Wasm 里声明全局变量
WebAssembly 支持创建 “全局变量实例”—— 这些变量既能被 JavaScript 访问,也能在多个WebAssembly.Module实例之间导入 / 导出。这个特性很有用,比如可以实现多个模块的动态链接。
在 WebAssembly 文本格式里,全局变量的写法大概是这样的(你可以在 GitHub 上看global.wat的完整代码,以及global.html的 JavaScript 调用示例):
;; 导入JavaScript的全局变量,然后读写它
(module
;; 导入:从"js"模块里导入"global",对应Wasm的$g(可修改的i32)
(global $g (import "js" "global") (mut i32))
;; 导出getGlobal:返回$g的值
(func (export "getGlobal") (result i32)
(global.get $g)) ;; 读取$g的值,压入栈
;; 导出incGlobal:把$g加1
(func (export "incGlobal")
(global.set $g ;; 给$g赋值
(i32.add (global.get $g) (i32.const 1)))) ;; 计算$g + 1
)
这段代码和之前的例子类似,不过有个新关键词mut—— 它表示这个全局变量是 “可修改的”,后面跟的是变量类型。如果变量不可修改,就不用写mut。
在 JavaScript 里,要创建对应的全局变量,可以用WebAssembly.Global()构造函数:
// 创建Wasm全局变量:i32类型、可修改,初始值0
const global = new WebAssembly.Global({ value: "i32", mutable: true }, 0);
WebAssembly 内存(Memory)
前面的例子都是用数字做运算 —— 那如果要处理字符串或者其他复杂数据类型怎么办?这时候就要用到 “内存” 了。Wasm 的内存可以在 Wasm 里创建,也能在 JavaScript 里创建,并且能在两种环境之间共享(新版本的 Wasm 还支持用 “引用类型” 处理复杂数据)。
在 WebAssembly 里,内存就是一块连续的、可修改的原始字节数组,而且可以动态扩容(具体看规范里的 “线性内存” 章节)。Wasm 提供了i32.load、i32.store这类指令,用来在栈和内存的任意位置之间读写字节。
从 JavaScript 的角度看,Wasm 内存就像一个可扩容的ArrayBuffer。JavaScript 可以通过WebAssembly.Memory()接口创建 Wasm 线性内存实例,然后把它导出给 Wasm 模块;也可以访问 Wasm 模块内部创建并导出的内存实例。而且Memory实例有个buffer属性,能返回指向整个线性内存的ArrayBuffer。
内存实例是可以扩容的 —— 比如在 JavaScript 里用Memory.grow()方法,或者在 Wasm 里用memory.grow指令。不过要注意:ArrayBuffer本身是不能改大小的,所以扩容后,原来的ArrayBuffer会失效,同时创建一个新的ArrayBuffer指向扩容后的内存。
另外,创建内存时必须指定 “初始大小”,也可以选填 “最大大小”(内存能扩容到的上限)。如果指定了最大大小,Wasm 会尝试预先申请这块内存 —— 如果成功,后续扩容会更高效;就算现在申请不到最大大小,后面也可能能扩容。只有当 “初始大小” 都申请不到时,创建内存才会失败。
提示:早期的 Wasm 每个模块实例只能有一块内存,但现在浏览器支持 “多内存” 了(如果浏览器兼容的话)。而且不用改旧代码 —— 只用到一块内存的代码依然能正常运行!
示例:用 Wasm 内存处理字符串
咱们用一个例子来演示怎么用 Wasm 内存处理字符串。字符串本质上就是线性内存里的一串字节 —— 假设我们已经把字符串的字节写入了 Wasm 内存,要把它传给 JavaScript,只需要做三件事:共享内存、告诉 JavaScript 字符串在内存里的 “偏移量”、告诉 JavaScript 字符串的 “长度”。
首先,我们要在 JavaScript 和 Wasm 之间共享一块内存。这里有两种方式:
- 在 JavaScript 里创建
Memory对象,让 Wasm 模块导入它。 - 在 Wasm 模块里创建内存,导出给 JavaScript。
咱们选第一种方式:在 JavaScript 里创建内存,然后导入到 Wasm。步骤如下:
- 创建一个初始大小为 1 页(Wasm 里 1 页等于 64KB)的
Memory对象。 - 把它加到
importObject里,键是js.mem。 - 用
WebAssembly.instantiateStreaming()加载 Wasm 模块(比如叫the_wasm_to_import.wasm),并传入importObject。
JavaScript 代码:
// 创建内存:初始1页(64KB)
const memory = new WebAssembly.Memory({ initial: 1 });
// 导入对象:js.mem对应上面的memory
const importObject = {
js: { mem: memory },
};
// 加载Wasm模块,传入importObject
WebAssembly.instantiateStreaming(
fetch("the_wasm_to_import.wasm"),
importObject,
).then((obj) => {
// 这里可以调用Wasm导出的函数...
});
然后在 Wasm 文件里导入这块内存。文本格式的导入语句是这样的:
;; 导入内存:从"js"模块导入"mem",要求至少1页
(import "js" "mem" (memory 1))
导入时必须用和importObject里一致的两级键(js.mem)。后面的1表示 “导入的内存至少要有 1 页”。
提示:因为这是模块导入的第一块内存,所以它的内存索引是
0。在内存指令里可以用索引来指定操作哪块内存,但单内存场景下0是默认值,所以不用写。
接下来,我们要把字符串数据写入内存。这里可以用 “数据段”(data section)—— 它能在模块实例化时,把一串字节写入线性内存的指定位置(类似原生可执行文件里的.data段)。比如我们要把字符串 “Hi” 写入内存的偏移量0处:
(module
(import "js" "mem" (memory 1))
;; ...其他代码...
(data (i32.const 0) "Hi") ;; 把"Hi"写入内存偏移量0的位置
;; ...其他代码...
)
提示:Wasm 文件里的注释用
;;开头,上面的注释是占位符,代表其他代码。
然后,我们需要把字符串的 “偏移量” 和 “长度” 传给 JavaScript,让它能读取到字符串。步骤如下:
- 从 JavaScript 导入一个日志函数(用来打印字符串),名字叫
$log,参数是 “偏移量” 和 “长度”(都是 i32)。 - 在 Wasm 里定义一个导出函数
writeHi(),调用$log并传入 “0”(偏移量)和 “2”(“Hi” 的长度)。
最终的 Wasm 模块文本代码:
;; 用Wasm内存共享字符串给JavaScript
(module
;; 导入:从"console"导入"log",对应$log(参数:偏移量、长度)
(import "console" "log" (func $log (param i32 i32)))
;; 导入:从"js"导入"mem",内存至少1页
(import "js" "mem" (memory 1))
;; 数据段:把"Hi"写入内存偏移量0
(data (i32.const 0) "Hi")
;; 导出writeHi:调用$log,传入偏移量0和长度2
(func (export "writeHi")
i32.const 0 ;; 传偏移量0给$log
i32.const 2 ;; 传长度2给$log
call $log ;; 调用$log
)
)
最后,在 JavaScript 里实现console.log的逻辑,把 Wasm 内存里的字节转成字符串:
// 创建内存:初始1页
const memory = new WebAssembly.Memory({ initial: 1 });
// Wasm里调用的$log函数:把内存里的字节转成字符串并打印
function consoleLogString(offset, length) {
// 从内存里取指定范围的字节(offset开始,长度length)
const bytes = new Uint8Array(memory.buffer, offset, length);
// 把Uint8Array转成UTF-8字符串
const string = new TextDecoder("utf8").decode(bytes);
console.log(string); // 打印字符串
}
// 导入对象:console.log对应上面的函数,js.mem对应内存
const importObject = {
console: { log: consoleLogString },
js: { mem: memory },
};
// 加载logger2.wasm,调用writeHi()
WebAssembly.instantiateStreaming(fetch("logger2.wasm"), importObject).then(
(obj) => {
obj.instance.exports.writeHi(); // 会打印"Hi"
},
);
运行这段代码后,控制台会输出 “Hi”。
提示:你可以在 GitHub 上找到这个例子的完整代码(
logger2.html),也能看在线演示。
多内存(Multiple memories)
新版本的 Wasm 支持在一个模块里用多块内存,而且和单内存代码兼容。多内存的用处很多,比如:
- 把不同用途的数据分开(比如公开数据和私有数据、需要持久化的数据、线程间共享的数据)。
- 给超大型应用突破 32 位地址空间的限制。
不管是直接声明的内存,还是导入的内存,都会按 “创建顺序” 被分配一个从 0 开始的索引。所有内存指令(比如load、store)都能通过索引指定操作哪块内存 —— 默认索引是0(第一块内存),所以单内存场景下不用写索引。
示例:用三块内存存储字符串
咱们把之前的例子扩展一下,用三块内存存储不同的字符串,然后打印结果。步骤如下:
- 从 JavaScript 导入两块内存(
mem0和mem1)。 - 在 Wasm 模块里创建第三块内存(
$mem2),并导出它。 - 给每块内存写入不同的字符串(用
data指令指定内存索引)。 - 导出函数
logAllMemory(),调用 JavaScript 的日志函数打印所有内存里的字符串。
首先看 Wasm 模块的代码:
;; 多内存示例:三块内存存储字符串
(module
;; 导入:从"console"导入"log"(参数:内存索引、偏移量、长度)
(import "console" "log" (func $log (param i32 i32 i32)))
;; 从JavaScript导入两块内存(各1页)
(import "js" "mem0" (memory 1))
(import "js" "mem1" (memory 1))
;; 在Wasm里创建第三块内存(1页),并导出为"memory2"
(memory $mem2 1)
(export "memory2" (memory $mem2))
;; 给三块内存写入字符串(指定内存索引)
(data (memory 0) (i32.const 0) "Memory 0 data") ;; 内存0:偏移0写"Memory 0 data"
(data (memory 1) (i32.const 0) "Memory 1 data") ;; 内存1:偏移0写"Memory 1 data"
(data (memory 2) (i32.const 0) "Memory 2 data") ;; 内存2:偏移0写"Memory 2 data"
;; 给默认内存(0号)追加字符串:偏移13写" (Default)"
(data (i32.const 13) " (Default)")
;; 辅助函数$logMemory:调用$log,传入内存索引、偏移量、长度
(func $logMemory (param $memIndex i32) (param $memOffSet i32) (param $stringLength i32)
local.get $memIndex
local.get $memOffSet
local.get $stringLength
call $log
)
;; 导出logAllMemory:打印三块内存里的字符串
(func (export "logAllMemory")
;; 打印内存0:索引0、偏移0、长度23("Memory 0 data (Default)"的长度)
(i32.const 0)
(i32.const 0)
(i32.const 23)
(call $logMemory)
;; 打印内存1:索引1、偏移0、长度20("Memory 1 data"的长度)
i32.const 1
i32.const 0
i32.const 20
call $logMemory
;; 打印内存2:索引2、偏移0、长度12("Memory 2 data"的长度)
i32.const 2
i32.const 0
i32.const 12
call $logMemory
)
)
注意:(memory 0)是默认值,所以可以省略 —— 比如后面追加 “(Default)” 时,没写内存索引,就会默认写入 0 号内存。
然后是 JavaScript 代码:
- 创建两块内存(
memory0和memory1),传给 Wasm 的importObject。 - 加载 Wasm 模块后,从实例的
exports里获取第三块内存(memory2)。 - 实现日志函数
consoleLogString:根据内存索引选择对应的Memory对象,把字节转成字符串并打印。
JavaScript 代码:
// 创建两块内存(各1页),供Wasm导入
const memory0 = new WebAssembly.Memory({ initial: 1 });
const memory1 = new WebAssembly.Memory({ initial: 1 });
let memory2; // 第三块内存:由Wasm模块创建,后面会从exports里获取
// 日志函数:根据内存索引,读取对应内存的字符串并打印
function consoleLogString(memoryInstance, offset, length) {
let memory;
// 根据内存索引选择对应的Memory对象
switch (memoryInstance) {
case 0:
memory = memory0;
break;
case 1:
memory = memory1;
break;
case 2:
memory = memory2;
break;
// 其他索引可以加默认处理
}
// 从内存取字节,转成UTF-8字符串
const bytes = new Uint8Array(memory.buffer, offset, length);
const string = new TextDecoder("utf8").decode(bytes);
log(string); // 这里的log可以是console.log,或者自定义日志函数
}
// 导入对象:console.log对应上面的函数,js.mem0/mem1对应两块内存
const importObject = {
console: { log: consoleLogString },
js: { mem0: memory0, mem1: memory1 },
};
// 加载multi-memory.wasm,获取第三块内存并调用logAllMemory()
WebAssembly.instantiateStreaming(fetch("multi-memory.wasm"), importObject).then(
(obj) => {
// 从Wasm实例的exports里获取第三块内存
memory2 = obj.instance.exports.memory2;
// 调用logAllMemory(),打印所有内存的字符串
obj.instance.exports.logAllMemory();
},
);
运行后,控制台输出大概是这样的(注意:Memory 1 data后面可能会有乱码,因为解码器读取的字节数比字符串实际长度多):
Memory 0 data (Default)
Memory 1 data
Memory 2 data
提示:你可以在 GitHub 上找到这个例子的完整代码(
multi-memory.html),也能看在线演示。另外,关于多内存的浏览器兼容性,可以看 webassembly.multiMemory 的文档。
WebAssembly 表格(Tables)
最后咱们来看 WebAssembly 里最复杂、也最容易让人困惑的部分:表格(Tables)。表格本质上是 “可扩容的引用数组”,Wasm 代码可以通过索引访问里面的引用。
为什么需要表格?
先想个问题:之前讲的call指令(调用模块内函数)只能用 “静态函数索引”,也就是说它永远只能调用同一个函数 —— 那如果想调用 “运行时才确定的函数”(比如动态函数),该怎么办?
这种场景在其他语言里很常见:
- JavaScript 里的函数是 “一等公民”,可以动态赋值和调用。
- C/C++ 里的函数指针。
- C++ 里的虚函数(virtual functions)。
为了支持这种场景,WebAssembly 引入了call_indirect指令 —— 它能接收 “动态函数操作数”。但问题来了:目前 Wasm 只能处理 i32/i64/f32/f64 这四种数值类型,没法直接传函数引用。
Wasm 本来可以加一种anyfunc类型(“any” 表示能存任意签名的函数),但出于安全考虑,这种类型不能存在线性内存里 —— 因为线性内存会把存储的值以原始字节形式暴露出来,这就导致 Wasm 代码能随意查看甚至篡改函数的原始地址,这在网页环境里是绝对不允许的。
所以解决方案是:把函数引用存在 “表格” 里,然后传递 “表格索引”(也就是 i32 值)。这样call_indirect的操作数就可以是 i32 类型的索引了。
在 Wasm 里定义表格
怎么把 Wasm 函数放进表格里呢?和 “数据段(data section)” 用来初始化线性内存类似,“元素段(elem section)” 用来初始化表格里的函数引用。来看个例子:
;; 定义表格并放入函数引用
(module
;; 表格:初始大小2,存储函数引用(funcref)
(table 2 funcref)
;; 元素段:从表格索引0开始,放入$f1和$f2的引用
(elem (i32.const 0) $f1 $f2)
;; 函数$f1:返回i32类型的42
(func $f1 (result i32)
i32.const 42)
;; 函数$f2:返回i32类型的13
(func $f2 (result i32)
i32.const 13)
...
)
咱们逐行解释:
(table 2 funcref):定义一个表格,初始大小是 2(能存 2 个引用),funcref表示表格里存的是 “函数引用”。- 函数段(
func):和普通 Wasm 函数一样,这里的$f1和$f2就是要放进表格的函数(为了简单,它们只返回常量)。注意:函数的声明顺序不影响 —— 不管在哪里声明,都能在元素段里引用。 - 元素段(
elem):可以指定模块里的任意子集函数,顺序也可以随便排,还能重复。括号里的(i32.const 0)是 “偏移量”—— 表示从表格的哪个索引开始放函数引用。这里从 0 开始,表格大小是 2,所以能放两个引用(索引 0 和 1)。如果想从索引 1 开始放,就要写(i32.const 1),而且表格大小至少得是 3。
提示:未初始化的表格元素会默认设为 “调用时抛错” 的引用。
在 JavaScript 里,创建对应的表格实例是这样的:
function module() {
// 创建表格:初始大小2,元素类型是"anyfunc"(任意函数)
const tbl = new WebAssembly.Table({ initial: 2, element: "anyfunc" });
// 模拟Wasm里的函数(实际是导入的Wasm函数)
const f1 = () => 42; /* 对应Wasm里的$f1 */
const f2 = () => 13; /* 对应Wasm里的$f2 */
// 给表格赋值:索引0放f1,索引1放f2(对应元素段的功能)
tbl.set(0, f1);
tbl.set(1, f2);
}
使用表格
定义好表格后,怎么用呢?来看这段代码:
;; 用call_indirect调用表格里的函数
(module
...
;; 定义类型$return_i32:返回i32的函数
(type $return_i32 (func (result i32))) ;; 如果这里写f32,类型检查会失败
;; 导出函数callByIndex:参数$i(表格索引),返回i32
(func (export "callByIndex") (param $i i32) (result i32)
local.get $i ;; 把索引$i压入栈
call_indirect (type $return_i32)) ;; 调用表格里索引$i的函数
)
逐行解释:
(type $return_i32 (func (result i32))):定义一个类型$return_i32,表示 “返回 i32 的函数”。后面调用表格函数时会用这个类型做 “类型检查”—— 如果表格里的函数签名和这个类型不匹配,就会抛错(比如这里如果写成f32,调用$f1就会失败)。- 导出函数
callByIndex:参数$i是表格的索引(i32 类型)。 - 函数体里:先把
$i压入栈,然后用call_indirect调用表格里对应索引的函数 ——call_indirect会自动从栈里弹出$i作为索引。
其实call_indirect的参数也可以直接写在指令里,不用先压栈,比如:
;; 和上面的效果一样,参数直接写在call_indirect里
(call_indirect (type $return_i32) (local.get $i))
如果用 JavaScript 类比,这个逻辑就像用数组存函数,然后通过索引调用:tbl[i]()—— 是不是很像?
再来看完整的模块代码(你可以在 GitHub 上看wasm-table.wat):
;; 完整的表格使用示例
(module
;; 表格:初始2个元素,存函数引用
(table 2 funcref)
;; 函数$f1:返回42
(func $f1 (result i32)
i32.const 42)
;; 函数$f2:返回13
(func $f2 (result i32)
i32.const 13)
;; 元素段:索引0放$f1,索引1放$f2
(elem (i32.const 0) $f1 $f2)
;; 类型:返回i32的函数
(type $return_i32 (func (result i32)))
;; 导出callByIndex:按索引调用表格里的函数
(func (export "callByIndex") (param $i i32) (result i32)
local.get $i
call_indirect (type $return_i32))
)
在 JavaScript 里加载并调用这个模块:
// 加载wasm-table.wasm,调用callByIndex()
WebAssembly.instantiateStreaming(fetch("wasm-table.wasm")).then((obj) => {
console.log(obj.instance.exports.callByIndex(0)); // 索引0:返回42
console.log(obj.instance.exports.callByIndex(1)); // 索引1:返回13
console.log(obj.instance.exports.callByIndex(2)); // 索引2不存在:抛错
});
提示:你可以在 GitHub 上找到这个例子的完整代码(
wasm-table.html),也能看在线演示。另外,和内存类似,表格也能在 JavaScript 里创建(用WebAssembly.Table()),还能在不同 Wasm 模块之间导入 / 导出。
表格修改和动态链接
JavaScript 能通过grow()、get()、set()方法修改表格对象;而 Wasm 代码也能通过 “引用类型” 相关的指令(比如table.get、table.set)直接操作表格。
正因为表格是可修改的,所以它能用来实现复杂的 “加载时动态链接” 和 “运行时动态链接”。动态链接的核心是:多个模块实例共享同一块内存和同一个表格 —— 这和原生应用里多个编译好的.dll文件共享一个进程的地址空间是类似的。
示例:多模块共享内存和表格
咱们来做个实验:创建一个包含 “内存” 和 “表格” 的导入对象,把它传给两个不同的 Wasm 模块(shared0.wasm和shared1.wasm),让它们共享这两个对象。
首先看两个 Wasm 模块的文本代码:
shared0.wat(定义函数并放入表格):
;; shared0.wat:定义函数,放入共享表格
(module
;; 导入共享内存(js.memory)
(import "js" "memory" (memory 1))
;; 导入共享表格(js.table)
(import "js" "table" (table 1 funcref))
;; 元素段:把$shared0func放入表格索引0
(elem (i32.const 0) $shared0func)
;; 函数$shared0func:读取内存索引0的值并返回
(func $shared0func (result i32)
i32.const 0 ;; 压入内存索引0
i32.load) ;; 读取内存索引0的值,压入栈(作为返回值)
)
shared1.wat(写入内存并调用表格里的函数):
;; shared1.wat:写内存,调用共享表格里的函数
(module
;; 导入共享内存(js.memory)
(import "js" "memory" (memory 1))
;; 导入共享表格(js.table)
(import "js" "table" (table 1 funcref))
;; 类型$void_to_i32:无参数、返回i32的函数
(type $void_to_i32 (func (result i32)))
;; 导出doIt:写内存,调用表格函数
(func (export "doIt") (result i32)
i32.const 0 ;; 压入内存索引0
i32.const 42 ;; 压入值42
i32.store ;; 把42写入内存索引0(弹出两个值)
i32.const 0 ;; 压入表格索引0
call_indirect (type $void_to_i32)) ;; 调用表格索引0的函数($shared0func)
)
这两个模块的工作流程是:
shared0.wat里的$shared0func会被放入共享表格的索引 0。这个函数的逻辑是:读取内存索引 0 的值并返回。shared1.wat里的doIt函数会做两件事:- 把 42 写入共享内存的索引 0。
- 调用共享表格索引 0 的函数(也就是
$shared0func),让它读取内存里的 42 并返回。
提示:上面的指令都是 “隐式弹栈” 的,其实也可以把参数直接写在指令里,比如:
wat
(i32.store (i32.const 0) (i32.const 42)) ;; 显式指定内存索引和值 (call_indirect (type $void_to_i32) (i32.const 0)) ;; 显式指定表格索引
接下来是 JavaScript 代码:创建共享的内存和表格,传给两个模块,然后调用doIt():
// 导入对象:包含共享的内存和表格
const importObj = {
js: {
memory: new WebAssembly.Memory({ initial: 1 }), // 共享内存(1页)
table: new WebAssembly.Table({ initial: 1, element: "anyfunc" }), // 共享表格
},
};
// 同时加载两个Wasm模块,传入同一个importObj
Promise.all([
WebAssembly.instantiateStreaming(fetch("shared0.wasm"), importObj),
WebAssembly.instantiateStreaming(fetch("shared1.wasm"), importObj),
]).then((results) => {
// 调用shared1.wasm导出的doIt(),会返回42
console.log(results[1].instance.exports.doIt()); // 打印42
});
这里的关键是:两个模块导入的是同一个内存和表格对象,所以它们共享同一个 “线性内存地址空间” 和 “表格地址空间”。
提示:你可以在 GitHub 上找到这个例子的完整代码(
shared-address-space.html),也能看在线演示。
批量内存操作(Bulk memory operations)
批量内存操作是 Wasm 后来新增的特性 —— 提供了 7 个新的内置操作,用来实现 “复制”“初始化” 这类批量内存操作。这样 Wasm 就能更高效地模拟memcpy、memmove这类原生函数,性能更好。
提示:关于批量内存操作的浏览器兼容性,可以看 webassembly.bulk-memory-operations 的文档。
这 7 个新操作分别是:
data.drop:丢弃一个数据段(data section)里的数据。elem.drop:丢弃一个元素段(elem section)里的数据。memory.copy:把线性内存里的一块数据复制到另一块。memory.fill:用指定的字节值填充线性内存的一块区域。memory.init:从数据段复制一块数据到线性内存。table.copy:把表格里的一块数据复制到另一块。table.init:从元素段复制一块数据到表格。
提示:想了解更多细节,可以看《批量内存操作和条件段初始化提案》。
类型(Types)
WebAssembly 支持多种类型,下面分别介绍主要的几类。
数值类型(Number types)
目前 Wasm 支持四种数值类型:
- i32:32 位整数
- i64:64 位整数
- f32:32 位浮点数
- f64:64 位浮点数
向量类型(Vector types)
- v128:128 位向量,可以存储打包的整数、浮点数,或者单个 128 位值。
引用类型(Reference types)
“引用类型” 提案主要带来了两个特性:
- 新增
externref类型:可以存储任意 JavaScript 值(比如字符串、DOM 引用、对象等)。从 Wasm 的角度看,externref是 “不透明” 的 ——Wasm 模块不能直接操作这些值,只能接收和传递它们。但这个类型很有用,比如让 Wasm 模块能调用 JavaScript 函数、DOM API,让 Wasm 和宿主环境的交互更简单。externref可以用作值类型,也能用作表格元素类型。 - 新增一批指令:让 Wasm 模块能直接操作 WebAssembly 表格,不用再通过 JavaScript API。
提示:如果你用 Rust 开发 Wasm,可以看
wasm-bindgen的文档,里面有关于如何使用externref的详细说明。另外,关于引用类型的浏览器兼容性,可以看 webassembly.reference-types 的文档。
多返回值 WebAssembly(Multi-value WebAssembly)
这是 Wasm 后来新增的另一个特性 —— 允许函数返回多个值,指令序列也能消费和产生多个栈值。
提示:关于多返回值的浏览器兼容性,可以看 webassembly.multi-value 的文档。
在 2020 年 6 月(本文撰写时),这个特性还处于早期阶段,目前唯一支持的多返回值场景是 “调用返回多值的函数”。比如:
;; 多返回值示例:函数返回两个i32,另一个函数调用它并求和
(module
;; 函数$get_two_numbers:返回两个i32(1和2)
(func $get_two_numbers (result i32 i32)
i32.const 1 ;; 第一个返回值
i32.const 2) ;; 第二个返回值
;; 导出add_two_numbers:调用上面的函数,求和后返回
(func (export "add_two_numbers") (result i32)
call $get_two_numbers ;; 调用后栈里有两个值:[1, 2]
i32.add) ;; 求和后返回:3
)
未来这个特性会支持更多场景,比如更丰富的指令类型。如果想了解目前的进展和工作原理,可以看 Nick Fitzgerald 写的《Multi-Value All The Wasm!》。
WebAssembly 线程(WebAssembly Threads)
WebAssembly 线程允许WebAssembly.Memory对象在多个 Web Worker 的 Wasm 实例之间共享 —— 和 JavaScript 里的SharedArrayBuffer类似。这样 Worker 之间的通信会非常快,能大幅提升 Web 应用的性能。
线程提案主要包含两部分:共享内存和原子内存访问。
提示:关于线程的浏览器兼容性,可以看 webassembly.threads-and-atomics 的文档。
共享内存(Shared memories)
正如前面提到的,你可以创建 “共享 WebAssembly 内存”—— 这类内存可以通过postMessage()在 Window 和 Worker 之间传递,和SharedArrayBuffer的用法一样。
在 JavaScript API 里,WebAssembly.Memory()构造函数的配置对象多了个shared属性 —— 设为true就能创建共享内存:
// 创建共享内存:初始10页,最大100页
const memory = new WebAssembly.Memory({
initial: 10,
maximum: 100,
shared: true,
});
这时内存的buffer属性返回的是SharedArrayBuffer,而不是普通的ArrayBuffer:
memory.buffer; // 返回SharedArrayBuffer
在 Wasm 文本格式里,创建共享内存要加shared关键字:
;; 共享内存:初始1页,最大2页
(memory 1 2 shared)
注意:和普通内存不同,共享内存必须在 JavaScript 构造函数和 Wasm 文本格式里都指定 “最大大小”。
提示:想了解更多细节,可以看《WebAssembly 线程提案》。
原子内存访问(Atomic memory accesses)
Wasm 新增了一批指令,用来实现互斥锁(mutexes)、条件变量(condition variables)这类高级特性。你可以在官方文档里查到这些指令的列表。
提示:如果你用 Emscripten 开发 Wasm,可以看《Emscripten Pthreads 支持文档》,里面讲了如何利用这个特性。
总结
到这里,我们已经对 WebAssembly 文本格式的主要组件,以及它们在 WebAssembly JavaScript API 中的对应形式,做了一个全面的概览。