VillainHR

webassembly 入门

wasm webassembly HTML5.0 2017-12-10

基本

wasm 是用来让低级汇编语言,比如 C/C++ 能够直接在浏览器上运行的一个标准。

它遵循着安全的同域策略。

wasm 不需要直接侵入 web 代码页面,从而可以实现 API 的提供。

wasm 运行机制

Web 的运行环境可以分为,你写的 JS 代码 和通过 JS 代码代用底层的 WebAPI。好听一点就是:

  • 虚拟机(VM):运行你写的 JS 代码
  • 底层内核:提供 JS 代码中使用到的 Web API,例如,DOM、BOM 等。

wasm能做什么?

因为以前 VM 只能运行 JS 代码,将 JS 无法运行高负载运算的缺点完美的暴露出来。而现在,随着技术的发展,比如,3D game,VR/AR,机器视觉,图像处理等等。这些需要大量计算的,在 JS 里面就变的有些尴尬,所以,基于该点浏览器提供了一个通用的技术共识,额外提供一个可以用来运行 编译代码的 VM。这也就是 wasm 诞生的初衷。

所以,这一个新的 VM 可以同时运行 JS 和 wasm 代码。

wasm 基本概念

wasm 可以和 JS 一起在同一个 VM 编译运行,他们之间可以通过模块化,来相互调用。在 wasm 存在几个基本的概念:

  • Module(模块): wasm 能够提供一些列无状态的方法和属性集合,其可以直接被存放在 indexeddb 或者其他缓存列表中。模块暴露和导出语法和 ES2015 类似。
  • Memory(内存):wasm 通过一个 ArrayBuffer 来存放编译后的二进制文件。
  • Table(表):wasm 除了 ArrayBuffer 还提供了一个 TypedArray 二进制 Array 暴露。
  • instance(实例):通过 Module 具体暴露的实例方法集合。

JS 可以直接在代码中,搭配使用上述四个基本的 wasm 部分,然后再封装成为另外一个 js 代码库,实现底层细节的隐藏。

基本运行流程图为:

image.png-13.5kB

开始编译

使用 WebAssembly 编译能够让你觉得你自己再写底层的编译语言,它的编译工具主要参考的是:emcc。不过,在按照它项目提示的操作时,经常会遇见很多 bug,翻了一下,找到比较稳妥的执行命令:

git clone https://github.com/juj/emsdk.git  (or download https://github.com/juj/emsdk/archive/master.zip and unzip to emsdk)
cd emsdk
emsdk install --build=Release sdk-incoming-64bit binaryen-master-64bit
emsdk activate --build=Release sdk-incoming-64bit binaryen-master-64bit
cd emscripten\incoming
where clang
clang -v
emcc -v
emcc -s WASM=1 tests/hello_world.c -o hello_world.html

上面 sh 代码执行完毕差不多需要 30+min,不过这还得取决于不同的机器。

使用 WASM 函数

通过 emcc 编译,一般是直接输入到 HTML 中。当然,如果还想使用 emcc 编译出来的函数,通过 ccall() 直接调用。这里需要借助 emscripten.h 并结合 CPP 实现预编译定义。

主要写法为:

#include <stdio.h>
#include <emscripten/emscripten.h>

int main(int argc, char ** argv) {
    printf("Hello World\n");
}

#ifdef __cplusplus
extern "C" {
#endif

void EMSCRIPTEN_KEEPALIVE myFunction(int argc, char ** argv) {
  printf("MyFunction Called\n");
}

#ifdef __cplusplus
}
#endif

通过 #ifdef 等相关 CPP,在需要暴露的函数中使用 EMSCRIPTEN_KEEPALIVE 进行定义。

默认情况下,emcc 会执行 main() 函数,而其他的函数则不会有任何作用。

执行编译

在完成基本 WASM 模块书写后,我们就可以借用 emcc 来执行相关编译输出结果。因为,这里是为了调用 WASM 的暴露函数,需要在 HTML 指定代码触发。HTML 的代码内容为:

<script>
var result = Module.ccall('myFunction', // name of C function
                             null, // return type
                             null, // argument types
                             null); // arguments
<script>

在执行编译命令时,需要注意一个参数 NO_EXIT_RUNTIME=1,防止 C 代码在执行完之后直接退出。因为,退出之后便无法指定的暴露函数。

编译命令为:

emcc -o output.html expose.c -O3 -s WASM=1 --shell-file template.html -s NO_EXIT_RUNTIME=1
  • -o output.html: 用来指定输出的 HTML 模板
  • WASM: 采用 wasm 编译
  • --shell-file : 指定采用的 HTML 模板
  • -s NO_EXIT_RUNTIME=1: 指定编译过后的文件不直接退出

WASM 拉取编译

在实际开发中,wasm 模块一般是通过 fetch/XHR 异步拉取,然后通过 WebAssembly.instantiate 编译二进制代码。编译完成后,就可以直接通过返回的对象进行方法调用。

核心代码为:

fetch('module.wasm').then(response =>
  response.arrayBuffer()
).then(bytes =>
  WebAssembly.instantiate(bytes, importObject)
).then(results => {
  // Do something with the compiled results!
});

返回的 results 结果其实就是对象,里面包含 module 和 instance 内容。

{
  module : Module // 等同于 WebAssembly.Module object,
  instance : Instance //  等同于 WebAssembly.Instance 
}

WASM Text 说明

WASM 文本

S-expressions 是一个非常老的用来描述模块节点树的文本格式。每个节点是使用 () 包裹,() 里面第一个字段表示该节点的类型,如果该节点还带参数,则直接根据空格切分。

(module (memory 1) (func))

该节点数的结构为:module 根节点带着两子节点,memoryfunc。其中,memory 属性参数为 1。

子节点定义

具体子节点定义可以带上可行性的函数 body:

( func <signature> <locals> <body> )
  • signature: 用来定义函数的参数和返回值
  • locals: 用来声明变量
  • body: 低级指令集合

子节点参数和变量定义

定义一个函数,需要设置其返回值,参数以及函数体内部调用的变量。在 WASM 中,提供了 4 中类型:

  • i32: 32-bit integer
  • i64: 64-bit integer
  • f32: 32-bit float
  • f64: 64-bit float

注意,这里并没有 string 类型作为计算值。那参数、返回值和变量应该如何进行定义和说明呢?

  • 参数定义:使用 param 定义说明,后面接上参数定义。默认情况下,获取参数是直接根据定义的顺序决定的,还有一种可以提供命名参数,即,使用 name 来标识。格式为:
param type; 
// 定义 i32 类型的参数
param i32;

param $name type; // 使用 name 来表明参数,name 前面必须接上 $。
// 定义带 name 的 i32 类型参数
param $target i32
  • 返回值定义:使用 result 定义,格式为:
result type
// 设置 i32 的返回值
result i32
  • 变量定义:使用 local 修饰:
local f64
// 变量也可以采用命名式定义
local $var1 i32

在定义参数和返回值时,还需要有几个注意点:

  • result 定义必须放在最后,即为:
(func (param i32) (param i32) (result f64) )

S-expression 变量和参数的读写

在 SE(S-expression) 格式里,还需要对参数变量等做相关处理,具体的方法就是通过 get_local/set_local 关键字来进行读写。默认情况下,可以直接根据参数定义的序号来决定读写的顺序。

(func (param i32) (param f32) (local f64)
  get_local 0
  get_local 1
  get_local 2)

前面也提到了,变量和参数也可以设置为 $name 的形式,所以,这里也可以通过 $name 的形式来获取值。

(func (param $p1 i32) (param $p2 f32) (local $loc i32)
    get_local $p1
)

导出函数

在完成一个函数之后,我们可以使用 export 关键字导出该函数。该函数可以通过命名的形式直接导出:

(module
  (func $add (param $lhs i32) (param $rhs i32) (result i32)
    get_local $lhs
    get_local $rhs
    i32.add) // 这里代表 $lhs+$rhs
  (export "add" (func $add))
)

导出的关键代码为:

(export "add" (func $add))
// "add" 为导出的函数名

通过 JS 调用的代码可以直接设置为:

fetchAndInstantiate('add.wasm').then(function(instance) {
   console.log(instance.exports.add(1, 2));  // "3"
});

// fetchAndInstantiate() found in wasm-utils.js
function fetchAndInstantiate(url, importObject) {
  return fetch(url).then(response =>
    response.arrayBuffer()
  ).then(bytes =>
    WebAssembly.instantiate(bytes, importObject)
  ).then(results =>
    results.instance
  );
}

函数调用函数

在一个模块中,我们可以通过 fn call fn 的形式来进行函数栈的调用。具体代码为:

(module
  (func $getAnswer (result i32)
    i32.const 42)
  (func (export "getAnswerPlus1") (result i32)
    call $getAnswer
    i32.const 1
    i32.add))

调用函数具体可以使用 call $fn 的形式来调用前面定义过的函数。其中 i32.const 给当前栈堆,push 类型为 interge-32 的值,42,由于函数返回值是直接取堆栈最新 push 的值,所以,这里的代码,也就相当于设置了函数的返回值。上面的代码还有另外一个简便的函数导出写法:

func (export "getAnswerPlus1") 

这里等同于

(export "getAnswerPlus1" (func $functionName))

WASM 导入 JS 函数

上面的交互是使用 WASM 内部的函数,那如果需要调用 JS 环境中的函数,应该怎么做呢?

在调用 WebAssembly.instantiate() 方法进行实例化时,则需要手动将 JS 中的函数传入。那在 WASM 怎么具体调用函数名呢?

和上面调用内部函数一样,需要使用到 callimport 关键子来进行调用。

具体代码为:

(module
  (import "console" "log" (func $log (param i32)))
  (func (export "logIt")
    i32.const 13
    call $log))

上面这行代码:

(import "console" "log" (func $log (param i32)))

实际上导入的就是,下面的对象结构:

console: {
    log: function(arg) {
      console.log(arg);
    }
  }

后面,又根据 export 导出一个 WASM 自定义的函数,并且在函数里面调用了外部引用的函数。

(func (export "logIt")
    i32.const 13
    call $log)

OK,现在我们又回到前面那个问题,在调用 WebAssembly.instantiate() 方法时,怎么指定的传入 JS 的代码?

这里,就根据导入函数的层级结构传入即可。

var importObject = {
  console: {
    log: function(arg) {
      console.log(arg);
    }
  }
};


WebAssembly.instantiate(buffer,importObject);

不过,这里还有一个需要注意,instantiate(bufferSource, importObject); 中的第二个参数 importObject 并不是必须的,只有当 WASM 需要调用 JS 代码时,才需要手动传入。

Memory 调用

WASM 中的 Memory 存在,主要是为了 WASM 能够处理多字节数据而创建的。具体写法为:

(import "js" "mem" (memory 1))

其中,(memory 1) 用来申明该 Buffer 的大小为 1 page === 64KB。由于,前面是通过 import 修饰的,所以,也需要在 JS 外部进行导入。

memory 常常用来作为非整型数据的处理,比如,处理 String 类型,如下代码:

(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  ;; pass offset 0 to log
    i32.const 2  ;; pass length 2 to log
    call $log))

其中 (data (i32.const 0) "Hi"),该 data 关键字是专门用来声明多字节类型的,它会将该数据,直接存储在当前 WASM 的线性堆栈当中。上面代码的实际意义就是:

在 0 的起始位,存储 Hi Buffer 变量内容。

后面 WASM 又导出了一个叫做 writeHi 的函数,它给 JS中导入的 $log 函数传入,0 和 2 的参数,那么我们来看一下,具体 log 函数操作的内容。

JS 中调用代码如下:

// 这就是 log 函数的主要内容,接收两个参数来代表,Buffer 中真实的数据内容
function consoleLogString(offset, length) {
      var bytes = new Uint8Array(memory.buffer, offset, length);
      var string = new TextDecoder('utf8').decode(bytes);
      console.log(string);
}

var memory = new WebAssembly.Memory({initial:1});

var importObj = { console: { log: consoleLogString }, js: { mem: memory } };

fetchAndInstantiate('logger2.wasm', importObj).then(function(instance) {
  instance.exports.writeHi();
});

通过,将 WebAssembly.Memory 实例的 memory 传入 WASM,在其内容进行动态调用并输出。

综上,Memory 就是用来作为在 JS 函数和 WASM 之间进行 Buffer 操作的传递 ArrayBuffer。

内存调用

在 WebAssembly 有一个非常重要 Buffer 对象 – Memory。该对象可以和 WebAssembly 直接进行交互和调用,利用的语法就是使用 S-expression 模式。Memory 本身就是一个 ArrayBuffer 不过底层提供了一些操作方法来专门对接 WASM。它是采用 Linear Memory 的读取方式,即,set/get 操作只能按照 size/length 合并完成。

其基本构造函数为:

new WebAssembly.Memory(memoryDescriptor);

memoryDescriptor 其实就是两个选项值:

  • initial: 用来设置初始 memory 的大小,单位是 64KB。
  • maximum: 设置 memory 最大的上线值。

例如,声明一个 [10 page, 60 page] 的 memory:

var memory = new WebAssembly.Memory({initial:10, maximum:60});

在实例过后的构造函数上,有两个基本的属性和方法:

  • Memory.prototype.buffer:用来获取该 memory buffer 的 ArrayBuffer 对象。并且,我们可以直接进行修改里面的内容。
new Uint32Array(memory.buffer)[0] = 42;
  • Memory.prototype.grow(num):将当前的 memory buffer 大小增长指定的 size。但是,如果设置了 maximum 的话,则不能超过上限值。 grow 的增长单位也是 page,即 64KB。
memory.grow(1);

理解 Table

由于 WASM 里面是 linear memory,有点不方便结构函数的调用,例如,我想直接找到内存中,存放的函数地址,然后执行调用。这里,我们可以利用 WASM 中的 Table 来完成 C/C++ 中指针的效果。Table 在 S-Expression 中指令的基本格式为:

table length type
  • length: 定义当前 table 的长度
  • type: table 里面元素存储的类型,现阶段只有 anyfunc 是合法的,表示只能存储函数,官方说后续会支持更多。anyfunc 原意就是:a function with any signature

在 HTML 中,我们可以实际访问的对象就是:WebAssembly.Table。其基本格式为:

var myTable = new WebAssembly.Table(tableDescriptor);

tableDescriptor 就是一个对象,里面可以包含如下字段:

  • element: 只能填写 anyfunc,理由是其它的不支持。表示只能存储函数引用。
  • initial: 设置 table 的大小。每一位可以存储一个函数索引。
  • maximum[可选]: 设置 table 的上限值。这个和 Memory 有点类似。

实例化过后,返回的 myTable 返回的对象上,还挂载了相关的方法和属性:

  • length:直接放回当前 tbale 的大小。相当于就是获得 initial 的值
  • get(index): 得到在 table 中指定索引的函数元素。
  • set(index, value):在执行索引位置,传入需要索引的函数。
  • grow(num):动态改变 table 的大小。实际上相当于就是,改变 ArrayBuffer 的大小。

上面几个方法,我们在后面的讲解中会一一提到。这里就不多说了。

WASM 中的 Table

Table 常常会直接定义在 WASM 源码中,这方便了函数的调用和声明内容。在 WASM 中,Table 还需要结合 elem 关键字一起使用。它是用来定义 table 中函数引用的 offset 和具体的引用函数名。格式为:

elem (offset) fn1 fn2 ...

上面的代码需要结合 table 指令一起使用才有意义:

(table 2 anyfunc)
(elem (i32.const 0) $f1 $f2)

通过 table 指令定义了长度为 2 的 Table。elem 通过 (i32.const 0) 定义了 offset 为 0,这表示的意义是函数索引放置的起始位置。也就是,在 2 个长度的 table 中,从 0 index 开始存储 func 索引。后面 $f1$f2 就代表着具体存储的函数索引。也就是说,通过上面两行代码,定义了如下的 table:

image.png-18.1kB

完整的代码为:

(module
  (table 2 anyfunc)
  (elem (i32.const 0) $f1 $f2)
  (func $f1 (result i32)
    i32.const 42)
  (func $f2 (result i32)
    i32.const 13)
)

Table 是属于 WASM 中的一个标准,上面只是属于 S-Expression 的语法标准。其实,我们可以直接通过 WebAssembly.Table 提供的 API 来实现 JS 定义 Table 的操作。上述 SE 代码,我们可以使用 JS 代码模拟得到:

function() {
  // table section
  var tbl = new WebAssembly.Table({initial:2, element:"anyfunc"});

  // function sections:
  var f1 = function() { … }
  var f2 = function() { … }

  // elem section
  tbl.set(0, f1); // 在 0 位,设置 f1 的缩影
  tbl.set(1, f2); // 在 1 位,设置 f2 的缩影
  
  // call fn
  tbl.get(0)(); // 调用 indices = 1 的函数
  
};

JS 调用 WASM Table 函数

在完整的 Table 代码为:

(module
  (table 2 anyfunc)
  (func $f1 (result i32)
    i32.const 42)
  (func $f2 (result i32)
    i32.const 13)
  (elem (i32.const 0) $f1 $f2)
  (type $return_i32 (func (result i32)))
  (func (export "callByIndex") (param $i i32) (result i32)
    get_local $i
    call_indirect $return_i32)
)

上面有两个多出来的指令:

  • type: 用来检查数据类型是否合法. 这其实就是用来限定 $return_i32 必须是一个能够返回 \i32 数据类型的函数。
  • call_indirect: 就是用来从 table 引用中调用相关函数。get_local $i 获取函数触发的序号。其实也可以直接写为:
(call_indirect $return_i32 (get_local $i))
// 等同于
tbl[i]();

那么我们怎么在 JS 调用 Table 里面的函数呢?

由于上面已经暴露了 callByIndex 接口,里面最关键的语法就是用到了 call_indirect 指令,我们直接利用其进行指定调用即可。

fetchAndInstantiate('wasm-table.wasm').then(function(instance) {
        console.log(instance.exports.callByIndex(0)); // returns 42
        console.log(instance.exports.callByIndex(1)); // returns 13
        console.log(instance.exports.callByIndex(2)); // err, no fn in index 2 
      });

原文链接: https://www.villianhr.com/2017/12/10/webassembly 入门