WASM 输入与输出
WASM 简介
WASM(WebAssembly)是一种用于堆栈式虚拟机的二进制指令格式。它是是一种文件格式标准,不是特定的虚拟机实现,因此有各种 compiler、interpreter 以及 runtime,如wastime、Wasmer、V8、 Lucet、WAMR、wasm3。
WASM 在设计之初,考虑了以下几点:🔗
- 高效和快速:体积小,加载速度快。可以流式加载,边下载边初始化,减少时间和内存占用。理论上可以达到原生的运行速度。
- 安全:WASM 描述了一个内存安全的沙箱执行环境,甚至可以在现有的 JavaScript 虚拟机中实现。
- 开放可调试:可以翻译为可读的文本格式
.wat
(可以理解为 WASM 平台的汇编语言),配合 source map 方便调试。 - Web 标准:模块能够传入和传出 JavaScript 上下文,并通过 JavaScript 访问 Web API 来访问浏览器功能。同时也支持非浏览器环境中运行。
但也有以下限制:
- 基础数据类型只有 i32、i64、f32、f64。高级数据结构(如:字符串)每种语言需要自己实现。🔗
- 只能进行同步计算,不支持异步函数。
- 对于 Go、Python 这种带运行时的语言编译到 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 之间传递数据已经有了初步了解。