zhengrenzhe's blog   About

WebAssembly初探

WebAssembly 是一种面向 web 的二进制格式,它的设计目标是可以在各种平台上被编译,然后发挥硬件性能以原生应用的速度运行。简单的来说,就是现在浏览器上运行的 js 太慢了,所以搞个新的语言来从根本上解决这个问题。

从 asm.js 到 WebAssembly

WebAssembly 是从 asm.js 发展来的,它是 Mozilla 开发的一种 js 的子集。众所周知 js 的解释型的,加上最初创造它的时间很短,导致性能差。后来 Mozilla 为了改善这个问题,开发了 asm.js。它通过给 js 加标注,再配上能识别这些标注的引擎,就把性能一下子给提高了。后来 Google,Mozilla,Microsoft,Apple 觉得这个有前途,打算共同开发,于是就有了WebAssembly。asm.js 虽然性能强悍,但是问题是它的体积太大了,不利于传输和编译,所以 WebAssembly 给出的是一个二进制格式,因为体积更小,易于传输和解析。

开发工具

官方给出的工具是使用 Emscripten,这是一个 LLVM-to-JavaScript 的编译器,他可以将 C/C++ 编译到 asm.js 或者 wasm(WebAssembly标准的二进制格式)。(注:WebAssembly 是允许任何语言编译到它制定的二进制格式的)

首先来安装Emscripten:

git clone https://github.com/juj/emsdk.git
cd emsdk
./emsdk install sdk-incoming-64bit binaryen-master-64bit
./emsdk activate sdk-incoming-64bit binaryen-master-64bit

这一步时间会比较长,要保证连终端连 GitHub 和 AWS 顺畅。安装成功后需要在每次开启一个新终端会话时把环境变量加上去:

source ./emsdk_env.sh

输入 emcc -v,如果能显示出一些版本信息那就是安装成功了。

C++ 编译到 asm.js

Emscripten 可以将 C++编译到 asm.js,生成的 js 文件可以运行在任何可以运行 js 的环境中,因为 asm.js 本来就是合法的 js 文件。

假设有下面的 C++ 代码:

#include <iostream>
int main(){
    std::cout << "hello, world!\n";
    return 0;
}

使用下面的命令编译:

emcc main.cpp -O3 -o cpp.js

输出的 cpp.js 尺寸达到了432K,如果不开优化则高达 2.3M。使用 NodeJS 运行该文件:

$ node cpp.js
hello, world!

Emscripten 默认只会导出 main 函数,main 导出后就像 js 的 IIFE 一样会立即执行。当然也可以手动导出其他函数,但其他函数导出后不会立即运行,而是像普通函数一样等待被调用。

#include <iostream>

extern "C" {
    int add(int, int);
}

int add(int a, int b){
  	return a+b;
}

int main(){
    std::cout << "hello, world!\n";
    return 0;
}
emcc main.cpp -O3 -o cpp.js -s EXPORTED_FUNCTIONS='["_add"]'

这里 EXPORTED_FUNCTIONS 说明只导出 add 函数,所以需要在 NodeJS 的 REPL 模式中运行:

node
> var a = require('./cpp.js')
> a._add(1,2)
> 3

由 Emscripten 生成的函数都会带有 _ 这个前缀。注意,由于 C++ 的名称修饰会导致导出的函数名带有不可预知的字符,所以需要将函数包在 extern "C" 中来阻止名称修饰。

C++ 编译到 wasm

要将上面的 C++ 代码编译到 wasm,使用下面的命令:

emcc main.cpp -O3 -s WASM=1 -s SIDE_MODULE=1 -o cpp.wasm

编译后的 wasm 文件尺寸为 286 字节,相比刚才的 asm.js ,wasm 的优势可见一斑。WASM=1 说明编译目标是 wasm,同时 wasm 需要以独立模式编译成一个动态库,所以还要加上 SIDE_MODULE=1。编译为动态库后,main 函数也会作为一个独立的函数等待被调用而不是像编译为 asm.js 后会立即执行。同时动态库会导出所有函数与变量,EXPORTED_FUNCTIONS 是无效的。

编译成 wasm 后需要将其运行在浏览器中,我这里使用的 Chrome 58.0.3011.0 canary 可以直接运行。

js 调用 wasm

js 调用 wasm 分为几个步骤:

  1. 加载 wasm 到类型数组中
  2. 编译类型数组的数据,生成一个模块
  3. 实例化该模块

以上三步可以由以下函数完成:

function load(path, imports) {
    return fetch(path)
        .then(res => res.arrayBuffer())
        .then(bytes => WebAssembly.compile(bytes))
        .then(module => {
            imports = imports || {};
            imports.env = imports.env || {};
            imports.env.memoryBase = imports.env.memoryBase || 0;
            imports.env.tableBase = imports.env.tableBase || 0;
            if (!imports.env.memory) {
                imports.env.memory = new WebAssembly.Memory({
                    initial: 256,
                });
            }
            if (!imports.env.table) {
                imports.env.table = new WebAssembly.Table({
                    initial: 0,
                    element: 'anyfunc'
                });
            }
            return new WebAssembly.Instance(module, imports);
        })
}

2-4行先加载 wasm 并编译,compile 方法返回一个 Module 对象,第21行返回一个 Module 的实例。在实例化 Module 时需要传入一个 imports 对象,该对象中保存了一些 wasm 的配置项,目前可以暂时忽略它,就照这样传就好。

接着就可以加载刚才编译好的 cpp.wasm 了。

load('cpp.wasm').then(instance => {
    console.log(instance);
    window.add = instance.exports._add;
    window.sub = instance.exports._sub;
})

这里导出的 add 也是带有 _ 前缀的,此时它也变成了 native code。

尝试调用一下:

wasm (asm.js) 调用 js

wasm 调用 js 两种方式:

  1. 在 C++ 中直接写 js 语句
  2. 在 C++ 中使用 js 提供的接口

第一种方式需要先包含 emscripten.h,它在 emsdk/emscripten/incoming/system/include 中。接着使用 emscripten_run_script 直接运行一个 js 语句。

#include <emscripten.h>

extern "C" {
    int add(int , int);
}

int add(int a, int b){
    emscripten_run_script("alert('hello')");
    return a+b;
}

由于编译到 wasm 时不会自动链接系统库,导致 wasm 是找不到 emscripten_run_script这个函数的,所以要将其编译到 asm.js。

emcc main.cpp -O3 -o cpp.js -s EXPORTED_FUNCTIONS='["_add"]'

NodeJS 环境里是没有 alert 的,所以我们把它放到浏览器里执行。编译出来的 asm.js 提供了一个顶层变量 Module,通过它我们可以直接引用导出的 C++ 方法,就像在 NodeJS 环境里 require 过的一样:

通过 emscripten_run_script 运行 js 实际上用的是 eval,一个更快的办法是使用 ES_ASM 来运行 inline JavaScript

int add(int a, int b){
    EM_ASM(alert("hi"));
    return a+b;
}

效果与前面是相同的。使用 ESASM 还可以实现 C++ 中的变量传递到 js 中:

int add(int a, int b){
    EM_ASM_({
        alert($0)
    }, a+b);
    return a+b;
}

棒极了!注意 alert 里的参数 $0,表示 EMASM 传递的第二个参数,以此类推。

第二种方式可以让 C++ 调用 js 提供的接口,此时不需要系统库,可以编译为 wasm。

首先需要定义传递到 C++ 里的接口,该接口定义在 wasm Module 实例化时传入的 imports.env 里:

imports.env._alert = function(arg){
    alert(arg);
}

接着在 C++ 中声明一个外部函数并调用:

extern "C" {
    int add(int , int);
    extern void alert(int);
}

int add(int a, int b){
    alert(a+b);
    return a+b;
}

返回浏览器 console,加载 wasm 后运行 add 方法:

结尾

WebAssembly 还有很多很高级的东西,本文只是简单的探讨下最初级的使用方法。目前它仍处于开发阶段,所以千万不要妄想能在生产环境运行。本篇暂时就到这里了。

← 机器语言与汇编语言是何如工作的  C 语言中数字的存储方式 →