基本语法

在深入学习了如何生成与使用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),其中xy都是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,需要:

    1. 将数字2和3压入栈中
    2. 调用add指令
    3. 虚拟机从栈上弹出两个数字,也就是2和3
    4. 将其相加
    5. 把结果5压入栈中
  • 对于寄存器机而言,我们需要维护一系列寄存器。add指令需要三个寄存器编号作为参数。我们若想执行2 + 3,需要:

    1. 先将2放入0号寄存器,然后将3放入1号寄存器,再将2号寄存器作为返回值存放的寄存器
    2. 将这3个寄存器编号,也就是0、1和2作为参数,调用add指令
    3. 虚拟机去相应的寄存器中寻找值
    4. 将其相加
    5. 把结果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中,整型往往需要有符号信息(如usizeisize),但是在底层中,我们可以举两个例子。

有无符号结果一致

在二进制层面,有符号整型往往是通过「补码」这种编码格式来存储,而无符号整型则直接原封不动存储就行。关于这种编码格式,各种本科的基础课里已经有了很多讲解。这里我们只需要知道一件事:

我们设计的编码格式非常强大,强大到:我们通过CPU的加法器将两个寄存器的值a和b相加,得到c:

  • 如果将a和b看作有符号整数的编码,那么将c按照有符号整数解码,就是之前两个有符号整数相加的和
  • 如果将a和b看作无符号整数的编码,那么将c按照无符号整数解码,就是之前两个无符号整数相加的和

也就是说,我们不需要有符号整数一个加法器、无符号整数一个加法器,而是一个加法器,就可以解决所有整型相加的问题,并且其有无符号,与底层CPU系统不再有关。

因此,在WASM层面也是一致的。这里的i32i64i,只是整数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_ui32.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这个标识符。