06 WAT:如何让一个 WebAssembly 二进制模块的内容易于解读? 你好,我是于航。

在前面的两节课中,我们分别讲解了 Wasm 模块在二进制层面的基本组成结构与数据编码方式。在 04 的结尾,我们还通过一个简单的例子,逐个字节地分析了定义在 C/C++ 源代码中的函数,在被编译到 Wasm 之后所对应的字节码组成结构。

比如字节码 “0x60 0x2 0x7f 0x7f 0x1 0x7f” ,便表示了 Type Section 中定义的一个函数类型(签名)。而该函数类型为 “接受两个 i32 类型参数,并返回一个 i32 类型值”。

我相信,无论你对 Wasm 的字节码组成结构、V-ISA 指令集中的各种指令使用方式有多么熟悉,在仅通过二进制字节码来分析一个 Wasm 模块时,都会觉得无从入手。那感觉仿佛是在上古时期时,直接面对着机器码来调试应用程序。那么,有没有一种更为简单、更具有可读性的方式来解读一个 Wasm 模块的内容呢?答案,就在 WAT。

WAT(WebAssembly Text Format)

首先,我们来直观地感受一下 WAT 的“样貌”。假设我们有如下这样一段 C/C++ 源代码,在这段代码中,我们定义了一个函数 factorial,该函数接受一个 int 类型的整数 n,然后返回该整数所对应的阶乘。现在,我们来将它编译成对应的 WAT 代码。 int factorial(int n) { if (n == 0) { return 1; } else { return n /* factorial(n-1); } }

经过编译和转换后,该函数对应的 WAT 文本代码如下所示。

(func $factorial (; 0 ;) (param $0 i32) (result i32) (local $1 i32) (local $2 i32) (block $label$0 (br_if $label$0 (i32.eqz (get_local $0) ) ) (set_local $2 (i32.const 1) ) (loop $label$1 (set_local $2 (i32.mul (get_local $0) (get_local $2) ) ) (set_local $0 (tee_local $1 (i32.add (get_local $0) (i32.const -1) ) ) ) (br_if $label$1 (get_local $1) ) ) (return (get_local $2) ) ) (i32.const 1) )

WAT 的全称 “WebAssembly Text Format”,我们一般称其为 “WebAssembly 可读文本格式”。它是一种与 Wasm 字节码格式完全等价,可用于编码 Wasm 模块及其相关定义的文本格式。

这种格式使用 “S-表达式” 的形式来表达 Wasm 模块及其定义,将组成模块各部分的字节码用一种更加线性的、可读的方式进行表达。

这种文本格式可以被 Wasm 相关的编译工具直接使用,比如 WAVM 虚拟机、Binaryen 调试工具等。不仅如此,Web 浏览器还会在 Wasm 模块没有与之对应的 source-map 数据时(即无法显示模块对应的源语言代码,比如 C/C++ 代码),使用对应的 WAT 可读文本格式代码来作为代替,以方便开发者进行调试。

OK,既然我们之前提到,WAT 使用了 “S-表达式” 的形式来表达 Wasm 模块及其相关定义,那么接下来,我们就来看看这个 “S-表达式” 究竟是什么?

S-表达式(S-Expression)

“S-表达式”,又被称为 “S-Expression”,或者简写为 “sexpr”,它是一种用于表达树形结构化数据的记号方式。最初,S-表达式被用于 Lisp 语言,表达其源代码以及所使用到的字面量数据。比如,在 Common Lisp 这个 Lisp 方言中,我们可以有如下形式的一段代码。 (print (/* 2 (+ 3 4)) )

不知道你有没有感受到,这段 Lisp 代码与之前我们生成的函数 factorial 所对应 WAT 可读文本代码,在结构上有着些许的相似。在这段代码中,我们调用了名为 print 的方法,将一个简单数学表达式 “2 /* (3 + 4)” 的计算结果值,打印到了系统的标准输出流(stdout)中。

在 “S-表达式” 中,我们使用一对小括号 “()” 来定义每一个表达式的结构。而表达式之间的相互嵌套关系则表达了一定的语义规则。比如在上面的 Lisp 代码中,子表达式 “(/* 2 (+ 3 4))” 的值直接作为了 print 函数的输入参数。而对于这个子表达式本身,也通过内部嵌套的括号表达式及运算符,规定了求值的具体顺序和规则。

不仅如此,每一个表达式在求值时,都会将该表达式将要执行的“操作”,作为括号结构的第一个元素,而对应该操作的具体操作“内容”则紧跟其后。

这里我将“操作”和“内容”都加上了引号,因为 “S-表达式” 可以被应用于多种不同的场景中,所以这里的操作可能是指一个函数、一个 V-ISA 中的指令,甚至是标识一个结构的标识符。而所对应的“内容”也可以是不同类型的元素或结构。因此,这里你只要了解这种通过括号划分出的所属关系就可以了。

对一个 “S-表达式” 的求值会从最内层的括号表达式开始。比如对于上述的 Lisp 代码,我们会首先计算其最内层表达式 “(+ 3 4)” 的值。计算完毕后,该括号表达式的位置会由该表达式的计算结果进行替换。以此类推,从内到外,最后计算出整个表达式的值。当然,除了求值,对于诸如 print 函数来说,也会产生一些如“与操作系统 IO 进行交互”之类的副作用(Side Effect)。

你可以参考下面这张图来理解 “S-表达式” 的组成结构与求值方式(以上述 Lisp 代码为例)。

我们再把目光移回到 WAT 身上。既然我们说,WAT 具有与 Wasm 字节码完全等价的表达能力,可以完全表达通过 Wasm 字节码定义的 Wasm 模块内容。那么从高级语言源代码,到 Wasm 模块字节码、再到对应的 WAT 可读文本代码,这三者是如何做到一一对应的呢?

源码、字节码与 Flat-WAT

为了能够让你更加直观地看清楚从源代码、Wasm 字节码再到 WAT 三者之间的对应关系,首先我们要做的第一件事就是将对应的 WAT 代码 “拍平(flatten)”,将其变成 “Flat-WAT”。这里还是以“factorial” 函数对应生成的 WAT 可读文本代码为例。

“拍平”的过程十分简单。正常在通过 “S-表达式” 形式表达的 WAT 代码中,我们通过“嵌套”与“小括号”的方式指定了各个表达式的求值顺序。而 “拍平” 的过程就是将这些嵌套以及括号结构去掉,以“从上到下”的先后顺序,来表达整个程序的执行流程。

上述 WAT 代码在被“拍平”之后,我们可以得到如下所示的 Flat-WAT 代码(这里我们只列出函数体所对应的部分)。 (func $factorial (param $0 i32) (result i32) block $label$0 local.get $0 i32.eqz br_if $label$0 local.get $0 i32.const 255 i32.add i32.const 255 i32.and call $factorial local.get $0 i32.mul i32.const 255 i32.and return end i32.const 1)

然后我们再将对应 “factorial” 函数的 C/C++ 源代码、Wasm 字节码以及上述 WAT 经过转换生成的 Flat-WAT 代码放到一起,相信你会有一个更加直观的感受。如下图所示,你可以看到 Flat-WAT 代码与 Wasm 字节码会有着直观的“一对一”关系。

模块结构与 WAT

除了我们前面看到的,WAT 可以通过“S-表达式”的形式,来描述一个定义在 Wasm 模块内的函数定义以外,WAT 还可以描述与 Wasm 模块定义相关的其他部分,比如模块中各个 Section 的具体结构。如下所示,这是用于构成一个完整 Wasm 模块定义的其他字节码组成部分,所对应的 WAT 可读文本代码。 (module (table 0 anyfunc) (memory $0 1) (export “memory” (memory $0)) (export “factorial” (func $factorial)) … )

在这里,我们仍然使用 “S-表达式” 的形式,通过为子表达式指定不同的“操作”关键字,进而赋予每个表达式不同的含义。

比如带有 “table” 关键字的子表达式,定义了 Table Section 的结构。其中的 “0” 表示该 Section 的初始大小为 0,随后紧跟的 “anyfunc” 表示该 Section 可以容纳的元素类型为函数指针类型。其他的诸如 “memory” 表达式定义了 Memory Section,“export” 表达式定义了 Export Section,以此类推。

WAT 与 WAST

在 Wasm 的发展初期,曾出现过一种以 “.wast” 为后缀的文本文件格式,这种文本文件经常被用来存放类似 WAT 的代码内容。

但实际上,以 “.wast” 为后缀的文本文件通常表示着 “.wat” 的一个超集。也就是说,在该文件中可能会包含有一些,基于 WAT 可读文本格式代码标准扩展而来的其他语法结构。比如一些与“断言”和“测试”有关的代码,而这部分语法结构并不属于 Wasm 标准的一部分。

相反的,以 “.wat” 为后缀结尾的文本文件,通常只能够包含有 Wasm 标准语法所对应的 WAT 可读文本代码。并且在一个文本文件中,我们也只能够定义单一的 Wasm 模块结构。

因此,在日常的 Wasm 学习、开发和调试过程中,我更推荐你使用 “.wat” 这个后缀,来作为包含有 WAT 代码的文本文件扩展名。这样可以保障该文件能够具有足够高的兼容性,能够适配大多数的编译工具,甚至是浏览器来进行识别和解析。

WAT 相关工具

在这节课的最后,我们来看看与 WAT 相关的编译工具。为了使用下面这些工具,你需要安装名为 WABT(The WebAssembly Binary Toolkit)的 Wasm 工具集。关于如何进行安装,你可以在这里找到答案。安装完毕后,我们便可以使用如下这些工具来进行 WAT 代码的相关处理。

  • wasm2wat:该工具主要用于将指定文件内的 Wasm 二进制代码转译为对应的 WAT 可读文本代码。
  • wat2wasm:该工具的作用恰好与 wasm2wat 相反。它可以将输入文件内的 WAT 可读文本代码转译为对应的 Wasm 二进制代码。
  • wat-desugar:该工具主要用于将输入文件内的,基于 “S-表达式” 形式表达的 WAT 可读文本代码“拍平”成对应的 Flat-WAT 代码。

上述这三个工具的用法十分简单,默认情况下,转译生成的目标代码将被输出到操作系统的标准输出流中。当然,你也可以通过 “-o” 参数来指定输出结果的保存文件。更详细的信息,你可以直接参考该项目在 Github 上的帮助文档。

总结

好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。

本节课我们主要讲解了 WAT,这是一种可以将 Wasm 二进制字节码基于 “S-表达式” 的结构,用“人类可读”的方式展现出来的文本代码格式。

WAT 使用嵌套的“括号表达式”结构来表达 Wasm 字节码的内容,表达式由“操作”关键字与相应的“内容”两部分组成。Wasm 字节码与 WAT 可读文本代码两者之间是完全等价的。

WAT 还有与之相对应的 Flat-WAT 形式的代码。在这个类型的代码中,WAT 内部嵌套的表达式结构(主要是指函数定义部分)将由按顺序平铺开的,由上至下的指令执行结构作为代替。

除此之外,我们还讲解了 “.wast” 与 “.wat” 两种文本文件格式之间的区别。其中,前者为后者的超集,其内部可能会含有与“测试”和“断言”相关的扩展性语法结构;而后者仅包含有与 Wasm 标准相关的可读文本代码结构。因此,在日常编写 WAT 的过程中,建议你以 “.wat” 作为保存 WAT 代码的文本文件后缀。

最后,我们还介绍了几个可以用来与 WAT 格式打交道的工具。这几个工具均来自于名为 WABT 的 Wasm 二进制格式工具集,它们的用法都十分简单,相信你可以快速上手。

课后练习

最后,我们来做一个小练习吧。

尝试使用 C/C++ 编写一个“计算第 n 项斐波那契数列值”的函数 fibonacci,然后在 WasmFiddle 上编译你的函数,并查看对应生成的 WAT 可读文本代码。

今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/WebAssembly%e5%85%a5%e9%97%a8%e8%af%be/06%20WAT%ef%bc%9a%e5%a6%82%e4%bd%95%e8%ae%a9%e4%b8%80%e4%b8%aa%20WebAssembly%20%e4%ba%8c%e8%bf%9b%e5%88%b6%e6%a8%a1%e5%9d%97%e7%9a%84%e5%86%85%e5%ae%b9%e6%98%93%e4%ba%8e%e8%a7%a3%e8%af%bb%ef%bc%9f.md