«

《WebAssembly标准入门》读书笔记之汇编语言(上)

image.png

—— 在汇编语言面前,一切了无秘密。

[toc]

《WebAssembly标准入门》读书笔记系列


本文是《WebAssembly标准入门》第4章节主要内容,本文将深入内部,通过系统地介绍WebAssembly汇编语言,揭示WebAssembly虚拟机的结构原理和运行机制。通过阅读本章,读者将会更深刻地理解WebAssembly编程的抽象模型。

S-表达式

WebAssembly文本格式是以S-表达式表示的。S-表达式是用于描述树状结构的一种简单的文本格式,其特征是树的每个结点均被一对圆括号“(...)”包围,结点可以包含子结点。

最简单的WebAssembly模块如下:

(module)

S-表达式中,这表示一棵根结点为module的树——尽管module下什么都没有,这仍然是一个合法的WebAssembly模块。

在WebAssembly文本格式中,左括号“(”后紧跟的是结点的类型,如刚才例子中的module,随后是由分隔符(空格、换行符等)分隔的属性和子结点列表。下面的代码描述了一个WebAssembly模块,该模块包含一个什么也没做的空函数以及属性为1的memory

(module
  (func)
  (memory 1)
)

WebAssembly文本格式中,结点类型如表所示:

image.png

数据类型

WebAssembly中共有以下4种数据类型。

i32:32位整型数。 ▪i64:64位整型数。 ▪f32:32位浮点数,IEEE 754标准。 ▪f64:64位浮点数,IEEE 754标准。

WebAssembly采用的是数据类型后置的表达方式(Go语言同样如此),如:

(param i32)

上述代码定义了i32类型的参数。

(result f64)

上述代码定义了f64类型的返回值。

对两种整型数(i32i64)来说,WebAssembly并不区分有符号整数/无符号整数,也就是说无论是有符号的32位整数还是无符号的32位整数,在WebAssembly中数据类型均为i32i64亦然)。然而某些操作需要明确区分有符号/无符号整数,在这些情况下,WebAssembly提供了有符号/无符号两个版本的指令以区分实际需要的操作。一般来说,无符号版本的指令后缀为“u”,而有符号版本的指令后缀为“s”。例如,i32.gt_u/i32.gt_s即分别为32位整数大于测试指令的无符号/有符号版本。

WebAssembly是强类型语言,它不支持隐式类型转换。

函数定义

WebAssembly模块的可执行代码位于函数中。函数的结构如下:

(func<函数签名> <局部变量表> <函数体>)

func类型标签外,函数包含3个可选的组成部分,即函数签名、局部变量表和函数体。

函数签名

函数签名表明了函数的参数及返回值,它由一系列param结点(参数列表)及result结点(返回值)构成。例如:

(func (param i32) (param f32) (result f64) ...)

上述代码中,(param i32) (param f32) (resultf64)即为函数的签名,它表示该函数有两个输入参数,第一个为i32类型,第二个为f32类型;该函数的返回值为f64类型。WebAssembly是强类型语言,因此其参数及返回值都必须显式地注明数据类型。目前WebAssembly的函数仅支持单返回值,也就是说一个函数签名中最多只能有一个result结点(未来可能会支持多返回值)。如果签名中没有result,则表明该函数没有返回值。

局部变量表

局部变量表由一系列local结点组成,例如:

(func (result f64) (local i32) (local f32) ...)

上述代码中,(local i32) (local f32)定义了两个局部变量,分别为i32类型及f32类型。与大多数语言类似,WebAssembly中局部变量仅能在函数内部使用。

函数体

函数体是一系列WebAssembly汇编指令的线性列表。例如:

(func (export "hello")
  i32.const 0 ;;pass offset 0 to js_print
  i32.const 13 ;;pass length 13 to js_print
  call $js_print
)

i32.const 0i32.const 13和call $js_print均为WebAssembly汇编指令,这3条指令构成了该函数的函数体。

函数别名

WebAssembly是通过函数索引(即函数在模块中定义的顺序)来标识它的。例如,下述两个函数的索引值分别为0和1:

(module
  (func ...) ;;func[0]
  (func ...) ;;func[1]
)

使用索引调用函数既不直观又容易出错,出于方便,WebAssembly文本格式允许给函数命名,方法是在func后增加一个以$开头的名字。例如:

(func $add ...)

这样我们就定义了一个名为$add的函数。在文本格式(.wat)被转换为二进制格式(.wasm)后,别名会被替换为索引值。因此可以认为别名只是WebAssembly文本格式为了方便程序员识别而引入的语法糖。

值得注意的是,在WebAssembly中,函数只能在模块下定义,而不能在函数内部嵌套定义。下列做法是错误的:

(func (func))

这意味着,对函数所处的模块来说,所有的函数都是全局函数。

变量

本节将介绍在WebAssembly汇编语言中如何使用变量。与C语言类似,WebAssembly中的变量分为局部变量和全局变量两类,二者主要区别为作用域不同。

参数与局部变量

来看一个例子:

(func $f1 (param i32) (param f32) (result i64)
  (local f64)
  ;;do sth.
)

通过上节我们可以知道,上述代码定义了一个名为$f1的函数,这个函数有两个参数(类型分别为i32f32),返回值为i64类型,同时有一个类型为f64的局部变量。函数体如何读写参数以及局部变量呢?答案是使用get_localset_local指令,例如:

(func $f1 (param i32) (param f32) (result i64)
  (local f64)
  get_local 0 ;;get i32
  get_local 1 ;;get f32
  get_local 2 ;;get f64
  ...
)

get_local 0指令将得到第一个参数(i32类型),get_local 1将得到第二个参数(f32类型),get_local 2将得到局部变量(f64类型)。由此我们可以看出,参数与局部变量的区别仅在于:参数的初始值是在调用函数时由调用方传入的;对函数体来说,参数与局部变量是等价的,都是函数内部的局部变量,WebAssembly按照变量声明出现的顺序给每个变量赋予了0,1,2,…这样的递增索引,get_local n指令的作用是读取第n个索引对应的局部变量的值并将其压入栈中(关于栈式虚拟机的概念将在下文介绍),相对地,set_local n的功能是将栈顶的数据弹出并存入第n个索引对应的局部变量中。

提示: 在WebAssembly中,除参数外,整型局部变量的初始值为0,浮点型局部变量的初始值为+0。

变量别名

与函数别名类似,WebAssembly文本格式允许为局部变量命名。例如:

(func $f1 (param $p0 i32) (param $p1 f32) (result i64)
  (local $l0 f64)
  get_local $p0 ;;get i32
  get_local $p1 ;;get f32
  get_local $l0 ;;get f64
  ...
)

提示: 事实上在WebAssembly中,不仅函数、局部变量可以命名,而且全局变量、表格、内存等都可以命名,命名方法均为在结点类型后写入以$开头的名字。

全局变量

与局部变量的作用域仅限于函数内部不同,全局变量的作用域是整个module。全局变量分为可变全局变量、只读全局变量两种,区别正如其名字:可变全局变量可读可写,而只读全局变量初始化后不可更改。

声明全局变量的语法为:

(global <别名><类型><初值>)

例如:

(module
  (global (mut i32) (i32.const 42))  ;;define global[0]
  (global $pi f32 (f32.const 3.14159)) ;;define global[1] as $pi
  ...
)

(global (mut i32) (i32.const 42))定义了i32类型的可变全局变量,初值为42

(global $pi f32 (f32.const 3.14159))定义了f32类型的只读全局变量,别名为$pi,值为3.14159

声明全局变量时,<类型>结点如果包含mut表示该变量是可变全局变量,否则为只读全局变量;<初值>结点只能是常数表达式。

全局变量的读写使用get_global/set_global指令。与局部变量类似,WebAssembly也是按照全局变量声明的顺序为其分配索引值,然后通过索引值进行读写。如果试图使用set_global修改只读全局变量的值,在编译阶段会抛出WebAssembly.CompileError。例如:

(module
  (global (mut i32) (i32.const 42))  ;;define global[0]
  (global $pi f32 (f32.const 3.14159)) ;;define global[1] as $pi
  (func
    get_global 0 ;;get 42
    get_global 1 ;;get 3.14159
    get_global $pi ;;get 3.14159

    i32.const 42
    set_global 0 ;;global[0] now become 42

    f32.const 2.1
    set_global $pi ;;CompileError!!!
    ...
  )
)

在WebAssembly中,全局对象(全局变量、函数、表格、内存和导入对象)可以在module的任意位置声明及使用,无须遵守先声明后使用的规则。例如,下述例子是合法的:

(module
  (func (result f32)
    get_global $pi ;;get 3.14159
  )
  (global $pi f32 (f32.const 3.14159)) ;;define $pi
)

全局变量、局部变量都不占用内存地址空间,三者各自独立。

栈式虚拟机

在前面各节中,我们反复提到了。例如,在参数与局部变量一节中我们曾介绍过:get_local n指令的作用是读取第n个索引对应的局部变量的值并将其压入栈。本节的主要内容是解释究竟是什么,以及它在WebAssembly虚拟机体系结构中的作用。

栈是一种先入后出的数据结构,我们可以把栈理解为一种特化的数组,它被限制为只能在一端执行插入和删除操作,习惯上这一端被称为栈顶,而对应的另一端被称为栈底。栈有两种基本操作。

▪压入:或者说入栈,在栈顶添加一个元素,栈中的元素个数加1。 ▪弹出:或者说出栈,将栈顶的元素删除,栈中的元素个数减1。

显然,在空栈中执行弹出操作是非法的;相对地,栈的最大容量受具体实现的限制存在上限,在一个满栈上执行压入操作也是非法的。

WebAssembly栈式虚拟机

WebAssembly不仅是一门编程语言,也是一套虚拟机体系结构规范。

大多数硬件的CPU体系中都有一定数量的通用及专用寄存器(如IA32中的EAX、EBX、ESP等),CPU指令使用这些寄存器存放操作数,执行数值运算、逻辑运算、内存读写等操作。而在WebAssembly体系中,没有寄存器,操作数存放在运行时的栈上,因此WebAssembly虚拟机是一种栈式虚拟机

nop之类的特殊指令外,绝大多数的WebAssembly指令都是在栈上执行某种操作。下面给出几个具体示例。

i32.const n:在栈上压入值为n的32位整型数。 ▪i32.add:从栈中取出2个32位整型数,计算它们的和并将结果压入栈。 ▪i32.eq:从栈中取出2个32位整型数,比较它们是否相等,相等的话在栈中压入1,否则压入0。

栈式调用

与其他很多语言类似,在WebAssembly中,函数调用时参数传递以及返回值获取都是通过栈来完成的。

(1)调用方将参数压入栈中。 (2)进入函数后,初始化参数(将参数从栈中弹出)。 (3)执行函数体中的指令。 (4)将函数的执行结果压入栈中返回。 (5)调用方从栈中获取函数的返回值。

提示: 上述过程是逻辑过程,实际执行过程取决于不同实现。

由于函数调用经常是嵌套的,因此在同一时刻,栈中会保存调用链上多个函数的信息。每个未返回的函数占用栈上的一段独立的连续区域,这段区域被称作栈帧。栈帧是栈的逻辑片段,调用函数时栈帧被压入栈中,函数返回时栈帧被弹出栈。多级调用时,栈中将保存与调用链序列一致的多个栈帧。

进入函数时,从逻辑上来说函数获得了一个独享的栈,这个栈初始是空的。随着函数体中指令的执行,数据不断地入栈出栈。例如,下列函数:

(func $add (param $a i32) (param $b i32) (result i32)
  get_local $a ;;stack:[$a]
  get_local $b ;;stack:[$a, $b]
  i32.add   ;;stack:[$a+$b]
)

函数体依次在栈中压入了参数$a$b的值,然后调用i32.add弹出操作数,计算其和,压入栈中返回。

WebAssembly验证规则会执行严格的检查以保证栈帧匹配:如果函数声明了i32类型的返回值,则函数体执行完毕后,栈上必须包含且仅包含一个i32类型的值,其他类型的返回值同理;如果函数没有返回值,则函数体执行完毕后,栈必须为空。例如,下列函数均是非法的:

(func $func1
  i32.const 1
)
(func $func2 (result i32)
)
(func $func3 (result f32)
  i32.const 1
)

$func1的签名中没有返回值,但i32.const 1指令在栈中压入了i32类型的整数1,返回时栈帧状态不匹配;$func2声明了i32类型的返回值,但是其返回时栈中是空的;$func3声明了f32型的返回值,但是其返回时栈中的值是i32类型。

事实上,WebAssembly验证规则会对所有的指令执行类型检查。例如,下述函数也是非法的:

;;type_error.wat
(module
  (func $func4 (result i32)
    f32.const 2.0
    i32.const 1
    i32.add
  )
)

非法的原因是:i32.add要求栈上的两个操作数均为i32类型,但是其中由f32.const 2.0压入的为f32型,类型不匹配。

严格的栈式虚拟机设计简化了指令架构,增强了可移植性和安全性。得益于WebAssembly的类型系统以及栈式虚拟机设计,在函数体的任意位置,栈的布局(即栈中的元素个数及数据类型)都是可以准确预估的。因此,无须运行即可对WebAssembly汇编代码进行操作数数量、操作数类型、函数签名等的核验,这种非运行时的合法性检查我们简称为静态检查。使用wabt工具集将.wat转换为.wasm时,会执行静态检查并输出出错的位置及原因,假如我们使用wabt转换上述例子代码,将得到以下输出:

wat2wasm.exe type_error.wat -o type_error.wasm  
type_error.wat:5:5: error: type mismatch in i32.add, expected [i32, i32] but got [f32, i32]  
  i32.add
  ^^^^^^^

函数调用

在WebAssembly中有两种调用函数的方式,即直接调用和间接调用。

直接调用

直接调用使用call指令,语法为:

call n  

参数n是欲调用的函数的索引或函数的别名。

例如:

(module
  (func $compute (result i32)
    i32.const 13
    f32.const 42.0
    call 1    ;;get 55
    f32.const 10.0
    call $add   ;;get 65
  )
  (func $add (param $a i32) (param $b f32) (result i32)
    get_local $a
    get_local $b
    i32.trunc_s/f32
    i32.add
  )
)

调用函数前,需要先在栈上按函数签名,正确地压入参数。由于参数是按照从右到左的顺序出栈初始化的,因此参数入栈的顺序与函数签名中参数声明的顺序一致,即:先声明的参数先入栈。参数入栈完成后使用call指令调用指定的函数。

在使用call指令调用函数时,如果函数签名不匹配(压入栈的参数个数不对或类型不符),将无法通过静态检查。

间接调用

指令call n中,n必须为常数,这意味着使用直接调用时,函数间的调用关系是固定的。与此相对的是间接调用:间接调用允许我们使用变量来选择被调用的函数。间接调用是通过表格和call_indirect指令协同完成的:表格中保存了一系列函数的引用,call_indirect通过函数在表格中的索引来调用它,call_indirect指令的语法为:

call_indirect (type n)  

参数n为被调用函数的函数签名索引或函数签名别名(如果函数签名有别名的话)。

例如:

(module
  (table 2 anyfunc)
  (elem (i32.const 0) $plus13 $plus42)   ;;set $plus13,$plus42 to table
  (type $type_0 (func (param i32)(result i32))) ;;define func Signatures
  (func $plus13 (param $i i32) (result i32)
    i32.const 13
    get_local $i
    i32.add)
  (func $plus42 (param $i i32) (result i32)
    i32.const 42
    get_local $i
    i32.add)
  (func (export "call_by_index") (param $id i32) (param $input i32) (result i32)
    get_local $input      ;;push param into stack
    get_local $id        ;;push Function id into stack
    call_indirect (type $type_0) ;;call table:id
  )
)

(table 2 anyfunc)声明了容量为2的表格。(elem (i32.const 0) $plus13 $plus42)从偏移0处开始,在表格中依次存入了函数$plus13$plus42的引用,其中(i32.const 0)表示开始存放的偏移为0。

在直接调用时,由于调用关系是固定的,WebAssembly虚拟机可以按被调用函数的签名进行参数出栈初始化的操作;但是在间接调用时被调用方是不确定的,因此必须通过某种方法协调调用双方的行为:(type $type_0 (func (param i32) (result i32)))声明了一个名为$type_0的函数签名,该签名包含了一个i32的参数以及i32的返回值,这与$plus13$plus42的函数签名是一致的。

call_indirect指令首先从栈中弹出将要调用的函数的索引(i32类型),然后根据(type $type_0)指定的函数签名$type_0依次弹出参数,调用函数索引指定的函数。

使用间接调用时,虽然显式地约定了函数签名,但由于调用关系是由变量控制的,有可能发生函数签名与被调用的函数不匹配的情况。为了保证栈的完整性,WebAssembly虚拟机执行间接调用时会动态检查函数签名,若不匹配,将抛出WebAssembly. RuntimeError。例如:

//call_by_index.wat
(module
  (table 2 anyfunc)
  (elem (i32.const 0) $func1)
  (type $type_0 (func (param i32) (result i32))) ;;define func Signatures
  (func $func1 (result i32)
    i32.const 13
  )
  (func (export "call_by_index") (param $index i32) (result i32)
    i32.const 42        ;;param
    get_local $index      ;;index
    call_indirect (type $type_0) ;;RuntimeError
  )
)
//signature_mismatch.html
  fetchAndInstantiate('call_by_index.wasm').then(
    function(instance) {
      instance.exports.call_by_index(0);
    }
  );

在JavaScript中调用call_by_index(0)call_indirect指令将按照签名$type_0调用$func1,而这显然是不匹配的——$func1没有输入参数,控制台将输出以下信息:

Uncaught (in promise) RuntimeError: function signature mismatch  
at wasm-function[1]:5  
at http://127.0.0.1:8000/signature_mismatch.html:24:28  
  ```

### 递归

WebAssembly允许递归调用,例如:

s ;;recurse.wat (module (func $sum (export "sum") (param $i i32) (result i32) (local $c i32) getlocal $i i32.const 1 i32.les if getlocal $i setlocal $c else getlocal $i i32.const 1 i32.sub call $sum getlocal $i i32.add setlocal $c end getlocal $c ) )


js //recurse.html fetchAndInstantiate('recurse.wasm').then( function(instance) { console.log(instance.exports.sum(10)); //55 } ); `` $sum`函数递归调用自身,计算指定长度的自然数列的和。要谨慎使用递归函数,避免因为递归深度过深导致栈溢出。WebAssembly并未规定栈的容量,不同的虚拟机实现可能有不同的最大栈尺寸。在笔者使用的Chrome环境中,如果输入参数大于25241,上述程序即会引发栈溢出。各位读者不妨在自己的环境中测试一下上述程序的最大递归深度。

分享