一文掌握 JavaScript 与 Wasm 之间的数据通讯(使用纯 wat 本文格式)

本文正在参加「金石计划」

前言

WebAssembly(简称 Wasm)是一个虚拟机,可以看作是一个 CPU。只能对接收传入的数据进行计算,然后将计算结果数据对外输出。

浏览器中的 Web API(DOM、WebGL、Web Worker)它则无法直接调用。

经常需要把 Web API 以及 JS 中的数据传递到 Wasm,把 Wasm 计算得到的结果数据传递到 JS,因此他们之间的数据通讯非常关键。

为何要了解通讯底层实现

目前像 Emscriptem、Rust、Go、AssemblyScript 都已支持将第三方编程语言编译为 Wasm 模块,且都对 JS 与 Wasm 的数据通讯做了各自的封装。

不过本文并非介绍以上这些编程语言、编译器提供的用法,而是介绍纯粹的 Wasm 与 JS 的通讯,与以上框架的封装无关,所以暂且称为:通讯底层实现

以上这些编程语言、编译器的封装底层其实都是借助最底层的 JS 与 Wasm 通讯方法实现的。

AssemblyScript 通讯示例

例如,使用 AssemblyScript 导出变量到 JS,代码如下:

export let version_number = '1.0.1'
复制代码

JS 中的使用代码如下:

import { version_number } from "./build/release.js";
console.log(version_number)
复制代码

release.js 是 AssemblyScript 自动生成的一个胶水层代码,部分逻辑如下:

export const {
  memory,
  version_number
} = await (async url => instantiate(
  await (async () => {
    try { return await globalThis.WebAssembly.compileStreaming(globalThis.fetch(url)); }
    catch { return globalThis.WebAssembly.compile(await (await import("node:fs/promises")).readFile(url)); }
  })(), {
  }
))(new URL("release.wasm", import.meta.url));
复制代码

以上用法看着非常简单,是因为 AssemblyScript 进行了封装,其实现如下:

使用 AssemblyScript 编译生成 Wasm 二进制文件时还是借助的 export 导出指令来将源代码里定义的变量进行导出。

image.png

由于源代码导出的变量是字符串类型,下面还对字符串类型做了处理(处理细节暂不深究)。

image.png

综上,标题问题的回答是:

了解了 JS 与 Wasm 通讯的底层实现方法后,才能更好地掌握对上层框架的使用。

Wasm 与 Wat

Wasm 指的是虚拟机用来执行的二进制文件

image.png

Wat 指的是与 Wasm 等价的文本表示,方便进行断点调试

image.png

Wat 语法简介

后文的 Wasm 文件都是使用 Wat 语法编写,使用 wat2wasm 工具编译生成的,为便于理解代码这里简单介绍下 Wat 语法。

注释

;; 两个分后之后是注释内容
复制代码

定义变量

Wasm 中不存在变量定义一说,只是向栈上插入数据

i32.const 0 ;; 定义常量 0
复制代码
(local $1 i32) ;; 定义变量 $1,是 32 位整型
复制代码

定义函数

(func $add (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add
)
复制代码

等价于

function $add($a: Number, $b: Number): Number {
    return $a + $b
}
复制代码

定义表格 Table

(module
  (export "table" (table $table))
  (table $table 5 funcref) ;; 定义有 5 个元素的 Table 表格,别名是 $table
)
复制代码

定义内存 Memory

(memory $memory 1) ;; 定义一个 1 页(64KB)大小的内存,别名是 $memory
复制代码

立即执行

类似于 Java 中的 main 方法

(start $start) ;; Wasm 实例化完成之后立即执行 $start 函数
复制代码

调用函数

1.无参数调用

call $getVersion
call $log
复制代码

等价于

$log($getVersion())
复制代码

2.带参数调用

i32.const 100
i32.const 5
i32.add     ;; 5 + 100 => 105
call $log
复制代码

等价于

$log(5 + 100)
复制代码

传递函数

JS 与 Wasm 的数据通讯可以大概分为:传递函数传递数据两类。

后文的示例代码中都使用了这个工具方法:

export const instantiate = (url, importObject = {}) => {
  return fetch(url).then(res => res.arrayBuffer()).then(ab => WebAssembly.instantiate(ab, importObject)).then(res => res.instance)
}
复制代码

作用是下载 Wasm 二进制文件并根据传入的 importObject 对象来实例化 Wasm。

1.JS 传递函数到 Wasm

只有 import 导入这一种方法,下面以传递 JS 中的 console.log 作为示例

const js2wasm1 = await instantiate('./wasm/js2wasm1.wasm', {
  // 传递函数
  console: {
    log: console.log
  },
})
复制代码

Wasm 中声明导入的对象

(module
  (func $log (import "console" "log") (param i32))
  (func $start
    ;; 测试调用外部传入的console.log函数 输出结果为 101
    i32.const 101
    call $log

  (start $start) ;; 立即执行$start函数
)
复制代码

浏览器的控制台会输出 101

2.Wasm 传递函数到 JS

导出函数Table 表格两种方法可以实现,下面以导出 Wasm 中定义的 add 方法给 JS 为例。

导出函数

Wasm 中定义导出的函数名叫做 add,在 Wasm 内部函数名叫做 $add(Wat 语法要求函数别名必须以 $ 开头)。

(module
  (export "add" (func $add)) ;; 导出函数 add 给 JS
  (func $add (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add
  )
)
复制代码
const wasm2js1 = await instantiate('./wasm/wasm2js1.wasm', {})
const { exports } = wasm2js1
console.log(exports.add(100, 1)) // 执行 wasm 导出的 add 函数
复制代码

浏览器的控制台会输出 101

Table 表格

主要思路为:

1.在 Wasm 中创建 Table 表格对象并定义为导出

2.在 Wasm 中定义函数,并将其存储到 Table 表格

3.JS 中实例化 Wasm 对象后 exports 导出对象上可得到导出的 Table 对象实例

4.JS 中调用 Table 对象实例的方法得到 Wasm 内部的函数,然后直接执行

代码实现如下:

Wasm 中定义了具有 5 个元素的 Table 表格,第 1 个元素存储了其内部函数 $add 的引用,并且将 Table 表格-$table 导出给了 JS。

(module
  (export "table" (table $table))
  (table $table 5 funcref) ;; 定义有 5 个元素的 Table 表格
  (elem (i32.const 0) $add) ;; 第 1 个元素存储函数 add 的引用
  (func $add (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add
  )
)
复制代码

exports.table 是 JS 中接收到的 Table 表格实例对象,调用 get(0) 方法找到第 1 个函数,然后执行。

const demo1 = await instantiate('./wasm/demo1.wasm', {})
console.log(demo1)
const { exports } = demo1
const add = exports.table.get(0) // Wasm 中 Table 存储的第 1 个函数 add
console.log(add(100, 1)) // 101
复制代码

浏览器控制台输出:101

JS 中找到函数显示是 [native code],并且 Table 表格对象只能存储 Wasm 内部定义的函数,不能将 JS 中的函数保存到 Table 表格对象。

image.png

传递数据

1.JS 传递数据到 Wasm

导入函数

主要思路为:

1.在 JS 中将数据包装成函数的返回值

2.实例化 Wasm 实例时将该函数导入

3.Wasm 中调用导入的方法获取返回值

代码实现:

Wasm 环境定义导入的函数 getVersion,使用 start 指令告诉 Wasm 实例化完成后立即执行。

(module
  (func $log (import "console" "log") (param i32))
  (func $getVersion (import "constant" "getVersion") (result i32))
  (import "memory" "mem" (memory 1))
  (func $start
    ;; 测试打印导入函数获取数据
    call $getVersion
    call $log
    )

  (start $start)
  )
复制代码

JS 环境给导入对象传递的 constant.getVersion 属性是一个返回值为 12 的函数。

const js2wasm1 = await instantiate('./wasm/js2wasm1.wasm', {
constant: {
  getVersion: () => 12
},
// 传递函数
console: {
  log: console.log
},
})
复制代码

执行结果:

image.png

Global 全局对象

主要思路为:

1.在 JS 中初始化 Global 对象

2.实例化 Wasm 实例时将该对象导入

3.Wasm 中通过 global.get global.set 指令读写数据

4.JS 中可以通过 global.value 的读写数据

代码实现如下:

JS 环境传入 Global 对象

const js2wasm1 = await instantiate('./wasm/js2wasm1.wasm', {
    // 传递 Global
    constant: {
      version: new WebAssembly.Global({ value: 'i32', mutable: false }, 11) // i32表示 wasm 中的数据类型,值为 11,不支持传入String、BigInt等其他类型
    },
    // 传递函数
    console: {
      log: console.log
    },
})
复制代码

Wasm 环境使用 global.get $version 接受数据

(module
  (func $log (import "console" "log") (param i32))
  (import "constant" "version" (global $version i32))
  (func $start
    ;; 测试打印外部传入的 Global
    global.get $version
    call $log

    ;; 测试调用外部传入的函数
    i32.const 101
    call $log
    )

  (start $start)
  )

复制代码

执行结果:

image.png

Memory 内存

主要思路为:

1.在 JS 中初始化 Memory 对象

2.在 JS 中使用 TypedArray 来修改 Memory 对象底层 buffer 的二进制数据

3.实例化 Wasm 实例时将该对象导入

4.Wasm 中可以通过 i32.load i32.store 等指令读写数据

5.数据是通过内存直接共享的,JS 还可以继续使用 TypedArray 来修改数据

i32 类型表示:32 位无符号整型,对应 JS 中的 Number 类型

代码实现如下:

JS 中初始化 Memory 对象,并赋值 [100,101,102],在实例化 Wasm 时传入导出的 memory.mem 是一个 Memory 对象。

const mem = new WebAssembly.Memory({ initial: 1 })
const i32ab = new Int32Array(mem.buffer) // 内存 Memory 对应的是 buffer,相当于给内存赋值
i32ab.set([100, 101, 102]) // [100, 101, 102, 0, 0, 0, 0, 0...]

const js2wasm1 = await instantiate('./wasm/js2wasm1.wasm', {
    // 传递函数
    console: {
      log: console.log
    },
    memory: {
      mem
    }
})
复制代码

Wasm 中定义导入 memory.mem 的类型是 WebAssembly.Memory 对象,使用 i32.load 指令读取导出的 Memory 对象的值。

(module
  (func $log (import "console" "log") (param i32))
  (import "memory" "mem" (memory 1))
  (func $start

    ;; 测试调用外部传入的函数
    i32.const 101
    call $log

    ;; 测试打印外部传入的 Memory
    i32.const 5 ;; 定义常量5
    i32.const 0 ;; 开始地址
    i32.load offset=0 align=4;; 偏移 0,对齐 4,在 Memory偏移0处取一个值 => 100
    i32.add     ;; 5 + 100 => 105
    call $log
    )

  (start $start)
)
复制代码

2.Wasm 传递数据到 JS

与前面的 JS 传递数据到 Wasm 的过程是相反的,因此也有以下三种方法。

导出函数

主要思路为:

1.在 Wasm 中将数据包装成函数的返回值

2.在 Wasm 将上述函数定义为导出函数

3.JS 中实例化 Wasm 后直接调用导出的方法获取返回值

Wasm 环境定义导出函数 getVersion,其返回值是一个 i32 类型的值 12。

(module
  (export "getVersion" (func $getVersion)) ;; 导出函数 getVersion 给 JS
  (func $getVersion (result i32)
    (local $version i32)
    i32.const 12
    local.set $version
    local.get $version
  )
)
复制代码

JS 环境调用 Wasm 导出的函数 getVersion 获取返回值。

const wasm2js2 = await instantiate('./wasm/wasm2js2.wasm', {})
console.log(wasm2js2)
const { exports } = wasm2js2
console.log(exports.getVersion()) // 调用 wasm 内部 getVersion 方法获取返回值
复制代码

执行结果:

image.png

Global 全局对象的

主要思路为:

1.在 Wasm 中创建 Global 对象并定义为导出

2.JS 中实例化 Wasm 后通过导出的对象可以获取到 Global 对象

3.Wasm 中通过 global.get global.set 指令读写数据

4.JS 中可以通过 global.value 的读写数据

代码实现如下:

Wasm 中创建 Global 对象(默认值为 0),并且将其导出,同时导出了 addVersion 函数使用 global.get global.set 来读写 Global 对象存储的数据。

(module
  (export "addVersion" (func $addVersion)) ;; 导出函数 addVersion 给 JS
  (export "version" (global $version))
  (global $version (mut i32) (i32.const 1))
  (func $addVersion (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add
    global.get $version
    i32.add
    i32.const 1
    i32.add
    global.set $version
    global.get $version
  )
)
复制代码
const wasm2js1 = await instantiate('./wasm/wasm2js1.wasm', {})
const { exports } = wasm2js1

console.log(exports.version) // 导出 Global 对象到 JS
console.log(exports.version.value)

console.log(exports.addVersion(0, 0)) // 调用 wasm 内部 addVersion 方法修改 Global 的值
console.log(exports.version.value)
复制代码

执行结果:

image.png

Memory 内存

主要思路为:

1.在 Wasm 中创建 Memory 对象并定义为导出

2.JS 中实例化 Wasm 后通过导出的对象可以获取到 memory 对象

3.Wasm 中可以通过 i32.load i32.store 等指令读写数据

4.数据是通过内存直接共享的,JS 可以使用 TypedArray 来读写数据

代码实现如下:

Wasm 中定义分别定义了 memory 对象、updateMemory 方法并导出, updateMemory 方法将接收的两个 i32 类型参数相加保存到 memory 对象中,并返回相加结果。

(module
  (export "updateMemory" (func $updateMemory)) ;; 导出函数 updateMemory 给 JS 
  (export "memory" (memory $memory)) ;; 将内存导出给 JS
  (memory $memory 1)
  
  (func $updateMemory (param $a i32) (param $b i32) (result i32)
    (local $s i32)
    local.get $a
    local.get $b
    i32.add
    local.set $s
    i32.const 0 ;; 开始地址
    local.get $s
    i32.store offset=0 ;; 修改 Memory 的值
    i32.const 0 ;; 开始地址
    i32.load offset=0 ;; 取 Memory 偏移 0 处的值
  )
)
复制代码
const wasm2js2 = await instantiate('./wasm/wasm2js2.wasm', {})
console.log(wasm2js2)
const { exports } = wasm2js2
console.log(exports.updateMemory(100, 5)) // 调用 wasm 内部 updateMemory 方法修改 Memory 的值
console.log(exports.memory)
复制代码

执行结果:

image.png

Memory 内部的 buffer 数据如下:

image.png

数据类型说明

JS 中有八种数据类型:Number、String、Boolean、Undefined、Null、Symbol、BigInt、Object。

其中,能有与 Wasm 进行通讯的只有 Number 类型,甚至 BigInt 都不行。

所以,本文所说的数据传递都是指 Number 类型数据或原始二进制数据 ArrayBuffer。

像字符串、布尔值这些需要转换为原始二进制数据再进行传递。

总结

使用导入、导出函数Global 全局对象进行数据传递时只能传递 JS 中的 Number 类型,其他类型要想用此方案需要设计方案将其序列化为 Number 类型再进行传递,接收到之后还需要反序列化,不是建议使用。

经过代码验证,Global 全局对象没法直接将 JS 里的 ArrayBuffer 传入 Wasm,不过倒是可以传递函数引用(这部分代码在 demo 中可以找到,不再赘述)。

使用 Memory 内存传递数据可以传递 JS 中的 Number、String、Boolean、BigInt 类型,因为其底层使用的二进制的 ArrayBuffer 来通讯的,传递的使用也需要设计方案序列化为二进制,接收到之后还需要反序列化,推荐使用。

不过,从 Wasm 的使用场景来说,一般情况都是用 JS 的 ArrayBuffer 类的二进制数组直接与 Wasm 通讯;不会涉及到 String、Boolean、BigInt 等类型数据的传递。

WebAssembly 完全可以看作是一个 CPU,JS 与其交互的部分可以看作 I/O 接口,底层 I/O 接口都是用二进制数据进行通讯的。

上文介绍的几种 JS 与 Wasm 数据通讯的方法特点汇总如下:

方法 JS 传递到 Wasm Wasm 传递到 JS 支持双向修改 传递数据类型
导入/出函数 支持 支持 不支持 仅 Number 类型
Table表格 支持 支持 支持 仅 Wasm 内部的函数引用
Global全局对象 支持 支持 支持 仅 Number 类型
Memory内存 支持 支持 支持 任意类型

支持双向修改指的是:JS 与 Wasm 通过内存直接操作数据双方都可以修改。

传递任意类型值的是:通过 ArrayBuffer 二进制数组传递数据,JS 里面的任意数据类型只要转为二进制数组的都可以传递。

参考资料

2023.04.09 于湖北省图书馆

猜你喜欢

转载自juejin.im/post/7219899306945806394
今日推荐