You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
从上面的描述中可以看出,函数调用经常是嵌套的,例如函数 A 调用函数 B,函数 B 调用函数 C。因此需要另外一个栈来维护函数之间的调用关系信息 —— 调用栈(Call Stack)。
调用栈是由一个个独立的栈帧组成,每次函数调用,都会向调用栈压入一个栈帧(注意:为了阐述的简洁明了,仅讨论函数情况,其他例如 If / Loop 等控制块暂不在本文讨论中)。每次函数执行结束,都会从调用栈弹出对应栈帧并销毁。一连串的函数调用,就是不停创建和销毁栈帧的过程。但在任一时刻,只有位于调用栈顶的栈帧是活跃的,也就是所谓的当前栈帧。
Wasm 解释器项目地址:
https://github.com/mcuking/wasmc
背景
从去年年底开始笔者决定深入 WebAssembly(为了书写方便,接下来简称为 Wasm)这门技术,在读《WebAssembly 原理与核心技术》这本书的过程中(这本书详细讲解了 Wasm 的解释器和虚拟机的工作原理以及实现思路),萌生了实现一个 Wasm 解释器的想法,于是就有了这个项目。接下来我们就直奔主题,看下到底如何实现一个 Wasm 解释器。
Wasm 背景知识
在具体阐述解释器实现过程之前,首先介绍下 Wasm 相关的背景知识。
Wasm 是什么
Wasm 是一种底层类汇编语言,能在 Web 平台上以趋近原生应用的速度运行。C/C++/Rust 等语言将 Wasm 作为编译目标语言,可以将已有的代码移植到 Web 平台中运行,以提升代码复用度。
而 Wasm 官网给出的定义是 —— WebAssembly(缩写为 Wasm)是一种基于栈式虚拟机的二进制指令格式。Wasm 被设计成为一种编程语言的可移植编译目标,可以通过将其部署到 Web 平台上,使其为客户端和服务端应用程序提供服务。
其中将 Wasm 定义为一种虚拟指令集架构 V-ISA(Virtual-Instruction Set Architecture),关于这方面的解读,请参考下面执行阶段的内容。
接着来看下 Wasm 的一些特点:
Wasm 能做什么
Wasm 目前已经在浏览器端的图像处理、音视频处理、游戏、IDE、可视化、科学计算等,以及非浏览器端的 Serverless、区块链、IoT 等领域有一定的应用。如果想要了解更多有关 Wasm 应用的内容,可以关注笔者的另一个 GitHub 仓库:
https://github.com/mcuking/Awesome-WebAssembly-Applications
Wasm 规范
Wasm 技术目前有 4 份规范:
本文主要介绍的 Wasm 解释器主要是运行在非浏览器环境,因此无需关注 JavaScript API 和 Web API 规范。
另外目前实现的版本并没有涉及到 WASI(后续有计划支持),所以目前只需要关注 核心规范 即可。
Wasm 模块
Wasm 模块主要有以下 4 种表现形式:
下图就是使用 C 语言编写的阶乘函数,以及对应的 Wasm 文本格式和二进制格式。
而内存格式和具体的 Wasm 解释器的实现有关,例如本项目的内存格式大致如下(在后面执行阶段部分会详细讲解):
各个格式之间的关联如下:
最后推荐一个名为 WebAssembly Code Explorer 的站点,可以更直观地查看 Wasm 二进制格式和文本格式之间的关联。
https://wasdk.github.io/wasmcodeexplorer/
解释器实现原理
通过上面的介绍,相信大家对 Wasm 技术已经有了大致的了解。接下来我们从分析 Wasm 二进制文件的执行流程开始,探讨解释器的实现思路。
Wasm 二进制文件被执行主要分 3 个阶段:解码、验证、执行
接下来我们就分别对解码阶段和执行阶段的实现细节进行详细阐述。
解码阶段
Wasm 二进制文件结构
和其他二进制格式(例如 Java 类文件)一样,Wasm 二进制格式也是以魔数和版本号开头,之后就是模块的主体内容,这些内容根据不同用途被分别放在不同的段(Section) 中。一共定义了 12 种段,每种段分配了 ID(从 0 到 11)。除了自定义段之外,其他所有段都最多只能出现一次,且须按照 ID 递增的顺序出现。ID 从 0 到 11 依次有如下 12 个段:
自定义段、类型段、导入段、函数段、表段、内存段、全局段、导出段、起始段、元素段、代码段、数据段
换句话说,每一个不同的段都描述了这个 Wasm 模块的一部分信息。而模块内的所有段放在一起,便描述了这个 Wasm 模块的全部信息:
类型段:类型段用于存储模块内所有的函数签名(函数签名记录了函数的参数和返回值的类型和数量),注意若存在多个函数的函数签名相同,则存储一份即可。
函数段:函数段用于存储函数对应的函数签名索引,注意是函数签名的索引,而不是函数索引。
代码段:代码段用于存储函数的字节码和局部变量,也就是函数体内的局部变量和代码所对应的字节码。
知道了每个段对应的用途以及每个段的具体编码格式(详细的编码格式可查看
module.c
中的load_module
函数中的注释),我们就可以对 Wasm 二进制文件进行解码,将其“翻译”成内存格式,也就是将模块的所有信息记录到一个统一的数据结构中 ——module
,module
结构如下图所示:最后展示下解码阶段对应的部分实际代码截图如下:
更多细节建议查阅 https://github.com/mcuking/wasmc/blob/master/source/module.c 中的
load_module
函数,其中有丰富的注释讲解。执行阶段
经过了上面的解码阶段,我们可以从 Wasm 二进制文件中得到涵盖执行阶段所需要的全部信息的内存格式,接下来我们来一起探索如何基于上面的内存格式实现执行阶段。在正式开始之前,首先需要介绍下栈式虚拟机的相关知识作为铺垫。
官网对 Wasm 的定义 —— Wasm 是基于栈式虚拟机的二进制指令格式。也就是说 Wasm 不仅仅是一门编程语言,也是一套虚拟机体系结构规范。那么什么是虚拟机,什么又是栈式虚拟机呢?
虚拟机概念
虚拟机是软件对硬件的模拟,借助操作系统和编译器提供的功能模拟硬件的工作,这里主要指对硬件 CPU 的模拟。虚拟机执行指令主要有以下 3 个步骤:
执行指令流中的一条条指令,就是不断循环执行上面的三个步骤。循环执行的过程中需要有一个标志来记录当前已经执行到哪一条指令,也就是程序计数器 PC (Program Count) —— 用于保存下一条待执行指令的地址。
Wasm 指令集
Wasm 指令主要分为 5 大类:
每条指令包含两部分信息:操作码和操作数。
下图是 Wasm 部分指令的操作码助记符的枚举,完成版请查阅 https://github.com/mcuking/wasmc/blob/master/source/opcode.h。
另外 GitHub 上有一个可视化表格比较直观地展示了 Wasm 所有的操作码,感兴趣的同学可以点击查看下。
https://pengowray.github.io/wasm-ops/
关于操作数的内容会在下面的栈式虚拟机部分介绍。
栈式虚拟机
虚拟机又大致分为两种:寄存器虚拟机和栈式虚拟机。
因为寄存器个数是有限的,如何将无限的变量分配到有限的寄存器中而不冲突,需要寄存器分配算法,例如经典的图着色算法。所以寄存器式虚拟机实现难度略大,但优化潜力更大。
接下来我们就详细介绍下栈式虚拟机的工作机制。
操作数
栈式虚拟机主要特点是拥有一个操作数栈,Wasm 绝大部分指令都是在操作数栈上执行某种操作,例如下面的指令:
f32.sub
:表示从操作数栈弹出 2 个 32 位浮点数,计算它们的差并将结果压入到操作数栈顶。其中从操作数栈弹出的 2 个 32 位浮点数就是操作数,下面是具体定义:
立即数
我们再看另一个指令的例子:
i32.const 3
:表示压入索引为 3 的 32 位整数类型的局部变量到操作数栈顶。而这个数值 3 就是立即数,下面是具体定义:
上面讨论的仅仅是一条指令的执行,下面我们在看下一个函数在栈式虚拟机上是如何被执行的:
如下图所示:
由此可见,函数调用时参数传递和返回值获取,以及函数体中的指令执行,都是通过操作数栈来完成的。
调用栈和栈帧
从上面的描述中可以看出,函数调用经常是嵌套的,例如函数 A 调用函数 B,函数 B 调用函数 C。因此需要另外一个栈来维护函数之间的调用关系信息 —— 调用栈(Call Stack)。
调用栈是由一个个独立的栈帧组成,每次函数调用,都会向调用栈压入一个栈帧(注意:为了阐述的简洁明了,仅讨论函数情况,其他例如 If / Loop 等控制块暂不在本文讨论中)。每次函数执行结束,都会从调用栈弹出对应栈帧并销毁。一连串的函数调用,就是不停创建和销毁栈帧的过程。但在任一时刻,只有位于调用栈顶的栈帧是活跃的,也就是所谓的当前栈帧。
每个栈帧包括以下内容:
需要提醒的是,所有函数关联的栈帧是共用一个完整的操作数栈,每个栈帧会占用这个操作数栈中的某一部分,每个栈帧只需要一个指针保存自己那部分操作数栈栈底地址,用以和其他栈帧的操作数栈部分做区分即可。
这样做的好处是:调用方函数和被调用函数所关联的栈帧的操作数栈部分在整个操作数栈中是相邻的,便于调用方函数将参数传递给被调用函数,也便于被调用函数执行完成后将返回值传递给调用函数。
实际示例
经过上面的铺垫,相信大家对栈式虚拟机有了一定的认识。最后我们用一个实际示例来阐述下整个执行过程:
下面这个 Wasm 文本格式中的有两个函数:compute 函数和 add 函数,其中 add 函数主要是接收两个数(类型分别是 32 位整数和 32 位浮点数),计算两数之和。compute 函数中调用了两次 add 函数,注意第二次调用 add 函数时,操作数栈上已经保存了上次调用 add 函数时的返回结果(再一次印证了两个函数关联的栈帧是共用同一个完整的操作数栈的,可以很便捷地实现函数之间参数的传递),所以这次仅需要传入第二个参数即可。
对应的就是其执行过程的示意图如下:
最后展示下执行阶段对应的部分实际代码截图如下:
可以看到虚拟机的取指、译码、执行三个阶段,可以使用 while 循环和 switch-case 语句来简单地实现。更多细节建议查阅 https://github.com/mcuking/wasmc/blob/master/source/interpreter.c 中的
interpreter
函数,其中有丰富的注释讲解。结束语
以上就是 Wasm 解释器实现中的核心内容,当然这仅仅是 Wasm 解释器的最基本的功能 —— 简单地逐条解析并执行指令,没有像其他专业的解释器那样提供 JIT 功能 —— 即先解释执行字节码来快速启动,然后再通过 JIT 将其编译成平台相关的机器码,以提升后面代码执行的速度(注:JIT 的具体实现过程因解释器而异)。
所以用本项目的解释器解释执行 Wasm 代码,速度上并没有太多优势。但也正是由于其实现比较简单,所以源码更易读,并且其中有丰富的注释,所以非常适合对 Wasm 有兴趣的读者快速了解该技术的核心原理。
参考资料
The text was updated successfully, but these errors were encountered: