本系列文章将不会从现有的bWebAssembly编译工具出发来介绍WebAssembly,本文将以手写WAT(WebAssembly Text Format)和WebAssembly的API为基础来介绍其基础概念以及基本应用。
什么是WebAssembly
WebAssembly(缩写为 Wasm)是一种基于堆栈式虚拟机的二进制指令集。Wasm 被设计成为一种编程语言的可移植编译目标,并且可以通过将其部署在 Web 平台上,以便为客户端及服务端应用程序提供服务。
WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.
上面的这段话摘自WebAssembly官网。这段话比较的官方,但是在我看来,WebAssembly = Web + Assembly。也就是说WebAssembly相当于在Web上应用的Assembly语言。
众所周知,Assembly语言,也就是汇编语言。汇编语言的性能是非常强大的,但是汇编语言也是相当低层次的语言,非常的难以理解,而且不同的平台的指令集也不一样(比如x86与ARM的指令集就完全不同),所以学习汇编语言非常的麻烦。
而WebAssembly继承了Web平台最大的好处,就是天生跨平台的特性,因为浏览器帮我们解决了跨平台这一大难题。所以我们学习了WebAssembly可以将其部署在很多地方,比如浏览器页面中,Node.js环境中等等。
而且WebAssembly也具有Assembly运行速度快的特性,而且WebAssembly在某些浏览器中还支持SIMD这一特性,更是大大的加快了代码的运算速度!
准备工作
在正式编写WebAssembly代码之前,我们需要做一点点的准备工作。
由于WebAssembly是低层次的语言,我们编写的代码需要经过编译才能够在浏览器中运行,所以我们需要准备相应的编译工具。
以下两个仓库都提供了将WAT格式编译为wasm的工具,大家可以按需自取。具体的使用方案请自行参考仓库的readme,本文不再赘述。
GitHub - WebAssembly/wabt: The WebAssembly Binary Toolkit
第一个WebAssembly程序
废话不多说,我们就开始编写我们的第一个WebAssembly程序!
记住,我们不使用C/C++,Rust,Go等高级语言再编译为wasm的手段,而是直接编写WebAssembly的WAT格式(可以类比于汇编语言)。Let's start it!
wasm
(module
(func $main (export "get1024") (result i32)
i32.const 1024
return
)
)
上面是一段十分简单的WebAssembly的WAT代码。
WAT格式的代码采用了S-表达式作为书写规则。S-表达式是一个非常古老和非常简单的用来表示树的文本格式。我们可以将其看做是类似于JSON的功能,它用于描述模块的结构和代码的节点组成的树。
我们上面的代码如果用JSON改写的话可以写成:
json
{
module: {
func: {
name: "$main",
exportName: "get1024",
returnType: i32,
body: "i32.const 1024\nreturn"
}
}
}
注意,上面的JSON格式是作业杜撰的,实际上并不存在这种JSON格式来解释WASM。 此处只是为了方便解释S表达式。
让我们来逐一解释上面的wasm代码。
- module: 表示一个wasm模块,一般来说一个wasm中只存在一个module,我们的所有的变量、函数都应该写在module中
- func: 用于声明一个函数,声明函数的格式如下:
wasm
( func <signature> <locals> <body> )
- <signature> 表示函数的参数,例如:
wasm
func (param i32) (param i32)
- <locals> 表示函数中用的局部变量,例如:
wasm
func (param i32) (param i32) (local f64)
- <body> 则表示函数中的函数体,函数体是一个低级指令的线性列表
WASM是如何执行代码的?
在文章的开头就说了,"WebAssembly(缩写为 Wasm)是一种基于堆栈式虚拟机的二进制指令集"。重点在于"堆栈式"。我们用下面这段代码来解释什么是堆栈式虚拟机
wasm
(func (return i32)
i32.const 1
i32.const 2
i32.add
return
)
- i32.const 1 表示将常量1入栈
- i32.const 2 表示将常量2入栈
- i32.add 表示从栈中出栈两个数,然后将其相加,再将其入栈。
其过程可以用下图表示:
完整的代码(first.wat)如下:
wasm
(module
(func (export "test") (result i32)
i32.const 1
i32.const 2
i32.add
return
)
)
区别在于我们将这个函数导出以供JavaScript调用。
笔者采用wat2wasm
工具进行编译。
shell
wat2wasm first.wat
经过编译后,我们得到一个名为first.wasm
的文件。我们在JavaScript加载并运行test
函数。
ts
function fetchAndInstantiate(url: string, importObject?: any): Promise<any> {
return fetch(url)
.then(response => response.arrayBuffer())
.then(bytes => WebAssembly.instantiate(bytes, importObject))
.then(results => results.instance);
}
fetchAndInstantiate('./assembly/first.wasm').then(function (instance) {
console.log(instance.exports.test());
});
我们可以成功的看到浏览器的控制台中打印了3。
接下来,我们会逐渐的往我们的WASM代码中增加一些复杂的特性。接下来是在WASM中增加调用函数的功能:
调用函数
众所周知,一个复杂的APP或者复杂的函数一般都是由若干个简单的函数组成,我们通过恰当的组织和调用这些简单功能的函数来完成一系列复杂的操作。
同样的,在WASM中,我们也可以使用这样的方式。我们利用add
函数,来完成一个3个数相加的功能。
wasm
(module
(func $add (param $a i32) (param $b i32) (result i32)
local.get $a
local.get $b
i32.add
return
)
(func (export "add3") (param $a i32) (param $b i32) (param $c i32) (result i32)
local.get $a
local.get $b
call $add
local.get $c
call $add
return
)
)
上述代码中形如 $add
表示该函数的别名,同样的(param $a i32)
中的 $a
表示的是参数的别名,方便我们在调用函数时和获取函数参数时使用。
比如,我们要获取函数的参数时可以使用如local.get $a
这样的语句。它表示获取函数中的名为 $a
的参数并将其压入栈中。
在WASM中对函数进行调用是一件非常简单的事情,我们直接使用 call
指令即可,根据函数的参数个数,WASM会从栈中弹出相应个数的值,并传入到对应的函数中。
如果弹出来的值与函数的签名不匹配的话,会发生什么事情呢?关于这个问题读者可以自行尝试一番。
从JavaScript导入函数
到目前为止,我们已经学会了如何在JavaScript中调用WebAssembly中的函数。那么我们是否能够在WASM中调用JavaScript中的函数呢?答案是肯定的。我们能够在WASM中调用JavaScript函数,比如我们想要在WASM中调用浏览器的console.log
打印日志的函数。
这里不得不得提一点的是,WASM中只提供了4种基本的数据类型,分别是:i32, i64, f32, f64
,它们分别表示32位整形、64位整形、32位浮点数、64位浮点数。
WASM中并没有字符串这类数据类型,所以我们不能够在WASM中直接打印字符串,所以console.log('hello world!')
这类的语句在wasm中是错误的!
WASM中有一个可以导入JavaScript函数的方法,我们看一下下面这个例子:
wasm
(module
(import "console" "log" (func $log (param i32)))
(func (export "logNum")(param $a i32)
local.get $a
call $log
)
)
我们可以看到上面的代码中我们使用了 import
来导入外部的函数,它的语法形式如下:
wasm
(import "MODULE_NAME" "ENTITY_NAME" (func $identifier (param i32) (result i32)))
接着,我们还是对这个WASM进行编译,然后使用上面的导入WASM的函数在浏览器中运行代码,但是我们发现浏览器抛出了一个错误:
Uncaught (in promise) TypeError: WebAssembly.instantiate(): Imports argument must be present and must be an object.
由于WASM中要求我们需要导入模块名字为console
, 方法名字为log
的函数,所以我们在实例化WASM模块时需要提供相应的函数。
ts
const importObj = {
console: {
log: console,
},
};
function console(v: number) {
console.log(v);
}
fetchAndInstantiate('./assembly/first.wasm', importObj).then(function (
instance
) {
instance.exports.logNum(5);
});
我们修改加载WASM的函数如上,我们就可以利用WASM中的函数打印任意的整形数字了。
WebAssemly 内存
上面的例子仅仅只是打印了整形数字,如果我们想要实现一个打印字符串的WASM函数又该怎么做呢?
打印字符串的方式取决于我们如何对字符串进行编码,如果我们仅仅只是打印ASCII码表示的字符,我们可以认为一个字符仅仅只占据一个字节。而如果使用UTF-8编码的话,对于中文这类字符可能占据的就不止1个字节了。所以我们还需要对文本进行编码和解码。
为了简单起见,我们先看如何在WASM中写入字符串,然后通过js函数打印出来。
我们向内存中写入 "hello" 字符串的ASCII码,通过这段代码可以找到对应的ASCII码:
js
"hello world".split('').map(item => item.charCodeAt())
//结果:[104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]
现在的问题是,我们要将这串数字写入到哪儿呢?
写入到堆栈中?现目前WASM只支持返回单个值,所以写入堆栈中是不合适的。
在WASM中,还可以将数据保存到内存中。然后JavaScript也可以读取这段内存中的值。这就是WASM中的memory 其用法如下:
wasm
(memory $mem 1)
memory 关键字声明了一段内存,$mem
是它的别名,1表示内存的初始大小,它的单位是"页",1页内存等于64KB。为什么我要说是初始大小?因为这段内存并不是一成不变的,而是可以增长的。如何增长的问题我们暂且不表。
另外,为了可以让JavaScript访问这段内存空间,我们还需要使用export
将其导出:
wasm
(export "mem" (memory $mem))
紧接着,我们往内存中写入数据:
wasm
(func (export "writeHello") (result i32)
i32.const 0
i32.const 104
i32.store
i32.const 1
i32.const 101
i32.store
i32.const 2
i32.const 108
i32.store
i32.const 3
i32.const 108
i32.store
i32.const 4
i32.const 111
i32.store
i32.const 5
i32.const 0
i32.store
i32.const 0
return
)
在上面这段代码中,大部分是重复的代码,我们选取一段来进行说明
wasm
i32.const 0
i32.const 104
i32.store
前面两句之前我们讲过了,就是往堆栈中压入0 和104两个数字
i32.store
是一个我们新学习的指令,i32.store
会取出栈中的2个值,第一个值0表示要往内存中写入的位置,104则是往内存中写入的值。
剩下的部分则与第一部分一样,只是改变了偏移位置以及写入的值。
最后,我们往堆栈中压入了0,然后将其返回,因为我们开始写入的地址就是0,我们需要告诉JavaScript从哪里开始读取内存。
我们编译这段程序,并在浏览器中运行。
可以看到,我们导出的内容多了一个名为 mem
的 Memory
对象。其大小为65536,恰好为64KB。
紧接着我们调用 writeHello
方法。再查看内存中的值:
可以看到,内存中已经被写入了hello的ASCII码。
在js中,我们可以这样写:
ts
fetchAndInstantiate('./assembly/first.wasm', importObj).then(function (
instance
) {
const offset = instance.exports.writeHello();
const mem = instance.exports.mem;
const view = new Uint8Array(mem.buffer);
const strArray = view.subarray(offset, 5);
const str = String.fromCharCode(...strArray);
console.log(str);
});
最终的结果也是打印出了 hello
字符串。
使用内存的另一种方法
除了在WASM中使用memory
关键字来使用内存,我们同样可以从外部"导入内存"。如下:
wasm
(import "js" "mem" (memory 1))
那么,我们的js代码的importObject
需要同步修改一下:
ts
const importObj = {
console: {
log: consoleLogString,
},
js: {
mem: memory,
},
};
其他的用法与之前一般无二。
data段
data段允许我们直接往内存中指定的偏移处写入数据,这与原生程序的.data
段类似。在wasm的使用方法如下:
wasm
(data (i32.const 0) "hello")
这样,我们就不用一个个通过i32.store
费劲的往内存中写入数据了。
小结
今天讲解的重点可以概括为以下:
- WASM是一个堆栈式的虚拟机
- WASM可以通过import的方式来执行JavaScript提供的函数,和使用JavaScript导入的内存
- WASM只有i32, i64, f32, f64四种基本数据类型
- data段可以直接往内存中写入数据
今天我们关于WAT格式的介绍就先到此为止,还剩余关于Table的部分没有讲解,这一部分我们放在下个章节再继续讲解,接下来会为大家带来一个使用WAT进行编程的实例。
如果你觉得本文有用,不要忘了给作者点个赞👍🏻。