WebAssembly汇编语言程序设计初步

Posted Jtag特工

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了WebAssembly汇编语言程序设计初步相关的知识,希望对你有一定的参考价值。

WebAssembly汇编语言程序设计初步

随着前端页面变得越来越复杂,javascript的性能问题一再被诟病。而Javascript设计时就不是为了性能优化设计的,这使得浏览器上可以运行的本地语言一再受到青睐。

从兼容性上看WebAssembly,从canIUse数据看,已经达到了94.7%的高覆盖率。这个值跟Javascipt的await支持程序差不多。基本上2017年以后的浏览器都支持,距现在已经5年了。主流的Chrome, Chrome for android, Android Browser, Safari, Safari on ios, Edge, Firefox, Opera全部都支持。

既然落地有戏,那我们就正式开始WebAssembly的破冰之旅。

初识WebAssembly

WebAssembly是定义在抽象机器上的一种汇编语言,浏览器负责将其编译成本地代码。负责这部分工作的一般仍然是v8这样的js引擎。
既然是个抽象机,那么就可以跨平台运行。我们既可以用工具链提供的解释器来运行,也可以通过本地的Node.js来运行。

与x86汇编使用命令式的汇编语言不同,WebAssembly使用类似于Lisp语言的S表达式来编写。S表达式就是以括号括起来语句的语言。

我们来看个最简单的例子:

(module
    (func (result i32)
        (i32.const 666)
    )
    (export "const_i32" (func 0))
)

最外面的括号是module,WebAssembly的代码以模块的方式组织。
模块里我们就可以通过func来定义函数。大家可以发现,函数没有名字。
如果想要给外界一个可以访问的名字,要通过export将其绑定到一个名字上。

WebAssembly二进制工具链

我们都知道,要将汇编语言编译机器指令需要汇编器。同时,还需要一大堆二进制的工具链,比如objdump,反汇编工具之类的来打辅助。

WebAssembly Community Group为我们提供了WebAssembly Binary Toolkit(wabt).

wabt是个多git库的工程,下载代码的方法如下:

git clone --recursive https://github.com/WebAssembly/wabt
cd wabt
git submodule update --init

然后可以通过cmake进行编译:

$ mkdir build
$ cd build
$ cmake ..
$ cmake --build .

编译成功之后,汇编器wat2wasm等工具就可以使用了。

比如我们把上节的例子存为test001.wat,就可以这样编译:

wabt/build/wat2wasm test001.wat 

编译成功之后,将生成test001.wasm。

我们可以使用wasm-objdump来查看wasm的内容:

wabt/build/wasm-objdump -s -d -x test001.wasm

其中:-d是反汇编,-x是显示section详情,-s是显示原始数据。

输出内容如下:

test001.wasm:	file format wasm 0x1

Section Details:

Type[1]:
 - type[0] () -> i32
Function[1]:
 - func[0] sig=0 <const_i32>
Export[1]:
 - func[0] <const_i32> -> "const_i32"
Code[1]:
 - func[0] size=5 <const_i32>

Code Disassembly:

000026 func[0] <const_i32>:
 000027: 41 9a 05                   | i32.const 666
 00002a: 0b                         | end

Contents of section Type:
000000a: 0160 0001 7f                             .`...

Contents of section Function:
0000011: 0100                                     ..

Contents of section Export:
0000015: 0109 636f 6e73 745f 6933 3200 00         ..const_i32..

Contents of section Code:
0000024: 0105 0041 9a05 0b                        ...A...

将wasm代码运行起来

通过wasm解释器运行

wabt提供了wasm解释器wasm-interp. 我们可以运行所有export出来的函数,例如:

wabt/build/wasm-interp --run-all-exports ./test001.wasm

输出如下:

const_i32() => i32:666

在Node.js环境中运行

因为WebAssembly是标准,所以不需要安装任何三方包就可以使用。

运行WebAssembly中的函数只需要三步:

  1. 通过WebAssembly.compile编译buffer中的二进行wasm
  2. 通过WebAssembly.instantiate建立WebAssembly的实例
  3. 通过实例的exports中的方法来运行功能

在Node环境中,我们只要用fs API将文件读出来就可以直接运行了:

const readFileSync = require('fs')

const outputWasm = './test001.wasm';

async function run()
    const buffer = readFileSync(outputWasm);
    const module = await WebAssembly.compile(buffer);
    const instance = await WebAssembly.instantiate(module);
    console.log(instance.exports.const_i32());


run();

在浏览器中运行

因为WebAssembly是标准,所以在浏览器中与Node.js中的API用法完全一样。不同的只是如何读取wasm文件。

下面我们启动一个本地服务器,使用fetch函数获取本地的wasm文件:

<!DOCTYPE html>
<html lang="en">
  <meta charset="utf-8" />
  <head>
    <script type="text/javascript">
      function fetchAndInstantiate(url) 
        return fetch(url)
          .then((response) => response.arrayBuffer())
          .then((bytes) => WebAssembly.instantiate(bytes))
          .then((results) => results.instance);
      

      function test001() 
        fetchAndInstantiate("./test001.wasm").then((instance) => 
          alert(instance.exports.const_i32());
        );
      
    </script>
  </head>
  <body onload="test001()">
    <p> WebAssembly测试页</p>
  </body>
</html>

运行效果如下:

反汇编wasm

既然是汇编语言,能做汇编,也能比较容易地做反汇编。

wabt提供了wasm2wat工具反汇编wasm文件:

wabt/build/wasm2wat test001.wasm

我们看看反汇编出来的结果:

(module
  (type (;0;) (func (result i32)))
  (func (;0;) (type 0) (result i32)
    i32.const 666)
  (export "const_i32" (func 0)))

我们可以看到,反汇编出来的结果,比我们手写的多了类型的定义。
这类型既然汇编器可以推断出来,那么我们还是不用手写了。

函数传参

通过反汇编我们可以看到,函数是没有名字的。只有需要导出给外部调用的时候才绑定一个符号做名字。
同样,函数参数也是没有名字的。
但是,我们可以在写汇编的时候给一个形式参数。在wat中,函数参数要用param关键字声明,可以给一个"$"开头的名字。

我们来看个例子:

(module
    (func (param $a i32) (result i32)
        (i32.add 
            (local.get $a) 
            (i32.const 1)
        )
    )
    (export "inc_i32" (func 0))
)

用wasm2wat反汇编出来的结果如下:

  (type (;0;) (func (param i32) (result i32)))
  (func (;0;) (type 0) (param i32) (result i32)
    local.get 0
    i32.const 1
    i32.add)
  (export "inc_i32" (func 0)))

我们发现’$a’形参已经不见了,在声明时直接就不存在了,在调用的时候变成了序号0.

另外我们还发现指令顺序的变化。我们采用的前序表达式,或者是叫波兰表达法,i32.add指令在前,它的两个操作数在后面。
反汇编之后,变成了逆波兰表达式,也就是后序后达式,i32.add这个指令在后面,它的两个操作数在前面。

算术运算

WebAssembly,以下简称wasm,的数字有4种类型:

  • i32: 有符号32位整数
  • i64: 有符号64位整数
  • f32: 有符号32位浮点数
  • f64: 有符号32位浮点数

针对这4种类型,有完整的4套指令集。

没有无符号数字类型,但是整数类型有无符号计算的指令。

对应4种基本数字类型,有4条指令是将一个这种类型的常量压入栈的操作,它们是i32.const, i64.const, f32.const和f64.const。

加减乘法

加法共4种,每种类型一种:

  • i32.add
  • i64.add
  • f32.add
  • f64.add

减法也是4种,每种类型一种:

  • i32.sub
  • i64.sub
  • f32.sub
  • f64.sub

乘法也一样:

  • i32.mul
  • i64.mul
  • f32.mul
  • f64.mul

我们来看一个f32乘法的例子吧:

(module
    (func (param $a f32) (result f32)
        (f32.mul 
            (local.get $a) 
            (f32.const 1024)
        )
    )
    (export "mul_1k_f32" (func 0))
)

写个Node.js脚本运行一下:

const readFileSync = require('fs')

const outputWasm = './test003.wasm';

async function run()
    const buffer = readFileSync(outputWasm);
    const module = await WebAssembly.compile(buffer);
    const instance = await WebAssembly.instantiate(module);
    console.log(instance.exports.mul_1k_f32(3.14));


run();

除法

除法对于浮点数比较简单,只有div一条指令:

  • f32.div
  • f64.div

对于整数来说,除法分为有符号除法和无符号除法:

  • i32.div_s
  • i64.div_s
  • i32.div_u
  • i64.div_u

除此之外,还有有符号求余数和无符号求余数两条指令:

  • i32.rem_s
  • i64.rem_s
  • i32.rem_u
  • i64.rem_u

我们以64位求余数为例:

(module
    (func (param $a i64) (param $b i64) (result i64)
        (i64.rem_u 
            (local.get $a) 
            (local.get $b)
        )
    )
    (export "rem_u_i64" (func 0))
)

i64类型在Node.js上运行的时候,需要输入为BigInt,在输入的时候要加一个"n"后缀:

const readFileSync = require('fs')

const outputWasm = './test_remu.wasm';

async function run()
    const buffer = readFileSync(outputWasm);
    const module = await WebAssembly.compile(buffer);
    const instance = await WebAssembly.instantiate(module);
    console.log(instance.exports.rem_u_i64(1000n,256n));


run();

浮点数特有指令

  • 绝对值
    • f32.abs
    • f64.abs
  • 取反
    • f32.neg
    • f64.neg
  • 取整
    • 向上取整
    • 向下取整
    • 向0取整
    • 向最接近的整数取整
  • 平方根
    • f32.sqrt
    • f64.sqrt
  • 最大最小值
    • f32.min
    • f64.min
    • f32.max
    • f64.max
  • 取符号位
    • f32.copysign
    • f64.copysign

我们挑不熟悉的copysign说起吧。它做的事情就是把当前数的正负号换成另一个数的正负号。

(module
    (func (param $a f64) (result f64)
        (f64.copysign
            (local.get $a) 
            (f64.const -1.0) 
        )
    )
    (export "copysign_f64" (func 0))
)

我们是将-1.0的符号,复制给copysign_f64函数传来的64位整数。

我们传一个3.14试试:

const readFileSync = require('fs')

const outputWasm = './copysign.wasm';

async function run()
    const buffer = readFileSync(outputWasm);
    const module = await WebAssembly.compile(buffer);
    const instance = await WebAssembly.instantiate(module);
    console.log(instance.exports.copysign_f64(3.14));


run();

运行结果为-3.14,果然换成了-1.0的符号。

比较指令

等于0

只有整数可以判断是否为0,所以是两种整数各一条指令:

  • i32.eqz
  • i64.eqz

我们来看个例子:

(module
    (func (param $a i32) (result i32)
        (i32.eqz 
            (local.get $a)
        )
    )
    (export "i32_eqz" (func 0))
)

运行一下:

const readFileSync = require('fs')

const outputWasm = './cmp.wasm';

async function run()
    const buffer = readFileSync(outputWasm);
    const module = await WebAssembly.compile(buffer);
    const instance = await WebAssembly.instantiate(module);
    console.log(instance.exports.i32_eqz(0));
    console.log(instance.exports.i32_eqz(-1));


run();

输出为1,0.

相等与不相等

相等4条:

  • i32.eq
  • i64.eq
  • f32.eq
  • f64.eq

不等4条:

  • i32.ne
  • i64.ne
  • f32.ne
  • f64.ne

小于与大于

浮点数比较简单,小于是lt,大于是gt:

  • f32.lt
  • f32.gt
  • f64.lt
  • f64.gt

整数还分为有符号和无符号两种情况:

  • i32.lt_s
  • i32.lt_u
  • i64.lt_s
  • i64.lt_u
  • i32.gt_s
  • i32.gt_u
  • i64.gt_s
  • i64.gt_u

如果是小于或等于,将lt换成le;如果是大于或等于,则将gt换成ge。

流程控制语句

函数调用

函数虽然没有名字,但是我们可以用一个引用来标记它,然后使用call指令来调用它。

我们用i32_eqz2给上节的i32_eqz函数封装一下。

(module
    (func $f1 (param $a i32) (result i32)
        (i32.eqz 
            (local.get $a)
        )
    )
    (func (param $b i32) (result i32)
        (call $f1 (local.get $b))
    )
    (export "i32_eqz2" (func 1))
)

我们看反汇编的结果,引用值被翻译成了函数的索引号:

(module
  (type (;0;) (func (param i32) (result i32)))
  (func (;0;) (type 0) (param i32) (result i32)
    local.get 0
    i32.eqz)
  (func (;1;) (type 0) (param i32) (result i32)
    local.get 0
    call 0)
  (export "i32_eqz2" (func 1)))

分支判断

Wasm中提供了if指令,它会从栈顶中读取一个i32类型的整数,如果参数不为0,则执行then块的代码;否则执行else块的代码。

我们来看个例子,再重写一遍i32_eqz:

(module
    (func (param $a i32)(result i32)
        (local.get $a)
        (if (result i32)
            (then (i32.const 0))
            (else (i32.const 1))
        ) 
    )
    (export "i32_eqz3" (func 0))
)

if可以像一个函数一样返回一个值。这时需要then和else都要有。

强调下,if判断的条件不是立即数,需要事先放到栈中。否则汇编会报错。

我们看下反汇编之后的结果,then并不是一个关键字,我们也可以用if…else…end的结构来写:

(module
  (type (;0;) (func (param i32) (result i32)))
  (func (;0;) (type 0) (param i32) (result i32)
    local.get 0
    if (result i32)  ;; label = @1
      i32.const 0
    else
      i32.const 1
    end)
  (export "i32_eqz3" (func 0)))

我们可以像下面这样写,注意不要给if外面加括号,否则(if)块是期望(then)和(else)块的。

(module
    (func (param $a i32)(result i32)
        (local.get $a)
        if (result i32)
            i32.const 0
        else
            i32.const 1
        end 
    )
    (export "i32_eqz4" (func 0))
)

循环

loop指令用于循环。
如果想要提前进行下一轮循环,可以使用br指令,这时候相当于C语言中的continue语句。

如果想要退出循环,可以使用br_if指令。

循环比较命令化,我就直接按照命令式语言的方式来写汇编了:

(module
    (func (param $a i32)(result i32)
        (local $sum i32)
        (local.set $sum (i32.const 0))
        loop
            local.get $a
            i32.const -1
            i32.add
            local.set $a
            local.get $a
            local.get $sum
            i32.add
            local.set $sum
            local.get $a
            br_if 0
        end
        (return (local.get $sum))
    )
    (export "i32_sum" (func 0))
)

local指令用于定义局部变量。
local.set指令用于为局部变量赋值。
return指令用于函数返回。

SIMD指令

WebAssembly虽然是个抽象的机器,但是也要发恢硬件的能力。
SIMD是单指令多数据的缩写。
说起SIMD,在Intel CPU上最早始于1996年的MMX指令集。它能将一个64位寄存器当成2个32位寄存器或者8个8位寄存器一起使用。
1999年,在Pentiun III处理器上引入了支持128位的寄存器的SSE指令集。
2008年,Intel在第二代Core处理器Sandy Bridge上引入了支持256位寄存器的AVX指令集。
2013年,Intel发布512位寄存器的AVX 512指令集。AVX 512指令集会导致功耗大大增加,被Linus Torvalds评论:“I hope AVX-512 dies a painful death”.
扯远了,WebAssembly也支持128位的SIMD的指令集,称为向量指令集。

空说有点抽象,我们来看个例子:

(module
    (func (result i32)
        v128.const i32x4 1 1 1 1 
        v128.const i32x4 2 2 2 2
        i32x4.add
        v128.any_true
        return
    )
    (export "v128_anytrue" (func 0))
)

不同于i32,i64,f32,f64这样的数值常量,v128的常量要指令是如何解释128位的用法的。比如本例中我们将其当作4个32位寄存器使用。
这时候,我们做操作就要使用i32x4的指令集。

但是同时,我们也可以针对v128整体进行处理,使用v128的指令集。

再举个例子,我们想使用swizzle指令给8x16个数字重新排一下序:

用8x16的指令写成如下:

        v128.const i8x16 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
        v128.const i8x16 1 2 0 3 4 5 6 7 8 9 10 11 12 13 14 15
        i8x16.swizzle

反汇编一下我们发现,原来被反汇编成了i32x4格式的。但是完全不影响i8x16指令的使用。

    v128.const i32x4 0x04030201 0x08070605 0x0c0b0a09 0x100f0e0d
    v128.const i32x4 0x03000201 0x07060504 0x0b0a0908 0x0f0e0d0c
    i8x16.swizzle

Wasm中的SIMD指令最早是作为Javascript的扩展SIMD.js开发的,现在作为wasm的一部分,详情请参看:https://github.com/WebAssembly/simd/tree/main/proposals

小结

本文简要介绍了WebAssembly抽象机的指令集和汇编语言的写法和运行方法。
有了这个基础,我们再看通过emsdk编译出来的wasm代码就不心慌了,看到v8相关的代码也容易理解到底在做些什么。

以上是关于WebAssembly汇编语言程序设计初步的主要内容,如果未能解决你的问题,请参考以下文章

WebAssembly汇编语言程序设计初步

如何在 Go 语言开发的宿主程序中嵌入 WebAssembly

Oracle在GraalVM中实现WebAssembly引擎GraalWasm

关于java取余问题

说说WebAssembly

WebAssembly,可以作为任何编程语言的编译目标,使应用程序可以运行在浏览器或其它代理中——浏览器里运行其他语言的程序?