控制语句与基本块
之前的几章中,我们反复提到WASM的局部变量机制与栈机、寄存器机的特性,其中我们指出,早期WASM的基本块语法设计导致局部变量的使用,但后来已经解决。在这一章里,我们将介绍在编程中不可或缺的元素——控制语句,并且用实际的例子,给大家分析基本块与寄存器机、栈机。
函数调用
首先,我们来讲讲函数调用,从某种意义上,它也是一种控制语句。在WASM中,我们可以使用call
语句进行函数调用。它与我们在高级语言中常见的函数调用几乎没有区别,唯一要注意的就是对于栈的控制。举一个实际的例子,我们会更好理解:
(module
(func $foo (param i32 i32) (result i32 i32 i32)
;; Do something...
)
(func $bar
i32.const 1
i32.const 1
call $foo
;; Here we got three elements on the stack
)
)
我们定义了一个接受两个参数,返回三个值的函数$foo
(函数返回多个值的提案Multi-value proposal已经成为标准化特性而被广泛实现了),当我们在函数$bar
中调用$foo
函数时,首先得确保栈上已经有两个元素了。在调用$foo
之后,栈上的两个元素被这个函数消耗掉了(也就是弹栈走了),然后$foo
返回了三个值,都存储在栈上,所以此时栈上有三个元素。
此外,值得一提,函数调用指令也适用我们之前提到的语法糖,也就是:
(func $bar
(call $foo (i32.const 1) (i32.const 1))
;; Here we got three elements on the stack
)
在了解了函数调用的栈性质之后,我们其实可以模拟出之前提到的peek和poke了:
(module
(func $peek0 (param i32) (result i32 i32)
local.get 0
local.get 0
)
(func $peek1 (param i32 i32) (result i32 i32 i32)
local.get 0
local.get 1
local.get 0
)
)
$peek0
函数做的事是将距离栈顶0个元素(也就是栈顶元素)复制并压栈,而peek1
则是将距离栈顶为1的元素复制并压栈。
选择语句
熟悉底层汇编的开发者一定了解,对于我们在高级语言中常见的选择语句if
...else
,在大部分的汇编架构中,其往往是实现成针对特定flag的标签跳转。但在WASM中,实际上提供了更高层面的抽象,也就是接近高级语言的if
...else
...end
语句,即:
if
;; True branch
else
;; False branch
end
在执行这个结构时,会从栈上将栈顶元素弹栈,若其为0,则执行False分支,否则执行True分支,非常符合直觉。
我们在高级语言中使用选择语句的时候,往往是通过比大小来判断执行哪个分支,其本质上就是将大小转变为布尔值,然后根据布尔值来做判断。在WASM中,也提供了一系列将大小转变为布尔值的指令,例如
i32.const 1219
i32.const 323
i32.ge_u
上述程序将得到1。其意思是,i32.ge_u
的ge
代表大于等于(greater than or equal to),而u
代表将两个操作数看做无符号整数(之前我们提过,WASM的整数的有无符号是根据指令来决定的)。类似地,我们一共有ge
、gt
、lt
、le
、eq
、ne
等大小比较,与s
、u
的组合。
在了解了函数调用与选择语句之后,我们就可以实现一个简单的递归形式的阶乘函数了:
(func $factorial_recur (param $input i32) (result i32)
(i32.le_u (local.get $input) (i32.const 1))
if
i32.const 1
return
end
(i32.mul
(local.get $input)
(call $factorial_recur (i32.sub (local.get $input) (i32.const 1)))
)
)
上述代码非常简洁清楚,如果不明白,我们可以用类似的C语言来帮助理解:
unsigned int factorial_recur(unsigned int input) {
if (input <= 1) {
return 1;
}
return input * factorial_recur(input - 1);
}
基本块与跳转
除了选择语句以外,我们常见的循环语句,在底层往往需要通过跳转来实现。在WASN中,循环语句的跳转往往与基本块相绑定。
具体而言,WASM中的跳转包括br
和br_if
,前者相当于底层汇编指令中的无条件跳转,例如AMD64中的jmp
,而br_if
则是有条件跳转,其工作方式与WASM中的if
类似,消耗掉栈顶的一个值,判断其是否为true,如果为true则跳转。这两者使用起来很类似,所以接下来就以br_if
为例。
在WASM中,如果需要使用这类跳转,往往必须在一个基本块block
或者loop
中,具体而言,是这样的一个结构:
(func
;; Do something
block $my_block
;; Do something inside this block
br_if $my_block
;; Do something else
end
;; ...
loop $my_loop
;; Do something inside this loop
br_if $my_loop
;; Do something else
end
)
我们通过block
和loop
可以附带一个标识符,然后在br
或者br_if
的指令中,操作数就是相应的标识符,也就是br_if $my_block
和br_if $my_loop
。
这两者有什么区别呢?很简单,br
以及br_if
,在block
中将跳转到block
之后的指令,而在loop
中则会跳转到loop
开头的第一条指令。我们可以近似地理解成,在block
中充当了C语言中break
的作用,而在loop
中充当了C语言continue
的作用。并且一点很重要的是,block
和loop
本身并不是循环,也就是说,如果内部没有br
或者br_if
,则执行完这个基本块就不会重复执行,而是接下来执行下一条指令。
基本块的参数和返回值
WASM中的block
和loop
可以拥有返回值,而Multi-value proposal这个提案则让基本块可以拥有参数。由于这个提案已经被广泛地实现,所以接下来,我们就介绍一下,block
和loop
的参数及返回值相关特性。
简单来说,我们可以像定义函数一样,给基本块定义参数和返回值,如:
(func $my_func
i32.const 1
i32.const 2
block $my_block (param i32 i32) (result i32)
;; ...
end
)
在这里,我们定义了一个接受两个i32
参数,返回一个i32
的基本块$my_block
。但是,这里的语义实际上是与函数不太一样的,参数并不可以看做一个局部变量,而是需要用栈机的视角去看待。
我们可以粗略地理解成,$my_block
这个基本块也有一个栈,这个栈位于$my_func
的栈内。当我们进入这个基本块之前,函数栈上已经有了1和2这两个元素。当我们进入这个基本块时,1和2这两个元素就归属于基本块的栈了,也就是说,在基本块的开头,栈上就有了两个元素1和2。
由于我们声明这个基本块需要返回1个元素,因此当这个基本块结束时,这个基本块的栈上应该只剩1个元素,并且返回后,这1个元素就归属回函数栈,并且之前的元素1和2,由于归属在基本块,所以可以供其操作,把栈上的元素从开头的两个,变成最终只剩一个。
换句话说,如果这个函数在进入基本块前,栈上已经有了3个元素,而我们的基本块接受2个参数,那么基本块是没有办法访问函数栈的第一个元素,只能访问后两个元素;类似地,基本块结束时,也必须保证栈上元素的数量等于其返回的值的数量。
这看上去似乎很复杂,那我们就通过两个例子来学习一下。
寄存器机形式的阶乘函数
首先,我们来看一下在WASM中,以迭代方式实现的阶乘函数。
(func $factorial_iter_register (param $input i32) (result i32)
(local $prod i32)
(local.set $prod (i32.const 1))
loop $main_loop (result i32)
(i32.le_u (local.get $input) (i32.const 1))
if
local.get $prod
return
end
(local.set $prod
(i32.mul (local.get $input) (local.get $prod))
)
(local.set $input (i32.sub (local.get $input) (i32.const 1)))
br $main_loop
end
)
这个例子看上去不算难以理解,其可粗略地看做下面的C程序:
unsigned int factorial_iter_register(unsigned int input) {
unsigned int prod = 1;
while (1) {
if (input <= 1) {
return prod;
}
prod = input * prod;
input = input - 1;
}
}
在这个例子中,有两点值得注意。
首先,这个例子中的基本块$main_loop
较为简单,没有参数,所以容易理解一些。由于其返回一个i32
类型的值,因此在这个基本块结束的时候,必须要求其栈上只剩1个值。在这里,我们通过return
指令巧妙地保证了这一点。
其次,这个例子大量使用了局部变量用于存储临时值,因此是以寄存器机的形式来实现的这一功能。
栈机形式的阶乘函数
事实上,在基本块可以接受参数之后,我们就可以用纯栈机的形式实现阶乘函数了,不过需要借助我们之前模拟实现的$peek0
和$peek1
(这段代码改编自Multi-value proposal中的官方示例):
(func $factorial_iter_stack (param $input i32) (result i32)
i32.const 1
local.get $input
loop $main_loop (param i32 i32) (result i32)
call $peek1
call $peek1
i32.mul
call $peek1
i32.const 1
i32.sub
call $peek0
i32.const 1
i32.gt_u
br_if $main_loop
call $peek1
return
end
)
这一段代码乍看很难理解,那么,为了方便理解,我们不妨举一个例子。当$input
为3时,我们来看看,这个函数的栈究竟是如何变化的:
[i32.const 1] 1
[local.get $input] 1 3
[INSIDE $main_loop] 1 3
[call $peek1] 1 3 1
[call $peek1] 1 3 1 3
[i32.mul] 1 3 3
[call $peek1] 1 3 3 3
[i32.const 1] 1 3 3 3 1
[i32.sub] 1 3 3 2
[call $peek0] 1 3 3 2 2
[i32.const 1] 1 3 3 2 2 1
[i32.gt_u] 1 3 3 2 1
[br_if $main_loop YES] 3 2
[INSIDE $main_loop] 3 2
[call $peek1] 3 2 3
[call $peek1] 3 2 3 2
[i32.mul] 3 2 6
[call $peek1] 3 2 6 2
[i32.const 1] 3 2 6 2 1
[i32.sub] 3 2 6 1
[call $peek0] 3 2 6 1 1
[i32.const 1] 3 2 6 1 1 1
[i32.gt_u] 3 2 6 1 0
[br_if $main_loop NO] 3 2 6 1
[call $peek1] 3 2 6 1 6
[return] 6
可以看到,在最终return
的时候,返回的值是6,确实计算成功了3的阶乘。
这里有几点需要注意的。在第一次进入$main_loop
的时候(第三行),由于这个基本块接受两个参数,因此此时栈上的1和3就归属于了$main_loop
这个基本块,从而之后的$peek1
操作,才能访问这两个元素。
在第一次条件跳转br_if $main_loop
时,首先消耗掉栈顶的值,也就是1,这个值是刚刚i32.gt_u
得到的比较结果。由于其为1,所以需要跳转到这个loop的开头。同时,br
和br_if
的语义要求,将此时基本块栈上的值只保留基本块参数个数。简单来说,由于$main_loop
接受两个参数,而在消耗掉栈顶的1后,栈上还有四个值(1, 3, 3, 2),因此3和2会被作为下一次循环时,这个基本块的参数,而剩下的1和3就被抛弃了,所以此时栈上就只剩下3和2了。