Retep

WASM101 | 运行时


上个博客中介绍了 WASM 的二进制文件里的一坨坨字节是如何组成有意义的模块的。这次进入主题:WASM 的运行时——如何在电脑里运行这些字节?我们先从最基本的计算模型入手。

计算模型

WASM 的计算模型是堆栈机。

Info

计算模型(computational model)描述了如何根据一组输入值(CPU 指令)计算输出。

计算模型可以分为堆栈机(Stack machine)、累加器(Accumulator)和寄存器机(Register machine)。完成特定任务,由于计算模型不同,CPU 指令集也会产生相应的变化。

堆栈机

堆栈机是一种以栈作为主要数据结构的计算模型,它依赖于后进先出(LIFO)的原则来管理数据。堆栈机会维护一个栈,所有的操作数都默认从栈顶取出。比如我们需要计算 1+2 的值,对应到堆栈机的指令是:

PUSH 1 // 将1压入栈
PUSH 2 // 将2压入栈
ADD    // 将2推出栈,将1推出栈,计算add,将结果3压入栈

堆栈机相比累加器和寄存器机,有很多优点:

  • 零地址或少地址指令:堆栈机的指令通常不包含操作数的地址(0-operand instruction set),因为操作数是从栈顶连续弹出的。
  • 简化的指令集:由于使用了栈来存储数据,堆栈机的指令集通常比寄存器机的简单。
  • 高效函数调用:递归和函数调用在堆栈机种很自然,因为每个函数调用的局部变量和返回地址可以自然地存储在栈中。

可以通过一个简单的WASM程序看到其堆栈机的计算模型:

(i32.add
	(i32.const 1) ; 压入常数1到栈顶
	(i32.const 2) ; 压入常数2到栈顶
) ; 执行i32.add指令,计算栈顶两个元素的和

累加器

累加器使用一个特殊的寄存器(累加器)来存储结果。计算 1+2 的值,对应到累加器的指令是:

LOAD 1  // 将1加载到累加器
ADD 2   // 将累加器中的值与2相加,由于只有一个寄存器,不需要
STORE   // 将累加器中的值3存入内存

寄存器机

寄存器机使用一个多个寄存器来存储结果。计算 1+2 的值,对应到累加器的指令是:

LOAD R1, 1     // 将1加载到寄存器R1
LOAD R2, 2     // 将2加载到寄存器R2
ADD R3, R1, R2 // 将R1,R2中的值相加,并将结果存储到寄存器R3

值类型

下一个问题是,堆栈机中的栈会存放哪些元素?首先介绍 WASM 所定义的值(value)的类型,总共有以下三类:

  • 数字类型(number)
    • i32
    • i64
    • f32
    • f64
  • 向量类型(vector)
    • v128
  • 引用类型(ref) - ref.null t - ref funcaddr - ref.extern externaddr 数字类型是最简明的,有 32 位/64 位的整形/浮点型。数字类型没有正负之分,其含义有操作数决定。向量类型由 128 位的整数/浮点数组成。它是后期为了支持 SIMD 新增的类型,可以对整数/浮点数组进行 SIMD 操作来提高计算性能,通常会用在多媒体处理,加密算法和科学计算等场景。引用类型是为了帮助 WASM 操作复杂的数据结构(比如宿主的对象)。引用类型分为三种:funcref是函数引用,允许将函数作为参数传递或存储在数据结构中。externref是外部引用,这个类型可以引用任何宿主的对象。null t 表示t类型(t为函数引用/外部引用)的空引用。

分支

标签(Label) 让我们能够跳转到代码的某一位置,从而实现分支/循环/函数调用等等能力。我们在很多编程语言中都会用到标签来实现跳转,比如以下 Javascript 的例子:

let str = "";

loop1: for (let i = 0; i < 5; i++) {
  if (i === 1) {
    continue loop1; // specify to continue the statement labeld as loop1
  }
  str = str + i;
}

console.log(str);
// Expected output: "0234"

标签在 WASM 同样也是标注了代码的某个位置,配合操作数实现控制流。具体来说,每个标签类型包含了一段连续的代码,可以通过开始位置和结束位置表示(即一个start_pos和一个end_pos)。标签分为以下两种用法:

  • Block: 用来标识一个代码块,如果中途有br指令则会继续执行end_pos后面的指令
  • Loop: 用来标识一个循环,如果中途有br指令则会重新回到start_pos

由上面可见,同样一个 branch 到一个标签,block 标签和 loop 标签的行为是不一样的。这是因为 WASM 的br并不会直接跳到代码的某处,而是通过读取在栈中储存的标签来实现。在栈中,block 标签和 loop 标签会告诉br,当 branch 到自己时,应该跳转到哪个代码位置。

(block $my_block
	br $my_block
)
// <- br jumps to here


(loop $my_loop // <- br jumps to here
	br $my_loop
)

Info

循环(loop),选择(if)底层都是由分支(branch)实现的。

在二进制中,并不会有实际的$my_block这样的标识符。相反,br的会接收一个参数l,代表弹出的 label 数量。 比如如下代码:

(loop // level 1 for this br
  (block // level 0 this br
    br 1
  )
)

可以看到br接受了一个参数1, 这意味着它要弹出两个 label,第一个是内部的 block,第二个是外部的 loop。所以这复现了上述 javascript 例子中的内部if(作为内部 block)执行外部loopcontinue指令。

循环

下面是一个数到三的循环,包含了算数,逻辑计算,循环,变量等等内容,为本片博客的内容做个总结。

(module
  (func $count_to_three
    (local $i i32)  ; 定义一个名为$i的本地变量,用于计数

    ;; 设置初始的计数器值为0
    (local.set $i (i32.const 0))

    ;; 开始循环结构
    (loop $loop_label          ; **这里定义了一个label标签来标示循环的顶部**


      ;; 增加计数器的值
      (local.get $i)            ; 获取$i的当前值
      (i32.const 1)             ; 准备增加的值
      (i32.add)                 ; 执行加法
      (local.set $i)            ; 更新$i的值

      ;; 判断计数器的值是否已经达到上限
      (local.get $i)            ; 获取$i的当前值
      (i32.const 3)
      (i32.lt_s)                ; 检查$i是否小于3

      ;; **如果$i小于3,则跳转到标签 $loop_label 指向的循环顶部,继续循环**
      (br_if $loop_label)

      ;; 当计数器的值达到3时,循环结束
    )
  )
)