WASM 输入与输出

WASM 简介

WASM(WebAssembly)是一种用于堆栈式虚拟机的二进制指令格式。它是是一种文件格式标准,不是特定的虚拟机实现,因此有各种 compiler、interpreter 以及 runtime,如wastimeWasmerV8LucetWAMRwasm3

WASM 在设计之初,考虑了以下几点:🔗

但也有以下限制:

接下来我们将使用 Node.js(V8)作为 runtime 语言,AssemblyScript 作为编译到 WASM 的语言,来做点实验。

AssemblyScript

A language made for WebAssembly.

对于很多在 WASM 诞生前就已经存在的语言,WASM 就像 x86、ARM64 一样,只是一种新的 platform target。

而 AssemblyScript 是一个为 WASM 而生的语言,目前只能编译到 WASM。它的语法是一种 TypeScript 的变体,但实际写起来时,有时却要像写 C 语言那样思考。

AssemblyScript 项目搭建请参考Quick Start

a+b

我们来写一个简单的 a+b,为什么不是 hello world?因为字符串在 WASM 里是高级数据结构。我们先从最简单的 i32 开始:

// sum.ts
export function sum(a: i32, b: i32): i32 {
  return a + b;
}

编译后得到一个二进制文件sum.wasm,和一个文本文件sum.wat。两者可以使用wabt互相转换。我们以文本格式为例:

; sum.wat
(module
  (type $t0 (func (param i32 i32) (result i32)))
  (func $sum (type $t0) (param $p0 i32) (param $p1 i32) (result i32)
    local.get $p0
    local.get $p1
    i32.add)
  (memory $memory 0)
  (export "sum" (func $sum))
  (export "memory" (memory 0)))

如果学过汇编,不了解 WAT 语法也可以看懂个大概。值得注意的是export "memory",这是 WSAM 把内存暴露给 runtime,后面的例子会用到。

接下来在 Node.js 中调用:

import fs from "fs";

const file = await fs.promises.readFile("./sum.wasm");
const module = new WebAssembly.Module(file);
const instance = new WebAssembly.Instance(module);

console.log((instance.exports.sum as Function)(1, 2));
// stdout: 3

只需几行代码就完成了调用,并输出了预期的结果。

'hello, world!'

来看看字符串是如何在 WASM 与 runtime 之间传递的。

// hello-world.ts
export function hello_world(): string {
  return `hello, world!`;
}

编译后得到:

; hello-world.wat
(module
  (type $t0 (func (result i32)))
  (func $hello_world (type $t0) (result i32)
    i32.const 1056)
  (memory $memory 1)
  (export "hello_world" (func $hello_world))
  (export "memory" (memory 0))
  (data $d0 (i32.const 1036) ",")
  (data $d1 (i32.const 1048) "\01\00\00\00\1a\00\00\00h\00e\00l\00l\00o\00,\00 \00w\00o\00r\00l\00d\00!"))

在 Node.js 中调用:

import fs from "fs";

const file = await fs.promises.readFile("./hello-world.wasm");
const module = new WebAssembly.Module(file);
const instance = new WebAssembly.Instance(module);

console.log((instance.exports.hello_world as Function)());
// stdout: 1056

结果不是"hello, world!",而是一个数字,发生什么事了?

这个数字其实是一个指针的地址,它指向了一个字符串。通过string-layout这篇文档,我们可以知道 AssemblyScript 的字符串是如何在内存中排布的,因此我们可以编写代码把字符串取出来。

// ...
const offset = (instance.exports.hello_world as Function)();
const memory = instance.exports.memory as WebAssembly.Memory;
const buffer = Buffer.from(memory.buffer);
const len = buffer.readUInt32LE(offset - 4);

console.log(buffer.slice(offset, offset + len).toString());
// stdout: hello, world!

WASM 使用的是little endian byte order,因此我们用readUInt32LE而不是readUInt32BE

`hello, ${str}!`

我们已经学会把字符串从 WASM 传到 runtime 中,接下来我们学习反向操作。

// hello.ts
export function hello(str: string): string {
  return `hello, ${str}!`;
}

使用编译参数:

asc hello.ts --use abort= --target release

我们只改了几个字符,但这次编译出的 hello.wat 已经有 400+行。

在前两个例子中,内存是静态的,而在这个例子里我们进行了字符串拼接,就涉及到了内存的动态管理。因此 AssemblyScript 需要把内存回收运行时打包进 WASM,就增加了文件体积。

// ...
const memory = instance.exports.memory as WebAssembly.Memory;
const buffer = Buffer.from(memory.buffer);
const str = "wasm";
const ptr = 1024;
buffer.writeUInt32LE(str.length, ptr - 4);
Buffer.from(str).copy(buffer, ptr);
const offset = (instance.exports.hello as Function)(ptr);
const len = buffer.readUInt32LE(offset - 4);

console.log(buffer.slice(offset, offset + len).toString());
// stdout: hello, wasm!

这里的ptr = 1024是一个危险的做法,这个例子里我们假定 WASM 不会使用第 1024 个字节附近的内存,实际使用中不能这么写,这样可能会覆盖掉 WASM 所需要的数据,使 WASM 运行异常。

AssemblyScript 提供了Loader,封装了这种高级数据结构传递的方法;Rust 也提供了wasm-bingen,可以生成.js.d.ts文件,在 js 代码中直接 import 就可以调用 Rust 编写的 WASM 模块中的函数。

到这里,相信你对如何在 WASM 与 runtime 之间传递数据已经有了初步了解。