基本
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 代码库,实现底层细节的隐藏。
基本运行流程图为:
开始编译
使用 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
根节点带着两子节点, memory
和 func
。其中, 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 怎么具体调用函数名呢?
和上面调用内部函数一样,需要使用到 call
和 import
关键子来进行调用。
具体代码为:
(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:
完整的代码为:
(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 });
注:本文内容来自互联网,旨在为开发者提供分享、交流的平台。如有涉及文章版权等事宜,请你联系站长进行处理。