《WebAssembly指南》第六章:读懂 WebAssembly 文本格式

作者:锋通科技日期:10/2/2025

为了让开发者能看懂、能修改 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 表达式写出来看起来不一样,但本质和其他语言的函数是类似的。

签名和参数

函数签名的结构是:先列参数类型声明,再列返回值类型声明。这里有两个点要注意:

  1. 如果没写(result),就说明函数没有返回值。
  2. 目前 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.getlocal.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语句明确导出,才能被外部调用。

和局部变量一样,函数默认也是用数字索引标识的,但为了方便,我们也可以给函数起名。步骤很简单:

  1. func关键字后面加个$开头的名字,比如(func $add …)
  2. 加一条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.loadi32.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 之间共享一块内存。这里有两种方式:

  1. 在 JavaScript 里创建Memory对象,让 Wasm 模块导入它。
  2. 在 Wasm 模块里创建内存,导出给 JavaScript。

咱们选第一种方式:在 JavaScript 里创建内存,然后导入到 Wasm。步骤如下:

  1. 创建一个初始大小为 1 页(Wasm 里 1 页等于 64KB)的Memory对象。
  2. 把它加到importObject里,键是js.mem
  3. 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,让它能读取到字符串。步骤如下:

  1. 从 JavaScript 导入一个日志函数(用来打印字符串),名字叫$log,参数是 “偏移量” 和 “长度”(都是 i32)。
  2. 在 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 开始的索引。所有内存指令(比如loadstore)都能通过索引指定操作哪块内存 —— 默认索引是0(第一块内存),所以单内存场景下不用写索引。

示例:用三块内存存储字符串

咱们把之前的例子扩展一下,用三块内存存储不同的字符串,然后打印结果。步骤如下:

  1. 从 JavaScript 导入两块内存(mem0mem1)。
  2. 在 Wasm 模块里创建第三块内存($mem2),并导出它。
  3. 给每块内存写入不同的字符串(用data指令指定内存索引)。
  4. 导出函数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 代码:

  1. 创建两块内存(memory0memory1),传给 Wasm 的importObject
  2. 加载 Wasm 模块后,从实例的exports里获取第三块内存(memory2)。
  3. 实现日志函数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)
  ...
)

咱们逐行解释:

  1. (table 2 funcref):定义一个表格,初始大小是 2(能存 2 个引用),funcref表示表格里存的是 “函数引用”。
  2. 函数段(func):和普通 Wasm 函数一样,这里的$f1$f2就是要放进表格的函数(为了简单,它们只返回常量)。注意:函数的声明顺序不影响 —— 不管在哪里声明,都能在元素段里引用。
  3. 元素段(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的函数
)

逐行解释:

  1. (type $return_i32 (func (result i32))):定义一个类型$return_i32,表示 “返回 i32 的函数”。后面调用表格函数时会用这个类型做 “类型检查”—— 如果表格里的函数签名和这个类型不匹配,就会抛错(比如这里如果写成f32,调用$f1就会失败)。
  2. 导出函数callByIndex:参数$i是表格的索引(i32 类型)。
  3. 函数体里:先把$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.gettable.set)直接操作表格。

正因为表格是可修改的,所以它能用来实现复杂的 “加载时动态链接” 和 “运行时动态链接”。动态链接的核心是:多个模块实例共享同一块内存和同一个表格 —— 这和原生应用里多个编译好的.dll文件共享一个进程的地址空间是类似的。

示例:多模块共享内存和表格

咱们来做个实验:创建一个包含 “内存” 和 “表格” 的导入对象,把它传给两个不同的 Wasm 模块(shared0.wasmshared1.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)
)

这两个模块的工作流程是:

  1. shared0.wat里的$shared0func会被放入共享表格的索引 0。这个函数的逻辑是:读取内存索引 0 的值并返回。
  2. 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 就能更高效地模拟memcpymemmove这类原生函数,性能更好。

提示:关于批量内存操作的浏览器兼容性,可以看 webassembly.bulk-memory-operations 的文档。

这 7 个新操作分别是:

  1. data.drop:丢弃一个数据段(data section)里的数据。
  2. elem.drop:丢弃一个元素段(elem section)里的数据。
  3. memory.copy:把线性内存里的一块数据复制到另一块。
  4. memory.fill:用指定的字节值填充线性内存的一块区域。
  5. memory.init:从数据段复制一块数据到线性内存。
  6. table.copy:把表格里的一块数据复制到另一块。
  7. table.init:从元素段复制一块数据到表格。

提示:想了解更多细节,可以看《批量内存操作和条件段初始化提案》。

类型(Types)

WebAssembly 支持多种类型,下面分别介绍主要的几类。

数值类型(Number types)

目前 Wasm 支持四种数值类型:

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

向量类型(Vector types)

  • v128:128 位向量,可以存储打包的整数、浮点数,或者单个 128 位值。

引用类型(Reference types)

“引用类型” 提案主要带来了两个特性:

  1. 新增externref类型:可以存储任意 JavaScript 值(比如字符串、DOM 引用、对象等)。从 Wasm 的角度看,externref是 “不透明” 的 ——Wasm 模块不能直接操作这些值,只能接收和传递它们。但这个类型很有用,比如让 Wasm 模块能调用 JavaScript 函数、DOM API,让 Wasm 和宿主环境的交互更简单。externref可以用作值类型,也能用作表格元素类型。
  2. 新增一批指令:让 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 中的对应形式,做了一个全面的概览。


《WebAssembly指南》第六章:读懂 WebAssembly 文本格式》 是转载文章,点击查看原文


相关推荐


【SpringAI中Chat-Client用法】
明志学编程-9/30/2025

这篇文章介绍了如何使用SpringAI框架中的ChatClient进行大模型交互开发。主要内容包括:1. 对比ChatClient与底层ChatModel的区别,建议优先使用更易用的ChatClient;2. 详细展示如何创建子工程、添加依赖(以阿里云百炼平台为例)和配置;3. 提供同步和流式两种调用方式的代码示例;4. 解决多模型依赖冲突问题,通过直接注入具体ChatModel实现动态选择;5. 最后提到多平台多模型动态配置的实战应用。文章配有CSDN博客链接和示例图片,适合开发者学习SpringAI框架


数电基础--电平规范_TTL与CMOS
逐步前行9/30/2025

高电平输出大于2.4V,如果落在2.4V至3.5V之间,CMOS电路不能检测到高电平,需要进行电平输换。(3)、3.3V的TTL驱动5V的CMOS,考虑可以存在电压钳位,比如单片机的GPIO,不适合上拉电阻。(1)、3.3V的TTL驱动3.3V的CMOS,可以通过简单的上拉电阻实现电平匹配。(2)、5V的TTL驱动5V的CMOS,可以通过简单的上拉电阻实现电平匹配。高电平输出3.3V,CMOS电路不能检测到高电平,需要进行电平转换。Tx输出5V,Q2截止,Rx端为3.3V;


AR/VR赋能工业巡检:开启智能化运维新时代
Teamhelper_AR2025/10/2

在工业 4.0 时代浪潮的推动下,增强现实(AR www.teamhelper.cn )与虚拟现实(VR)技术加速从理论概念迈向工业应用前沿,尤其在工业设备巡检这一关键领域,正展现出前所未有的变革潜力,有望彻底颠覆传统依赖人工经验、效率低下、风险高且数据不连贯的巡检模式。 AR技术:重塑工业巡检核心优势 AR技术通过巧妙地将虚拟信息与真实环境相融合,为工业巡检人员带来了革新性的工作体验。借助AR智能眼镜,巡检人员能够实时获取设备参数、操作指南以及历史数据等关键信息,无需再频繁翻阅纸质


释放模型潜能:ONNX Runtime 如何进行优化与加速?
Cosolar2025/10/2

在机器学习从实验室走向真实世界的过程中,模型的部署与运行效率往往是决定项目成败的“最后一公里”。一个在离线环境中表现优异的模型,如果无法满足生产环境对低延迟、高吞吐和低资源消耗的要求,其商业价值将大打折扣。 ONNX Runtime (ORT) 作为由微软主导的开源跨平台推理引擎,凭借其出色的性能、广泛的硬件支持和活跃的社区,已成为业界部署模型的事实标准之一。然而,仅仅将模型转换为 ONNX 格式并使用 ORT 运行,只是拿到了“入场券”。要真正释放其潜能,我们需要从模型优化、推理引擎配置、硬


SIMD编程入门:让性能飞起来的实践指南
oioihoii2025/10/3

在现代计算中,单指令多数据流(SIMD)技术就像是一把性能优化的瑞士军刀,能让你的程序速度提升数倍甚至数十倍。本文将带你从零开始,掌握这把利器的使用之道。 什么是SIMD?从汽车生产线说起 想象一下汽车生产线:传统方式是一个工人依次安装每个轮胎,而SIMD就像是培训了一个专门团队,能够同时安装四个轮胎。这就是单指令多数据流的核心思想——一条指令,多个数据。 // 传统标量计算 - 依次处理每个元素 for (int i = 0; i < 4; i++) { result[i] = a[


HTTP为什么不安全?
你的人类朋友2025/10/4

🌐 前言 你好呀,我是你的人类朋友! 本文主要讲讲 HTTP 为什么不安全,以及 HTTPS 如何解决这些问题。 ❗❗ 核心问题速览 HTTP(超文本传输协议):互联网上应用最广泛的网络协议,但数据以明文形式传输。注意,是明文,谁都能看!! HTTPS(安全超文本传输协议):HTTP 的安全版本,= HTTP + SSL/TLS 加密,就像把明信片放进防拆信封里寄送,别人无法看到信息的内容。 补充知识 1:SSL/TLS在【传输层】对 HTTP 数据进行加密,确保隐私和完整性。 补充知识 2


【深度相机术语与概念】
是刘彦宏吖2025/10/5

获取相机输出的 深度图、灰度图、彩色图 和 点云图,用于导航、避障、三维建模、手势识别等应用。 【深度相机术语与概念】 相机类型 3D 相机 3D 相机是一种能够捕捉三维图像的相机。它通过各种技术手段(如立体视觉、飞行时间、结构光等)获取物体的三维形状和深度信息。3D 相机可以生成具有 3D 空间坐标信息的点云数据,使得计算机能够理解和处理三维空间中的物体。 主动双目立体相机 主动双目立体相机是一种结合了双目立体视觉和主动光源(如结构光)的相机系统。它通过投射已知的光图案到场景中,并使用双目相


Python 的 TCP 编程
hubenchang05152025/10/6

#Python 的 TCP 编程 传输控制协议(Transmission Control Protocol) 是一种 面向连接、可靠传输 的网络通信协议,是现代互联网最核心的协议之一。 #客户端程序 TCP 客户端程序通常只需要连接到服务器然后收发数据即可。下面是一个示例,它向 tcpbin.com 的 4242 端口发送 hello\n,对方会原样返回。 import socket # 创建 TCP socket sock = socket.socket(socket.AF_INET, so


第7章:数据库与持久化存储
芝麻开门-新起点2025/10/8

7.1 为何需要数据库:记忆与状态管理 内容讲解 默认情况下,AI Bot 是**“无状态”的。这意味着除了短暂的当前对话上下文,它不记得任何过去的事情。每次对话都是一次全新的开始。然而,在许多真实场景中,我们需要 Bot 拥有记忆**,能够持久化地存储和检索信息。这就是**数据库(Database)**的作用。 数据库为 Bot 提供了以下关键能力: 长期记忆:记住用户的偏好、历史订单、个人信息等。例如,一个订餐 Bot 应该记住你常去的地址和喜欢的口味。状态跟踪:在复杂的多轮任务中,跟踪当前


【SCI一区】【电动车】基于ADMM双层凸优化的燃料电池混合动力汽车研究(Matlab代码实现)
荔枝科研社2025/10/9

💥💥💞💞欢迎来到本博客❤️❤️💥💥 🏆博主优势:🌞🌞🌞博客内容尽量做到思维缜密,逻辑清晰,为了方便读者。 ⛳️座右铭:行百里者,半于九十。 💥1 概述 基于ADMM双层凸优化的燃料电池混合动力汽车研究 随着车辆互联性的出现,互联汽车 (CVs) 在增强道路安全、改善乘坐舒适性、提高交通效率和提高能源效率方面提供了巨大的潜力。通过从车对车 (V2V) 和车对基础设施 (V2I) 通信中获取交通信息,CV 能够更准确、更广泛地感知

首页编辑器站点地图

Copyright © 2025 聚合阅读

License: CC BY-SA 4.0