控制语句与基本块

之前的几章中,我们反复提到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_uge代表大于等于(greater than or equal to),而u代表将两个操作数看做无符号整数(之前我们提过,WASM的整数的有无符号是根据指令来决定的)。类似地,我们一共有gegtltleeqne等大小比较,与su的组合。

在了解了函数调用与选择语句之后,我们就可以实现一个简单的递归形式的阶乘函数了:

(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中的跳转包括brbr_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
)

我们通过blockloop可以附带一个标识符,然后在br或者br_if的指令中,操作数就是相应的标识符,也就是br_if $my_blockbr_if $my_loop

这两者有什么区别呢?很简单,br以及br_if,在block中将跳转到block之后的指令,而在loop中则会跳转到loop开头的第一条指令。我们可以近似地理解成,在block中充当了C语言中break的作用,而在loop中充当了C语言continue的作用。并且一点很重要的是,blockloop本身并不是循环,也就是说,如果内部没有br或者br_if,则执行完这个基本块就不会重复执行,而是接下来执行下一条指令

基本块的参数和返回值

WASM中的blockloop可以拥有返回值,而Multi-value proposal这个提案则让基本块可以拥有参数。由于这个提案已经被广泛地实现,所以接下来,我们就介绍一下,blockloop的参数及返回值相关特性。

简单来说,我们可以像定义函数一样,给基本块定义参数和返回值,如:

(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的开头。同时,brbr_if的语义要求,将此时基本块栈上的值只保留基本块参数个数。简单来说,由于$main_loop接受两个参数,而在消耗掉栈顶的1后,栈上还有四个值(1, 3, 3, 2),因此3和2会被作为下一次循环时,这个基本块的参数,而剩下的1和3就被抛弃了,所以此时栈上就只剩下3和2了。