用 Lua 实现一个微型虚拟机-基本篇
目录
- 介绍
- 机器指令模拟
- 最终核心代码
- 虚拟机内部状态可视化
- 完整项目代码
- 后续计划
- 参考
介绍
在网上看到一篇文章 使用 C 语言实现一个虚拟机, 这里是他的代码 Github示例代码, 觉得挺有意思, 作者用很少的一些代码实现了一个可运行的虚拟机, 所以打算尝试用 Lua
实现同样指令集的虚拟机, 同时也仿照此文写一篇文章, 本文中大量参考引用了这位作者的文章和代码, 在此表示感谢.
准备工作:
- 一个
Lua
环境 - 文本编辑器
- 基础编程知识
为什么要写这个虚拟机?
原因是: 很有趣, 想象一下, 做一个非常小, 但是却具备基本功能的虚拟机是多么有趣啊!
指令集
谈到虚拟机就不可避免要提到指令集, 为简单起见, 我们这里使用跟上述那篇文章一样的指令集, 硬件假设也一样:
- 寄存器: 本虚拟机有那么几个寄存器:
A,B,C,D,E,F
, 这些也一样设定为通用寄存器, 可以用来存储任何东西. - 程序: 本虚拟机使用的程序将会是一个只读指令序列.
- 堆栈: 本虚拟机是一个基于堆栈的虚拟机, 我们可以对这个堆栈进行压入/弹出值的操作.
这样基于堆栈的虚拟机的实现要比基于寄存器的虚拟机的实现简单得多.
示例指令集如下:
PSH 5 ; pushes 5 to the stack
PSH 10 ; pushes 10 to the stack
ADD ; pops two values on top of the stack, adds them pushes to stack
POP ; pops the value on the stack, will also print it for debugging
SET A 0 ; sets register A to 0
HLT ; stop the program
注意,POP
指令将会弹出堆栈最顶层的内容, 然后把堆栈指针, 这里为了方便观察, 我们会设置一条打印命令,这样我们就能够看到 ADD
指令工作了。我还加入了一个 SET
指令,主要是让你理解寄存器是可以访问和写入的。你也可以自己实现像 MOV A B
(将A的值移动到B)这样的指令。HTL
指令是为了告诉我们程序已经运行结束。
说明: 原文的 C语言版
在对堆栈的处理上不太准确, 没有把 stack
的栈顶元素 "弹出", 在 POP
和 ADD
后, stack
中依然保留着应该弹出的数据,,
虚拟机工作原理
这里也是本文的核心内容, 实际上虚拟机很简单, 遵循这样的模式:
- 读取: 首先,我们从指令集合或代码中读取下一条指令
- 解码: 然后将指令解码
- 执行: 执行解码后的指令
为聚焦于真正的核心, 我们现在简化一下这个处理步骤, 暂时忽略虚拟机的编码部分, 因为比较典型的虚拟机会把一条指令(包括操作码和操作数)打包成一个数字, 然后再解码这个数字, 因此, 典型的虚拟机是可以读入真实的机器码并执行的.
项目文件结构
正式开始编程之前, 我们需要先设置好我们的项目. 我是在 OSX
上写这个虚拟机的, 因为 Lua
的跨平台特性, 所以你也可以在 Windows
或 Linux
上无障碍地运行这个虚拟机.
首先, 我们需要一个 Lua
运行环境(我使用Lua5.3.2
), 可以从官网下载对应于你的操作系统的版本. 其次我们要新建一个项目文件夹, 因为我打算最终把这个项目分享到 github
上, 所以用这个目录 ~/GitHub/miniVM
, 如下:
Air:GitHub admin$ cd ~/GitHub/miniVM/
Air:miniVM admin$
如上,我们先 cd
进入 ~/GitHub/miniVM
,或者任何你想放置的位置,然后新建一个 lua
文件 miniVM.lua
。 因为现在项目很简单, 所以暂时只有这一个代码文件。
运行也很简单, 我们的虚拟机程序是 miniVM.lua
, 只需要执行:
lua miniVM.lua
机器指令集
现在开始为虚拟机准备要执行的代码了. 首先, 我们需要定义虚拟机使用的机器指令集.
指令集数据结构设计
我们需要用一种数据结构来模拟虚拟机中的指令集.
C语言版
在 C语言版
中, 作者用枚举类型来定义机器指令集, 因为机器指令基本上都是一些从 0
到 n
的数字, 我们就像在编辑一个汇编文件, 使用类似 PSH
之类的助记符, 再翻译成对应的机器指令.
假设助记符 PSH
对应的机器指令是 0
, 也就是把 PSH, 5
翻译为 0, 5
, 但是这样我们读起来会比较费劲, 因为在 C
中, 以枚举形式写的代码更具可读性, 所以 C语言版
作者选择了使用枚举来设计机器指令集, 如下:
typedef enum {
PSH,
ADD,
POP,
SET,
HLT
} InstructionSet;
Lua版的其他方案
看看我们的 Lua
版本如何选择数据结构, 众所周知 Lua
只有一种基本数据结构: table
, 因此我们如果想使用枚举这种数据结构. 就需要写出 Lua
版的枚举来, 在网络上搜到这两篇文档:
- Lua 与C/C++ 交互系列:注册枚举enum到Lua Code中
- Lua封装创建枚举类型
第一篇是直接用 Lua
使用 C
定义的枚举, 代码比较多, 就不在这里列了, 不符合我们这个项目对于简单性的要求.
第二篇是用Lua
的table
模拟实现了一个枚举, 代码比较短, 列在下面.
function CreateEnumTable(tbl, index)
local enumtbl = {}
local enumindex = index or 0
for i, v in ipairs(tbl) do
enumtbl[v] = enumindex + i
end
return enumtbl
end
local BonusStatusType = CreateEnumTable({"NOT_COMPLETE", "COMPLETE", "HAS_TAKE"},-1)
不过这种实现对我们来说也不太适合, 一方面写起来比较繁琐, 另一方面代码也不太易读, 所以需要设计自己的枚举类型.
最终使用的Lua版
现在的方案是直接选择用一个 table
来表示, 如下:
InstructionSet = {"PSH","ADD","POP","SET","HLT"}
这样的实现目前看来最简单, 可读性也很不错, 不过缺乏扩展性, 我们暂时就用这种方案.
测试程序数据结构设计
现在需要一段用来测试的程序代码了, 假设是这样一段程序: 把 5
和 6
相加, 把结果打印出来.
在 C语言版
中, 作者使用了一个整型数组来表示该段测试程序, , 如下:
const int program[] = {
PSH, 5,
PSH, 6,
ADD,
POP,
HLT
};
注意:
PSH
是前面C语言版
定义的枚举值, 是一个整数0
, 其他类似.
我们的 Lua
版暂时使用最简单的结构:表, 如下:
program = {
"PSH", "5",
"PSH", "6",
"ADD",
"POP",
"HLT"
}
这段代码具体来说, 就是把 5
和 6
分别先后压入堆栈, 调用 ADD
指令, 它会将栈顶的两个值弹出, 相加后再把结果压回栈顶, 然后我们用 POP