Wasm Text
撰写时间:2024-11-15
修订时间:2025-01-29
没有实践,我们大脑中很难对Wasm形成一个清晰而精准的概念。在这篇文章中,我们通过一系列的实操,逐渐接近我们与Wasm的原本遥远的距离。
本文概要
对于一个.wasm的文件,您能一眼就看出其内容吗?这怎么可能?使用任一软件打开,全都是乱码。
实际上,Wasm共有2种格式,一种是可直接执行的二进制代码格式,另一种是文本格式。
二进制格式完全充斥着0、1,计算机看得懂,但人类看不懂,也无法转换为可打印的文本。但这是老观念了。Wasm的出现,还给我们带来了一种全新的体验:用人眼直接读懂Wasm的源代码!
Wasm文本格式使用了S-expression (S-表达式,本站使用S表达式来称代) 的语法,一一对应于其二进制的源代码,因此,只要读懂了S表达式,我们就可以直接查看.wasm的内容,进而能清晰地了解其工作机制。
而在我们了解了如何使用S表达式来构造一个Wasm后,我们可以借助Wabt命令行工具,将Wasm文本直接转换为可运行的二进制Wasm,并与各种编程语言交互。
在本文中,我们将:
- 了解如何读懂Wasm文本
- 如何通过Wasm文本表现.wasm对象
- 如何将Wasm文本转换为二进制的.wasm对象
- 如何设置Apache服务器,以让其支持.wasm的MIME Type
- 如何通过JavaScript与.wasm对象交互
- 更进一步,我们将实现在线编译并运行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通常嵌入到一个宿主环境中。宿主环境规范如何初始化、导入、导出、访问模块等。
Wasm分3阶段运行:解码、检验及运行。运行又分为初始化及调用2个阶段,均在宿主环境中进行。
S表达式
初识S表达式
Wasm文本使用S表达式来表现对象。而S表达式可以很方便、直观地表现各种树形结构。下面结合Wasm规范来了解S表达式。一个最简单的S表达式如下所示:
使用()
来表示树形结构中的每个节点。上面意为,根节点为module
。module
是Wasm的顶级节点,可用以表示部署、加载及编译模块。
Wasm的二级节点不多,只有少数的几个,如funcs
, globals
, imports
, exports
等等。
module
下面可以包括函数。
上面的module
下面包含了2个函数,每个函数都是一个节点,因此也都需要使用()
来包围起来。上面的...
是为了讲解方便而加入的符号,并非Wasm规范,但在这里表示该节点的表达式还未完成。
要表达一个诸如下面的函数:
可使用下面的文本表示方法:
Wasm代码是基于栈的操作。
当调用上面的函数时,其栈帧示意图如下:
Index | Local Stack | ||
...... | |||
1 | param2 | ||
0 | param1 |
实参依声明的顺序压进Local Stack中。此时若有局域变量,也将依序压进该栈中。序号从栈底到栈顶从0开始正向增长。
代码local.get 0
的示意图如下:
Index | Local Stack | ||
...... | |||
1 | param2 | ||
0 | param1 |
param1 | ||
Result Stack |
从Local Stack中取出序号为0的数值,压进Result Stack中。
代码local.get 1
的示意图如下:
Index | Local Stack | ||
...... | |||
1 | param2 | ||
0 | param1 |
param2 | ||
param1 | ||
Result Stack |
从Local Stack中取出序号为1的数值,压进Result Stack中。
local的get指令的栈操作也可使用下面的表达方式:
从参数列表中取出数值(不消耗输出栈中的元素),将结果压进输出栈。
代码i32.add
的示意图如下:
首先,弹出栈顶元素至求和公式的第2个因子。
x | + | param2 | = | z |
param2 | ||
param1 | ||
Result Stack |
接着,弹出栈顶元素至求和公式的第1个因子。
param1 | + | param2 | = | z |
param1 | ||
Result Stack |
计算出结果后,将结果压进Result Stack的栈顶。
param1 | + | param2 | = | result |
result | ||
Result Stack |
i32的add指令也可表达如下:
从输出栈弹出2个数值以进行相加的操作,将结果压进输出栈。
代码result i32
将从Result Stack弹出栈顶元素,返回至调用者。
result | ||
Result Stack |
可用;;
添加注释:
上面,我们知道了如何通过S表达式来编写Wasm文本,但这是不能执行的文本内容,下节分析相对应的可由计算机运行的二进制代码是怎么样的,以及如何运行。
参考资源
func的几种表达方式
参数
基本方式:
参数命名:
将多个形参合并在一起:
但合并参数时,不能为各个参数命名。
为函数命名
函数命名后,可在Wasm内部调用命名函数,进而可以编写递归函数。
导出函数
在函数原型中导出:
在全局导出:
二进制格式
最简单的二进制
当代码为:
则二进制内容为:
为方便引用,我在上面各行的最左边均加上了行号。
第1行表示,对于此字节流,在偏移值为0的地方,存储了4个字节,分别为0x00, 0x61, 0x73及0x6D。
Wasm规范要求使用UTF-8的编码来存储。因此,我们可以通过下面的JavaScript代码来查看其内容:
说明上面4个字节代表了字符串asm
,这是Wasm文件的标识符,Wasm规范称其为magic number
,故此上面由Wabt注释为WASM_BINARY_MAGIC
。Wabt并没有严格地按照每行4字节的格式来作记录,对于存储内容为字符串的内容来讲,将它们一并放在一行上,反倒十分清晰。只是我们需清楚,虽然在一行上,但它占用了4个字节。因此下一行的字符流的偏移值从0000004开始。
第2行的0100 0000
使用4个字节来表示Wasm的版本号为1
。其值类型为整数。
下面的所有内容,声明了一个称为Custom Section的自定义节点,可用于提供一些辅助的信息。但自定义节点不影响Wasm的检验及运行。当前的Wasm规范只定义了一种自定义节点,即name section。该节点只能出现一次,且应置于data section(如果有此节点)之后。
接下来,在第4行的偏移值为08的地方,该字节的值为0,对应于规范所要求的自定义节点的类型应为0。
第5行,为该自定义节点的字节尺寸。因目前暂时无法知道该节点的具体大小,因此,其值先暂时设置为0。而在第12行,当所有信息都处理完毕后,自定义节点的尺寸(从偏移值000a至0011处,共有8个字节,FIXUP部分为修正代码,不考虑在内)也就确定下来了,所以该行的:
表示,修正偏移值为0009处的值为8。
第6行,使用4
个字节来存储该自定义节点的名称。
第7行,Wasm规范要求自定义节点的name section,其名称应为name
,故在此存储了name
的ASCII值:6e61 6d65。
每个name section下面可以有多个subsections节点。第8行,值为02,表示这是一个local names
的节点。另外的值有:00代表module name
,01代表function names
。
subsections节点的内容可根据name section的类型选择使用相应的子节点。第9行,选用了名为namesubsection的子节点,在第10行说明所用的函数数量为0。同样,第11行将第10行所需字节数修订为1个字节。
带有函数的二进制
当代码为:
则二进制内容为:
上面同时出现了许多section,下表为各类section所对应的id
:
Id | Section | 行号 |
---|---|---|
0 | custom section | 46 |
1 | type section | 04 |
2 | import section | |
3 | function section | 16 |
4 | table section | |
5 | memory section | |
6 | global section | |
7 | export section | 22 |
8 | start section | |
9 | element section | |
10 | code section | 31 |
11 | data section |
上表列出了许多专有的节点。在这些节点中,custom section是唯一的可以在随意位置放置的节点,而其他节点,如果有的话,则必须严格按上表顺序来排列。
第4行开始定义了type section,其定义了共有多少个function type。第6行显示,只有1个function 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对应于func
,1对应于table
,2对应于mem
,3对应于global
。
从第31行开始,一直到第44行,均为code section,存储了可直接运行的函数代码。第33行先列出函数总共数量为1。第35行至第42行,为函数的body部分,对应于我们上面使用Wasm文本所编写的:
其中,第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文本的内容就行了:
这回我们知道,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文件。
其次,调用WabtModule函数,等待其模块加载完毕。
加载完后,触发一个自定义的wabt-ready事件,并传入加载结果wabtModule。这样做的目的是在同一网页的不同脚本模块间也可共享相应变量。
现在,可以在线编译并运行Wasm Text的内容了。
编译及运行
在一个id为src
的trim-pre的标签中编写Wasm Text的内容如下:
根据上节的内容,这是一个简单的求和函数,导出函数名称为sum。
下面代码编译并运行:
代码解析
代码:
先监听wabt-ready事件,在其完成后,以evt中的detail属性作为参数调用onWabtModuleReady函数,该属性即为Wabt的核心对象。
在onWabtModuleReady函数中,打印wabtModule的FEATURES的默认属性值,其中,multi-value的值被设置为true,则函数可返回多个数值。而multi_memory被设置为false,则只能使用一片内存区域。关注这些属性值,在以后需要时可修改相应值。
读取Wasm Text的源代码,并打印出来,确保内容读取无误。
编译为模块:
生成二进制内容:
module.toBinary方法返回binaryOutput,其log属性即我们之前学过的二进制文本格式的内容,其buffer属性为WASM的二进制内容。
好了,下面可以使用JavaScript的内置功能来编写后续代码了。
初始化为Wasm实例:
打印Wasm对象所导出的名称及其类型:
从输出结果可知,只有一个名为sum的函数。
最后,调用此函数并输出结果:
小结
上面,我们先使用Wasm Text的格式来编写源代码,借助Wabt这个强大的工具,我们将其分别编译为二进制文本的内容及二进制的Wasm对象,最终在JavaScript环境中得到了运行。