WebAssembly介绍之6:间接函数调用

Wasm介绍之6:间接函数调用

在前面的5篇文章里,我们详细讨论了WebAssembly(简称Wasm)二进制格式和除call_indirect之外的所有指令。这篇文章将详细介绍Wasm间接函数调用机制和call_indirect指令。

call_indirect指令

为了更好的理解call_indirect指令,我们首先来回顾一下call指令的工作方式。根据之前文章的介绍 可知,call指令带有一个立即数参数,指定被调用函数的索引。在Wasm实现执行call指令之前,必须保证要传递给被调用函数的参数已经在栈顶,且参数的顺序和类型必须完全匹配被调函数的签名。call指令执行完毕之后,参数已经从栈顶弹出,函数的返回值(如果有的话)会出现在栈顶。我们假设被调用函数接收两个参数,类型分别是f32f64,返回值类型是i64,下面是call指令的示意图:

bytecode:
...][ call ][ func_idx ][...

stack:
|           |          |           | 
|           |          |           | 
|   d(f64)  |➚         |           |
|   c(f32)  |➚        ➘|   r(i64)  | # funcs[func_idx](c,d) 
|     b     |          |     b     |  
|     a     |          |     a     | 
└───────────┘          └───────────┘

call_indirect指令主要是用来实现C/C++、Rust等语言中的函数指针的。顾名思义,call_indirect指令给函数调用引入了间接层。call_indirect指令在格式上和call指令一致,但是调用语义有很大不同。第一,被调用函数并不是通过存储在立即数里的函数索引直接定位,而是从表间接定位。表索引和参数一起放在操作数栈顶,位于所有参数之上。第二,由于具体要调用的是哪个函数在编译期并不知道,只要在运行时才能知道,所以没办法像call指令那样通过函数索引拿到函数签名。但是被调用函数的签名在编译期就已经是知道的了,所以可以把函数签名的索引放在立即数里。假设被调用函的签名和上图一样,下面是call_indirect指令的示意图:

bytecode:
...][ call_indirect ][ type_idx ][...

stack:
|           |          |           | 
|   i(i32)  |➚         |           | 
|   d(f64)  |➚         |           |
|   c(f32)  |➚        ➘|   r(i64)  | # table[i](c,d)
|     b     |          |     b     |  
|     a     |          |     a     | 
└───────────┘          └───────────┘

根据之前文章的介绍可知,Wasm模块可以定义或导入表,表的初始数据放在元素段里。Wasm1.0规范对于表有诸多限制。第一、每个Wasm模块最多可以导入或定义一个表。第二、表只支持一种元素,也就是函数引用(funcref)。在未来的版本中,可能会放开这些限制。由上图可知,call_indirect指令首先要根据栈顶操作数得到元素索引,然后通过元素索引拿到函数引用(或者函数地址),最后通过函数引用调用函数。在定位到具体函数之后,Wasm实现会校验实际函数的签名,确保它和指令立即数指定的签名一致。介绍了这么多,可能还是不太好理解,下面通过一个具体的例子进行说明。

实例分析

我们写一个简单的Rust例子来说明call_indirect指令。请读者创建一个Cargo项目,把下面的Rust代码复制到src/main.rs文件里:

#![no_std]
#![no_main]

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

type Binop = fn(f32, f32) -> f32;
fn add(a: f32, b: f32) -> f32 { a + b }
fn sub(a: f32, b: f32) -> f32 { a - b }
fn mul(a: f32, b: f32) -> f32 { a * b }
fn div(a: f32, b: f32) -> f32 { a / b }

#[no_mangle]
pub extern "C" fn main(op: usize, a: f32, b: f32) -> f32 {
    let ops: [Binop; 4] = [add, sub, mul, div];
    if op < 4 {
        ops[op](a, b)
    } else {
        0.0
    }
}

上面的例子非常简单,定义了add()sub()mul()div()四个函数,然后在main()函数里通过函数指针调用其中一个。可以执行cargo build命令把项目编译成Wasm二进制格式,然后可以通过WABT提供的wasm2wat命令把Wasm二进制格式转成文本格式(预告,Wasm文本格式将在下一篇文章中详细介绍)以便于观察。下面是需要用到的全部命令:

$ # install rustup & wabt
$ rustup target add wasm32-unknown-unknown
$ cargo new table_demo
$ cd table_demo/
$ # edit src/main.rs
$ cargo build --target wasm32-unknown-unknown --release
$ wasm2wat target/wasm32-unknown-unknown/release/table_demo.wasm

让我们来看看编译后的Wasm模块:

(module
  (type (;0;) (func (param f32 f32) (result f32)))
  (type (;1;) (func (param i32 f32 f32) (result f32)))
  (func $add (type 0) (f32.add (local.get 0) (local.get 1)))
  (func $sub (type 0) (f32.sub (local.get 0) (local.get 1)))
  (func $mul (type 0) (f32.mul (local.get 0) (local.get 1)))
  (func $div (type 0) (f32.div (local.get 0) (local.get 1)))
  (func $main (type 1) (param i32 f32 f32) (result f32)
    ...
  )
  (table (;0;) 5 5 funcref)
  (elem (;0;) (i32.const 1) funcref $div $mul $sub $add)
  (memory (;0;) 16)
  (global (;0;) (mut i32) (i32.const 1048576))
  (global (;1;) i32 (i32.const 1048576))
  (global (;2;) i32 (i32.const 1048576))
  (export "memory" (memory 0))
  (export "__data_end" (global 1))
  (export "__heap_base" (global 2))
  (export "main" (func $main))
)

main()函数稍微有点长,稍后给出。可以看到,Rust编译器的确生成了表和元素段,而且看起来也的确是把div()mul()sub()add()这四个函数(注意顺序)填入了表里,索引分别是1、2、3、4:

  (table (;0;) 5 5 funcref)
  (elem (;0;) (i32.const 1) funcref $div $mul $sub $add)

下面来看一下main()函数(格式进行了适当调整,并且添加了注释):

  (func $main (type 1)
    (param $op i32) (param $a f32) (param $b f32) (result f32)
    (local $l3 i32) (local $l4 f32)

    (i32.sub (global.get 0) (i32.const 16))           ;; $tmp0 = $g0 - 16
    (local.tee 3)                                     ;; $l3 = $tmp0
    (global.set 0)                                    ;; $g0 = $tmp0
    (i32.store offset=12 (local.get 3) (i32.const 1)) ;; $mem[$g0 -  4] = 1
    (i32.store offset=8  (local.get 3) (i32.const 2)) ;; $mem[$g0 -  8] = 2
    (i32.store offset=4  (local.get 3) (i32.const 3)) ;; $mem[$g0 - 12] = 3
    (i32.store           (local.get 3) (i32.const 4)) ;; $mem[$g0 - 16] = 4
    (local.set 4 (f32.const 0x0p+0))                  ;; $l4 = 0.0
    (block
      (br_if 0 (i32.gt_u (local.get 0) (i32.const 3))) ;; $op > 3 ? br
      (local.get 1) (local.get 2) ;; $tmp0 = $a, $tmp1 = $b
      (local.get 3) (local.get 0) ;; $tmp2 = $l3, $tmp3 = $op
      (i32.const 2)               ;; $tmp4 = 2
      (i32.shl)                   ;; $tmp3 = $op * 4
      (i32.add)                   ;; $tmp2 = $l3 + $op*4
      (i32.load)                  ;; $tmp2 = $mem[$g0 - 16 + $op*4]
      (call_indirect (type 0) )   ;; $tmp0 = call_indirect($tmp0, $tmp1, $tmp2)
      (local.set 4)               ;; $l4 = $tmp0
    )
    (i32.add (local.get 3) (i32.const 16)) ;; $tmp0 = $l3 + 16
    (global.set 0) ;; $g0 = $tmp0
    (local.get 4)  ;; return $l4
  )

由于Rust编译器用了全局变量和内存来操作表索引,所以main()函数看起来比想象中要复杂一些。如果把这些多余的因素去掉,那么模块看起来应该是下面这样:

(module
  (type (;0;) (func (param f32 f32) (result f32)))
  (type (;1;) (func (param i32 f32 f32) (result f32)))
  (func $add (type 0) (f32.add (local.get 0) (local.get 1)))
  (func $sub (type 0) (f32.sub (local.get 0) (local.get 1)))
  (func $mul (type 0) (f32.mul (local.get 0) (local.get 1)))
  (func $div (type 0) (f32.div (local.get 0) (local.get 1)))
  (func $main (type 1) (param i32 f32 f32) (result f32)
    (block (result f32)
      (f32.const 0x0p+0)
      (br_if 0 (i32.gt_u (local.get 0) (i32.const 3)))
      (drop)
      (local.get 1) (local.get 2) (local.get 0)
      (call_indirect (type 0) )
    )
  )
  (table (;0;) 5 5 funcref)
  (elem (;0;) (i32.const 1) func $add $sub $mul $div)
  (export "main" (func $main))
)

本文由CoinEx Chain团队Chase写作,转载无需授权。