WebAssembly原理与核心技术
上QQ阅读APP看书,第一时间看更新

3.2 指令分析

前面的介绍可能比较抽象,这一节将通过一些实例来具体分析指令。为了让例子尽可能简单,我们不再使用前两章用过的Rust版“Hello,World!”程序,而是用Wasm文本格式(也就是WAT语言)编写一些有针对性的代码。请读者注意,这一节我们的目标是掌握各种指令的编码格式,所以即使不能完全理解这些例子也没关系,在第4章,我们将详细讨论Wasm文本格式,从第5章开始,我们再详细讨论并实现各种指令。

由于两条参数指令都比较简单(也没有立即数),本节就不专门分析了,在第5章会做具体介绍。下面先来分析较为简单的数值指令,然后分析变量和内存指令,最后再分析控制指令。

3.2.1 数值指令

数值指令中的饱和截断指令比较特殊。在写作本书时,该指令正式加入Wasm规范。饱和截断指令其实是一组指令,共8条,和普通的浮点数截断指令一一对应。这组指令的格式为前缀操作码(0x0F)+子操作码,可以将其当作是8条指令,每条指令的操作码占两个字节;也可以当作一条指令,子操作码是该指令的立即数。为了和其他指令统一,本书将其当作一条指令处理。因此,在全部133条数值指令中,只有4条常量指令和饱和截断指令有立即数。

1)i32.const(操作码0x41):带一个s32类型的立即数,使用LEB128有符号格式编码。

2)i64.const(操作码0x42):带一个s64类型的立即数,使用LEB128有符号格式编码。

3)f32.const(操作码0x43):带一个f32类型的立即数,固定占4字节。

4)f32.const(操作码0x44):带一个f32类型的立即数,固定占8字节。

5)trunc_sat(操作码0xFC):带一个单字节的立即数。该指令比较特殊,详见第5章。

关于LEB128编码格式的详细内容请参考2.1.3节。第5章将详细介绍数值指令,下面给出数值指令的编码格式(沿用上一章的描述方式)。


i32.const: 0x41|s32
i64.const: 0x42|s64
f32.const: 0x43|f32
f64.const: 0x44|f64
trunc_sat: 0xfc|byte
num_instr: opcode

数值指令比较多,无法逐一介绍,下面的例子仅展示常量指令、加法指令和饱和截断指令。


(module
  (func
    (f32.const 12.3) (f32.const 45.6) (f32.add)
    (i32.trunc_sat_f32_s) (drop)
  )
)

WAT示例代码在code/wat目录下,本章的示例全部以ch03开头,这些例子只是为了展示指令编码格式,除此之外没有实际的意义。

使用wat2wasm命令编译上面的例子,然后使用xxd命令观察二进制模块代码段(ID是0x0A),就能很容易地找出两条f32.const指令,以及跟在这两条指令后面的f32.add指令(操作码0x92),以及饱和截断指令(操作码0xFC)。


$ wat2wasm code/wat/ch03_eg1_num.wat
$ xxd -u -g 1 ch03_eg1_num.wasm
00000000: 00 61 73 6D 01 00 00 00 01 04 01 60 00 00 03 02  .asm.......`....
00000010: 01 00 0A 12 01 10 00 43 CD CC 44 41 43 66 66 36  .......C..DACff6
00000020: 42 92 FC 00 1A 0B                                B.....

下面是wasm-objdump命令打印出的结果(使用-d选项开启字节码反编译)。


$ wasm-objdump -d ch03_eg1_num.wasm
...
000016 func[0]:
 000017: 43 cd cc 44 41             | f32.const 0x1.89999ap+3
 00001c: 43 66 66 36 42             | f32.const 0x1.6cccccp+5
 000021: 92                         | f32.add
 000022: fc 00                      | i32.trunc_sat_f32_s
 000024: 1a                         | drop
 000025: 0b                         | end

3.2.2 变量指令

变量指令共5条,其中3条用于读写局部变量,立即数是局部变量索引;另外2条用于读写全局变量,立即数是全局变量索引。第7章将详细介绍变量指令,下面给出变量指令的编码格式。


local.get : 0x20|local_idx
local.set : 0x21|local_idx
local.tee : 0x22|local_idx
global.get: 0x23|global_idx
global.set: 0x24|global_idx

我们来看一个具体的例子。


(module
  (global $g1 (mut i32) (i32.const 1))
  (global $g2 (mut i32) (i32.const 1))

  (func (param $a i32) (param $b i32)
    (global.get $g1) (global.set $g2)
    (local.get $a) (local.set $b)
  )
)

编译WAT文件,然后用xxd命令观察二进制模块代码段。由第2章可知,索引在Wasm二进制格式中按LEB128无符号整数格式编码。采用这种编码格式,小于128的整数编码后就是它本身(2的补码表示),编码后的数据只占一个字节。根据这些线索就能很容易地找到4条变量指令。


$ wat2wasm code/wat/ch03_eg2_var.wat
$ xxd -u -g 1 ch03_eg2_var.wasm
00000000: 00 61 73 6D 01 00 00 00 01 06 01 60 02 7F 7F 00  .asm.......`....
00000010: 03 02 01 00 06 0B 02 7F 01 41 01 0B 7F 01 41 01  .........A....A.
00000020: 0B 0A 0C 01 0A 00 23 00 24 01 20 00 21 01 0B     ......#.$. .!..

下面是wasm-objdump命令打印出的结果。


$ wasm-objdump -d ch03_eg2_var.wasm
...
000025 func[0]:
 000026: 23 00                      | global.get 0
 000028: 24 01                      | global.set 1
 00002a: 20 00                      | local.get 0
 00002c: 21 01                      | local.set 1
 00002e: 0b                         | end

3.2.3 内存指令

内存指令共25条,其中14条是加载指令,用于将内存数据载入操作数栈,还有9条是存储指令,用于将操作数栈顶数据写回内存,这23条指令统一带有两个立即数:对齐提示和内存偏移量。剩余两条指令用于获取和扩展内存页数,立即数是内存索引。由于Wasm规范规定模块只能导入或定义一块内存,所以这个内存索引目前只起到占位作用,索引值必须为0。第6章将详细介绍内存指令,下面给出内存指令的编码格式。


load_instr : opcode|align|offset # align: u32, offset: u32
store_instr: opcode|align|offset
memory.size: 0x3f|0x00
memory.grow: 0x40|0x00

我们来看一个具体的例子。


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

  (func
    (i32.const 1) (i32.const 2)
    (i32.load offset=100)
    (i32.store offset=100)
    (memory.size) (drop)
    (i32.const 4) (memory.grow) (drop)
  )
)

编译WAT文件,然后用xxd命令观察二进制模块代码段。根据i32.load和i32.store指令的操作码(0x28和0x36)可以找到这两条指令。这两条指令的对齐提示都是2,全局变量索引都是100(0x64)。后面是memory.size和memory.grow指令(操作码0x3F和0x40,中间隔着drop和一条常量指令),内存索引值为0。


$ wat2wasm code/wat/ch03_eg3_mem.wat
$ xxd -u -g 1 ch03_eg3_mem.wasm
00000000: 00 61 73 6D 01 00 00 00 01 04 01 60 00 00 03 02  .asm.......`....
00000010: 01 00 05 04 01 01 01 08 0A 16 01 14 00 41 01 41  .............A.A
00000020: 02 28 02 64 36 02 64 3F 00 1A 41 04 40 00 1A 0B  .(.d6.d?..A.@...
00000030: 0B 0C 01 00 41 E4 00 0B 05 68 65 6C 6C 6F        ....A....hello

下面是wasm-objdump命令打印出的结果。


$ wasm-objdump -d ch03_eg3_mem.wasm
...
00001c func[0]:
 00001d: 41 01                      | i32.const 1
 00001f: 41 02                      | i32.const 2
 000021: 28 02 64                   | i32.load 2 100
 000024: 36 02 64                   | i32.store 2 100
 000027: 3f 00                      | memory.size 0
 000029: 1a                         | drop
 00002a: 41 04                      | i32.const 4
 00002c: 40 00                      | memory.grow 0
 00002e: 1a                         | drop
 00002f: 0b                         | end

3.2.4 结构化控制指令

控制指令共13条,包括结构化控制指令、跳转指令、函数调用指令等。其中结构化控制指令有3条,分别是block(操作码0x02)、loop(操作码0x03)和if(操作码0x04)。这3条指令必须和end指令(操作码0x0B)搭配,成对出现。如果if指令有两条分支,则中间由else指令(操作码0x05)分隔。由于end和else指令比较特殊,只起分隔作用,所以也可以称为伪指令。第8章将详细介绍结构化控制指令,下面给出这3条指令的编码格式。


block_instr: 0x02|block_type|instr*|0x0b
loop_instr : 0x03|block_type|instr*|0x0b
if_instr   : 0x04|block_type|instr*|(0x05|instr*)?|0x0b
block_type : s32

我们来看一个具体的例子。


(module
  (func (result i32)
    (block (result i32)
      (i32.const 1)
      (loop (result i32)
        (if (result i32) (i32.const 2)
          (then (i32.const 3))
          (else (i32.const 4))
        )
      )
      (drop)
    )
  )
)

编译WAT文件,然后用xxd命令观察二进制模块代码段。根据这几条指令的操作码,可以很容易地在代码段中找到它们的启始和结束。


$ wat2wasm code/wat/ch03_eg4_block.wat
$ xxd -u -g 1 ch03_eg4_block.wasm
00000000: 00 61 73 6D 01 00 00 00 01 05 01 60 00 01 7F 03  .asm.......`....
00000010: 02 01 00 0A 17 01 15 00 02 7F 41 01 03 7F 41 02  ..........A...A.
00000020: 04 7F 41 03 05 41 04 0B 0B 1A 0B 0B              ..A..A......

用wasm-objdump命令可以观察得更清楚一些,下面是打印出的结果。


$ wasm-objdump -d ch03_eg4_block.wasm
...
000017 func[0]:
 000018: 02 7f                      | block i32
 00001a: 41 01                      |   i32.const 1
 00001c: 03 7f                      |   loop i32
 00001e: 41 02                      |     i32.const 2
 000020: 04 7f                      |     if i32
 000022: 41 03                      |       i32.const 3
 000024: 05                         |     else
 000025: 41 04                      |       i32.const 4
 000027: 0b                         |     end
 000028: 0b                         |   end
 000029: 1a                         |   drop
 00002a: 0b                         | end
 00002b: 0b                         | end

3.2.5 跳转指令

跳转指令共4条,其中br指令(操作码0x0C)进行无条件跳转,立即数是目标标签索引;br_if指令(操作码0x0D)进行有条件跳转,立即数也是目标标签索引;br_table指令(操作码0x0E)进行查表跳转,立即数是目标标签索引表和默认标签索引。return指令(操作码0x0F)只是br指令的一种特殊形式,执行效果是直接跳出最外层循环并且导致整个函数返回,没有立即数。第8章将详细介绍跳转指令,下面给出这4条指令的编码格式。


br_instr    : 0x0c|label_idx
br_if_instr : 0x0d|label_idx
br_table    : 0x0e|vec<label_idx>|label_idx
return_instr: 0x0f

来看一个具体的例子。


(module
  (func
    (block (block (block
      (br 1)
      (br_if 2 (i32.const 100))
      (br_table 0 1 2 3)
      (return)
    )))
  )
)

编译WAT文件,然后用xxd命令观察二进制模块代码段。根据操作码不难找出这4条跳转指令。


$ wat2wasm code/wat/ch03_eg5_br.wat
$ xxd -u -g 1 ch03_eg5_br.wasm
00000000: 00 61 73 6D 01 00 00 00 01 04 01 60 00 00 03 02  .asm.......`....
00000010: 01 00 0A 1B 01 19 00 02 40 02 40 02 40 0C 01 41  ........@.@.@..A
00000020: E4 00 0D 02 0E 03 00 01 02 03 0F 0B 0B 0B 0B     ...............

下面是wasm-objdump命令打印出的结果。


$ wasm-objdump -d ch03_eg5_br.wasm
...
000016 func[0]:
 000017: 02 40                      | block
 000019: 02 40                      |   block
 00001b: 02 40                      |     block
 00001d: 0c 01                      |       br 1
 00001f: 41 e4 00                   |       i32.const 100
 000022: 0d 02                      |       br_if 2
 000024: 0e 03 00 01 02 03          |       br_table 0 1 2 3
 00002a: 0f                         |       return
 00002b: 0b                         |     end
 00002c: 0b                         |   end
 00002d: 0b                         | end
 00002e: 0b                         | end

3.2.6 函数调用指令

Wasm支持直接和间接两种函数调用方式。call指令(操作码0x10)进行直接函数调用,函数索引由立即数指定。call_indirect指令(操作码0x11)进行间接函数调用,函数签名的索引由立即数指定,到运行时才能知道具体调用哪个函数。第7章将详细介绍直接函数调用指令,第9章将详细介绍间接函数调用指令,下面给出这两条指令的编码格式。


call_instr   : 0x10|func_idx
call_indirect: 0x11|type_idx|0x00

间接函数调用指令需要查表才能完成,由第2个立即数指定查哪张表。我们已经知道,模块最多只能导入或定义一张表,所以这个立即数只起占位作用,必须是0。下面来看一个具体的例子。


(module
  (type $ft1 (func))
  (type $ft2 (func))
  (table funcref (elem $f1 $f1 $f1))
  (func $f1
    (call $f1)
    (call_indirect (type $ft2) (i32.const 2))
  )
)

编译WAT文件,然后用xxd命令观察二进制模块代码段。根据操作码,不难找出这两条函数调用指令。


$ wat2wasm code/wat/ch03_eg6_call.wat
$ xxd -u -g 1 ch03_eg6_call.wasm
00000000: 00 61 73 6D 01 00 00 00 01 07 02 60 00 00 60 00  .asm.......`..`.
00000010: 00 03 02 01 00 04 05 01 70 01 03 03 09 09 01 00  ........p.......
00000020: 41 00 0B 03 00 00 00 0A 0B 01 09 00 10 00 41 02  A.............A.
00000030: 11 01 00 0B                                      ....

下面是wasm-objdump命令打印出的结果。


$ wasm-objdump -d ch03_eg6_call.wasm 
...
00002b func[0]:
 00002c: 10 00                      | call 0
 00002e: 41 02                      | i32.const 2
 000030: 11 01 00                   | call_indirect 1 0
 000033: 0b                         | end