Wasm 介绍(六):间接函数调用

在前面的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撰写。CoinEx Chain是全球首条基于Tendermint共识协议和Cosmos SDK开发的DEX专用公链,借助IBC来实现DEX公链、智能合约链、隐私链三条链合一的方式去解决可扩展性(Scalability)、去中心化(Decentralization)、安全性(security)区块链不可能三角的问题,能够高性能的支持数字资产的交易以及基于智能合约的Defi应用。

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章