WebGL Tutorial
and more

Wasm Text

撰写时间:2024-11-15

修订时间:2025-01-29

没有实践,我们大脑中很难对Wasm形成一个清晰而精准的概念。在这篇文章中,我们通过一系列的实操,逐渐接近我们与Wasm的原本遥远的距离。

本文概要

对于一个.wasm的文件,您能一眼就看出其内容吗?这怎么可能?使用任一软件打开,全都是乱码。

实际上,Wasm共有2种格式,一种是可直接执行的二进制代码格式,另一种是文本格式。

二进制格式完全充斥着01,计算机看得懂,但人类看不懂,也无法转换为可打印的文本。但这是老观念了。Wasm的出现,还给我们带来了一种全新的体验:用人眼直接读懂Wasm的源代码!

Wasm文本格式使用了S-expression (S-表达式,本站使用S表达式来称代) 的语法,一一对应于其二进制的源代码,因此,只要读懂了S表达式,我们就可以直接查看.wasm的内容,进而能清晰地了解其工作机制。

而在我们了解了如何使用S表达式来构造一个Wasm后,我们可以借助Wabt命令行工具,将Wasm文本直接转换为可运行的二进制Wasm,并与各种编程语言交互。

在本文中,我们将:

  1. 了解如何读懂Wasm文本
  2. 如何通过Wasm文本表现.wasm对象
  3. 如何将Wasm文本转换为二进制的.wasm对象
  4. 如何设置Apache服务器,以让其支持.wasmMIME Type
  5. 如何通过JavaScript与.wasm对象交互
  6. 更进一步,我们将实现在线编译并运行Wasm文本!

既然与特定语言无关,为何这里选择了与JavaScript语言交互?很简单,Wasm本就是为在Web上部署应用而创制。而W3C的存在、发展、壮大,使各路豪杰纷纷向Web汇拢,无论是哪种技术,一旦能与Web关联上,必将大放光芒。

在学习过程中,我们将一并了解JavaScript对Wasm的内在支持,也将清晰地明白Wasm为何能有与特定的编程语言无关的特性。无关并不是指无能,相反,其是指并不绑定于特定的语言,而是各种语言通吃!

Wasm总览

为对Wasm能有一个整体上的印象,我们可以从各个方面快速地进行大概的了解。而在以后的编辑实践中,就能慢慢体会其特点。

Wasm的目的是在Web上进行高效部署。但规范并不涉及任何Web的特性。

具体来说,具有以下特点:

  • 快速:接近原生的代码效率,能充分利用各种硬件设施。
  • 安全:在一个隔离的沙箱环境中运行。
  • 内容规范:全面、精确的设计规范。
  • 与硬件无关:在部署在各种体系、桌面系统、移动设备或嵌入式系统中。
  • 与语言无关:不依赖于任何特定的编程语言或模型。
  • 与平台无关:可在Web环境下的浏览器中作为独立的虚拟机运行,也可在其他环境中运行。
  • 开放:可与所在环境进行简单、统一的交互。
  • 精简:有比文本或原生代码格式更为精简的二进制格式。
  • 模块化:可以分割为更小的部分,以便进行传输、缓存、使用。
  • 高效:以just-in-time (JIT)或ahead-of-time (AOT)的方式进行解码、校验及编译。
  • 流式:不必等到读取所有数据后才进行解码、校验及编译,依据数据在流中的顺序即可。
  • 并发:解码、校验及编译可以并发方式同时进行。
  • 可移植:不限定于任何体系架构。

正好比:我很帅,但我不结婚。因此,反倒可以与任何人成为朋友。

Wasm本质是虚拟指令集体系virtual instruction set architecture, virtual ISA),有自身的计算指令集,常见的指令如: i32.add等。数量不多,学起来比较容易。

基于安全考虑,Wasm不会自主访问所在环境,例如获取文件资源、调用系统命令等。相反,所在环境可自行提供函数或命令、嵌入到Wasm模块中。其安全性由嵌入者负责。

Wasm以类似于汇编语言的方式来编码,其组件包括:

只有4种基本的数据类型:i32, i64, f32, f64
指令
是一个基于的机器 (stack machine)。指令在一个隐式的栈上操作数组,分为2种。一种是操作数据的简单指令,使用(consume)数据时从操作数栈中弹出参数,并将结果压进栈中,栈中的结果用作返回值。控制指令用于改变代码运行流程。
Traps
用于立即中断执行。Wasm无法处理中断代码,但可向运行环境传送信号。
函数
可以读取一系列的参数值,并返回一系列的数值。
允许通过索引值来获取元素。目前的实现只支持一个函数引用的元素,用以实现函数指针。
线性内存
一片连续的、数据可以被修改的字节缓冲区。创建时有初始值,其尺寸可动态扩展。通过字节地址来加载并存储数据。
模块
以模块的方式来管理函数、表、线性内存、全局变量等。可导入、导出相应的定义 。可定义一个自动运行的start函数。
嵌入者
Wasm通常嵌入到一个宿主环境中。宿主环境规范如何初始化、导入、导出、访问模块等。

Wasm3阶段运行:解码、检验及运行。运行又分为初始化及调用2个阶段,均在宿主环境中进行。

S表达式

初识S表达式

Wasm文本使用S表达式来表现对象。而S表达式可以很方便、直观地表现各种树形结构。下面结合Wasm规范来了解S表达式。一个最简单的S表达式如下所示:

(module)

使用()来表示树形结构中的每个节点。上面意为,根节点为modulemoduleWasm的顶级节点,可用以表示部署、加载及编译模块。

Wasm的二级节点不多,只有少数的几个,如funcs, globals, imports, exports等等。

module下面可以包括函数。

(module (func ...) (func ...) )

上面的module下面包含了2个函数,每个函数都是一个节点,因此也都需要使用()来包围起来。上面的...是为了讲解方便而加入的符号,并非Wasm规范,但在这里表示该节点的表达式还未完成。

要表达一个诸如下面的函数:

function sum(a, b) { return a + b; }

可使用下面的文本表示方法:

( func (param i32) (param i32) (result i32) local.get 0 local.get 1 i32.add )

Wasm代码是基于栈的操作。

当调用上面的函数时,其栈帧示意图如下:

digraph { graph [ splines=spline; rankdir=TB; ] edge [ fontcolor=gray; ] local_stack [shape=plain, fillcolor=invis, label=<
IndexLocal Stack
......
1param2
0param1
>]; }

实参依声明的顺序压进Local Stack中。此时若有局域变量,也将依序压进该栈中。序号从栈底到栈顶从0开始正向增长。

代码local.get 0的示意图如下:

digraph { graph [ splines=spline; rankdir=TB; ] edge [ fontcolor=gray; ] local_stack [shape=plain, fillcolor=invis, label=<
IndexLocal Stack
......
1param2
0param1
>]; result_stack [shape=plain, fillcolor=invis, label=<
param1
Result Stack
>]; local_stack:param1:e -> result_stack:param1 [label="push"]; }

Local Stack中取出序号为0的数值,压进Result Stack中。

代码local.get 1的示意图如下:

digraph { graph [ splines=spline; rankdir=TB; ] edge [ fontcolor=gray; ] local_stack [shape=plain, fillcolor=invis, label=<
IndexLocal Stack
......
1param2
0param1
>]; result_stack [shape=plain, fillcolor=invis, label=<
param2
param1
Result Stack
>]; local_stack:param2:e -> result_stack:param2 [label="push"]; }

Local Stack中取出序号为1的数值,压进Result Stack中。

localget指令的栈操作也可使用下面的表达方式:

local.get x : [] → [t]

从参数列表中取出数值(不消耗输出栈中的元素),将结果压进输出栈

代码i32.add的示意图如下:

首先,弹出栈顶元素至求和公式的第2个因子。

digraph { graph [ splines=spline; rankdir=TB; ] edge [ fontcolor=gray; ] calc [shape=plain, fillcolor=invis, label=<
x+param2=z
>]; result_stack [shape=plain, fillcolor=invis, label=<
param2
param1
Result Stack
>]; calc:param2 -> result_stack:param2 [dir=back, label="pop"]; }

接着,弹出栈顶元素至求和公式的第1个因子。

digraph { graph [ splines=spline; rankdir=TB; ] edge [ fontcolor=gray; ] calc [shape=plain, fillcolor=invis, label=<
param1+param2=z
>]; result_stack [shape=plain, fillcolor=invis, label=<
param1
Result Stack
>]; calc:param1 -> result_stack:param1 [dir=back, label="pop"]; }

计算出结果后,将结果压进Result Stack的栈顶。

digraph { graph [ splines=spline; rankdir=TB; ] edge [ fontcolor=gray; ] calc [shape=plain, fillcolor=invis, label=<
param1+param2=result
>]; result_stack [shape=plain, fillcolor=invis, label=<
result
Result Stack
>]; calc:result -> result_stack:result [label="push"]; }

i32add指令也可表达如下:

i32.add : [i32 i32] → [i32]

输出栈弹出2个数值以进行相加的操作,将结果压进输出栈

代码result i32将从Result Stack弹出栈顶元素,返回至调用者。

digraph { graph [ splines=spline; rankdir=TB; ] edge [ fontcolor=gray; ] result_stack [shape=plain, fillcolor=invis, label=<
result
Result Stack
>]; caller -> result_stack:result [dir=back, label="pop and return"]; }

可用;;添加注释:

( func (param i32) (param i32) (result i32) ;; func signature local.get 0 ;; push the 0th param local.get 1 ;; push the 1st param i32.add ;; pop 2 operands and push the sum )

上面,我们知道了如何通过S表达式来编写Wasm文本,但这是不能执行的文本内容,下节分析相对应的可由计算机运行的二进制代码是怎么样的,以及如何运行。

参考资源

  1. Formal Notation

func的几种表达方式

参数

基本方式:

(module (func (param i32) (param i32) (result i32) local.get 0 local.get 1 i32.add ) )

参数命名:

(module (func (param $1 i32) (param $2 i32) (result i32) local.get $1 local.get $2 i32.add ) )

将多个形参合并在一起:

(module (func (param i32 i32) (result i32) local.get 0 local.get 1 i32.add ) )

但合并参数时,不能为各个参数命名。

为函数命名

(module (func $sum (param i32 i32) (result i32) local.get 0 local.get 1 i32.add ) )

函数命名后,可在Wasm内部调用命名函数,进而可以编写递归函数。

导出函数

在函数原型中导出:

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

在全局导出:

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

二进制格式

最简单的二进制

当代码为:

(module)

则二进制内容为:

01. 0000000: 0061 736d ; WASM_BINARY_MAGIC 02. 0000004: 0100 0000 ; WASM_BINARY_VERSION 03. ; section "name" 04. 0000008: 00 ; section code 05. 0000009: 00 ; section size (guess) 06. 000000a: 04 ; string length 07. 000000b: 6e61 6d65 ; name ; custom section name 08. 000000f: 02 ; local name type 09. 0000010: 00 ; subsection size (guess) 10. 0000011: 00 ; num functions 11. 0000010: 01 ; FIXUP subsection size 12. 0000009: 08 ; FIXUP section size

为方便引用,我在上面各行的最左边均加上了行号。

第1行表示,对于此字节流,在偏移值为0的地方,存储了4个字节,分别为0x00, 0x61, 0x730x6D

Wasm规范要求使用UTF-8的编码来存储。因此,我们可以通过下面的JavaScript代码来查看其内容:

let bytesArr = new Uint8Array([0x00, 0x61, 0X73, 0x6D]); let decoder = new TextDecoder('utf-8'); let str = decoder.decode(bytesArr); console.log(str); // asm

说明上面4个字节代表了字符串asm,这是Wasm文件的标识符,Wasm规范称其为magic number,故此上面由Wabt注释为WASM_BINARY_MAGICWabt并没有严格地按照每行4字节的格式来作记录,对于存储内容为字符串的内容来讲,将它们一并放在一行上,反倒十分清晰。只是我们需清楚,虽然在一行上,但它占用了4个字节。因此下一行的字符流的偏移值从0000004开始。

第2行的0100 0000使用4个字节来表示Wasm的版本号为1。其值类型为整数。

下面的所有内容,声明了一个称为Custom Section的自定义节点,可用于提供一些辅助的信息。但自定义节点不影响Wasm的检验及运行。当前的Wasm规范只定义了一种自定义节点,即name section。该节点只能出现一次,且应置于data section(如果有此节点)之后。

接下来,在第4行的偏移值为08的地方,该字节的值为0,对应于规范所要求的自定义节点的类型应为0

第5行,为该自定义节点的字节尺寸。因目前暂时无法知道该节点的具体大小,因此,其值先暂时设置为0。而在第12行,当所有信息都处理完毕后,自定义节点的尺寸(从偏移值000a0011处,共有8个字节,FIXUP部分为修正代码,不考虑在内)也就确定下来了,所以该行的:

0000009: 08 ; FIXUP section size

表示,修正偏移值为0009处的值为8

第6行,使用4个字节来存储该自定义节点的名称。

第7行,Wasm规范要求自定义节点的name section,其名称应为name,故在此存储了nameASCII值:6e61 6d65

每个name section下面可以有多个subsections节点。第8行,值为02,表示这是一个local names的节点。另外的值有:00代表module name01代表function names

subsections节点的内容可根据name section的类型选择使用相应的子节点。第9行,选用了名为namesubsection的子节点,在第10行说明所用的函数数量为0。同样,第11行将第10行所需字节数修订为1个字节。

带有函数的二进制

当代码为:

(module (func (export "sum") (param i32) (param i32) (result i32) local.get 0 local.get 1 i32.add))

则二进制内容为:

01. 0000000: 0061 736d ; WASM_BINARY_MAGIC (wabt-demo.html, line 64) 02. 0000004: 0100 0000 ; WASM_BINARY_VERSION 03. ; section "Type" (1) 04. 0000008: 01 ; section code 05. 0000009: 00 ; section size (guess) 06. 000000a: 01 ; num types 07. ; func type 0 08. 000000b: 60 ; func 09. 000000c: 02 ; num params 10. 000000d: 7f ; i32 11. 000000e: 7f ; i32 12. 000000f: 01 ; num results 13. 0000010: 7f ; i32 14. 0000009: 07 ; FIXUP section size 15. ; section "Function" (3) 16. 0000011: 03 ; section code 17. 0000012: 00 ; section size (guess) 18. 0000013: 01 ; num functions 19. 0000014: 00 ; function 0 signature index 20. 0000012: 02 ; FIXUP section size 21. ; section "Export" (7) 22. 0000015: 07 ; section code 23. 0000016: 00 ; section size (guess) 24. 0000017: 01 ; num exports 25. 0000018: 03 ; string length 26. 0000019: 7375 6d sum ; export name 27. 000001c: 00 ; export kind 28. 000001d: 00 ; export func index 29. 0000016: 07 ; FIXUP section size 30. ; section "Code" (10) 31. 000001e: 0a ; section code 32. 000001f: 00 ; section size (guess) 33. 0000020: 01 ; num functions 34. ; function body 0 35. 0000021: 00 ; func body size (guess) 36. 0000022: 00 ; local decl count 37. 0000023: 20 ; local.get 38. 0000024: 00 ; local index 39. 0000025: 20 ; local.get 40. 0000026: 01 ; local index 41. 0000027: 6a ; i32.add 42. 0000028: 0b ; end 43. 0000021: 07 ; FIXUP func body size 44. 000001f: 09 ; FIXUP section size 45. ; section "name" 46. 0000029: 00 ; section code 47. 000002a: 00 ; section size (guess) 48. 000002b: 04 ; string length 49. 000002c: 6e61 6d65 name ; custom section name 50. 0000030: 02 ; local name type 51. 0000031: 00 ; subsection size (guess) 52. 0000032: 01 ; num functions 53. 0000033: 00 ; function index 54. 0000034: 00 ; num locals 55. 0000031: 03 ; FIXUP subsection size 56. 000002a: 0a ; FIXUP section size

上面同时出现了许多section,下表为各类section所对应的id:

IdSection行号
0custom section46
1type section04
2import section
3function section16
4table section
5memory section
6global section
7export section22
8start section
9element section
10code section31
11data section

上表列出了许多专有的节点。在这些节点中,custom section是唯一的可以在随意位置放置的节点,而其他节点,如果有的话,则必须严格按上表顺序来排列。

第4行开始定义了type section,其定义了共有多少个function type。第6行显示,只有1function type

function type指的是函数原型,或称函数签名(funciton signature)。function type也有固定对应的id值,第8行列出了其id值为0x60。第9行表函数参数的数量为2,第10行与第11行列出这两个参数的数据类型均为i32,第12行,只有1个返回值,第13行,该返回值的类型为i32

第16行为function section,第18行列出所有函数数量为1,第19行,第0个函数的函数签名的索引值为0,则可在第8行中找到所对应的函数签名。

第22行开始了export section,第24行表示总共只有1个这样的节点,第26行,所导出的名称为sum,第27行,该导出的类型为函数。其对应的id值为:0对应于func1对应于table2对应于mem3对应于global

从第31行开始,一直到第44行,均为code section,存储了可直接运行的函数代码。第33行先列出函数总共数量为1。第35行至第42行,为函数的body部分,对应于我们上面使用Wasm文本所编写的:

(func ... // signature local.get 0 // body start local.get 1 i32.add )

其中,第36行表示,函数局部变量的数量为0(因为我们只取两个函数参数进行相加,未定义函数内的局部变量),第37行,函数代码为local.get,第38行,取值自第0个参数,则会导致Wasm取第10行的值;第39行,函数代码为local.get,第40行,取值自第1个参数,则会导致Wasm取第11行的值。第41行,函数代码为i32.add,则直接从栈中取出2个数值进行相加并压进栈。第42行,使用0x0B标志函数的body结束。

至此,Wasm的二进制结构对我们来讲不再神秘。它只不过是以字节流的方式,将运行一个程序所需的各种数据,分类存储进字节流中,然后,当宿主环境需要调用函数代码时,Wasm知道该从哪里执行相应的代码。

当然,Wasm二进制是给计算机看的,我们只是通过上面的步骤,了解了其技术细节,以消除我们的因为未知而带来的恐惧症。当了解细节后,我们则可以再次回头关注Wasm文本的内容就行了:

(module (func (export "sum") (param i32) (param i32) (result i32) local.get 0 local.get 1 i32.add))

这回我们知道,Wasm会自动帮助我们编译为精确对应的二进制代码;我们在上面所看到的内容,也正是计算机内部所看到的内容。

支持.wasm的MIME Type

默认情况下,Apache服务器不支持.wasm文件的MIME Type。要在Apache服务器添加对.wasm文件的MIME Type的支持,可参见Apache Server MAMP

安装Wabt

Wabt, The WebAssembly Binary Toolkit, 可用于在Wasm文本格式与二进制格式之间相互转换。

从Github上安装Wabt时,其自身的库文件可以顺利安装,但众多第三方库文件往往不能顺利下载。这将导致下载后不能编译。

但主库文件中包含了一个可在Web上使用libwabt.js文件,该文件是Wabt通过Emscripten将其主要功能编译为能在Web环境下使用的.js文件,我们就可以使用它在Web环境下开发了。

我们也可以在Wabt在Gitgub的官网上直接下载其压缩包,这是非常干净的主库文件。

上面这两种安装方法,我们都可以在wabt/docs/demo目录下找到libwabt.js文件,这即是我们所需。

wasmModule有以下方法:

  • validate
  • resolveNames
  • toText
  • toBinary
  • destroy

在线编译Wasm Text

加载libwabt.js文件

因为Wabt未提供模块化的JavaScript文件,因此,需提前做好以下准备。

首先,在页面上加载libwabt.js文件。

<script src="examples/wabt/js/libwabt.js"></script>

其次,调用WabtModule函数,等待其模块加载完毕。

<script type="module"> WabtModule().then((wabtModule) => { window.dispatchEvent(new CustomEvent('wabt-ready', { detail: wabtModule })); }); </script>

加载完后,触发一个自定义的wabt-ready事件,并传入加载结果wabtModule。这样做的目的是在同一网页的不同脚本模块间也可共享相应变量。

现在,可以在线编译并运行Wasm Text的内容了。

编译及运行

在一个idsrctrim-pre的标签中编写Wasm Text的内容如下:

(module (func (export "sum") (param i32) (param i32) (result i32) local.get 0 local.get 1 i32.add ) )

根据上节的内容,这是一个简单的求和函数,导出函数名称为sum

下面代码编译并运行:

window.addEventListener('wabt-ready', (evt) => { onWabtModuleReady(evt.detail); }); function onWabtModuleReady(wabtModule) { pc.log(wabtModule.FEATURES); let srcElement = document.getElementById('src'); let srcCode = srcElement.getCodeText(srcElement.textContent); pc.log('%s', srcCode); let module = wabtModule.parseWat('test.wabt', srcCode); module.resolveNames(); module.validate(); let binaryOutput = module.toBinary({log: true, write_debug_names: true}); pc.log('%s', binaryOutput.log); let binaryBuffer = binaryOutput.buffer; pc.log(binaryBuffer); let wasmModule = new WebAssembly.Module(binaryBuffer); const wasmInstance = new WebAssembly.Instance(wasmModule, {}); let entries = Object.entries(wasmInstance.exports); for (const [key, value] of entries) { pc.log('%s, %s', key, typeof value); } const {sum} = wasmInstance.exports; let a = 10; let b = 20; let c = sum(a, b); pc.log(c); }

代码解析

代码:

window.addEventListener('wabt-ready', (evt) => { onWabtModuleReady(evt.detail); });

先监听wabt-ready事件,在其完成后,以evt中的detail属性作为参数调用onWabtModuleReady函数,该属性即为Wabt的核心对象。

onWabtModuleReady函数中,打印wabtModuleFEATURES的默认属性值,其中,multi-value的值被设置为true,则函数可返回多个数值。而multi_memory被设置为false,则只能使用一片内存区域。关注这些属性值,在以后需要时可修改相应值。

读取Wasm Text的源代码,并打印出来,确保内容读取无误。

let srcElement = document.getElementById('src'); let srcCode = srcElement.getCodeText(srcElement.textContent); pc.log('%s', srcCode);

编译为模块:

let module = wabtModule.parseWat('test.wast', srcCode); module.resolveNames(); module.validate();

生成二进制内容:

let binaryOutput = module.toBinary({log: true, write_debug_names: true}); pc.log('%s', binaryOutput.log); let binaryBuffer = binaryOutput.buffer; pc.log(binaryBuffer);

module.toBinary方法返回binaryOutput,其log属性即我们之前学过的二进制文本格式的内容,其buffer属性为WASM的二进制内容。

好了,下面可以使用JavaScript的内置功能来编写后续代码了。

初始化为Wasm实例:

let wasmModule = new WebAssembly.Module(binaryBuffer); const wasmInstance = new WebAssembly.Instance(wasmModule, {});

打印Wasm对象所导出的名称及其类型:

let entries = Object.entries(wasmInstance.exports); for (const [key, value] of entries) { pc.log('%s, %s', key, typeof value); }

从输出结果可知,只有一个名为sum的函数。

最后,调用此函数并输出结果:

const {sum} = wasmInstance.exports; let a = 10; let b = 20; let c = sum(a, b); pc.log(c);

小结

上面,我们先使用Wasm Text的格式来编写源代码,借助Wabt这个强大的工具,我们将其分别编译为二进制文本的内容及二进制的Wasm对象,最终在JavaScript环境中得到了运行。

参考资源

W3C

  1. WebAssembly Core Specification
  2. WebAssembly Specification
  3. WebAssembly JavaScript Interface
  4. WebAssembly Web API

WebAssembly

  1. webassembly.org
  2. Text Format
  3. WebAssembly / wabt (Github)
  4. AssemblyScript / wabt.js (Github)
  5. WebAssembly Specification
  6. WebAssembly WASI
  7. WASI.dev
  8. Wasmtime

Others

  1. Understanding WebAssembly text format (MDN)
  2. Using files from web applications (MDN)
  3. WebAssembly-examples (MDN)
  4. What’s in that .wasm? Introducing: wasm-decompile (V8)
  5. Outside the web: standalone WebAssembly binaries using Emscripten (V8)
  6. Wasm MIME Type (IANA)