在2025年,WebAssembly(后文中用Wasm简称)已经被主流的浏览器和非浏览器JS运行时支持。你也许从来没有直接接触过WASM,但是你使用的各种软件、框架、库很有可能已经用到了它。在这一系列文章里,我们来近距离地接触一下这个熟悉又陌生的朋友。
Web汇编?
望文生义一下,这不就是"Web汇编"的意思吗。现在的Web环境早已不是几十年前那样的蛮荒之地,我们也在不断向Web平台委以重任。JS毫无疑问是Web的"母语"(native language),但是为了能够让Web能够以更高的效率处理复杂工作,开发者们自然在不断研究比解释JS代码更高效的解决方案。
asm.js又是啥?
如果谈WebAssembly那不得不提asm.js。OK,又是一个名字里带"汇编"(asm即assembly的缩略)的是吧?这次是JS汇编?
asm.js是Mozilla设计的一个JS的"严格子集"。你可以认为它就是一种只准你写一些最基本的语法的奇怪的JS:
ini
function add1(x) {
x = x|0;
return (x+1)|0; // x|0这种语法来表示一个值是整数类型
}
你会问那这有什么用呢?它的诞生不是为了让你来写的,而是作为C/C++这种native语言的"编译目标"(后面我们会多次看到这个词)的。也就是说让C++的代码编译成这种奇怪的JS。说白了就是很多所谓高性能库都是C/C++写的,为了让这些代码能够在浏览器中运行,那么就要生成浏览器环境能够理解的东西------最简单粗暴的就是生成足够高效的JS。
不过asm.js只是昙花一现,它的标准也停止在2014年。WebAssembly是它事实上的继承人。甚至它的开发者也说WASM是"asm.js done right"。当然,asm.js作为JS的子集,即使在它诞生的时候基本也不用考虑浏览器支不支持------毕竟它就是JS。然而Wasm为了更进一步是会编译得到二进制代码的,所以它需要得到各家浏览器的"支持"。好在2025年所有主流的浏览器都是支持的。

Emscripten和asm.js以及WASM也有不解之缘。虽然asm.js已经成为历史,但Emscripten是一个至今仍然活跃在舞台上的C/C++编译器工具链。它的目的就是为了把C/C++代码编译成浏览器可以运行的代码。在若干年前,它就是为了把C++代码(先编译成LLVM的中间语言,然后再)编译成JS(包括asm.js)。现在的Emscripten默认就是生成WASM了,而你如果想看它生成asm.js,可能只能去找远古版本了。
那些"用C/C++编写、又有在浏览器中运行的需要"的代码实际上有相当一部分就是游戏引擎。实际上你能叫得上名字的主流游戏引擎很有可能大部分核心代码都是C/C++写的,而它们生成可以在Web上运行的build也很可能利用了Emscripten和Wasm。
WebAssembly是一种安全、可移植、低级的代码格式
Wasm定义了一套较为低级的指令集用来操作一个虚拟的栈式机器(stack machine)。这些指令会被编码为紧凑的二进制格式嵌入到宿主环境中执行。
"Web"Assembly并不是Web专用
WebAssembly虽然名字里带有Web字眼,但是它并没有要求必须要浏览器来执行。它的指令中没有任何和浏览器直接相关的东西。也不存在所谓标准库(你没法直接 在Wasm中console.log
)。尽管无论JavaScript还是Wasm一开始都是为了解决浏览器中的问题,但实际上它们都可以在Web以外的环境运行。
你可以在任何地方实现一个运行Wasm的环境。就像node、bun之于JavaScript一样。浏览器之外也有wasmtime(这个名字太容易看成waste time了)这样的运行时可以让你直接运行Wasm代码。
Wasm不是一种虚拟机
Wasm本身只是一套指令集和代码格式的标准。虽然它的执行模型是围绕一个虚拟的栈机来设计的,但是本身没有告诉你要如何设计一个执行Wasm代码的虚拟机。
Wasm营造的虚拟环境更接近于裸机(bare metal)。如果你要手写Wasm,那么你不仅没有什么标准库,也没有内存分配器(更别提什么垃圾回收了),"系统调用"也是不存在的。
Wasm作为编译目标
就像手写x86汇编一样,你没事做也可以手写Wasm。毕竟Wasm除了二进制格式也定义了对应的文本格式。但是这些相当低级语言要手写怎么也不会好受。所以Wasm和"真正的"汇编语言一样主要是作为高级语言的编译目标。
一些较为年轻的语言的标准工具链实际上都对编译到Wasm提供了比较好的支持。例如在Go中:
ini
GOOS=js GOARCH=wasm go build -o main.wasm
rust也可以直接编译成Wasm。不过默认情况下rustup没有安装Wasm相关的target。如果需要进行相关的编译工作需要先添加target:
sql
rustup target add wasm32-unknown-unknown
然后在build时通过各种方式指定此target即可。
一写在Wasm诞生之前就存在已久的编程语言可能就需要一些其它的工具来支持了。
初识Wasm代码
"Wasm代码"有两种形式,交给宿主环境执行的自然是二进制格式(通常以.wasm
为后缀名),但是标准也定义了可以勉强给一般人类看和写的文本形式(通常缀名为.wat
,t就是text)。例如下面就是一段文本形式的Wasm代码:
bash
(module
(func (export "add") (param $a i32) (param $b i32) (result i32)
local.get $a
local.get $b
i32.add))
熟悉Lisp系语言的朋友应该已经看出来,Wasm的文本形式大致也就是S表达式的写法。
上面这段代码定义了一个模块,里面只有一个函数。这个函数导出为add。此函数有两个类型为i32
(32位有符号整数)的参数一个叫a,一个叫b,返回值类型也是i32
。
函数只有三条指令。前两条指令分别将a和b堆到栈上,第三条指令消耗掉栈上的两个值,相加,并将结果放在栈上。最终作为返回值(实质上省略了return指令,函数中的指令执行完毕后,栈的状态正好和返回类型匹配)。
这里的标识符($a
)实际上只是方便人类的语法糖。Wasm在很多地方直接操作各种索引。上面的代码实际上等价于:
rust
(module
(func (export "addTwo") (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add))
推荐工具
学习和研究Wasm的过程中可能经常需要手写一小段Wat然后转换成二进制,又或者需要解读一下和二进制对应的Wat。这种时候可以使用WABT(读作"wabbit")系列工具。不仅可以把文本格式和二进制格式来回转换,还提供了二进制验证、wasm转C等奇奇怪怪的工具。
如果你不想编译或者下载全套工具,你也可以在网页上使用其中两个工具。它提供了两个方向的工具,wasm2wat
可以把现有的二进制Wasm代码(一般大家默认就是.wasm
为后缀名),转换成文本格式。wat2wasm
就是另一个方向,这里你可以手写或者复制粘贴wat代码然后生成二进制Wasm。下方还可以直接写JS来测试。
从其它语言编译出来的Wasm可能更复杂
上面例子中这个简单的函数实际上非常简单,用高级语言编写只会更简单:
arduino
int add(int a, int b) {
return a + b;
}
然而如果你用Emscripten直接编译仅包含这个函数的代码的话(如果你一定要去试一下,最好写个main去调用它,不然会被优化掉),实际上得到的结果远不止上面那段wat那么一点。顺带一提,如果你去看一眼go或者rust编译一个同样的函数生成的Wasm模块的话其实还要复杂得多。正如前面提到的那样,Wasm抽象的环境比较低级,各种高级语言可能会有自己的运行时状态、内存分配机制,这些东西都需要生成额外的代码来支持。所以它们肯定不如你手写的代码来得简单。
和JS交互
WebAssembly如果不能和JS交互那么还是不配冠上Web之名。
和Wasm相关的JS API位于全局对象WebAssembly
中。假如你需要使用add.wasm
中的函数:
csharp
const result = await WebAssembly.instantiateStreaming(fetch("add.wasm"));
const {add} = result.instance.exports;
console.log(add(1, 2))
instantiateStreaming
会从fetch的响应中拿到Wasm二进制,源代码正常的话就会返回一个包含module
和instance
的对象。module
是Wasm源码编译得到的结果,instance
是对模块实例化的结果,是一个有状态的(Wasm中存在全局可变变量等状态),随时可以运行的实例。如果想要执行运行Wasm中导出的(export)函数,只需要解开实例的exports对象即可拿到各个函数,然后照常调用即可。
将外部函数导入到Wasm
另一个方向,我们可能希望在Wasm中调用一些外来函数(类似于extern)。举个喜闻乐见的例子就是在Wasm里调用console.log
。首先在Wasm一侧,我们需要用import
来表示导入一些外来函数:
lua
(module
(import "console" "log" (func $print (param i32)))
(func (export "add") (param i32 i32) (result i32)
(local $t i32)
local.get 0
local.get 1
i32.add
local.tee $t
call $print
local.get $t
)
)
文本格式的import
在import后面分别是模块名、命名空间,然后是具体的导入定义。这里的函数定义就是其中一种支持导入的东西。这里导入一个标识符为print
的,接受一个i32
类型参数、不返回值的函数。
下面修改了函数定义,这次我们直接在Wasm中调用console.log
输出相加结果而不是在JS中调用。如果你不感兴趣也可以略过我对这段代码的解释。Wasm的各种指令会直接修改栈的状态。print
函数的定义意味着调用它时会从栈顶消耗掉(consume,就是把它"吃掉")一个i32
值。由于print
不返回值,所以说消耗掉i32.add
作为返回值推到栈顶的值之后,就没有返回值可以返回了,此时Wasm会无法通过验证。因此这里在函数一开始定义一个本地变量。
i32.add
之后,local.tee $t
指令的作用就是"弹出栈顶的值,然后把这个值再次推到栈顶(复制),然后再local.set $t
",这样把结果给$t
暂存之后,栈顶还有一个值可供下面的print
调用。最后再直接把$t
推上来作为返回值。
接下来在JS侧,给instantiateStreaming
传入第二个参数即可将JS函数链接到Wasm:
javascript
const result = await WebAssembly.instantiateStreaming(fetch("add.wasm"),{console: {log: (n)=>console.log(n)}});
const {add} = result.instance.exports;
add(1, 2)
下篇文章再见👋