Webassembly 和 Emscripten 入门

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)))
)

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

importexport 导入和导出域是 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.instantiateWebAssembly.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()
    })

运行上面代码将会在控制台打印 42instantiateStreaming 第二个参数是给 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.wasma.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 表示暴露出运行时的 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 函数。

相关推荐
rteybftr_mjku17 天前
Web前端仿项目:探索实践之路
java·前端·webassembly·dash
rteybftr_mjku21 天前
web前端培训生:深入探索与技能进阶之路
前端·docker·webassembly
rteybftr_mjku22 天前
web前端筛选器:深度解析与高效应用
前端·docker·webassembly
rteybftr_mjku1 个月前
web前端屏保:技术与创意的交融之旅
前端·docker·webassembly
前端小魔女1 个月前
宝贝,带上WebAssembly,换个姿势来优化你的前端应用
前端·rust·webassembly
Aaaaaaaaaaayou1 个月前
从零实现 React v18,但 WASM 版 - [16] 实现 React Noop
react.js·rust·webassembly
Aaaaaaaaaaayou1 个月前
从零实现 React v18,但 WASM 版 - [15] 实现 useEffect
react.js·rust·webassembly
Aaaaaaaaaaayou2 个月前
从零实现 React v18,但 WASM 版 - [13] 引入 Lane 模型,实现 Batch Update
react.js·rust·webassembly
rocksun2 个月前
如何使用PYSCRIPT创建PYTHON WEB应用程序
python·webassembly
heroboyluck2 个月前
rust前端web开发框架yew使用
开发语言·前端·rust·webassembly