«

《WebAssembly标准入门》读书笔记之快速入门

image.png

—— WebAssembly,一次编译到处运行。

[toc]

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


—— WebAssembly,一次编译到处运行。

[toc]

本文是《WebAssembly标准入门》第二章节主要内容,介绍WebAssembly的关键概念,并通过2个基础例子来对WebAssembly形成一个大致的印象,以便掌握其基本的用法和原理。

WebAssembly概览

JavaScript代码运行在JavaScript虚拟机上,相对地,WebAssembly代码也运行在其特有的虚拟机上。大部分最新的浏览器均提供了WebAssembly虚拟机(见后文兼容性介绍),然而WebAssembly代码并非只能在浏览器中运行,Node.js 8.0之后的版本也能运行WebAssembly;更进一步来说,WebAssembly虚拟机甚至可以脱离JavaScript环境的支持。不过正如其名,WebAssembly的设计初衷是运行于网页之中,因此本书绝大部分内容均是围绕网页应用展开。

WebAssembly中的关键概念

在深入WebAssembly内部运行机制之前,我们暂时将其看作一个黑盒,这个黑盒需要通过一些手段来与外部环境(调用WebAssembly的JavaScript网页程序等)进行交互,这些手段可以抽象为以下几个关键概念:

  1. 模块
    模块是已被编译为可执行机器码的二进制对象,模块可以简单地与操作系统中的本地可执行程序进行类比,是无状态的(正如Windows的.exe文件是无状态的)。模块是由WebAssembly二进制汇编代码(.wasm)编译而来的。

  2. 内存
    在WebAssembly代码看来,内存是一段连续的空间,可以使用loadstore等低级指令按照地址读写其中的数据(时刻记住WebAssembly汇编语言是一种低级语言),在网页环境下,WebAssembly内存是由JavaScript中的ArrayBuffer对象实现的,这意味着,整个WebAssembly的内存空间对JavaScript来说是完全可见的,JavaScript和WebAssembly可以通过内存交换数据。

  3. 表格
    C/C++等语言强烈依赖于函数指针,WebAssembly汇编代码作为它们的编译目标,必须提供相应的支持。受限于WebAssembly虚拟机结构和安全性的考虑,WebAssembly引入了表格对象用于存储函数引用。

  4. 实例
    在WebAssembly中,实例用于指代一个模块及其运行时的所有状态,包括内存、表格以及导入对象等,配置这些对象并基于模块创建一个可被调用的实例的过程称为实例化。模块只有在实例化之后才能被调用——这与C++/Java中类与其实例的关系是类似的,也类似于可执行程序与进程的关系; 导入/导出对象是模块实例很重要的组成部分,模块内部的WebAssembly代码可以通过导入对象中的导入函数,调用外部JavaScript环境的方法,导出对象中的导出函数是模块提供给外部JavaScript环境使用的接口。 按照目前的规范,一个实例只能拥有一个内存对象以及一个表格对象。内存对象和表格对象都可以通过导入/导出对象被多个实例共有,这意味着多个WebAssembly模块可以以.dll动态链接库的模式协同工作。

WebAssembly程序生命周期

整个生命周期如图所示。 image.png

WebAssembly程序从开发到运行于网页中大致可以分为以下几个阶段。
- (1)使用WebAssembly文本格式或其他语言(C++、Go、Rust等)编写程序,通过各自的工具链编译为WebAssembly汇编格式.wasm文件。 - (2)在网页中使用 fetch、XMLHttpRequest 等获取.wasm文件(二进制流)。 - (3)将.wasm编译为模块,编译过程中进行合法性检查。 - (4)实例化。初始化导入对象,创建模块的实例。 - (5)执行实例的导出函数,完成所需操作。

第2步到第5步为WebAssembly程序的运行阶段,该阶段与JavaScript环境密切相关,后续例子中有详细分析这两部分的相关代码。

WebAssembly虚拟机体系结构

WebAssembly模块在运行时由以下几部分组成,如图所示。
image.png

在WebAssembly中,操作某个具体的对象(如读写某个全局变量/局部变量、调用某个函数等)都是通过其索引完成的。在当前版本中,所有的“索引”都是32位整型数。。

WebAssembly兼容性

你可以在 浏览器 或 Node.js 的环境下运行 WebAssembly,基本上现代浏览器都已支持 WebAssembly,常见的桌面浏览器兼容性如下:

image.png

常见移动版(Android及iOS)浏览器对WebAssembly特性的支持情况如表所示,表内的数字表示浏览器版本。

image.png

当浏览器启用WebAssembly模块时,会强行启用同源及沙盒等安全策略。因此WebAssembly示例需通过http服务部署后方可运行。

一个简单的方法,就是在 Intellij IDEA 中通过浏览器打开HTML文件,即可通过HTTP Server的方式加载,如下图所示: image.png

当然也可以使用Nginx、IIS、Apache或任意一种惯用的工具(比如Go Present)来完成该操作。

准备工作

工欲善其事,必先利其器,在正式开始之前,需要先准备好兼容WebAssembly的运行环境以及WebAssembly文本格式转换工具集。

WebAssembly文本格式与wabt工具集

一般来说,浏览器加载并运行的WebAssembly程序是二进制格式的WebAssembly汇编代码,文件扩展名通常为.wasm。由于二进制文件难以阅读编辑,WebAssembly提供了一种基于S-表达式的文本格式,文件扩展名通常为.wat。下面是一个WebAssembly文本格式的例子:

 (module
    (import "console" "log" (func $log (param i32)))
    (func $add (param i32 i32)
        get_local 0
        get_local 1
        i32.add
        call $log
    )
    (export "add" (func $add))
)

上述程序定义了一个名为$add的函数,该函数将两个i32类型的输入参数相加,并使用由外部JavaScript导入的log函数将结果输出。最后该add函数被导出,以供外部JavaScript环境调用。

WebAssembly文本格式(.wat)与WebAssembly汇编格式(.wasm)的关系类似于宏汇编代码与机器码的关系。如同.asm文件向机器码转换需要使用nasm这样的编译工具一样,.wat文件向.wasm文件的转换需要用到wabt工具集,该工具集提供了.wat与.wasm相互转换的编译器等功能。

wabt工具集可从GitHub上下载获取。wat2wasm工具及本文代码可以从文末附件下载,在命令行下执行:

wat2wasm input.wat -o output.wasm  

即可将WebAssembly文本格式文件input.wat编译为WebAssembly汇编格式文件output.wasm。

使用-v选项调用wat2wasm可以查看汇编输出,例如将前述的代码保存为test.wat并执行:wat2wasm test.wat -v

终端输出如图所示。

image.png

WebAssembly调试及代码编辑环境

作为最主要的WebAssembly运行平台,浏览器普遍提供了WebAssembly的调试环境,当页面上包含WebAssembly模块时,可以使用开发面板对其运行进行调试。

下图是在Chrome中使用F12调出开发面板调试程序的截图,我们可以在WebAssembly的函数体中下断点,查看局部变量/全局变量的值,查看调用栈和当前函数栈等。 image.png

WebAssembly文本格式(.wat)文件可以使用任何文本编辑器编辑,对于Windows用户我们推荐使用VSCode,并安装WebAssembly插件,如下图所示:
image.png

该插件除.wat文件编辑器语法高亮之外,甚至还支持直接打开.wasm文件反汇编,如图是安装插件后打开下文例子中hello.wasm文件的截图: image.png

最简单的例子

很多语言的入门教程都始于“Hello, World!”例程,但是对于WebAssembly来说,一个完整的“Hello, World!”程序仍然过于复杂,因此,我们将从一个更简单的例子开始,完整代码参见文末附件

本节的例程名为ShowMeTheAnswer,其中WebAssembly代码位于showmethe_answer.wat中,如下:

(module
    (func (export "showMeTheAnswer") (result i32)
        i32.const 42
    )
)

上述代码定义了一个返回值为42(32位整型数)的函数,并将该函数以showMeTheAnswer为名字导出,供JavaScript调用。

JavaScript代码位于showmethe_answer.html中,如下:

<!doctype html>  
<html>  
    <head>
        <meta charset="utf-8">
        <title>Show me the answer</title>
    </head>
    <body>
        <script>
            fetch('show_me_the_answer.wasm').then(response =>
                response.arrayBuffer()
            ).then(bytes =>
                WebAssembly.instantiate(bytes)
            ).then(result =>
                console.log(result.instance.exports.showMeTheAnswer()) //42
            );
        </script>
    </body>
</html>  

上述代码首先使用fetch()函数获取WebAssembly汇编代码文件,将其转为ArrayBuffer后使用WebAssembly.instantiate()函数对其进行编译及初始化实例,最后调用该实例导出的函数showMeTheAnswer()并打印结果。 将例程目录发布后,通过浏览器访问showmethe_answer.html,浏览器控制台应输出结果42,如图所示。

image.png

Hello, WebAssembly

本节将介绍经典的HelloWorld例子,完整代码参见文末附件

开始之前让我们先梳理一下需要完成哪些功能。 - (1)函数导入。WebAssembly虚拟机本身没有提供打印函数,因此需要将JavaScript中的字符串输出功能通过函数导入的方法导入WebAssembly中供其使用。 - (2)初始化内存,并在内存中存储将要打印的字符串。 - (3)函数导出,提供外部调用入口。

WebAssembly部分

在WebAssembly部分,首先将来自JavaScript的 js.print 对象导入为函数,并命名为 js.print

;;hello.wat
(module
    ;;import js:print as js_print():
    (import "js" "print" (func $js_print (param i32 i32)))

该函数有两个参数,类型均为32位整型数,第一个参数为将要打印的字符串在内存中的开始地址,第二个参数为字符串的长度(字节数)。

提示: WebAssembly文本格式中,双分号“;;”表示该行后续为注释,作用类似于JavaScript中的双斜杠“//”

接下来将来自JavaScript的 js.mem 对象导入为内存:

(import "js" "mem" (memory 1)) ;;import js:mem as memory

memory后的1表示内存的起始长度至少为1页(在WebAssembly中,1页=64 KB=65 536字节)。

接下来的 data 段将字符串"你好,WASM"写入了内存,起始地址为0:

(data (i32.const 0) "你好,WASM")

最后定义了导出函数 hello():

    (func (export "hello")
        i32.const 0    ;;pass offset 0 to js_print
        i32.const 13   ;;pass length 13 to js_print
        call $js_print
    )
)

函数hello()先后在栈上压入了字符串的起始地址以及字符串的字节长度13(每个汉字及全角标点的UTF-8编码占3字节),然后调用导入的函数打印输出。

JavaScript部分

在JavaScript部分,首先创建内存对象wasmMem,初始长度为1页:

var wasmMem = new WebAssembly.Memory({initial:1});  

接下来定义用于打印字符串的方法printStr()

function printStr(offset, length) {  
    var bytes = new Uint8Array(wasmMem.buffer, offset, length);
    var string = new TextDecoder('utf8').decode(bytes);
    console.log(string);
}

对应于.wat部分的定义,该方法的两个参数分别为字符串在内存中的起始地址及字节长度。从内存中获取字节流后,使用TextDecoder将其解码为字符串并输出。

然后将上述Memory对象wasmMem、printStr()方法组合成对象importObj,导入并实例化:

var importObj = { js: { print: printStr, mem: wasmMem } };

fetch('hello.wasm').then(response =>  
    response.arrayBuffer()
).then(bytes =>
    WebAssembly.instantiate(bytes, importObj)
).then(result =>
    result.instance.exports.hello()
);

最后调用实例的导出函数hello(),在控制台输出“你好,WASM”,如下图所示。 image.png

分享