基本语法
在深入学习了如何生成与使用WASM程序之后,我们已经有了许多模板,比如Rust生成WASM的,比如Web使用WASM的。因此,接下来我们就可以完全关注在WASM代码本身,一切新学习到的知识都可以通过之前拥有的模板来验证、测试。
从本章开始,我们将学习WASM的基本语法。在本章中,我将介绍一些最基础的WASM的语法。这些基础知识往往比较零散,之间的联系并不是十分紧密,但如果单独一章介绍一个的话,又不足以支撑篇幅。因此,本章就将那些基础的、小块儿的知识放在一起来介绍。
我们仍然以Hello world一章中最基本的程序library.wat
为例:
(module ;; Define a module
;; Define a function with name `add`, two parameters of type i32, and returns i32
(func $add (param $left i32) (param $right i32) (result i32)
local.get $left ;; Push parameter `left` to stack
local.get $right ;; Push parameter `right` to stack
i32.add ;; Consume two values at stack top, and push the sum to stack
)
(export "add" (func $add)) ;; Export this function with symbol "add"
)
注释
首先,我们第一眼看这个代码,就可以立刻明白,在WASM中,以两个分号;;
打头的是注释。当然,C语言中的块注释/* ... */
,WASM中也有类似的(; ... ;)
:
(module (; This is comment ;))
因此,在仔细研究WASM的语法结构的时候,可以在脑中直接把注释部分忽略,也就是下面这个样子:
(module
(func $add (param $left i32) (param $right i32) (result i32)
local.get $left
local.get $right
i32.add
)
(export "add" (func $add))
)
S表达式
熟悉Lisp语言的开发者在看到WASM的文本格式代码时,肯定第一眼就会说,这不就是S表达式(S-Expression)嘛!没错,WASM的文本格式,其整体而言是以S表达式的形式组织的。
严格来说,一个S表达式的定义为:
- 一个原子元素,或
(x y)
,其中x
和y
都是S表达式
这种格式非常适合表示树结构,在编程语言中,非常常见的树结构就是抽象语法树。例如,在Lisp中:
(* 5 (+ 7 3))
意思就是5 * (7 + 3)
。通过S表达式,Lisp的代码可以轻松地解析为抽象语法树。
那我们就以S表达式的眼光,来看看我们的WASM代码的结构:
- 从最外层来看,是一个
module
,其有两个组件:一个func
,一个export
。- 对于
func
来说,其有三个子组件:两个param
和一个result
。其余部分均可以看做原子元素 - 对于
export
来说,其有一个子组件func
。
- 对于
因此,从树结构的角度来理解的话,这段WASM代码,其最大深度为3,也就是可以看做这种形状:
module
|------func
| |-------param
| |-------param
| |-------result
|
|------export
|-------func
模块
在WASM代码的顶层,是module
。WASM规定,一个WASM程序对应一个WASM模块。因此,在我们的WASM代码中,顶层永远是module
,并且不允许出现多个module
。
事实上,在我们之前通过Rust使用WASM的过程中,经常出现Module
类型,对应一个WASM模块,这就是对应的其代码中的根结点module
。
函数
接下来,我们就好好研究一下WASM的函数是怎么写的。在WASM中,一个模块中可以有任意多个函数,而下面我们研究研究之前代码里的函数
(func $add (param $left i32) (param $right i32) (result i32)
local.get $left
local.get $right
i32.add
)
标识符
首先,我们看到,紧跟在func
后面的,是$add
。在WASM中,以$
打头的称为标识符,它和我们在高级编程语言里遇到的标识符有着同样的作用。也就是说,func $add
就代表这个函数名字叫add。
有两点需要注意的。首先,标识符不是必须的。标识符的存在,只是为了方便后续对这个元素的引用。例如,我们可以看到,在我们的WASM代码中,最后的export
一段,引用了我们之前定义的函数func $add
。因此,我们才必须给这个函数一个名字$add
。如果我们在整个代码中,不需要引用这个函数,那我们无需给这个函数名字,可以直接写成
(func (param $left i32) (param $right i32) (result i32)
local.get $left
local.get $right
i32.add
)
第二点,标识符不是必须出现在二进制格式中的。和高级编程语言一样,标识符只是方便开发者进行编程。而一个标识符究竟会不会出现在生成的二进制镜像中,这取决于开发者的意愿。如果我们想将这个标识符导出,那么这个标识符就可以以字符串的形式出现在二进制镜像中。我们WASM代码的最后一段的export
就做了这个事。
除了使用标识符以外,我们还可以用索引来引用WASM中的元素。我们提到,二进制镜像中不会包含标识符,只会包含开发者手动指定的导出符号。那么,如果我们将一个文本格式的WASM程序转译成二进制格式,然后再转译回文本格式,那么标识符会变成什么呢?
我们可以手写一个index.wat
:
(module
(func $add (param $left i32) (param $right i32) (result i32)
local.get $left
local.get $right
i32.add
)
(func $sub (param $left i32) (param $right i32) (result i32)
local.get $left
local.get $right
i32.sub
)
(export "sub" (func $sub))
)
这里声明了两个函数,导出的是第二个函数$sub
,因此更方便我们理解索引结构。我们使用如下指令:
wat2wasm index.wat -o index.wasm
wasm2wat index.wasm -o index2.wat
我们查看index2.wat
的内容,会发现是:
(module
(type (;0;) (func (param i32 i32) (result i32)))
(func (;0;) (type 0) (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add)
(func (;1;) (type 0) (param i32 i32) (result i32)
local.get 0
local.get 1
i32.sub)
(export "sub" (func 1)))
首先先不用管type
相关的语句,这之后马上就会提到。我们发现,原先在函数定义时的标识符$add
和$sub
,由于没有记录到二进制镜像中,因此在还原的时候,被替换为了块注释(;0;)
和(;1;)
。这些注释相当于空字符,不会对语义产生影响,所以我们可以忽略。
但是,在最后的导出语句中,使用了(func 1)
来替代(func $sub)
。这是因为,在WASM中,同级同属性的节点会自动拥有从0开始的索引。说得直白一点,这个WASM程序有两个函数,因此自动地,第一个函数有索引值0,第二个函数有索引值1。我们可以直接通过索引值来引用这个函数,所以export
语句就可以通过(func 1)
引用第二个函数,也就是我们先前定义的sub
。
除了函数之外,几乎所有的元素都会有其索引值,我们也可以在这个代码中看到(type 0)
、(local.get 0)
等语句,这就是索引值的使用。
签名
在func $add
之后,是声明这个函数的参数、返回值类型。这里我们可以很直观地看到,这个函数接收两个i32
类型的参数,返回一个i32
类型的值。一个函数的参数+返回值类型,称为这个函数的签名(Signature)。
对于参数来说,正如我们之前提到的,如果一个参数不需要用名字去引用它,就可以省略相应的标识符,例如(param i32)
。如果一个函数的参数都没有标识符,我们有一个语法糖:
(func $add (param i32 i32) (result i32)
;; ...
)
此外,我们也可以额外加一个type
语句,用来double check一下这个函数的签名的正确性(只会在生成二进制程序时被检查):
(module
(type $add_type (func (param i32 i32) (result i32)))
(func $add (type $add_type) (param $left i32) (param $right i32) (result i32)
local.get $left
local.get $right
i32.add
)
(export "add" (func $add))
)
这也解释了我们在之前,将生成的二进制程序还原成文本形式时出现的type
语句。
栈机
我们接下来就重点关注其函数体,也就是其中的指令部分:两个local.get
和一个i32.add
。
在了解具体指令之前,我们首先需要知道「栈机」(Stack machine)和「寄存器机」(Register machine)。
计算模型
对于一个基于虚拟机的编程语言(例如Java基于JVM,Rust、C/C++可以基于WASM),所谓的「虚拟机」就是指,输入其自定义的指令字节码(如JVM字节码、WASM二进制表示等),虚拟机将根据指令字节码,执行相应的指令。在我们实现这样一个虚拟机的时候,往往需要设计一种「计算模型」(Computational model)。在虚拟机的常见实现中,主要分为两种计算模型:「栈机」和「寄存器机」。
例如,我们希望我们的虚拟机能够实现加法add
指令:
-
对于栈机而言,我们需要在整个执行过程中,维护一个操作数栈。
add
指令不需要显式给出参数。我们若想执行加法功能,例如实现2 + 3
,需要:- 将数字2和3压入栈中
- 调用
add
指令 - 虚拟机从栈上弹出两个数字,也就是2和3
- 将其相加
- 把结果5压入栈中
-
对于寄存器机而言,我们需要维护一系列寄存器。
add
指令需要三个寄存器编号作为参数。我们若想执行2 + 3
,需要:- 先将2放入0号寄存器,然后将3放入1号寄存器,再将2号寄存器作为返回值存放的寄存器
- 将这3个寄存器编号,也就是0、1和2作为参数,调用
add
指令 - 虚拟机去相应的寄存器中寻找值
- 将其相加
- 把结果5存到2号寄存器中去
熟悉Intel的汇编以及调用约定的开发者想必会有一些共鸣,从某种意义上,32位x86的调用约定类似一种栈机(但其返回值不通过栈传递),而64位AMD64的调用约定类似一种寄存器机。
此外,还需要注意的是,这里讲的栈机中的栈,和我们真正编程过程中使用的,进程的栈并不是同一个东西。进程的栈在执行过程中,还要把什么返回地址、帧指针之类的全压到栈上去,是一种混合了「调用栈」与「操作数栈」的模型。而这里讲的栈机的栈,仅仅是用来传递参数和返回值的。
当然,还有一点值得指出。无论是栈机还是寄存器机,都只是一种「模型」。也就是说,通过这种模型,可以很好地定义其执行的方式、顺序,但真正的虚拟机实现里,是不一定需要维护一个真实的栈或者一系列寄存器的,可以通过优化去做更多的事。
WASM栈机
WASM大致是一个栈机(但实际上,WASM的局部变量机制导致它不是一个真正的栈机,详情可见WebAssembly Is Not a Stack Machine),目前我们可以粗略地用栈机的眼光来理解WASM的函数。
回到我们之前的add函数。从栈机的角度来简单地解释一下这个函数的意思:
-
local.get $left
将第一个参数压栈
-
local.get $right
将第二个参数压栈
-
i32.add
从栈上弹出两个参数,将其求和,然后将结果压栈
我们之前声明了add
函数,可以注意到,我们说了它有一个i32
类型的返回值。然后在这个函数结束的时候,它的栈上正好还剩这一个结果。这个最终剩在栈上的值,就会成为这个函数的返回值。这种栈的「平衡性」,也是WASM引擎的「验证」阶段可以静态完成的一个重要的事。
但我们在写WASM的过程中,有时候很难保证,函数结束的时候,恰好栈上剩下的值的个数等于返回值的个数。有可能有一些中间变量也在栈上,只不过不在栈顶。为了保证栈的平衡性,我们可以使用return
或者drop
指令。return
指令首先会查看当前函数返回值的个数,然后从栈上弹出相应个数个值作为返回值,剩下的全部丢掉。而drop
指令则一般用在没有返回值的函数中,直接将当前栈上所有值丢掉。
此外,有一个非常重要的,值得注意的事:参数传递的顺序。我们可以发现,与x86不同,我们是从左往右压栈。也就是说,接下来的指令的第一个参数先压栈,最后一个参数最后再压栈。这是值得注意的。
另外,我们需要强调的是,尽管在上述指令的解释中,我们用了「压栈」,但实际上这些指令本身是不会压栈的,也没有一个指令专门是「压栈」。只不过是,WASM在执行每条指令时,将根据这条指令的特性,调整栈。例如,WASM在执行时,发现这条指令是local.get
,它不接受参数,返回一个值,因此将这条执行执行后的值放到栈上;在执行到i32.add
时,发现这条指令接受两个参数,返回一个值,因此把栈上弹两个值出来,作为参数,执行后把结果存到栈上。
在真正书写WASM的过程中,我们有一个语法糖(一般被称为折叠格式(folded form)):
(func $add (param $left i32) (param $right i32) (result i32)
(i32.add (local.get $left) (local.get $right))
)
这个语法一看就和我们正常的高级语言的语法类似了,但是这是个语法糖,其底层还是会变成之前的栈机形式,不过这种写法更利于人类阅读和书写。
指令
在WASM中,大部分的指令都分为两个部分,前半部分表示指令所属的类别,后半部分表示指令的内容。例如,之前我们遇到的local.get
,其类别属于local
,内容是获取local的值;i32.add
,类别属于i32
,内容是将两个i32的值相加。
WASM的指令数目不多,和AArch64类似,基本属于精简指令集了,不会有非常复杂的指令。具体的指令列表可以参考官方文档Index of Instructions。在本系列中,不会集中地讲解指令集,而是会在需要的地方,详细解释与某些概念息息相关的指令。
基本的数字类型
WASM最常做的事,就是进行大量的数字相关的计算。在WASM中,有以下四个数字类型:
-
i32
32位整数类型
-
i64
64位整数类型
-
f32
32位单精度浮点型
-
f64
64位双精度浮点型
熟悉底层,特别是LLVM IR的开发者应该更方便理解这里的概念,因为和LLVM IR类似,WASM中的整型有无符号,并不是记录在类型信息中,而是根据不同的指令加以区别。对于了解高级语言的开发者来说,如Rust中,整型往往需要有符号信息(如usize
和isize
),但是在底层中,我们可以举两个例子。
有无符号结果一致
在二进制层面,有符号整型往往是通过「补码」这种编码格式来存储,而无符号整型则直接原封不动存储就行。关于这种编码格式,各种本科的基础课里已经有了很多讲解。这里我们只需要知道一件事:
我们设计的编码格式非常强大,强大到:我们通过CPU的加法器将两个寄存器的值a和b相加,得到c:
- 如果将a和b看作有符号整数的编码,那么将c按照有符号整数解码,就是之前两个有符号整数相加的和
- 如果将a和b看作无符号整数的编码,那么将c按照无符号整数解码,就是之前两个无符号整数相加的和
也就是说,我们不需要有符号整数一个加法器、无符号整数一个加法器,而是一个加法器,就可以解决所有整型相加的问题,并且其有无符号,与底层CPU系统不再有关。
因此,在WASM层面也是一致的。这里的i32
、i64
的i
,只是整数integer的代表,不代表有无符号。当我们定义了add函数:
(func $add (param $left i32) (param $right i32) (result i32)
local.get $left
local.get $right
i32.add
)
i32.add
指令也不再指定符号信息,因为与底层无关。而其究竟有无符号,是根据使用这个的高级语言来决定的。在Rust层面,我们既可以将其看作一个(i32, i32) -> i32
的函数,也可以看做一个(u32, u32) -> u32
的函数。
有无符号结果不一致
在加法层面,有无符号结果是一致的。然而有些指令则不一样。例如在整数除法时,根据符号不同,我们需要将结果按不同的方式向上或者向下取整。
因此,在WASM指令集层面,提供了两个指令i32.idiv_u
和i32.idiv_s
,分别提供无符号整数除法和有符号整数除法。无符号整数除法将两个操作数看做无符号整数,有符号整数除法则是看做有符号整数。
从这里,我们就可以看出,在WASM层面,与LLVM IR类似,整型变量有无符号,不存储在类型信息中,而是根据指令的不同来决定的。
导出
在最后,我们使用了
(export "add" (func $add))
来表示,我们要导出之前声明的一个叫$add
的函数,并且它的导出的名字是"add"。事实上,还有一种更简单的写法:
(module
(func (export "add") (param $left i32) (param $right i32) (result i32)
local.get $left
local.get $right
i32.add
)
)
将最后一个export语句直接移到函数的定义中。这样的话,就不需要在后面引用这个函数,从而不需要再给这个函数命名了,所以我们自然也省去了$add
这个标识符。