为什么需要 WebAssembly?

首先看一下 js 代码的执行过程:
10024246-55add135e323edaa.webp

上述是 Microsoft Edge 之前的 ChakraCore 引擎结构,目前 Microsoft Edge 的 JS 引擎已经切换为 V8 。

动态语言之踵

js代码整体的流程是:

  • 拿到 js 源代码,交给 Parser,生成 AST
  • ByteCode Compiler 将 AST 编译为字节码(ByteCode)
  • ByteCode 进入翻译器,翻译器将字节码一行一行翻译(Interpreter)为机器码(Machine Code),然后执行

但其实我们平时写的代码有很多可以优化的地方,如多次执行同一个函数,那么可以将这个函数生成的 Machine Code 标记可优化,然后打包送到 JIT Compiler(Just-In-Time),下次再执行这个函数的时候,就不需要经过 Parser-Compiler-Interpreter 这个过程,可以直接执行这份准备好的 Machine Code,大大提高的代码的执行效率。

上述的 JIT 优化只能针对静态类型的变量,如我们要优化的函数,它只有两个参数,每个参数的类型是确定的,而 JavaScript 却是一门动态类型的语言,这也意味着,函数在执行过程中,可能类型会动态变化,参数可能变成三个,第一个参数的类型可能从对象变为数组,这就会导致 JIT 失效,需要重新进行 Parser-Compiler-Interpreter-Execuation,而 Parser-Compiler 这两步是整个代码执行过程中最耗费时间的两步,这也是为什么 JavaScript 语言背景下,Web 无法执行一些高性能应用,如大型游戏、视频剪辑等。

静态语言优化

通过上面的说明了解到,其实 JS 执行慢的一个主要原因是因为其动态语言的特性,导致 JIT 失效,所以如果我们能够为 JS 引入静态特性,那么可以保持有效的 JIT,势必会加快 JS 的执行速度,这个时候 asm.js 出现了。

asm.js 只提供两种数据类型:

  • 32 位带符号整数
  • 64 位带符号浮点数

其他类似如字符串、布尔值或对象都是以数值的形式保存在内存中,通过 TypedArray 调用。整数和浮点数表示如下:

ArrayBuffer对象、TypedArray视图和DataView 视图是 JavaScript 操作二进制数据的一个接口,以数组的语法处理二进制数据,统称为二进制数组。参考 ArrayBuffer[2] 。

  1. var x = a | 0; // x 是32位整数
  2. var y = +a; // y 是64位浮点数

而函数的写法如下:

  1. function add(x, y) {
  2. x = x | 0;
  3. y = y | 0;
  4. return (x + y) | 0;
  5. }

上述的函数参数及返回值都需要声明类型,这里都是 32 位整数。
而且 asm.js 也不提供垃圾回收机制,内存操作都是由开发者自己控制,通过 TypedArray 直接读写内存:

  1. var buffer = new ArrayBuffer(32768); // 申请 32 MB 内存
  2. var HEAP8 = new Int8Array(buffer); // 每次读 1 个字节的视图 HEAP8
  3. function compiledCode(ptr) {
  4. HEAP[ptr] = 12;
  5. return HEAP[ptr + 4];
  6. }

从上可见,asm.js 是一个严格的 JavaScript 子集要求变量的类型在运行时确定且不可改变,且去除了 JavaScript 拥有的垃圾回收机制,需要开发者手动管理内存。这样 JS 引擎就可以基于 asm.js 的代码进行大量的 JIT 优化,据统计 asm.js 在浏览器里面的运行速度,大约是原生代码(机器码)的 50% 左右。


推陈出新

但是不管 asm.js 再怎么静态化,干掉一些需要耗时的上层抽象(垃圾收集等),也还是属于 JavaScript 的范畴,代码执行也需要 Parser-Compiler 这两个过程,而这两个过程也是代码执行中最耗时的。
为了极致的性能,Web 的前沿开发者们抛弃 JavaScript,创造了一门可以直接和 Machine Code 打交道的汇编语言 WebAssembly,直接干掉 Parser-Compiler,同时 WebAssembly 是一门强类型的静态语言,能够进行最大限度的 JIT 优化,使得 WebAssembly 的速度能够无限逼近 C/C++ 等原生代码。
相当于下面的过程:

10024246-1cd64c9b7cb2979a.webp

WebAssembly 初探

我们可以通过一张图来直观了解 WebAssembly 在 Web 中的位置:
10024246-9a6b59528861a28a.webp
WebAssembly(也称为 WASM),是一种可在 Web 中运行的全新语言格式,同时兼具体积小、性能高、可移植性强等特点,在底层上类似 Web 中的 JavaScript,同时也是 W3C 承认的 Web 中的第 4 门语言。
为什么说在底层上类似 JavaScript,主要有以下几个理由:

  • 和 JavaScript 在同一个层次执行:JS Engine,如 Chrome 的 V8
  • 和 JavaScript 一样可以操作各种 Web API

同时 WASM 也可以运行在 Node.js 或其他 WASM Runtime 中。

WebAssembly 文本格式

实际上 WASM 是一堆可以直接执行二进制格式,但是为了易于在文本编辑器或开发者工具里面展示,WASM 也设计了一种 “中间态” 的文本格式[3],以 .wat 或 .wast 为扩展命名,然后通过 wabt[4] 等工具,将文本格式下的 WASM 转为二进制格式的可执行代码,以 .wasm 为扩展的格式。
来看一段 WASM 文本格式下的模块代码:

  1. (module
  2. (func $i (import "imports" "imported_func") (param i32))
  3. (func (export "exported_func")
  4. i32.const 42
  5. call $i
  6. )
  7. )

上述代码逻辑如下:

  • 首先定义了一个 WASM 模块,然后从一个 imports JS 模块导入了一个函数 imported_func ,将其命名为 $i ,接收参数 i32
  • 然后导出一个名为 exported_func 的函数,可以从 Web App,如 JS 中导入这个函数使用
  • 接着为参数 i32 传入 42,然后调用函数 $i

我们通过 wabt 将上述文本格式转为二进制代码:

  • 将上述代码复制到一个新建的,名为 simple.wat 的文件中保存
  • 使用 wabt[5] 进行编译转换

当你安装好 wabt 之后,运行如下命令进行编译:

  1. wat2wasm simple.wat -o simple.wasm

虽然转换成了二进制,但是无法在文本编辑器中查看其内容,为了查看二进制的内容,我们可以在编译时加上 -v 选项,让内容在命令行输出:

  1. wat2wasm simple.wat -v

输出结果如下:

10024246-030b87e666ee7029.webp

可以看到,WebAssembly 其实是二进制格式的代码,即使其提供了稍为易读的文本格式,也很难真正用于实际的编码,更别提开发效率了。

将 WebAssembly 作为编程语言的一种尝试

因为上述的二进制和文本格式都不适合编码,所以不适合将 WASM 作为一门可正常开发的语言。
为了突破这个限制,AssemblyScript[6] 走到台前,AssemblyScript 是 TypeScript 的一种变体,为 JavaScript 添加了 WebAssembly 类型[7], 可以使用 Binaryen[8] 将其编译成 WebAssembly。

WebAssembly 类型大致如下:

  • i32、u32、i64、v128 等
  • 小整数类型:i8、u8 等
  • 变量整数类型:isize、usize 等

Binaryen 会前置将 AssemblyScript 静态编译成强类型的 WebAssembly 二进制,然后才会交给 JS 引擎去执行,所以说虽然 AssemblyScript 带来了一层抽象,但是实际用于生产的代码依然是 WebAssembly,保有 WebAssembly 的性能优势。AssemblyScript 被设计的和 TypeScript 非常相似,提供了一组内建的函数可以直接操作 WebAssembly 以及编译器的特性。

内建函数:

  • 静态类型检查:
    • function isInteger(value?: T): bool 等
  • 实用函数:
    • function sizeof(): usize 等
  • 操作 WebAssembly:
    • function select(ifTrue: T, ifFalse: T, condition: bool): T 等
    • function load(ptr: usize, immOffset?: usize): T 等
    • function clz(value: T): T 等
  • 数学操作
  • 内存操作
  • 控制流
  • SIMD
  • Atomics
  • Inline instructions

然后基于这套内建的函数向上构建一套标准库。

标准库:

  • Globals
  • Array
  • ArrayBuffer
  • DataView
  • Date
  • Error
  • Map
  • Math
  • Number
  • Set
  • String
  • Symbol
  • TypedArray

如一个典型的 Array 的使用如下:

  1. var arr = new Array<string>(10)
  2. // arr[0]; // 会出错 😢
  3. // 进行初始化
  4. for (let i = 0; i < arr.length; ++i) {
  5. arr[i] = ""
  6. }
  7. arr[0]; // 可以正确工作 😊

可以看到 AssemblyScript 在为 JavaScript 添加类似 TypeScript 那样的语法,然后在使用上需要保持和 C/C++ 等静态强类型的要求,如不初始化,进行内存分配就访问就会报错。
还有一些扩展库,如 Node.js 的 process、crypto 等,JS 的 console,还有一些和内存相关的 StaticArray、heap 等。
可以看到通过上面基础的类型、内建库、标准库和扩展库,AssemblyScript 基本上构造了 JavaScript 所拥有的的全部特性,同时 AssemblyScript 提供了类似 TypeScript 的语法,在写法上严格遵循强类型静态语言的规范。
值得一提的是,因为当前 WebAssembly 的 ES 模块规范依然在草案中,AssemblyScript 自行进行了模块的实现,例如导出一个模块:

  1. // env.ts
  2. export declare function doSomething(foo: i32): void { /* ... 函数体 */ }

导入一个模块:

  1. import { doSomething } from "./env";

一个大段代码、使用类的例子:

  1. static ONE: i32 = 1;
  2. static add(a: i32, b: i32): i32 { return a + b + Animal.ONE; }
  3. two: i16 = 2; // 6
  4. instanceSub<T>(a: T, b: T): T { return a - b + <T>Animal.ONE; } // tsc does not allow this
  5. }
  6. export function staticOne(): i32 {
  7. return Animal.ONE;
  8. }
  9. export function staticAdd(a: i32, b: i32): i32 {
  10. return Animal.add(a, b);
  11. }
  12. export function instanceTwo(): i32 {
  13. let animal = new Animal<i32>();
  14. return animal.two;
  15. }
  16. export function instanceSub(a: f32, b: f32): f32 {
  17. let animal = new Animal<f32>();
  18. return animal.instanceSub<f32>(a, b);
  19. }

AssemblyScript 为我们打开了一扇新的大门,可以以 TS 形式的语法,遵循静态强类型的规范进行高效编码,同时又能够便捷的操作 WebAssembly/编译器相关的 API,代码写完之后,通过 Binaryen 编译器将其编译为 WASM 二进制,然后获取到 WASM 的执行性能。
得益于 AssemblyScript 兼具灵活性与性能,目前使用 AssemblyScript 构建的应用生态已经初具繁荣,目前在区块链、构建工具、编辑器、模拟器、游戏、图形编辑工具、库、IoT、测试工具等方面都有大量使用 AssemblyScript 构建的产物:https://www.assemblyscript.org/built-with-assemblyscript.html#games

一种鬼才哲学:将 C/C++ 代码跑在浏览器

虽然 AssemblyScript 的出现极大的改善了 WebAssembly 在高效率编码方面的缺陷,但是作为一门新的编程语言,其最大的劣势就是生态、开发者与积累。

WebAssembly 的设计者显然在设计上同时考虑到了各种完善的情况,既然 WebAssembly 是一种二进制格式,那么其就可以作为其他语言的编译目标,如果能够构建一种编译器,能够将已有的、成熟的、且兼具海量的开发者和强大的生态的语言编译到 WebAssembly 使用,那么相当于可以直接复用这个语言多年的积累,并用它们来完善 WebAssembly 生态,将它们运行在 Web、Node.js 中。

幸运的是,针对 C/C++ 已经有 Emscripten[9] 这样优秀的编译器存在了。
10024246-a03008e51d33118c.webp
即将 C/C++ 的代码(或者 Rust/Go 等)编译成 WASM,然后通过 JS 胶水代码将 WASM 跑在浏览器中(或 Node.js)的 runtime,如 ffmpeg 这个使用 C 编写音视频转码工具,通过 Emscripten 编译器编译到 Web 中使用,可直接在浏览器前端转码音视频。

上述的 JS “Gule” 代码是必须的,因为如果需要将 C/C++ 编译到 WASM,还能在浏览器中执行,就得实现映射到 C/C++ 相关操作的 Web API,这样才能保证执行有效,这些胶水代码目前包含一些比较流行的 C/C++ 库,如 SDL[10]、OpenGL[11]、OpenAL[12]、以及 POSIX[13] 的一部分 API。

目前使用 WebAssembly 最大的场景也是这种将 C/C++ 模块编译到 WASM 的方式,比较有名的例子有 Unreal Engine 4[14]、Unity[15] 之类的大型库或应用。

WebAssembly 会取代 JavaScript 吗?

答案是不会。
根据上面的层层阐述,实际上 WASM 的设计初衷就可以梳理为以下几点:

  • 最大程度的复用现有的底层语言生态,如 C/C++ 在游戏开发、编译器设计等方面的积淀
  • 在 Web、Node.js 或其他 WASM runtime 获得近乎于原生的性能,也就是可以让浏览器也能跑大型游戏、图像剪辑等应用
  • 还有最大程度的兼容 Web、保证安全
  • 同时在开发上(如果需要开发)易于读写和可调试,这一点 AssemblyScript 走得更远

所以从初衷出发,WebAssembly 的作用更适合下面这张图:
10024246-d68d82831ef55be9.webp

WASM 桥接各种系统编程语言的生态,进一步补齐了 Web 开发生态之外,还为 JS 提供性能的补充,正是 Web 发展至今所缺失的重要的一块版图。

Rust Web Framework:https://github.com/yewstack/yew

深入探索 Emscripten

地址:https://github.com/emscripten-core/emscripten 下面所有的 demo 都可以在仓库:https://code.byted.org/huangwei.fps/webassembly-demos/tree/master 找到

Emscripten 是一个开源的,跨平台的,用于将 C/C++ 编译为 WebAssembly 的编译器工具链,由 LLVM、Binaryen、Closure Compiler 和其他工具等组成。
Emscripten 的核心工具为 Emscripten Compiler Frontend(emcc),emcc 是用于替代一些原生的编译器如 gcc 或 clang,对 C/C++ 代码进行编译。
实际上为了能让几乎所有的可移植的 C/C++ 代码库能够编译为 WebAssembly,并在 Web 或 Node.js 执行,Emscripten Runtime 其实还提供了兼容 C/C++ 标准库、相关 API 到 Web/Node.js API 的映射,这份映射存在于编译之后的 JS 胶水代码中。
再看下面这张图,红色部分为 Emscripten 编译后的产物,绿色部分为 Emscripten 为保证 C/C++ 代码能够运行的一些 runtime 支持:
10024246-b1a6142c30aa99e5.webp

简单体验一下 “Hello World”

值得一提的是,WebAssembly 相关工具链的安装几乎都是以源码的形式提供,这可能和 C/C++ 生态的习惯不无关系。
为了完成简单的 C/C++ 程序运行在 Web,我们首先需要安装 Emscripten 的 SDK:

  1. # Clone 代码仓库
  2. git clone https: // github . com / emscripten-core / emsdk . git
  3. # 进入仓库
  4. cd emsdk
  5. # 获取最新代码,如果是新 clone 的这一步可以不需要
  6. git pull
  7. # 安装 SDK 工具,我们安装 1.39.18,方便测试
  8. ./emsdk install 1.39.18
  9. # 激活 SDK
  10. ./emsdk activate 1.39.18
  11. # 将相应的环境变量加入到系统 PATH
  12. source ./emsdk_env.sh
  13. # 运行命令测试是否安装成功
  14. emcc -v #

如果安装成功,上述的命令运行之后会输出如下结果:

  1. emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 1.39.18
  2. clang version 11.0.0 (/b/s/w/ir/cache/git/chromium.googlesource.com-external-github.com-llvm-llvm--project 613c4a87ba9bb39d1927402f4dd4c1ef1f9a02f7)
  3. Target: x86_64-apple-darwin21.1.0
  4. Thread model: posix

让我们准备初始代码:

  1. mkdir -r webassembly/hello_world
  2. cd webassembly/hello_world && touch main.c

在 main.c 中加入如下代码:

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

然后使用 emcc 来编译这段 C 代码,在命令行切换到 webassembly/hello_world 目录,运行:
emcc main.c
上述命令会输出两个文件:a.out.js 和 a.out.wasm ,后者为编译之后的 wasm 代码,前者为 JS 胶水代码,提供了 WASM 运行的 runtime。
可以使用 Node.js 进行快速测试:
node a.out.js
会输出 “hello, world!” ,我们成功将 C/C++ 代码运行在了 Node.js 环境。
10024246-845ee06a31ff0c88.webp
接下来我们尝试一下将代码运行在 Web 环境,修改编译代码如下:
emcc main.c -o main.html
上述命令会生成三个文件:

  • main.js 胶水代码
  • main.wasm WASM 代码
  • main.html 加载胶水代码,执行 WASM 的一些逻辑

Emscripten 生成代码有一定的规则,具体可以参考:https://emscripten.org/docs/compiling/Building-Projects.html#emscripten-linker-output-files
如果要在浏览器打开这个 HTML,需要在本地起一个服务器,因为单纯的打开通过 file:// 协议访问时,主流浏览器不支持 XHR 请求,只有在 HTTP 服务器下,才能进行 XHR 请求,所以我们运行如下命令来打开网站:
npx serve .
打开网页,访问 localhost:3000/main.html,可以看到如下结果:
10024246-63997d5c06765350.webp
同时开发者工具里面也会有相应的打印输出:
10024246-cda7b07007934645.webp

尝试在 js 中调用 C / C++ 函数

上一小节我们初步体验了一下如何在 Web 和 Node.js 中运行 C 程序,但其实如果我们想要让复杂的 C/C++ 应用,如 Unity 运行在 Web,那我们还有很长的路要走,其中一条,就是能够在 JS 中操作 C/C++ 函数。
让我们在目录下新建 function.c 文件,添加如下代码:

  1. #include <stdio.h>
  2. #include <emscripten/emscripten.h>
  3. int main() {
  4. printf("Hello World\n");
  5. }
  6. EMSCRIPTEN_KEEPALIVE void myFunction(int argc, char ** argv) {
  7. printf("MyFunction Called\n");
  8. }


值得注意的是 Emscripten 默认编译的代码只会调用 main 函数,其他的代码会作为 “死代码” 在编译时被删掉,所以为了使用我们在上面定义的 myFunction ,我们需要在其定义之前加上 EMSCRIPTEN_KEEPALIVE 声明,确保在编译时不会删掉 myFunction 函数相关的代码。

我们需要导入 emscripten/emscripten.h 头文件,才能使用 EMSCRIPTEN_KEEPALIVE 声明。

同时我们还需要对编译命令做一下改进如下:
emcc function.c -o function.html -s NO_EXIT_RUNTIME=1 -s “EXTRA_EXPORTED_RUNTIME_METHODS=[‘ccall’]”
上述额外增加了两个参数:

  • -s NO_EXIT_RUNTIME=1 表示在 main 函数运行完之后,程序不退出,依然保持可执行状态,方便后续可调用 myFunction 函数
  • -s “EXTRA_EXPORTED_RUNTIME_METHODS=[‘ccall’]” 则表示导出一个运行时的函数 ccall ,这个函数可以在 JS 中调用 C 程序的函数

进行编译之后,我们还需要修改生成的 function.html 文件,加入我们的函数调用逻辑如下:```

  1. <html>
  2. <body>
  3. <!-- 其它 HTML 内容 -->
  4. <button class="mybutton">Run myFunction</button>
  5. </body>
  6. <!-- 其它 JS 引入 -->
  7. <script>
  8. document
  9. .querySelector(".mybutton")
  10. .addEventListener("click", function () {
  11. alert("check console");
  12. var result = Module.ccall(
  13. "myFunction", // 需要调用的 C 函数名
  14. null, // 函数返回类型
  15. null, // 函数参数类型,默认是数组
  16. null // 函数需要传入的参数,默认是数组
  17. );
  18. });
  19. </script>
  20. </html>

可以看到我们增加了一个 Button,然后增加了一段脚本,为这个 Button 注册了 click 事件,在回调函数里,我们调用了 myFunction 函数。
在命令行中运行 npx serve . 打开浏览器访问 http://localhost:3000/function.html,查看结果如下:

  • 只执行 main 函数:

1.webp

  • 尝试点击按钮执行 myFunction 函数:

2.webp
3.webp
可以看到首先进行 alert 弹框展示,然后打开控制台,可以看到 myFunction 的调用结果,打印 “MyFunction Called” 。

初尝Emscripten文件系统

我们可以在 C/C++ 程序中使用 libc stdio API 如 fopen 、fclose 来访问你文件系统,但是 JS 是运行在浏览器提供的沙盒环境里,无法直接访问到本地文件系统。所以为了兼容 C/C++ 程序访问文件系统,编译为 WASM 之后依然能够正常运行,Emscripten 会在其 JS 胶水代码里面模拟一个文件系统,并提供和 libc stdio 一致的 API。

让我们重新创建一个名为 file.c 的程序,添加如下代码:

  1. #include <stdio.h>
  2. int main() {
  3. FILE *file = fopen("file.txt", "rb");
  4. if (!file) {
  5. printf("cannot open file\n");
  6. return 1;
  7. }
  8. while (!feof(file)) {
  9. char c = fgetc(file);
  10. if (c != EOF) {
  11. putchar(c);
  12. }
  13. }
  14. fclose (file);
  15. return 0;
  16. }

上述代码我们首先使用 fopen 访问 file.txt ,然后一行一行的读取文件内容,如果程序执行过程中有任何的出错,就会打印错误。
我们在目录下新建 file.txt 文件,并加入如下内容:

  1. ==
  2. This data has been read from a file.
  3. The file is readable as if it were at the same location in the filesystem, including directories, as in the local filesystem where you compiled the source.
  4. ==


如果我们要编译这个程序,并确保能够在 JS 中正常运行,还需要在编译时加上 preload 参数,提前将文件内容加载进 Emscripten runtime,因为在 C/C++ 等程序上访问文件都是同步操作,而 JS 是基于事件模型的异步操作,且在 Web 中只能通过 XHR 的形式去访问文件(Web Worker、Node.js 可同步访问文件),所以需要提前将文件加载好,确保在代码编译之前,文件已经准备好了,这样 C/C++ 代码可以直接访问到文件。

运行如下命令进行代码编译:

  1. emcc file.c -o file.html -s EXIT_RUNTIME=1 --preload-file file.txt

上述添加了 -s EXIT_RUNTIME=1 ,依然是确保 main 逻辑执行完之后,程序不会退出。

然后运行我们的本地服务器,访问 http://localhost:3000/file.html,可以查看结果:
4.webp

尝试编译已存在 WebP 模块并使用

通过上面三个例子,我们已经了解了基础的 C/C++ 如打印、函数调用、文件系统相关的内容如何编译为 WASM,并在 JS 中运行,这里的 JS 特指 Web 和 Node.js 环境,通过上面的例子基本上绝大部分自己写的 C/C++ 程序都可以自行编译到 WASM 使用了。

而之前我们也提到过,其实当前 WebAssembly 最大的一个应用场景,就是最大程度的复用当前已有语言的生态,如 C/C++ 生态的库,这些库通常都依赖 C 标准库、操作系统、文件系统或其他依赖,而 Emscripten 最厉害的一点就在于能够兼容绝大部分这些依赖的特性,尽管还存在一些限制,但是已经足够可用。

简单的测试

接下来我们来了解一下如何将一个现存的、比较复杂且广泛使用的 C 模块:libwebp,将其编译到 WASM 并允许到 Web。libwebp 的源码是用 C 实现的,能够在 Github[16] 上找到它,同时可以了解到它的一些 API 文档[17]。

首先准备代码,在我们的目录下运行如下命令:

  1. git clone https://github.com/webmproject/libwebp

为了快速测试是否正确的接入了 libwebp 进行使用,我们可以编写一个简单的 C 函数,然后在里面调用 libwebp 获取版本的函数,测试版本是否可以正确获取。

我们在目录下创建 webp.c 文件,添加如下内容:

  1. #include "emscripten.h"
  2. #include "src/webp/encode.h"
  3. EMSCRIPTEN_KEEPALIVE int version() {
  4. return WebPGetEncoderVersion();
  5. }

上述的 WebPGetEncoderVersion 就是 libwebp 里面获取当前版本的函数,而我们是通过导入 src/webp/encode.h 头文件来获取这个函数的,为了让编译器在编译时能够找到这个头文件,我们需要在编译的时候将 libwebp 库的头文件地址告诉编译器,并将编译器需要的所有 libwebp 库下的 C 文件传给编译器。

让我们运行如下编译命令:

  1. emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
  2. -I libwebp \
  3. webp.c \
  4. libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

上述命令中主要做了如下工作:

  • -I libwebp 将 libwebp 库的头文件地址告诉编译器
  • libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c 将编译器所需的 C 文件传给编译器,这里将 dec,dsp,demux,enc,mux,utils 等目录下的所有 C 文件都传递给了编译器,避免了一个个列出所需文件的繁琐,然后让编译器去自动识别那些没有使用的文件,并将其过滤掉
  • webp.c 是我们编写的 C 函数,用于调用 WebPGetEncoderVersion 获取库版本
  • -O3 代表在编译时进行等级为 3 的优化,包含内联函数、去除无用代码、对代码进行各种压缩优化等
  • 而 -s WASM=1 其实是默认的,就是在编译时输出 xx.out.wasm ,这里之所以会设置这个选项主要是针对那些不支持 WASM 的 runtime,可以设置 -s WASM=0 ,输出等价的 JS 代码替代 WASM
  • EXTRA_EXPORTED_RUNTIME_METHODS= ‘[“cwrap”]’ 则是输出 runtime 的函数 cwrap ,类似 ccall 可以在 JS 中调用 C 函数

上述的编译输出只有 a.out.js 和 a.out.wasm ,我们还需要建一份 HTML 文档来使用输出的脚本代码,新建 webp.html ,添加如下内容:

  1. <html>
  2. <head></head>
  3. <body></body>
  4. <script src="./a.out.js"></script>
  5. <script>
  6. Module.onRuntimeInitialized = async _ => {
  7. const api = {
  8. version: Module.cwrap('version', 'number', []),
  9. };
  10. console.log(api.version());
  11. };
  12. </script>

值得注意的是,我们通常在 Module.onRuntimeInitialized 的回调里面去执行我们 WASM 相关的操作,因为 WASM 相关的代码从加载到可用是需要一段时间的,而 onRuntimeInitialized 的回调则是确保 WASM 相关的代码已经加载完成,达到可用状态。

接着我们可以运行 npx serve . ,然后访问 http://localhost:3000/webp.html,查看结果:
5.webp
可以看到控制台打印了 66049 版本号。

libwebp 通过十六进制的 0xabc 的 abc 来表示当前版本 a.b.c ,例如 v0.6.1,则会被编码成十六进制 0x000601 ,对应的十进制为 1537。而这里为十进制 66049,转成 16 进制则为 0x010201 ,表示当前版本为 v1.2.1。 6.webp

在 JavaScript 中获取图片并放入 WebAssembly 中运行

刚刚通过调用编码器的 WebPGetEncoderVersion 方法来获取版本号来证实了已经成功编译了 libwebp 库到 wasm,然后可以在 JavaScript 使用它,接下来我们将了解更加复杂的操作,如何使用 libwebp 的编码 API 来转换图片格式。

libwebp 的 encoding API 需要接收一个关于 RGB、RGBA、BGR 或 BGRA 的字节数组,幸运的是,Canvas API 有一个 CanvasRenderingContext2D.getImageData 方法,能够返回一个 Uint8ClampedArray ,这个数组包含 RGBA 格式的图片数据。

首先我们需要在 JavaScript 中编写加载图片的函数,将其写到上一步创建的 HTML 文件里:

  1. <script src="./a.out.js"></script>
  2. <script>
  3. Module.onRuntimeInitialized = async _ => {
  4. const api = {
  5. version: Module.cwrap('version', 'number', []),
  6. };
  7. console.log(api.version());
  8. };
  9. async function loadImage(src) {
  10. // 加载图片
  11. const imgBlob = await fetch(src).then(resp => resp.blob());
  12. const img = await createImageBitmap(imgBlob);
  13. // 设置 canvas 画布的大小与图片一致
  14. const canvas = document.createElement('canvas');
  15. canvas.width = img.width;
  16. canvas.height = img.height;
  17. // 将图片绘制到 canvas 上
  18. const ctx = canvas.getContext('2d');
  19. ctx.drawImage(img, 0, 0);
  20. return ctx.getImageData(0, 0, img.width, img.height);
  21. }
  22. </script>

现在剩下的操作则是如何将图片数据从 JavaScript 复制到 wasm,为了达成这个目的,需要在先前的 webp.c 函数里面暴露额外的方法:

  • 一个为 wasm 里面的图片分配内存的方法
  • 一个释放内存的方法

修改 webp.c 如下:

  1. #include <stdlib.h> // 此头文件导入用于分配内存的 malloc 方法和释放内存的 free 方法
  2. EMSCRIPTEN_KEEPALIVE
  3. uint8_t* create_buffer(int width, int height) {
  4. return malloc(width * height * 4 * sizeof(uint8_t));
  5. }
  6. EMSCRIPTEN_KEEPALIVE
  7. void destroy_buffer(uint8_t* p) {
  8. free(p);
  9. }

}
create_buffer 为 RGBA 的图片分配内存,RGBA 图片一个像素包含 4 个字节,所以代码中需要添加 4 * sizeof(uint8_t) ,malloc 函数返回的指针指向所分配内存的第一块内存单元地址,当这个指针返回给 JavaScript 使用时,会被当做一个简单的数字处理。当通过 cwrap 函数获取暴露给 JavaScript 的对应 C 函数时,可以使用这个指针数字找到复制图片数据的内存开始位置。

我们在 HTML 文件中添加额外的代码如下:

  1. <script src="./a.out.js"></script>
  2. <script>
  3. Module.onRuntimeInitialized = async _ => {
  4. const api = {
  5. version: Module.cwrap('version', 'number', []),
  6. create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
  7. destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
  8. encode: Module.cwrap("encode", "", ["number","number","number","number",]),
  9. free_result: Module.cwrap("free_result", "", ["number"]),
  10. get_result_pointer: Module.cwrap("get_result_pointer", "number", []),
  11. get_result_size: Module.cwrap("get_result_size", "number", []),
  12. };
  13. const image = await loadImage('./image.jpg');
  14. const p = api.create_buffer(image.width, image.height);
  15. Module.HEAP8.set(image.data, p);
  16. // ... call encoder ...
  17. api.destroy_buffer(p);
  18. };
  19. async function loadImage(src) {
  20. // 加载图片
  21. const imgBlob = await fetch(src).then(resp => resp.blob());
  22. const img = await createImageBitmap(imgBlob);
  23. // 设置 canvas 画布的大小与图片一致
  24. const canvas = document.createElement('canvas');
  25. canvas.width = img.width;
  26. canvas.height = img.height;
  27. // 将图片绘制到 canvas 上
  28. const ctx = canvas.getContext('2d');
  29. ctx.drawImage(img, 0, 0);
  30. return ctx.getImageData(0, 0, img.width, img.height);
  31. }
  32. </script>

可以看到上述代码除了导入之前添加的 create_buffer 和 destroy_buffer 外,还有很多用于编码文件等方面的函数,我们将在后续讲解,除此之外,代码首先加载了一份 image.jpg 的图片,然后调用 C 函数为此图片数据分配内存,并相应的拿到返回的指针传给 WebAssembly 的 Module.HEAP8 ,在内存开始位置 p,写入图片的数据,最后会释放分配的内存。

编码图片

现在图片数据已经加载进 wasm 的内存中,可以调用 libwebp 的 encoder 方法来完成编码过程了,通过查阅 WebP 的文档[18],发现可以使用 WebPEncodeRGBA 函数来完成工作。这个函数接收一个指向图片数据的指针以及它的尺寸,以及每次需要跨越的 stride 步长,这里为 4 个字节(RGBA),一个区间在 0-100 的可选的质量参数。在编码的过程中,WebPEncodeRGBA 会分配一块用于输出数据的内存,我们需要在编码完成之后调用 WebPFree 来释放这块内存。
我们打开 webp.c 文件,添加如下处理编码的代码:

  1. int result[2];
  2. EMSCRIPTEN_KEEPALIVE
  3. void encode(uint8_t* img_in, int width, int height, float quality) {
  4. uint8_t* img_out;
  5. size_t size;
  6. size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);
  7. result[0] = (int)img_out;
  8. result[1] = size;
  9. }
  10. EMSCRIPTEN_KEEPALIVE
  11. void free_result(uint8_t* result) {
  12. WebPFree(result);
  13. }
  14. EMSCRIPTEN_KEEPALIVE
  15. int get_result_pointer() {
  16. return result[0];
  17. }
  18. EMSCRIPTEN_KEEPALIVE
  19. int get_result_size() {
  20. return result[1];
  21. }

上述 WebPEncodeRGBA 函数执行的结果为分配一块输出数据的内存以及返回内存的大小。因为 C 函数无法使用数组作为返回值(除非我们需要进行动态内存分配),所以我们使用一个全局静态数组来获取返回的结果,这可能不是很规范的 C 代码写法,同时它要求 wasm 指针为 32 比特长,但是为了简单起见我们可以暂时容忍这种做法。

现在 C 侧的相关逻辑已经编写完毕,可以在 JavaScript 侧调用编码函数,获取图片数据的指针和图片所占用的内存大小,将这份数据保存到 WASM 的缓冲中,然后释放 wasm 在处理图片时所分配的内存,让我们打开 HTML 文件完成上述描述的逻辑:

  1. <script src="./a.out.js"></script>
  2. <script>
  3. Module.onRuntimeInitialized = async _ => {
  4. const api = {
  5. version: Module.cwrap('version', 'number', []),
  6. create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
  7. destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
  8. encode: Module.cwrap("encode", "", ["number","number","number","number",]),
  9. free_result: Module.cwrap("free_result", "", ["number"]),
  10. get_result_pointer: Module.cwrap("get_result_pointer", "number", []),
  11. get_result_size: Module.cwrap("get_result_size", "number", []),
  12. };
  13. const image = await loadImage('./image.jpg');
  14. const p = api.create_buffer(image.width, image.height);
  15. Module.HEAP8.set(image.data, p);
  16. api.encode(p, image.width, image.height, 100);
  17. const resultPointer = api.get_result_pointer();
  18. const resultSize = api.get_result_size();
  19. const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
  20. const result = new Uint8Array(resultView);
  21. api.free_result(resultPointer);
  22. api.destroy_buffer(p);
  23. };
  24. async function loadImage(src) {
  25. // 加载图片
  26. const imgBlob = await fetch(src).then(resp => resp.blob());
  27. const img = await createImageBitmap(imgBlob);
  28. // 设置 canvas 画布的大小与图片一致
  29. const canvas = document.createElement('canvas');
  30. canvas.width = img.width;
  31. canvas.height = img.height;
  32. // 将图片绘制到 canvas 上
  33. const ctx = canvas.getContext('2d');
  34. ctx.drawImage(img, 0, 0);
  35. return ctx.getImageData(0, 0, img.width, img.height);
  36. }
  37. </script>


在上述代码中我们通过 loadImage 函数加载了一张本地的 image.jpg 图片,你需要事先准备一张图片放置在 emcc 编译器输出的目录下,也就是我们的 HTML 文件目录下使用。

注意:new Uint8Array(someBuffer) 将会在同样的内存块上创建一个新视图,而 new Uint8Array(someTypedArray) 只会复制 someTypedArray 的数据,确保使用复制的数据进行操作,不会修改原内存数据。

当你的图片比较大时,因为 wasm 不能自动扩充内存,如果默认分配的内存无法容纳 input 和 output 图片数据的内存,你可能会遇到如下报错:

11.webp

但是我们例子中使用的图片比较小,所以只需要单纯的在编译时加上一个过滤参数 -s ALLOW_MEMORY_GROWTH=1 忽略这个报错信息即可:

  1. emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
  2. -I libwebp \
  3. webp.c \
  4. libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c \
  5. -s ALLOW_MEMORY_GROWTH=1`

再次运行上述命令,得到添加了编码函数的 wasm 代码和对应的 JavaScript 胶水代码,这样当我们打开 HTML 文件时,它已经能够将一份 JPG 文件编码成 WebP 的格式,为了进一步证实这个观点,我们可以将图片展示到 Web 界面上,通过修改 HTML 文件,添加如下代码:

  1. <script>
  2. // ...
  3. api.encode(p, image.width, image.height, 100);
  4. const resultPointer = api.get_result_pointer();
  5. const resultSize = api.get_result_size();
  6. const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
  7. const result = new Uint8Array(resultView);
  8. // 添加到这里
  9. const blob = new Blob([result], {type: 'image/webp'});
  10. const blobURL = URL.createObjectURL(blob);
  11. const img = document.createElement('img');
  12. img.src = blobURL;
  13. document.body.appendChild(img)
  14. api.free_result(resultPointer);
  15. api.destroy_buffer(p);
  16. </script>


然后刷新浏览器,你应该可以看到如下界面:
12.webp
通过将这个文件下载到本地,可以看到其格式转成了 WebP:
13.webp

通过上述的流程我们成功编译了现有的 libwebp C 库到 wasm 使用,并将 JPG 图片转成了 WebP 格式并展示在 Web 界面上,通过 wasm 来处理计算密集型的转码操作可以大大提高网页的性能,这也是 WebAssembly 带来的主要优势之一。

如何调试 WebAssembly 代码?

WebAssembly 的原始调试方式

Chrome 开发者工具目前已经支持 WebAssembly 的调试,虽然存在一些限制,但是针对 WebAssembly 的文本格式的文件能进行单个指令的分析以及查看原始的堆栈追踪,具体见如下图:
21.webp
上述的方法对于一些无其他依赖函数的 WebAssembly 模块来说可以很好的运行,因为这些模块只涉及到很小的调试范围。但是对于复杂的应用来说,如 C/C++ 编写的复杂应用,一个模块依赖其他很多模块,且源代码与编译后的 WebAssembly 的文本格式的映射有较大的区别时,上述的调试方式就不太直观了,只能靠猜的方式才能理解其中的代码运行方式,且大多数人很难以看懂复杂的汇编代码。

更加直观的调试方式


现代的 JavaScript 项目在开发时通常也会存在编译的过程,使用 ES6 进行开发,编译到 ES5 及以下的版本进行运行,这个时候如果需要调试代码,就涉及到 Source Map 的概念,source map 用于映射编译后的对应代码在源代码中的位置,source map 使得客户端的代码更具可读性、更方便调试,但是又不会对性能造成很大的影响。

而 C/C++ 到 WebAssembly 代码的编译器 Emscripten 则支持在编译时,为代码注入相关的调试信息,生成对应的 source map,然后安装 Chrome 团队编写的 C/C++ Devtools Support[20] 浏览器扩展,就可以使用 Chrome 开发者工具调试 C/C++ 代码了。

这里的原理其实就是,Emscripten 在编译时,会生成一种 DWARF 格式的调试文件,这是一种被大多数编译器使用的通用调试文件格式,而 C/C++ Devtools Support[21] 则会解析 DWARF 文件,为 Chrome Devtools 在调试时提供 source map 相关的信息,使得开发者可以在 89+ 版本以上的 Chrome Devtools 上调试 C/C++ 代码。
22.webp

参考链接

作者:涅槃快乐是金
链接:https://www.jianshu.com/p/d4b588b54d7f
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

参考链接