查看原文
其他

WebAssembly 模块解析

The following article is from 字节前端 ByteFE Author 张地、杨文明

1. 前言

在前面章节中,我们已经对 WebAssembly 的关键特性、历史演变和核心的应用场景做了详细的介绍;基于

对 WebAssembly 的入门和初步了解,在第二部分的各个章节中,我们会从 WebAssembly 模块入手,和大家一起学习 WebAssembly 基础知识,包括核心规范,核心开发语言和工具链以及常用的执行引擎等相关内容。

本文将从 WebAssembly 模块入手介绍相关基础概念和 W3C 二进制格式核心规范,与此同时,进一步介绍 WebAssembly 的文本格式及语法,并给出一个 WebAssembly 文本 demo;以便读者可以与本文格式的介绍相互印证,进一步加深理解。

2. 基础概念

WebAssembly 本质上也是一种语言格式,而且其在二进制格式之外还支持文本格式,它也是可人工编程的。与其他语言一样,它也包含变量、函数、指令等基础概念。本小节将首先介绍 WebAssembly 的基础概念。

2.1 模块(Modules)

模块(Module)是 WebAssembly 的基本单元;一个模块内部包含了完成其功能所需的函数、线性内存、全局变量以及表的完整定义;此外,模块还是通过导入功能从外部执行环境引入对象,同时,可以通过导出功能提供外部执行环境可用的对象,从而实现与外部的双向交互。

模块作为一个程序的静态表示,它需要经过加载、解码与验证之后进行实例化,从而得到该程序的动态表示,我们称之为一个 WebAssembly 实例(instance)。WebAssembly 模块的实例化在宿主语言或是独立的虚拟机中完成:如在 JavaScript 环境中,该过程可以通过 WebAssembly.instantiate 或者 new WebAssembly.Instance 等函数接口实现。一般而言,一个 WebAssembly 实例包括一个操作数栈和一块可变的线性内存,与原生(native)程序中的栈和堆相对应,如下图 1 所示。

图 1. WebAssembly 模块实例

2.2 类型(Types)

在 WebAssembly 生态中,一个基本设定就是 WebAssembly 使用通用的硬件能力,并在此之上构建一个虚拟的指令集架构(ISA)。遵循这个设定,WebAssembly 提供了四种基本变量类型,分别是 i32i64f32f64,用来表示两种长度的整数和浮点数。其中,整型数值不区分有符号或无符号,但是针对整型的操作符通常分为 signedunsigned 两种版本,以各自的方式对数值进行理解和进一步的操作。

在 4 种基本类型之外,MVP 之后的 WebAssembly 核心规范吸收了 Fixed-width SIMD[4] 和 Reference Type[5] 两个提案,支持向量类型 v128 和引用类型。前者可以表示不同类型的打包数据,如 2 个 i64f64、4 个 i32f32 以及 8 个 16 位的整型。后者则可以表示各类函数或者来自宿主的实体(如一个对象的指针)。

2.3 变量(Variables)

WebAssembly 的变量按照作用域,可以分为局部变量(local)和全局变量(global)两种。

局部变量只存在于一个函数内部,并在函数开始时进行声明。局部变量总是可变的,并与函数的参数共用索引空间,通过 i32 类型的 index 来访问。比如,指令 local.get 0 即表示加载序号为 0 的局部变量,如果该函数有参数的话,该指令表示加载第一个参数,否则加载第一个局部变量。

全局变量可声明为可变或不可变的。与局部变量类似,通过序号和指令 global.get 或者 global.set 进行访问。此外,global 可以由外部引入,或者被导出到外部。

2.4 函数(Functions)

函数是 WebAssembly 指令的组织单位——每一条指令都必须属于某个函数。每一个函数都会接受一系列特定类型的参数,并返回一系列特定类型的值。值得注意的是,WebAssembly 函数可以返回多个值,而非局限于单返回值。在 WebAssembly 的规范中,函数并不允许嵌套定义。

2.5 指令(Instructions)

基于一个栈式的机器构建其计算模型,是 WebAssembly 的一个重要特征。因此,WebAssembly 的指令均围绕着一个隐式的操作数栈进行设计——通过压入和弹出的操作进行数据的操作。如前述的局部变量获取指令 local.get idx,就是加载第 idx 个局部变量到操作数栈栈顶。

考虑到 WebAssembly 产物将频繁地通过网络进行传输,如是的设计与基于寄存器的指令集相比可以得到体积更小的产物,因而可以减少网络延迟[2]。

2.6 陷阱(Traps)

在某些情况下,如遇到一条 unreachable 指令,会出现一个陷阱。它会马上中断 WebAssembly 指令的执行,并且返回到宿主,因为 WebAssembly 内部无法处理陷阱。通常情况下,宿主会捕获 WebAssembly 陷阱,并进入处理例程。如 JavaScript 环境中,遭遇因 unreachable 指令产生的 WebAssembly 陷阱,JS 引擎会抛出一个 RuntimeError,如下图 2 所示。

图 2. WebAssembly Trap

2.7 表(Tables)

WebAssembly 的表是一系列元素的向量,其中元素类型目前只支持前述的引用类型funcref | externref。表在定义时,要求提供存储的元素类型和初始大小,并可以选择性地指定最大大小。Table 主要作用就是可以动态地根据所提供的 index 来获取目标元素。比如,可以借助 Table 在运行时决定调用某个函数,使用 call_indirect 实现间接调用,从而模拟函数指针。

2.8 线性内存(Linear memory)

顾名思义,线性内存表示一个连续字节数组,作为 WebAssembly 程序的主要内存空间。

一个 WebAssembly 模块暂时(WebAssembly 1.0/2.0 SPEC[3])只允许定义至多一个 memory,并可以通过 import/export 跟其他实例共享(但并非所有引擎都支持)。与表类似,在定义线性内存时,要求提供初始大小,并可以选择性地指定最大大小,其单位是页(page,大小为 64KB)。在 WebAssembly 程序的运行过程中,可以通过指令 grow_memory 动态地扩展线性内存的大小。

通过 load/store 指令,并提供 i32 类型的偏移值参数,WebAssembly 程序可以完成对线性内存的读写。当然,会导致越界访问的偏移值会被动态的检测所拦截,并产生一个陷阱。考虑到线性内存是独立且纯粹的,与指令空间、执行栈、WebAssembly 引擎的元数据结构等均处于分离状态,故错误甚至恶意的 WebAssembly 代码最多只能破坏自身的 memory。这样的沙盒属性,对于安全运行通过网络传输、不受信任的 WebAssembly 代码至关重要。

图 3. WebAssembly 线性内存

3 模块结构

WebAssembly 的二进制格式是 WebAssembly 的抽象语法的密集线性编码。和文本格式一样,产物是由抽象语法生成的,最终产生的终端符号是字节。包含二进制格式 WebAssembly 模块的文件的推荐扩展名是 ".wasm",推荐的媒体类型是 "application/wasm"。

本小节将重点介绍 WebAssembly 二进制格式的文件结构;指令的二进制编码,可以阅读 W3C 核心规范该文档[6]。

3.1 部分(Sections)

一个完整的 WebAssembly 模块包含 12 个部分,部分和文本格式的域概念相近;部分是可选的,模块中省略的部分相当于该部分存在空内容;WebAssembly 模块结构如下图 4 所示。

图 4. WebAssembly 模块段结构示意图

  • 自定义部分(custom section):自定义部分的 id 为 0。它们旨在用于调试信息或第三方扩展,并且被 WebAssembly 语义忽略。它们的内容包括进一步标识自定义部分的名称,后面是用于自定义使用的未解释的字节序列。如果解释器实现了自定义部分的数据,则该数据中的错误或部分的位置不能使模块失效。

  • 类型部分(type section):类型部分由 id 以及一个函数类型向量组成。它表示了模块中的所有自定义函数类型。

  • 函数部分(function section):函数段的 id 为 3。它由类型索引的向量组成,这些类型索引表示模块的 funcs 组件中函数的类型。函数的局部变量和函数体在代码部分中。

  • 表部分(table section),内存部分(memory section),全局部分(global section),导入部分(import section),导出部分(export section):这几部分顾名思义,由每部分名称对应的元素的向量组成。

  • 起始部分(start section):起始部分的 id 为 8,它能表示一个可选的开始函数。

  • 元素部分(element section):元素部分的 id 为 9,它由多个元素段(element segments)组成。元素段的二进制格式有 8 种,开头用一个 u32 整数来区分。u32 的第 0 位表示被动(passive)或声明性(declarative)段,第 1 位表示主动(active)段存在显式表索引(explicit table index),并以其他方式区分被动和声明性段,第 2 位表示使用元素类型和元素表达式,而不是元素类型和元素索引。在 WebAssembly 的未来版本中可能会添加更多的元素类型。

  • 代码部分(code section):代码部分的 id 为 10,它包括了一个代码向量。向量里的每个元素是一段代码,每段代码包括代码长度,函数的局部变量和函数体表达式。与其他部分一样,解码不需要代码大小,但可用于在浏览二进制文件时跳过函数。如果大小与相应函数代码的长度不匹配,则模块格式错误。

  • 数据部分(data section):数据部分的 id 为 11,它由数据向量组成。表示数据的二进制格式有 3 种,用开头的 u32 整数区分。位 0 表示被动段,位 1 表示主动段的显式内存索引的存在。在当前版本的 WebAssembly 中,在单个模块中最多可以定义或导入一个内存,因此所有有效的活动数据段都有一个 0 的内存值。

  • 数据计数部分(data count section):数据计数部分的 id 为 12。它由一个可选的 u32 数字组成,表示数据部分中数据段(data segment)的数量。如果此计数与数据段向量的长度不匹配,则模块格式错误。

3.2 模块(Modules)

本小节会介绍组成一个完整的 WebAssembly 二进制格式模块所需要的内容,除了部分(sections)之外,WebAssembly 二进制文本还需要魔数和版本数来表示这个模块是有效的。下面给出完整的模块定义:

从定义 1 中可以看出,WebAssembly 模块的每一个段都是可选的,只有四字节的魔数和四字节的版本号不可省略。在每一个部分中间,工具链可以选择是否插入自定义部分。除了自定义部分,其他部分必须严格按照定义 1 给出的顺序来组织。

自此,二进制格式基本介绍完成。读者可以根据本小节的内容了解 WebAssembly 二进制格式的组织形式,如果想要更进一步,可以参照标准指令格式文档来实现一个简单的 WebAssembly 加载器 (loader)。

4. 文本格式

作为一种低级的类汇编二进制指令格式,WebAssembly 有二进制格式.wasm 和对应的文本格式.wat。WebAssembly 的文本格式可以直接编写代码或者用于阅读,二进制格式则用于分发和执行。对于初次接触 WebAssembly 的读者,可以简单地将 WebAssembly 文本和二进制的关系理解为汇编语言和机器码的关系。但要注意的是,WebAssembly 编译工具链只会为字节码生成一种二进制产物,并且产物平台无关,因此需要一个虚拟机来解析执行。常见的 WebAssembly 虚拟机有 V8、JSC、WAMR、Wasm3、Wasmtime 等。此外,作为 W3C 标准支持的第四种语言,现代浏览器几乎都实现了 WebAssembly 的运行时(V8,JSC),可以直接执行 WebAssembly 代码。

在本小节中,我们将会详细展开基础概念中较为笼统的部分,让读者能够更深入地理解 WebAssembly 标准的各种细节。

4.1 S-expression

虽然 WebAssembly 是一种类汇编的指令格式,但是它的文本格式和汇编语言的指令-寄存器有些许不同—— WebAssembly 采用 S-表达式(S-expression 或是 sexp)作为文本组织形式。

Sexp 是一种通过人类可读的文本形式表达半结构化数据的约定,它既可以用于表示代码,也能够用于表示数据。因此,使用了 sexp 的语言天然的存在数据和逻辑的一致性。例如 Lisp 强大的 macro system 就得益于此。

下面给出 sexp 的形式化定义:

从定义上不难看出,实际上 sexp 可以表现为一棵树的先序遍历形式。为了表现出这一点,我们给定一个简单的表达式 1,并给出它的二叉树图像:

图 5. S 表达式树形结构

可以看出,这个二叉树的中序遍历就是表达式 1 的原始形式。如果将这个二叉树进行先序遍历,则会得到表达式 2:

我们将其稍微做一点转换,就成为了 sexp 形式:

再做一点形式简化,最终,我们得到了表达式 1 的 sexp 表示:

4.2 词法定义

简而言之,sexp 是一棵 AST 的先序遍历表示。

接下来我们将会进一步展开讨论 WebAssembly 的词法定义。为了不机械地重复 WebAssembly 标准,本节只会有选择地介绍每个词法单元的定义,然后会从 WebAssembly 示例代码开始,逐步深入探索文本格式的奥秘。

WebAssembly 的文本形式是由 WebAssembly 的属性语法(又称属性文法)定义的。属性语法是语法制导定义(Syntax-Directed Definitions,简称 SDD)的无副作用形式。详细内容不在本文展开,读者可以自行阅读 属性语法[7] 相关内容。

由于词法和语法强关联,因此在介绍词法的时候,会连带解释部分语法含义,以便读者更好地理解词法含义。

4.2.1 词法格式(Lexical Format)

WebAssembly 文本由字符串构成,每个字符都是一个合法的 Unicode [8]。首先我们看一个最简单的 WebAssembly 模块——空 module:

(module)
;; this is a linecomment
(;
this is a blockcomment
;)

这段代码展示了一个最小的合法 WebAssembly 文本格式,他的二进制产物可以被 WebAssembly 虚拟机解释执行,不会有任何输出。3 到 5 行展示了 WebAssembly 的两种注释格式,一种是行注释(linecomment),一种是块注释(blockcomment)。行注释最终会忽略分号后的内容,块注释则会忽略括号对里的所有内容。

此外,WebAssembly 支持水平制表(U+09)、换行(U+0A)和回车(U+0D)三种格式化字符。空格,格式化字符以及注释并称为 WebAssembly 的空白(write space),可以看做是无意义的段落。

4.2.2 值(Values)

本小节中我们将介绍 WebAssembly 的词法(lexical syntax),词法是属性语法定义的产物,每个词法单元都必须有意义,因此不允许空白存在。词法中存在五种值:整数(Integers)、浮点数(Floating-Point)、字符串(Strings)、名称(Names)、标识符(Identifiers)。

其中,前三种值很好理解,是我们编程中常用的词法规范。其中,整数包含正负符号,十进制数字,十六进制数字;浮点数包含数字,小数点,十六进制数字和自然底数 e/E;字符串包含了字符,转义符,引号,换行符,制表符,以及\u{hexnum}表示字符 Unicode 编码。

词法的后两种值则略微有点难以理解。首先是名称,名称的常见组织形式是字符串,在 WebAssembly 导出函数时,函数会有特定的名称。其他时候,名称则很少被提到;其次是标识符,标识符可以看做是变量的名字,包含两个词法单元—— id 和 idchar。id 通常是$和多个 idchar 拼接,下面给出具体示例:

(func $add ...)
...
(export "add" (func $add)) ;; "add"是一个名称,$add 是一个 id,'a', 'd', '$'都是 idchar

4.2.3 类型(Types)

WebAssembly 是强类型的,因此文本格式中,值都具有其特定的类型。本小节将会介绍 WebAssembly 文本格式中的类型词法,其中包括:数字(Number)、向量(Vector)、引用(Reference)、值(Value)、函数(Function)、限定(Limits)、内存(Memory)、表(Table)和全局(Global)类型。

  • 数字类型包括 32 和 64 位的整型 i32 i64 和浮点型 f32 f64

  • 向量类型则是 128 位长度的向量 v128,可以解释为整型或者浮点型数据。

  • 引用类型包括函数引用 funcref 或者外部引用 externref。所有的引用类型都是不透明的,用户无法观测到大小或者是位模式(bit pattern),引用类型的值可以存在表里。

  • 值类型则是数字,向量,引用类型的统称。它没有自己的特定的类型标识符,仅仅用于词法定义上

  • 函数类型能够表示完整的函数文本。函数词法定义中,用到了值类型作为占位标识符,下面给出形式化定义:

定义 3 中 t 是值类型,它可以在实际代码中被替换为 i32 i64 f32 f64 v128 funcref externref,下面的示例中,t 最终被替换为 i32

(func $add (param $lhs i32) (param $rhs i32) (result i32) ;; t 最终被替换为 i32
  get_local $lhs
  get_local $rhs
  i32.add
)
  • 限定和内存类型的词法都表示为数字,WebAssembly 中声明一块内存,则可以完全的用限定类型表示,示例中的 1 和 0 1 都是限定类型的文本表示:
(memory 1;; 1 page initialized
(memory 0 1;; maxinum 1 page
  • 表类型也需要用到限定类型,因为需要初始化表的大小,具体可以写成这样:
(table 2 anyfunc) ;; 2 is a limit type
  • 最后就是全局类型。我们可以把全局类型近似于值类型,唯一的区别就是存在一个可选的 mut 字段表示是否是可变的,比如我们声明一个存在不可变 32 位整数参数的全局类型,初始化为 100:
(global $g1 (mut i32) (i32.const 100)) ;; mutable

4.2.4 指令(Instructions)

指令类型内容繁多,主要内容都是 WebAssembly 指令的文本格式内容。本文不在此详细介绍,感兴趣的读者可以查阅 WebAssembly 文本格式指令标准文档[9]。在本文第一大章的最后一节,我们会实现一个简单的 WebAssembly 文本模块,其中也会介绍部分常用指令,读者可以参阅 "4.3 写一个 WebAssembly 模块"。

4.2.5 模块(Modules)

在了解了上面的内容之后,我们终于可以从整体上浏览 WebAssembly 文本了。这一小节,我们会介绍一个完整的 WebAssembly 模块应该具备的所有内容。希望在本小节结束之后,读者们能够上手实现一个完整的 WebAssembly 文本模块。

一个完整的 WebAssembly 文本至少存在一个模块,每个模块由 0 或多个域(fields)组成。在文本中,程序员可以重复声明某个域,在二进制格式中,所有相同的域会合并到一个段中。WebAssembly 模块最多包含 10 个域,分别是:类型(type),导入(import),导出(export),函数(func),表(table),内存(mem),全局(global),起点(start),元素(elem),数据(data)。上面的小节中我们其实已经见过域的文本片段了,下面给出一个拥有所有域的 WebAssembly 模块:

(module
(type ... )
(import ... )
(func ... )
(table ... )
(mem ... )
(global ... )
(export ... )
(start ... )
(elem ... )
(data ... 

接下来,我们会逐渐往这个 WebAssembly 模块中填写内容。

函数域(Functions)

在 4.2.3 小节中,我们已经见过了函数的词法定义(def 3),并且也给出了一个实际的函数定义,这里我们重新解读一下 add 函数的文本写法:

(func $add (param $lhs i32) (param $rhs i32) (result i32) ...)

开头的 (func ...) 其实就是声明了一个函数域。函数的唯一标识符是 $add,接收两个标识符为 $lhs$rhs 的 32 位整数。函数最终返回一个 32 位整数。我们故意忽略了函数体的指令部分用来缩短篇幅,后面会加上它。

类型域(Types)

类型域的定义很像 C 语言中的 typedef,它的用法是给某个特定的函数类型取一个别名,使用的时候可以通过别名来指代函数类型。

(module
  (type $ft1 (func (param i32 i32) (result i32)))
  (func $add (type $ft1) ... )
)

这个案例定义了一个函数类型 $ft1,这个函数接收两个 32 位整数参数,返回一个 32 位整数。第 3 行的 func 域中定义了一个 $add 函数,这个函数的类型就是$ft1

导入导出域(Imports and Exports)

导入域的作用是声明导入的内容。WebAssembly 中存在 4 种导入的对象的类型:函数(func),表(table),内存(memory),全局(global)。这四种类型在 4.2.3 小节已经介绍过了,这里不再赘述。我们来看他的具体形式:

(module
  (import "console" "log" (func $log (param i32)))
  (func (export "logIt") ... )
)

代码中 import 后跟了两个名称,意思是从 console 模块导入 log 方法。名称后的函数域则定义了 log 方法的类型。第 3 行存在一个导出域,export 后也跟了一个 logIt 名称。这行代码的意思是在函数域中定义一个会被导出的函数,导出的名称为 logIt

内存域(Memories)

一块内存(memory)在 WebAssembly 中被定义为一段线性的(linear)未解释的(uninterpreted)原始字节序列(vector of raw bytes)。内存的限定(limits) 中的最小值是内存的初始大小,而最大值(如果存在)则限制了内存最大能增长到的大小。

内存能主动初始化,也可以被数据段初始化,还可以导入导出。也就是说,内存域中可以内联(inline)数据段,也可以内联导入导出域。我们看一个示例:

(module
  (memory $m0 1 1;; limits: {min: 1, max: 1}
  (memory $m1 (data "Hello, " "World!\n")) ;; limits: {min: 1}, inline data
  (memory $m2 (import "env" "m2"1 1;; inline import
  (memory $m3 (export "env" "m3"1 1;; inline export
)

现在的 WebAssembly 模块至多只能存在一块内存,在之后的版本中可能会放开限制。

数据段(Data Segments)

数据段在内存域小节中介绍过,它的用处是用一串字符串(strings)来初始化内存。由于当前版本的 WebAssembly 模块最多存在一块内存,因此在使用数据段初始化内存的时候,需要给定偏移量。看下面的示例:

(module
  (memory 4 16)
  (data (offset (i32.const 100)) "Hello, ")
  (data (offset (i32.const 108)) "World!\n")
)

上面的示例使用 data 段初始化一块内存的另一种方式。由于只有一块内存,所以我们不必指定初始化的内存标识符。在语法定义中,数据段存在两种模式:1. 被动(passive)2. 主动(active)。上面的示例表示的是主动模式下的数据段定义。主动模式会在实例化内存的时候将数据段的内容拷贝到内存中。而被动模式下,data 段的数据只能通过调用 memory.init 指令才能拷贝到内存中:

(module
  (memory 4 16)
  (data $d0 "Hello, " "World!\n";; passive mode
  (func $f0 memory.init $d0) ;; calling `memory.init` instruction in func $f0
)
全局域(Globals)

全局域的作用是定义全局变量,每个全局域能够定义一个全局变量。全局域有 mut 关键词,能够定义变量是否可变。同时,全局域能够内联导入和导出域。

(module
  (global $g1 (import "env" "gbl") i32) ;; import
  (global $g2 (mut i32) (i32.const 100)) ;; mutable
  (global $g3 f32 (f32.const 3.14)) ;; immutable
  (global $g4 (export "gbl") ...) ;; export
)
表域(Tables)

当我们提到函数调用,我们可能首先想起的是静态函数调用,类似于:

(module
  (func $log ...)
  (func (export "writeHi") call $log))
)

上面的代码展示的是静态函数调用,但是在很多语言中, 并不只有静态函数调用,比如 C 语言的函数指针,C++ 的虚函数。WebAssembly 作为这些语言支持的目标格式,需要有能够表示动态调用的方式,这就是表域。表域可以理解为一个对用户透明的数组,数组中存储的元素是 WebAssembly 函数,用户仅能通过下标来访问元素。下面给出一个完整的示例:

(module
(func $f1 (result i32) i32.const 42)
  (func $f2 (result i32) i32.const 13)
  (table funcref (elem (offset i32.const 0) $f1 $f2))
  (type $return_i32 (func (result i32)))
  (func (export "callByIndex") (param $i i32) (result i32)
    local.get $i
    call_indirect (type $return_i32))
)

示例代码声明了一个大小为 2 的表域,表域中的元素类型为任意函数。第 4 行定义了表域中的两个元素,分别是 $f1$f2。第 5 行定义了一个函数类型。第 6 行则最终使用了表域中的元素。callByIndex 函数接收一个参数,这个参数实际上对应的就是表域的下标。如果传入的值是 1,那么 call_indrect 最终会调用表域中下标为 1 的函数(从 0 开始),也就是 $f2

元素段(Element Segments)

元素段能够将一个模块中的任意函数子集以任意顺序列入其中,并允许出现重复。列入其中的函数将会被表格引用并,且引用顺序是元素的排列顺序。

表域的第二个示例代码段中,元素段中的 (i32.const 0) 值是一个偏移量,作用是表明函数引用是在表中的什么索引位置开始存储的。这里我们指定的偏移量是 0,表格大小是 2,因此,我们可以在索引 0 和 1 的位置填入两个引用。如果想在偏移量 1 的位置开始写入引用,那么,我们必须使用 (i32.const 1) 并且表格大小必须是 3。

起始函数(Start Function)

起始函数会在 WebAssembly 模块实例化的时候被调用,调用时间点是在表和内存初始化完毕之后。注意,起始函数的作用是初始化模块的状态,在初始化阶段,外部是访问不到模块以及模块的导出元素的。不要将起始函数和 C 语言的 main 函数混为一谈。

(module
  (func $start ... )
  (start $start)
)

4.3 手写一个简单的 WebAssembly 模块

前一小节完整地介绍了 WebAssembly 的文本格式。在这一节,我们会尝试写一个有意义的 WebAssembly 文本。完成以下步骤:

  1. 声明一个模块
(module)
  1. 插入两个函数类型,其中一个接受一个 i32 类型的参数,另外一个没有参数,且两个都没有返回值
(module
  (type (;0;) (func (param i32)))
  (type (;1;) (func))
)
  1. 声明本模块需要导入一个函数,类型是索引为 0 的函数类型,函数自身的索引为 0
(module
  (type (;0;) (func (param i32)))
  (type (;1;) (func))
  (import "imports" "imported_func" (func (;0;) (type 0)))
)
  1. 增加一个内部定义的函数,使用索引为 1 的函数类型,这个函数自身的索引也为 1。这个函数将一个 i32 类型、值为 88 的常量压入栈,并调用索引为 0 的函数
(module
  (type (;0;) (func (param i32)))
  (type (;1;) (func))
  (import "imports" "imported_func" (func (;0;) (type 0)))
  (func (;1;) (type 1)
    i32.const 88
    call 0)
)
  1. 最后,将第 4 步定义、索引为 1 的函数,用命名 exported_func 导出到外部
(module
(type (;0;) (func (param i32)))
(type (;1;) (func))
(import "imports" "imported_func" (func (;0;) (type 0)))
(func (;1;) (type 1)
  i32.const 88
  call 0)
(export "exported_func" (func 1))
)
  1. 将上述文件保存为 simple.wat,并使用 wat2wasm 转换为 WebAssembly 二进制文件
# install wat2wasm by `npm install -g wat2wasm`
wat2wasm simple.wat -o simple.wasm
  1. 在 JavaScript 中调用它

  2. 读取模块的二进制表示,并存储到一个 ArrayBuffer 中;

  3. 根据模块所需的导入内容构建导入对象 import_obj;

  4. 调用 WebAssembly.instantiate 实例化并调用导出的函数;

  5. 得到了来自 WebAssembly 的问候!

// test.js
// execute:node test.js
const fs = require('fs');
const wasm_buffer = fs.readFileSync("simple.wasm");
const js_func = (i) => console.log("From WebAssembly: " + i);
const import_obj = {imports: {imported_func: js_func}};

WebAssembly.instantiate(wasm_buffer, import_obj)
.then((result) => result.instance.exports.exported_func());

5. 总结

从整体上来看,WebAssembly 文本格式和二进制格式是相近的,都是从基本概念的语法生成而来。我们可以把二进制格式文本的语义和文本格式的语义一一对应。文本格式让用户能够阅读、Debug 工程生成的 WebAssembly 代码,或者用户可以手写文本。

希望通过阅读这篇文章,读者能够学习理解 WebAssembly 的基础知识。更进一步地,期望读者能够获得部分手写 WebAssembly 文本的能力,同时,能够对二进制格式的结构有大概的了解。

6. 参考文献

[1]. WebAssembly Core specification: https://webassembly.github.io/spec/core/intro/introduction.html#wasm
[2] Y. Shi, K. Casey, M. A. Ertl, and D. Gregg. Virtual Machine showdown: Stack versus registers. ACM Transactions on Architecture and Code Optimizations, 4(4):2:1–2:36, Jan. 2008.
[3] WebAssembly Moudles: https://webassembly.github.io/spec/core/syntax/modules.html#memories
[4]. Fixed-width SIMD: https://github.com/webassembly/simd
[5]. Reference Type: https://github.com/WebAssembly/reference-types
[6]. WebAssembly Core Specs: https://webassembly.github.io/spec/core/binary/instructions.html
[7]. Attribute Grammar: https://en.wikipedia.org/wiki/Attribute_grammar
[8]. Unicode: https://www.unicode.org/versions/Unicode15.0.0/
[9]. WebAssembly 文本格式指令标准文档: https://webassembly.github.io/spec/core/text/instructions.html#instructions

点击左下方“阅读原文”,或扫描上方二维码,进入专栏阅读《走进 WebAssembly 的世界》完整版。

继续滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存