Webassembly(Wasm)是一种用于基于堆栈的虚拟机的二进制指令格式,Wasm 格式可以直接运行在浏览器上,其他编程通过编译器编译成 Wasm 从而实现运行在浏览器上。Wasm 和 JS 并不是竞争关系,而是互补关系,随着 Web 功能越来越强大,对性能的要求也越来越高,Wasm 可以让 C/C++ 等更底层语言直接运行在浏览器上从而获得本地应用相接近的性能。
历史
2010 年 Alon Zakai 创业失败加入 Mozilla,Alon 想把 C++ 游戏运行到浏览器中,但又不想用 JS 重写一遍,于是他就利用业余时间开发 Emscripten,Emscripten 利用 LLVM IR 把 C++ 代码转换成 JS 代码。(LLVM 是一套编译器基础设施项目,利用它可以快速开发编译器,而不用重复造轮子)。
2011 年 Emscripten 正式发布,它不仅可以将上面提到的 C++ 游戏编译成 JS 代码,还可以将 Python 等大型C++ 项目编译成 JS。由于 JS 代码太灵活,将 C++ 代码编译成 JS 后性能并不是很好,于是 Alon 和 Luke Wagner、David Herman 等人一起,在 2013 年提出了asm.js。
asm.js 是 JS 的子集,通过减少动态特性和添加类型提示的方式帮助浏览器提升 JS 的优化空间,相关代码如下所示。
ini
function add(x, y){
a = x | 0; // 参数x为整数
b = y | 0; // 参数y为整数
return a + b | 0; // add函数的返回值也是整数
}
asm.js 代码兼容普通 JS,支持 asm.js 的浏览器会进行加速,不支持的会当成普通 JS 代码执行。相比普通 JS,asm.js 的性能达到原生 C 语言的 50% 到 70%。但是 asm.js 属于比较 hack 的方法,受限于 JS 的语法,还是文本格式,浏览器还是要下载解析执行。同时 Chrome 团队也给出了解决 JS 性能问题的方法,NaCl(Google Native Client)和 PNaCl(Portable NaCl)。通过 NaCl/PNaC1,Chrome 浏览器可以在沙箱环境中直接执行本地代码。asm.js 和 NaCl/PNaC1 可以取长补短,所以在 2013 年两个团队就经常合作交流,开发一种基于字节码的技术 WebAssembly。
2015年正式公开 WebAssembly,W3C 成立 WebAssembly 社区小组,Firefox、Chrome、Safari 和 Edge达成合作,宣布联手开发 WebAssembly。2017年这 4 个浏览器相继支持了 WebAssembly。2019年 W3C 发布 WebAssembly 正式标准,成为新的 Web 语言。
除了 C/C++ 还有非常多的语言支持编译成 Wasm,如 Rust、GO、C# 等,通过这个项目可以查看目前支持 Wasm 的语言和语言支持的进度。
二进制格式
Wasm 程序编译、传输和加载的单位称为模块。Wasm 规范定义了二进制和文本两种模块格式。Wasm 二进制格式,以 .wasm
为后缀,推荐的 mime 为 application/wasm
。Wasm 采用了虚拟机/字节码技术,其他语言编译成 Wasm 字节码后由浏览器的虚拟机执行。
Wasm 二进制文件以 4 字节的魔数和 4 字节的版本号开头,魔术为 0x00 0x61 0x73 0x6D
(\0asm
),版本号为 0x01 0x00 0x00 0x00
当前版本号为 1。Wasm 二进制格式采用小端方式编码数值,所以版本号 0x01
在最前面。
Wasm 二进制文件除了前面 8 字节的魔数和版本号,后面的字节被划分为一个个段(section),每个段都有一个类型 ID,文件整体结构如下代码所示。
css
magic = 0x00 0x61 0x73 0x6D
version = 0x01 0x00 0x00 0x00
1 type section 用到的所有函数类型
2 import section 所有的导入项
3 function section 索引表,内部函数所对应的签名索引
4 table section 定义的所有表
5 memory section 定义的所有内存
6 global section 定义的所有全局变量信息
7 export section 所有的导入项
8 start section 起始函数索引,加载模块后会自动执行起始函数
9 element section 表初始化数据
10 code section 存储内部函数的局部变量信息和字节码
11 data section 内存初始化数据
12 data count section data section 中的数据段数,为了简化单边验证
Wasm 规范一共定义了 13 种段,每种段都有一个 ID (0 到 12)。上面代码中没有写自定义段 0 custom section
自定义段主要给编译器等工具使用,里面可以存放调试信息,删除它并不会对文件执行造成任何影响。
除了自定义段,其他所有的段都最多只能出现一次,且必须按照段 ID 递增的顺序出现。有这个规则是因为 Wasm 设计为可以一遍完成解析、验证和编译,也就是可以边下载边分析。
文本格式
Wasm 文本格式(WebAssembly Text Format 主要是为了方便理解和分析 Wasm 模块,以 .wat
为后缀。可以使用 wabt(WebAssembly Binary Toolkit) 工具中的汇编器 wat2wasm
将 wat 转为 wasm,反汇编器 wasm2wat 将 wasm 转为 wat。
Wasm 文本格式是一个树的结构,每个节点用 (
和 )
括起来,(
后面跟着这个节点的类型,后面是它的子节点或属性,文本格式的根节点是 module
,它的结构与二进制格式相似,如下代码所示。
scss
(module
(type ...)
(import ...)
(func ...)
(table ...)
(memory ...)
(global ...)
(export ...)
(start ...)
(elem ...)
(data ...)
)
上面代码中表示的是已 module
为根节点的树,它有 10 个子节点。
go
(module
(type (func (param i32) (param i32) (result i32)))
;; 两个分号表示注释
)
上面代码中我们定义了一个函数签名,接收两个 i32
参数并返回一个 i32
。每个参数都要声明它的类型,目前 Wasm 一共支持 i32
32位整数、i64
64位整数、f32
32位浮点数和 f64
64位浮点数这 4 种类型。
我们还可以给上面函数声明一个标识符,也就是给函数取个名。
rust
(module
(type $f1 (func (param i32 i32) (result i32)))
)
标识符以 $
开头,而且参数类型一致的话可以将它们进行合并。
import
和 export
导入和导出域是 Wasm 与外部沟通的工具,import
导入外部的值,export
导出值给外部。导入和导出支持函数、表、内存和全局变量这 4 种类型。
go
(module
(import "imports" "imported_func" (func $log (param i32)))
(func (export "exported_func")
i32.const 13
call $log
)
)
上面代码中程序从外部传入对象的 imports
属性上获取 imported_func
函数命名为 $log
它接收一个 i32
参数,没有返回值。7
下面代码中 export
写在了 func
域中,这是一种简写的语法糖,func
中的函数使用 i32.const
指令压入一个 13
,然后使用 call
指令调用从外部导入的 $log
函数,13
为它的参数。
Wasm 程序的函数体是由一条一条的指令构成的,一条指令分为操作码和操作数,操作数相当于操作码的参数。Wasm 指令的操作码固定为一个字节,所以指令集最多只能包含 256 条指令,Wasm 指令可以分为控制指令、参数指令、变量指令、内存指令和数值指令 5 大类。
WebAssembly API
WebAssembly API 可以让 JS 与 Wasm 模块进行交互,WebAssembly 不是一个构造函数,而是一个命名空间,与 Math 类似。
要在浏览器中运行 Wasm,首先需要下载 Wasm 文件,目前下载 Wasm 文件需要自己通过 XHR 或 Fetch 去下载,下载好后我们需要将二进制文件编译成一个 WebAssembly.Module
,该对象包含已经由浏览器编译的无状态 WebAssembly 代码,可以与 Workers 共享或缓存在 IndexedDB 中,并且可以实例化。成功编译成 WebAssembly.Module
后,就可以通过 WebAssembly.Module
实例化一个 WebAssembly.Instance
对象。
WebAssembly.Module
就像是一个类它没有任何状态,WebAssembly.Instance
就像是一个通过类创建的实例。
javascript
fetch('module.wasm')
.then(response => response.arrayBuffer())
.then(bytes => WebAssembly.compile(bytes)) // 该方法进行编译
.then(module => WebAssembly.instantiate(module)) // 该方法进行实例化
.then(instance => {
// instance 为 WebAssembly.Instance 对象
})
如果不需要与 Workers 共享或缓存在 IndexedDB 中,我们可以跳过 WebAssembly.compile
方法,因为 WebAssembly.instantiate
还可以直接接收二进制数据,内部会自动编译并实例化一个实例。
javascript
fetch('module.wasm')
.then(response => response.arrayBuffer())
.then(bytes => WebAssembly.instantiate(bytes))
.then(({ instance, module }) => {
// instance 为 WebAssembly.Instance 对象
// module 为 WebAssembly.Module 对象
})
Wasm 支持流式处理,我们还可以再简化上面代码。
javascript
WebAssembly.instantiateStreaming(fetch('module.wasm'))
.then(({ instance, module }) => {
// instance 为 WebAssembly.Instance 对象
// module 为 WebAssembly.Module 对象
})
// 当然还有 compileStreaming 方法,用于流式编译
WebAssembly.compileStreaming(fetch('module.wasm'))
.then(module => WebAssembly.instantiate(module))
.then(instance => {})
WebAssembly.Instance
实例对象非常简单,它就一个 exports
属性上面挂载着 Wasm 模块的导出值。WebAssembly.instantiate
和 WebAssembly.instantiateStreaming
的第二个参数是 JS 传给 Wasm 模块的值。
go
(module
(import "imports" "imported_func" (func $log (param i32)))
(func (export "exported_func")
i32.const 13
call $log
)
)
如上 Wasm 程序,它从外部的 imports.imported_func
上导入一个函数,然后导出一个 exported_func
函数,该函数调用导入的函数并传入参数 42
。
javascript
WebAssembly.instantiateStreaming(fetch('module.wasm'), {
imports: {
imported_func: console.log
}
})
.then(({ instance }) => {
instance.exports.exported_func()
})
运行上面代码将会在控制台打印 42
。instantiateStreaming
第二个参数是给 Wasm 模块导入的值,这里的属性层级需要与 Wasm 程序中的 import
节点后的 "imports" "imported_func"
对应,这样才能被 Wasm 正确导入,这里我们直接传入了 console.log
函数,Wasm 程序中导出的函数名为 exported_func
,所以我们可以通过 instance.exports.exported_func()
调用。
上面代码中我们只是简单的打印了一个数值,如果要处理数组、字符串这样的复杂类型,我们就需要 WebAssembly.Memory
构造函数,它可以创建一个 Wasm 内存。Wasm 内存就像一个可变大小的 ArrayBuffer
。
php
const memory = new WebAssembly.Memory({initial:10, maximum:100})
// initial 为初始内存页数量
// maximum [可选] 为最大内存页数量
memory.buffer // 表示这个内存的 ArrayBuffer
memory.grow(1) // 该方法表示再增加多少内存页
WebAssembly.Memory
参数都是以内存页为参数,一个 Wasm 内存页大小为 65536 字节,即 64KB。
go
(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 ;; 内存偏移量
i32.const 2 ;; 字符串长度
call $log
)
)
上面 Wasm 程序不光从外部导入一个函数,该导入了一个内存,内存大小为 1 页,然后在内存中设置一个字符串 Hi
最后导出一个函数,该函数调用导入的 $log
函数并传入两个参数,分别是内存偏移量和字符串长度。
typescript
const mem = new WebAssembly.Memory({ initial:1 })
WebAssembly.instantiateStreaming(fetch('module.wasm'), {
console: {
log(offset, length) {
const bytes = new Uint8Array(memory.buffer, offset, length)
const string = new TextDecoder('utf8').decode(bytes)
console.log(string)
}
},
js: { mem }
})
.then(({ instance }) => {
instance.exports.writeHi()
})
上面代码中我们创建一个内存对象然后传给 Wasm 程序,Wasm 调用 log
函数并传入字符串在内存中的偏移量和长度,通过这两个参数就可以获取在内存中表示这个字符串的数值,最后使用 TextDecoder
解码并打印。
Emscripten
上面我们已经提到了 Emscripten 最初是 Alon Zakai 的业余项目,Mozilla 觉得这个项目很有前途,就让他全职开发。Emscripten 是跨平台的开源项目,它可以将 C/C++ 代码编译成 WebAssembly、JS 胶水和 HTML 文件。
HTML 文件用来展示代码运行结果,JS 胶水文件用于加载和运行 Wasm 模块,JS 胶水文件是必须的,因为目前 Wasm 中并不能直接调用 Web API,JS 胶水文件会将 Wasm 文件中用到的 API 传递给 Wasm 文件。
bash
# 下载 emsdk
git clone https://github.com/emscripten-core/emsdk.git
# 进入目录
cd emsdk
# 下载和安装最新 SDK
./emsdk install latest
# 激活最新版本 SDK
./emsdk activate latest
# 添加执行路径到 PATH 和环境变量到当前终端
source ./emsdk_env.sh
我们可以通过上方代码下载安装 emsdk,emsdk 中有多个工具,最关键的就是 emcc
,它用于将 C/C++ 代码转为 Wasm 和 JS 胶水文件,下面让我们将 C 代码编译成 Wasm。
arduino
#include <stdio.h>
int main() {
printf("hello, world!\n");
return 0;
}
编写上面 C 代码后,就可以执行 emcc ./hello_world.c
,它会生成一个 a.out.wasm
和 a.out.js
文件,执行 a.out.js
就可以看到在控制台打印的 hello, world!
字符串。
在 JS 中调用 C 函数
我们在修改上方 C 代码,添加一个 add 方法给 JS 调用。
arduino
#include <stdio.h>
#include <emscripten/emscripten.h>
int main() {
printf("hello, world!\n");
return 0;
}
EMSCRIPTEN_KEEPALIVE
int add(int a, int b) {
return a + b;
}
在 add
方法前面加上 EMSCRIPTEN_KEEPALIVE
是防止 LLVM 把这个方法当作死码删除了。然后就可以使用 emcc 进行编译了。
ini
emcc ./hello_world.c -o index.html -s EXPORTED_FUNCTIONS=_main,_add -s EXPORTED_RUNTIME_METHODS=ccall,cwrap
这里的 -o index.html
是指定输出的文件,.html
后缀会输出同名的 html,js 和 wasm 文件,.js
后缀会输出同名的 js 和 wasm 文件,.wasm
后缀会输出一个 wasm 文件。
-s
用于设置 emcc 的编译参数,EXPORTED_FUNCTIONS=_main,_add
表示对外暴露出 _main
和 _add
方法,方法名需要加上 _
。EXPORTED_RUNTIME_METHODS=ccall,cwrap
表示暴露出运行时的 ccall
和 cwrap
方法。
然后就可以本地起一个静态服务器访问 index.html
了。JS 胶水文件会暴露出一个 Module
对象,通过这个对象我们可以访问到 C 暴露出来的方法,比如通过 Module._add
可以调用 C 的 add
方法。
另外我们还可以使用 Module.ccall
和 Module.cwrap
来调用 C 的方法,这两个方法是 emscripten 的内置方法,通过这两个方法调用可以不用手动通过 EXPORTED_FUNCTIONS
导出特定方法。
ccall
的签名为 ccall(函数名, 返回类型, 参数类型, 参数)
,它会直接调用指定函数名的函数。
cwrap
的签名为 cwrap(函数名, 返回类型, 参数类型)
,它不会调用 C 函数,而是返回一个 JS 函数,通过这个 JS 函数可以调用 C 函数。