«

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

image.png

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

[toc]

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


本文接上篇,继续从内存读写、控制流、导入导出函数以及指令折叠几个方面,继续介绍WebAssembly汇编语言用法。本篇相对上篇会难懂一些,需要更仔细的阅读学习。

内存读写

本节将介绍在WebAssembly语言中如何操作内存。

内存初始化

《WebAssembly标准入门》之JavaScript对象中,我们曾介绍过,内存可以在WebAssembly内部创建,语法为:

(memory initial_size)

参数initial_size为内存的初始容量,单位为页。

新建内存中所有字节的默认初值都是0,我们可以用数据段(Data)来为它赋自定义的值。例如:

(module
  (memory 1)
  (data (offset i32.const 0) "hello")
)

实例化时,(data (offset i32.const 0) "hello")将在偏移0处存入字符串"hello"的字节码。(offset i32.const 0)表示起始偏移为0,语句中的offset对象是可省略的,省略时默认偏移为0。

多个data段之间可以重叠,在重叠部分,后声明的值会覆盖先声明的值。例如:

(module
  (memory 1)
  (data (i32.const 0) "hello")
  (data (i32.const 4) "u")
)

(data (i32.const 4) "u")在偏移为4处存入的字符“u”覆盖了之前的“o”,内存前5个字节变为“hellu”。

无论最终运行在哪种系统上,WebAssembly固定使用小端序。下列语句将在偏移为12处存入32位整数0x00123456:

(data (i32.const 12) "\56\34\12\00")

在函数体中,可以使用一系列load/store指令来按数据类型读写内存。

读取内存

读取内存中的数据时,需要先将内存地址(即欲读取的数据在内存中的起始偏移)压入栈中,然后调用指定类型的load指令,load指令将地址弹出后,从内存中读取数据并压入栈上。例如,下列代码将从内存地址12处读入i32类型整数到栈上:

i32.const 12  
i32.load  

类似于x86的变址寻址,load允许在指令中输入立即数作为寻址的额外偏移,例如下列代码与上面的例子是等价的:

i32.const 4  
i32.load offset=8 align=4  

offset=8表示额外偏移为8字节,因此实际的有效地址仍然为4+8=12。通过设置offset的方法,可以获得的最大有效地址为233,但是按当前的标准,内存的最大容量仅为232字节(即4 GB)。如果不显式声明offset,则默认offset为0。

align=4是地址对齐标签,提示虚拟机按4字节对齐来读取数据,对齐数值必须为2的整数次幂,在当前的标准下,align数值只能为1、2、4、8。从内存中读取数据时,地址是否对齐并不会影响执行结果,但是会影响执行效率。

如果不显式地声明align值,则align默认与将要读取的数据长度一致。

WebAssembly的4种数据类型分别有各自的内存读取指令与之一一对应,分别为i32.loadf32.loadi64.loadf64.load。除此之外,某些情况下我们需要单独读取内存中的某些字节,或者某些字(双字节)等,为了满足这种“部分读取”的需求,WebAssembly提供了以下指令。

写入内存

写入内存使用store指令,使用时,先将内存地址入栈,然后将数据入栈,调用store,例如下列代码在内存偏移12处存入了i32类型42

i32.const 12 ;;address  
i32.const 42 ;;value  
i32.store  

load一样,store指令可以额外指定地址偏移量和对齐值,二者使用方法雷同,例如:

i32.const 4 ;;address  
i32.const 42 ;;value  
i32.store offset=8 align=4  

i32.storef32.storei64.storef64.store这4个基本类型指令外,WebAssembly还提供了一组部分写入指令。

获取内存容量及内存扩容

内存的当前容量可以用memory.size指令获取。例如:

(func $mem_size (result i32)
  memory.size
)

memory.grow指令可用于内存扩容。该指令从栈上弹出欲扩大的容量(i32类型),如果执行成功,则将扩容前的容量压入栈,否则将-1压入栈。例如:

;;grow_size.wat
(module
  (memory 3)
  (func (export "size") (result i32)
    memory.size
  )
  (func (export "grow") (param i32) (result i32)
    get_local 0
    memory.grow
  )
)

  fetchAndInstantiate('grow_size.wasm').then(
    function(instance) {
      console.log(instance.exports.size()); //3
      console.log(instance.exports.grow(2)); //3
      console.log(instance.exports.size()); //5
    }
  );

控制流

控制流指令指的是改变代码执行顺序,使其不再按照声明的顺序线性执行的一类特殊的指令。事实上,之前章节介绍过的函数调用指令call/call_indirect也属于控制流指令。本节将介绍剩余的用于函数体内部的控制流指令。

nopunreachable

首先介绍两个特殊的控制流指令,即nopunreachable

nop指令什么也不干——是的,就是字面上的意思。

unreachable指令字面上的意思是“不应该执行到这里”,实际作用也很相似,当执行到unreachable指令时,会抛出WebAssembly.RuntimeError。该语句常见的用法是在意料之外的执行路径上设置中断,类似于C++的assert(0)

block指令块

blockloopif这3条指令被称为结构化控制流指令,它们的特征非常明显:这些指令不会单独出现,而是和endelse成对或者成组出现。例如:

block  
  i32.const 42
  set_local $answer
end  

blockend以及被它们包围起来的两条指令构成了一个整体,我们称之为一个指令块end指令的作用是标识出指令块的结尾,除此之外它没有实际操作。对block指令块来说,倘若其内部没有跳转指令(brbr_ifbr_tablereturn),则顺序执行指令块中的指令,然后继续执行指令块的后续指令(即end后的指令)。

指令块与无参数的函数颇为相似,这种相似性,体现在以下两点。

下列例子可以证明第一点:

i32.const 13  
block  
  drop ;;pop 13? error!
end  

drop指令用于从栈中弹出一个值(即丢弃一个值)。代码表面上看起来没有问题,实际上无法通过静态合法性检查,错误信息如下:

error: type mismatch in drop, expected [any] but got []  
 drop
 ^^^^

这意味着当程序执行刚进入指令块内时,在指令块内部看来,栈是空的

关于第二点,再来看一个例子:

block  
  i32.const 42
end  

不出所料,上述代码无法通过合法性检查,出错信息与同类型的错误函数(即无返回值的函数在栈上保留了返回值)如出一辙:

error: type mismatch in block, expected [] but got [i32]  
 end
 ^^^

当我们以看待函数的方式来看待指令块时,这一切就容易理解了。为何WebAssembly使用了这种设计方式?答案是为了维持栈平衡。在条件分支指令if和循环指令loop构成的指令块中,指令块的执行路径是动态的,如果指令块没有独立的栈,将使得栈的合法性检查变得异常困难(某些情况下甚至根本不可能);而指令块函数化的设计,在简化了合法性检查的同时,保持了整个体系结构的优雅。

为指令块声明返回值的方法与函数一样,都是增加result属性声明,例如:

block (result i32)  
  i32.const 13
end ;;get 13 on the stack  

上述指令块执行完毕后,栈上增加了一个i32的值,这与调用了一个无参数且返回值为i32的函数相比,对栈的影响是一致的。目前WebAssembly不允许函数有多返回值,这一限制对指令块同样成立。

除栈之外,指令块可以访问其所在函数能访问的所有资源——局部变量、内存和表格等,也可以调用其他函数,例如:

(func $sum (param $a i32) (param $b i32) (result i32)
  get_local $a
  get_local $b
  i32.add
)
(func $sum_mul2 (param $a i32) (param $b i32) (result i32)
  block (result i32)
    get_local $a
    get_local $b
    call $sum
    i32.const 2
    i32.mul
  end
)

上述代码中,block指令块中读取了函数的局部变量,调用了$sum函数。

if指令块

与大多数语言类似,if指令可以搭配else指令形成典型的if/else条件分支语句:

if  
<BrunchA>  
else  
<BrunchB>  
end  

if指令先从栈中弹出一个i32的值;如果该值不为0,则执行分支BrunchA;若该值为0,则执行分支BrunchB。例如:

(func $func1(param $a i32) (result i32)
  get_local $a
  if (result i32)
    i32.const 13
  else
    i32.const 42
  end
)

上述代码中,如果调用$func1时输入参数不为0,则返回13,否则返回42

loop指令块

loop指令块来说,如果指令块内部不含跳转指令,那么loop指令块的行为与block指令块的行为是一致的。例如:

(func $func1 (result i32)
  (local $i i32)
  i32.const 12
  set_local $i
  loop
    get_local $i
    i32.const 1
    i32.add
    set_local $i
  end
  get_local $i
)

上述代码中,loop指令块只会被执行一次,函数的$func1的返回值是13。loop指令块与block指令块的区别将在后文介绍。

指令块的label索引及嵌套

指令块是可以嵌套的,例如:

if       ;;lable 2  
  nop
  block    ;;label 1
    nop
    loop  ;;label 0
      nop
    end   ;;end of loop-label 0
  end     ;;end of block-label 1
end       ;;end of if-label 2  

每个指令块都被隐式地赋予了一个label索引。大多数对象的索引是以声明顺序递增的(如函数索引),而label索引有所不同:label索引是由指令块的嵌套深度决定的,位于最内层的指令块索引为0,每往外一层索引加1。例如,在上述代码中,loopblockif指令块的label索引分别为0、1、2。与其他对象类似,label也可以命名:

if $L1  
  nop
  block $L2
    nop
    loop $L3
      nop
    end   ;;end of $L3
  end     ;;end of $L2
end       ;;end of $L1  

label的用处是作为跳转指令的跳转目标。

br

跳转指令共有4条,即brbr_ifbr_tablereturn

我们先来看无条件跳转指令br,指令格式:

br L  

br指令的基本作用是跳出指令块,由于指令块是可以嵌套的,br指令的参数L指定了跳出的层数:如果L为0,则跳转至当前指令块的后续点,如果L为1,则跳转至当前指令块的父指令块的后续点,依此类推,L每增加1,多向外跳出一层。

block指令块和if指令块的后续点是其结尾,例如:

block (result i32)  
  i32.const 13
  br 0
  drop
  i32.const 42
end  

在上述代码中,br 0直接跳转至了end处,后续的dropi32.const 42都被略过了,因此指令块的返回值是13

loop指令块的后续点则是其开始处。例如:

(func $func1 (result i32)
  (local $i i32)
  i32.const 12
  set_local $i
  loop
    get_local $i
    i32.const 1
    i32.add
    set_local $i
    br 0
  end
  get_local $i
)

在上述代码中,br 0跳转到了loop处,因此实际上上述loop指令块是个无法结束的死循环。直观上来说,brblock指令块和if指令块中的作用类似于C语言的break,而brloop指令块中的作用类似于continue

下面的例子展示了多级跳出的用法:

(func (result i32)
  (local $i i32)
  i32.const 12
  set_local $i
  block        ;;label 1
    loop       ;;label 0
      get_local $i
      i32.const 1
      i32.add
      set_local $i
      br 1
    end       ;;end of loop
  end         ;;end of block,"br 1"jump here
  get_local $i
)

br 1向外跳出2层,跳转到了block指令块的后续点(即第二个end处),进而使上述函数返回13。使用br指令时,也可以将label的别名直接作为跳转目标。例如,下列代码与上述例子是等价的:

(func (result i32)
 (local $i i32)
  i32.const 12
  set_local $i
  block $L1
    loop
      get_local $i
      i32.const 1
      i32.add
      set_local $i
      br $L1
    end
  end         ;;end of $L1,"br $L1"jump here
  get_local $i
)

br_if

br_if指令格式:

br_if L  

br_ifbr大体上是相似的,区别是br_if执行时,会先从栈上弹出一个i32类型的值,如果该值不为0,则执行br L的操作,否则执行后续操作。例如:

(func (param $i i32) (result i32)
  block (result i32)
    i32.const 13
    get_local $i
    i32.const 5
    i32.gt_s
    br_if 0
    drop     ;;drop 13
    i32.const 42
  end
)

在上述代码中,如果$i大于5,将导致br_if 0跳转至end,指令块返回预先放在栈上的13;如果$i小于等于5,br_if 0无效,后续指令将丢弃之前放在栈上的13,返回42

return

return指令用于跳出至最外层的结尾(即函数结尾)处,其执行效果等同于直接返回。例如:

(func (result i32)
  block (result i32)
    block (result i32)
      block (result i32)
        i32.const 4
        return    ;;return 4
      end
      drop
      i32.const 5
    end
    drop
    i32.const 6
  end
  drop
  i32.const 7
)

在上述代码中,return直接跳至函数结尾处,函数返回值为4

br_table

br_table指令较为复杂,指令格式为:

br_table L[n] L_Default  

L[n]是一个长度为n的label索引数组,br_table执行时,先从栈上弹出一个i32类型的值m,如果m小于n,则执行br L[m],否则执行br L_Default。例如:

;;br_table.wat
(module
  (func (export "brTable")(param $i i32) (result i32)
    block
      block
        block
          get_local $i
          br_table 2 1 0
        end
        i32.const 4
        return
      end
      i32.const 5
      return
    end
    i32.const 6
  )
)

br_table 0 1 2根据$i的值选择跳出的层数:$i等于0时跳出2层,$i等于1时跳出1层,$i大于等于2时跳出0层。跳出0、1、2层时,函数$brTable分别返回456。在JavaScript中调用:

//br_table.html
  fetchAndInstantiate('br_table.wasm').then(
    function(instance) {
      console.log(instance.exports.brTable(0)); //6
      console.log(instance.exports.brTable(1)); //5
      console.log(instance.exports.brTable(2)); //4
      console.log(instance.exports.brTable(3)); //4
    }
  );

控制台输出如下:

6  
5  
4  
4  

导入和导出

《WebAssembly标准入门》之JavaScript对象中,我们已经陆续介绍过一些导入/导出的例子,本节将对导入/导出进行系统归纳。

导出对象

WebAssembly中可导出的对象包括内存、表格、函数、只读全局变量。若要导出某个对象,只需要在该对象的类型后加入(export "export_name")属性即可。WebAssembly代码如下:

;;exports.wat
(module
  (func (export "wasm_func") (result i32)
    i32.const 42
  )
  (memory (export "wasm_mem") 1)
  (table (export "wasm_table") 2 anyfunc)
  (global (export "wasm_global_pi") f32 (f32.const 3.14159))
)

JavaScript代码如下:

//exports.html
fetch("exports.wasm").then(response =>  
  response.arrayBuffer()
).then(bytes =>
  WebAssembly.instantiate(bytes)
).then(results =>{
    var exports = WebAssembly.Module.exports(results.module);
    for (var e in exports) {
      console.log(exports[e]);
    }
    console.log(results.instance.exports);
    console.log(results.instance.exports.wasm_func());
    console.log(results.instance.exports.wasm_global_pi);
    console.log(typeof(results.instance.exports.wasm_global_pi));
  }
);

上述JavaScript程序将.wasm编译后,使用WebAssembly.Module.exports()方法获取了模块的导出对象信息,并输出了实例的exports属性,如下:

{name: "wasm_func", kind: "function"}
{name: "wasm_mem", kind: "memory"}
{name: "wasm_table", kind: "table"}
{name: "wasm_global_pi", kind: "global"}
{wasm_func: ƒ, wasm_mem: Memory, wasm_table: Table, wasm_global_pi: 3.141590118408203}
wasm_func:ƒ 0()  
wasm_global_pi:3.141590118408203  
wasm_mem:Memory {}  
wasm_table:Table {}  
42  
3.141590118408203  
number  

注意,模块的导出信息只包含了导出对象的名字和类别,实际的导出对象必须通过实例的exports属性访问。

在所有的导出对象中,导出函数使用频率最高,它是JavaScript访问WebAssembly模块提供的功能的入口。导出函数中封装了实际的WebAssembly函数,调用导出函数时,虚拟机会按照函数签名执行必要的类型转换、参数初始化,然后调用WebAssembly函数并返回调用结果。导出函数使用起来与正常的JavaScript方法别无二致,区别只是函数体的实际执行是在WebAssembly中。除了通过实例的exports属性获取导出函数,还可以通过表格的get()方法获取已被存入表格中的函数。

在.wat中声明导出对象时,除了在对象类型后加入export属性,还可以通过单独的export结点声明导出对象,二者是等价的。例如:

(module
  (func (result i32)
    i32.const 42
  )
  (memory 1)
  (table $t 2 anyfunc)
  (global $g0 f32 (f32.const 3.14159))
  (export "wasm_func" (func 0))
  (export "wasm_mem" (memory 0))
  (export "wasm_table" (table $t))
  (export "wasm_global" (global $g0))
)

导入对象

与可导出的对象类似,WebAssembly中的可导入对象包括内存、表格、函数、只读全局变量。下列例子依次展示了各种对象的导入方法:

(module
  (import "js" "memory" (memory 1))            ;;import Memory
  (import "js" "table" (table 1 anyfunc))         ;;import Table
  (import "js" "print_i32" (func $js_print_i32 (param i32))) ;;import Fucntion
  (import "js" "global_pi" (global $pi f32))        ;;import Global
)

注意,由于导入函数必须先于内部函数定义,因此习惯上导入对象一般在module的开始处声明。

与导出对象类似,使用WebAssembly.Module.imports()可以获取模块的导入对象信息。例如:

fetch("imports.wasm").then(response =>  
  response.arrayBuffer()
).then(bytes =>
  WebAssembly.compile(bytes)
).then(module =>{
    var imports = WebAssembly.Module.imports(module);
    for (var e in imports) {
      console.log(imports[e]);
    }
  }
);

运行后控制台将输出:

{module: "js", name: "memory", kind: "memory"}
{module: "js", name: "table", kind: "table"}
{module: "js", name: "print_i32", kind: "function"}
{module: "js", name: "global_pi", kind: "global"}

import 结点使用了两级名字空间的方式对外部导入的对象进行识别,第一级为模块名(即上例中的js),第二级为对象名(即上例中的memorytable等)。导入对象是在实例化时导入实例中去的,在JavaScript的环境下,如果导入对象为importObj,那么(import "m" "n"...)对应的就是importObj.m.n。例如,上述imports.wasm模块实例化时应提供的导入对象如下:

function js_print_i32(param){  
  console.log(param);
}
var memory = new WebAssembly.Memory({initial:1, maximum:10});  
var table = new WebAssembly.Table({element:'anyfunc', initial:2});  
var importObj = {js:{print_i32:js_print_i32, memory:memory, table:table, global_pi:3.14}};  
fetchAndInstantiate("imports.wasm", importObj).then(instance =>  
  console.log(instance)
);

与导出函数相对应,导入的作用是让WebAssembly调用外部对象。WebAssembly代码调用导入对象时,虚拟机同样执行了参数类型转换、参数和返回值的出入栈等操作,因此导入函数的调用方法与内部函数是一致的,例如:

;;imports.wat
(module
  (import "js" "print_f32" (func $js_print_f32 (param f32) (result f32)))
  (import "js" "global_pi" (global $pi f32))
  (func (export "print_pi") (result f32)
    get_global $pi
    call $js_print_f32
  )
)

print_pi()函数读取了导入的只读全局变量$pi并压入栈中,然后调用了导入函数$js_print_f32,并将其返回值一并返回。

//imports.html
  function js_print_f32(param){
    console.log(param);
    return param * 2.0;
  }
  var importObj = {js:{print_f32:js_print_f32, global_pi:3.14}};
  fetchAndInstantiate("imports.wasm", importObj).then(
    function(instance) {
      console.log(instance.exports.print_pi());
    }
  );

在JavaScript的部分,将js_print_f32()方法通过importObj.js.print_f32导入了WebAssembly,注意我们特意将其参数乘2后返回。上述程序运行后控制台输出:

3.140000104904175  
6.28000020980835  

《WebAssembly标准入门》之JavaScript对象中的WebAssembly.Memory对象WebAssembly.Table对象

通过导入函数,WebAssembly可以调用外部JavaScript环境中的方法,执行读写DOM等操作。

提示:假如把WebAssembly看作CPU,那么导入/导出对象可以看作CPU的I/O接口

start()函数及指令折叠

本节将介绍WebAssembly中start()函数的使用以及文本格式指令折叠书写法。

start()函数

有时候我们希望模块在实例化后能够立即执行一些启动操作,此时可以使用start()函数。例如:

;;start.wat
(module
  (start $print_pi)
  (import "js" "print_f32" (func $js_print_f32 (param f32)))
  (func $print_pi
    f32.const 3.14
    call $js_print_f32
  )
)

`

start后的函数$print_pi在实例化后将自动执行。

下面的JavaScript代码仅仅创建了start.wasm的实例,没有调用实例的任何函数:

i64

控制台输出为:

i64.load8_u

start段引用的启动函数不能包含参数,不能有返回值,否则无法通过静态检测。

4.10.2 指令折叠

在之前的章节中,我们书写函数体中的指令时,是按照每条指令一行的格式来书写的。除此之外,指令还可以S-表达式的方式进行书写,指令的操作数可以使用括号嵌套折叠其中。例如:

i64

i64.load16_s 是等价的。指令可以嵌套折叠,折叠后的执行顺序为从内到外,从左到右。例如:

i64

i64.load16_u 是等价的。

结构化控制流指令也是可以折叠的,折叠后无须再写对应的end指令。例如:

i64

i64.load32_s 是等价的。

略有不同的是if指令块,if分支必须折叠为then。例如:

if $label1 (result i32)  
  i32.const 13
else  
  i32.const 42
end  

折叠后为

(if $label1 (result i32) (then (i32.const 13)) (else (i32.const 42)))

注意,指令折叠只是语法糖,过度使用不仅不会提高可读性,相反,嵌套层数过多时会增加阅读难度。

分享