Webassembly 和 Emscripten 入门

avatar
FE @字节跳动

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 的优化空间,相关代码如下所示。

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,文件整体结构如下代码所示。

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,它的结构与二进制格式相似,如下代码所示。

(module
    (type   ...)
    (import ...)
    (func   ...)
    (table  ...)
    (memory ...)
    (global ...)
    (export ...)
    (start  ...)
    (elem   ...)
    (data   ...)
)

上面代码中表示的是已 module 为根节点的树,它有 10 个子节点。

(module
  (type (func (param i32) (param i32) (result i32)))
  ;; 两个分号表示注释
)

上面代码中我们定义了一个函数签名,接收两个 i32 参数并返回一个 i32。每个参数都要声明它的类型,目前 Wasm 一共支持 i32 32位整数、i64 64位整数、f32 32位浮点数和 f64 64位浮点数这 4 种类型。

我们还可以给上面函数声明一个标识符,也就是给函数取个名。

(module
  (type $f1 (func (param i32 i32) (result i32)))
)

标识符以 $ 开头,而且参数类型一致的话可以将它们进行合并。

importexport 导入和导出域是 Wasm 与外部沟通的工具,import 导入外部的值,export 导出值给外部。导入和导出支持函数、表、内存和全局变量这 4 种类型。

(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 就像是一个通过类创建的实例。

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 还可以直接接收二进制数据,内部会自动编译并实例化一个实例。

fetch('module.wasm')
    .then(response => response.arrayBuffer())
    .then(bytes => WebAssembly.instantiate(bytes))
    .then(({ instance, module }) => {
         // instance 为 WebAssembly.Instance 对象
         // module 为 WebAssembly.Module 对象
    })

Wasm 支持流式处理,我们还可以再简化上面代码。

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.instantiateWebAssembly.instantiateStreaming 的第二个参数是 JS 传给 Wasm 模块的值。

(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

WebAssembly.instantiateStreaming(fetch('module.wasm'), { 
    imports: { 
        imported_func: console.log 
    } 
})
    .then(({ instance }) => {
         instance.exports.exported_func()
    })

运行上面代码将会在控制台打印 42instantiateStreaming 第二个参数是给 Wasm 模块导入的值,这里的属性层级需要与 Wasm 程序中的 import 节点后的 "imports" "imported_func" 对应,这样才能被 Wasm 正确导入,这里我们直接传入了 console.log 函数,Wasm 程序中导出的函数名为 exported_func,所以我们可以通过 instance.exports.exported_func() 调用。

上面代码中我们只是简单的打印了一个数值,如果要处理数组、字符串这样的复杂类型,我们就需要 WebAssembly.Memory 构造函数,它可以创建一个 Wasm 内存。Wasm 内存就像一个可变大小的 ArrayBuffer

const memory = new WebAssembly.Memory({initial:10, maximum:100})
// initial 为初始内存页数量
// maximum [可选] 为最大内存页数量
memory.buffer // 表示这个内存的 ArrayBuffer
memory.grow(1) // 该方法表示再增加多少内存页

WebAssembly.Memory 参数都是以内存页为参数,一个 Wasm 内存页大小为 65536 字节,即 64KB。

(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 函数并传入两个参数,分别是内存偏移量和字符串长度。

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 文件。

# 下载 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。

#include <stdio.h>
int main() {
  printf("hello, world!\n");
  return 0;
}

编写上面 C 代码后,就可以执行 emcc ./hello_world.c,它会生成一个 a.out.wasma.out.js 文件,执行 a.out.js 就可以看到在控制台打印的 hello, world! 字符串。

在 JS 中调用 C 函数

我们在修改上方 C 代码,添加一个 add 方法给 JS 调用。

#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 进行编译了。

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 表示暴露出运行时的 ccallcwrap 方法。

然后就可以本地起一个静态服务器访问 index.html 了。JS 胶水文件会暴露出一个 Module 对象,通过这个对象我们可以访问到 C 暴露出来的方法,比如通过 Module._add 可以调用 C 的 add 方法。

另外我们还可以使用 Module.ccallModule.cwrap 来调用 C 的方法,这两个方法是 emscripten 的内置方法,通过这两个方法调用可以不用手动通过 EXPORTED_FUNCTIONS 导出特定方法。

ccall 的签名为 ccall(函数名, 返回类型, 参数类型, 参数),它会直接调用指定函数名的函数。

cwrap 的签名为 cwrap(函数名, 返回类型, 参数类型),它不会调用 C 函数,而是返回一个 JS 函数,通过这个 JS 函数可以调用 C 函数。