变量与常量
在讲基本语法时,我们故意地忽略了函数的参数、返回值相关的深入知识。在这一章里,我们将连同这些知识一起,统一介绍在WASM中的变量与常量。
局部变量
我们之前提到过,在WebAssembly Is Not a Stack Machine一文中指出,WASM的局部变量设计让它不是一个真正的栈机,甚至可以说是一个寄存器机。同时,这也让WASM代码很难变成SSA形式,从而难以利用SSA的各种优化。那么,我们来看看,WASM的局部变量究竟是如何设计的。
在WASM中,我们可以这样声明一个函数:
(func (param $a i32) (param $b i32) (result i32)
(local $c i32)
;; ...
)
在这里,我们定义了一个函数,它接受两个i32
类型的参数,返回一个i32
类型的值,并且主动声明了一个i32
类型的局部变量。事实上,与C语言类似,除了我们主动声明的局部变量外,函数的参数也可以看做局部变量。也就是说,这个函数的局部变量,是$a
、$b
和$c
。因此,我们之前提到,除了可以通过标识符来引用WASM的元素之外,也可以通过每个元素自带的编号来引用。而对于局部变量来说,其编号是从参数开始编的。也就是说,对于我们这个函数来说,参数$a
的编号是0,而局部变量$c
的编号是2。
对于局部变量,我们最常做的两个操作是local.get
和local.set
。例如:
local.get $a
将返回局部变量$a
的值,也就是说往栈上压入一个$a
的值。
local.set $a
则是将栈顶元素弹出,并赋值给局部变量$a
。
在我们使用高级语言编程的过程中,一个常见的编程错误是使用未初始化的变量。例如,当我们在Rust中写下如下语句:
fn foo() {
let a: i32;
let b = a - 1;
}
会报错:
error[E0381]: used binding `a` isn't initialized
--> src/lib.rs:3:13
|
2 | let a: i32;
| - binding declared here but left uninitialized
3 | let b = a - 1;
| ^ `a` used here but it isn't initialized
|
help: consider assigning a value
|
2 | let a: i32 = 0;
| +++
很显然,使用未初始化的变量会造成程序错误。
在WASM中,我们会遇到同样的问题吗?我们可以编写如下程序:
(module
(func (export "local_initialize") (result i32)
(local $dummy i32)
local.get $dummy
)
)
我们并没有手动给$dummy
变量赋值,那么我们直接获取它的值会发生什么呢?我们可以把这个程序放在各种WASM引擎中运行,会发现这个函数顺利通过了「验证」阶段,并且执行的结果是0。
事实上,在WASM的核心标准中(这一段)规定了,在调用一个函数的时候,它的局部变量是已经被初始化了的。而对于我们常用的数字类型来说,其默认值就是0。因此,在WASM中,不会出现使用未初始化的局部变量的问题(顺便一提,在Function Reference Types Proposal这个提案中引入了非Null的引用类型,这种类型就没有默认值了)。
寄存器机
我们可以声明任意多个局部变量,而这些局部变量,都可以用来存放--获取值。所以,一个局部变量实际上就是一个寄存器,而我们的WASM,实际上变成了一个拥有无数个寄存器的寄存器机!
这该如何理解呢?我们知道,寄存器机就是可以把变量临时存放在寄存器中,在参与运算的时候,将寄存器的值给相应的指令即可。那么,例如对于i32.add
指令,我们可以将其操作数利用local.set
先存放在两个局部变量中。在执行指令之前,再使用local.get
将两个局部变量的值读出放在栈上,就模拟出了寄存器机。
寄存器机好不好呢?我只能说,SSA的寄存器机是好文明。所谓SSA,简单来说就是指任何一个寄存器的值都是不可变的。如果我们想改变一个寄存器的值,那最好的方法就是再用一个新的寄存器。SSA的寄存器机编写的代码,能够非常高效地进行各种程序自动分析算法,例如活性检测算法等,从而能得到更好的优化结果。LLVM IR就是一个著名的SSA的寄存器机。但很显然,WASM不是一个SSA的寄存器机。直观上来看,在对栈机编写的代码进行程序分析时,可以轻松地将其转变为SSA的寄存器机。
局部变量的必要性
为什么WASM会引入局部变量,而让它不完全是一个栈机,甚至不是SSA的寄存器机呢?从历史上看,WASM设计之初的目标并不是成为一个底层指令集,而且也没有很好的编译器实现,所以就自然引入了局部变量机制(详情可看这一节)。
但是对于这样的底层语言来说,历史原因往往并不重要,因为大部分情况下,WASM都是直接由编译器自高级语言生成。所以如果我们不想被局部变量破坏了我们的栈机,为什么不直接让编译器不生成局部变量呢?因此,这就涉及到了局部变量的必要性。也就是说,在WASM代码中,有一些事,不能通过栈机来实现,而只能通过局部变量来实现。
这里需要指出,WASM层面的局部变量的必要性,并不与高级语言层面的局部变量的必要性挂钩。例如我们的C语言程序
int a = 1 + 2;
int b = 3 * 4;
int c = a & b;
int d = 5 - 2;
int e = c / d;
尽管这里C语言中用了好多局部变量,但是这些仍然可以只用栈机来描述,也就是通过压栈、弹栈,而不通过存放寄存器,仍然能够实现这样的操作。那究竟怎样的操作不能通过栈机来描述呢?
第一个原因是语言设计上的限制。首先,在很早之前,WASM的局部作用域(比如说block
)是不能读取这个作用域开始之前的栈上的内容的,因此只能通过局部变量来传递一些值。这个问题在「WASM is not a stack machine」这篇文章里被着重强调了。
但到了2023年的今天,multi-value提案已经成了标准化的WASM特性,被各大引擎都实现了,上述这个问题也不再是问题了。那我们还有用局部变量的必要性吗?
针对这个问题,我特意请教了之前这篇文章的作者JEF。我节选一下他关于这的回信:
I believe that locals are still necessary as (as far as I know) Wasm still doesn't have any stack manipulation instructions such as peek and poke. This means that if you want to store values for later you still need locals.
简单来说,第二个问题是复用的问题。我们在编程的过程中,往往需要变量的复用,如在C语言中:
int a = 1 + 2;
int b = 3 * a;
int c = a & b;
int d = 5 - a;
int e = a + c / d;
可以看到,这里变量a
在很多语句中都被使用。但是对于栈机来说,不能保证每次在构建这些值的时候,a
的值都在栈顶。对于真正的栈机,往往会提供peek
和poke
指令。peek
可以将此时栈上指定位置的值复制到栈顶,而poke
则可将栈顶的值插入到栈的指定位置中。目前WASM并没有这样的指令,不过我们可以模拟出来,之后在控制语句与基本块一章中,我们会给出一个实际的例子。
所以在WASM中,我们仍然需要局部变量机制来存储这些值,也就是说,在计算出a
的值后,我们需要一个local.set
将其临时存储。在之后计算别的值的时候如果需要用到a
,再使用local.get
。
常量
我们之前的WASM代码中,指令的操作数最终都是通过local.get
来获得的,但是往往我们还是会需要常量的,比如说b = a + 1
,其一个操作数可以通过local.get
来获取,但是另一个操作数就需要常量1
。
在WASM中,我们可以使用
i32.const 1
这种语句来获得一个常量1。
全局变量
WASM也支持全局变量。我们可以用global
来声明,通过global.get
来获取。例如:
(module
(global $kitzuki i32 (i32.const 323))
(func $get_kitzuki (result i32)
global.get $kitzuki
)
)
我们声明了一个叫$kitzuki
的全局变量,其初始值为323(WASM的全局变量必须初始化)。随后,我们在函数$get_kitzuiki
中,使用了global.get
获取了这个全局变量的值。
对于全局变量的设置,也就是global.set
,则有一点点不太一样。正如Rust需要特别使用static mut
来声明可变的静态变量,在WASM中,我们也需要mut
来声明可变的全局变量:
(module
(global $kitzuki (mut i32) (i32.const 323))
(func $set_kitzuki
(global.set $kitzuki (i32.const 1219))
)
)
在这里,我们声明了一个可变的全局变量$kitzuki
,初始值为323。随后,我们使用global.set
给其赋值为1219。
在WASM中,全局变量和局部变量类似,也会破坏栈机的性质。不过对于目前现代化的高级语言以及高级的编程模型,我们往往并不需要全局变量。全局变量在WASM中还有一个作用是通过导入与导出,与外界传递信息。不过这个留在之后的章节再说。