必要的准备工作及注意事项:
在开始之前需要做以下工作:
- 一个C编译器——我使用了 clang 3.4,也可以用其它支持 c99/c11 的编译器;
- 文本编辑器——我建议使用基于IDE的文本编辑器,我使用 Emacs;
- 基础编程知识——最基本的变量,流程控制,函数,数据结构等;
- Make 脚本——能使程序更快一点。
为什么要写个虚拟机?
有以下原因:
- 想深入了解计算机工作原理。本文将帮助你了解计算机底层如何工作,虚拟机提供简洁的抽象层,这不就是一个最好的学习它们原理的方法吗?
- 更深入了解一些编程语言是如何工作。例如,当下多种经常使用那些语言的虚拟机。包括JVM,Lua VM,FaceBook 的 Hip—Hop VM(PHP/Hack) 等。
- 只是因为有兴趣学习虚拟机。
指令集
我们将要实现一种非常简单的自定义的指令集。我不会讲一些高级的如位移寄存器等,希望在读过这篇文章后掌握这些。
我们的虚拟机具有一组寄存器,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</div>
这就是我们的指令集,注意,POP 指令将会打印我们弹出的指令,这样我们就能够看到 ADD 指令工作了。我还加入了一个 SET 指令,主要是让你理解寄存器是可以访问和写入的。你也可以自己实现像MOV A B(将A的值移动到B)这样的指令。HTL 指令是为了告诉我们程序已经运行结束。
虚拟机是如何工作的呢?
现在我们已经到了本文最关键的部分,虚拟机比你想象的简单,它们遵循一个简单的模式:读取;解码;执行。首先,我们从指令集合或代码中读取下一条指令,然后将指令解码并执行解码后的指令。为简单起见,我们忽略了虚拟机的编码部分,典型的虚拟机将会把一个指令(操作码和它的操作数)打包成一个数字,然后再解码这个指令。
项目结构
开始编程之前,我们需要设置好我们的项目。第一,你需要一个C编译器(我使用 clang 3.4)。还需要一个文件夹来放置我们的项目,我喜欢将我的项目放置于~/Dev:
$cd ~/Dev/ mkdir mac cd mac mkdir src</div>
如上,我们先 cd 进入~/Dev 目录,或者任何你想放置的位置,然后新建一个目录(我称这个虚拟机为"mac")。然后再 cd 进这个目录并新建我们 src 目录,这个目录用于放置代码。
Makefile
makefile 相对直接,我们不需要将什么东西分成多个文件,也不用包含任何东西,所以我们只需要用一些标志来编译文件:
SRC_FILES = main.c CC_FLAGS = -Wall -Wextra -g -std=c11 CC = clang all: ${CC} ${SRC_FILES} ${CC_FLAGS} -o mac</div>
这对目前来说已经足够了,你以后还可以改进它,但是只要它能完成这个工作,我们应该满足了。
指令编程(代码)
现在开始写虚拟机的代码了。第一,我们需要定义程序的指令。为此,我们可以使用一个枚举类型enum,因为我们的指令基本上是从0到X的数字。事实上,可以说你是在组装一个汇编文件,它会使用像 mov 这样的词,然后翻译成声明的指令。
我们可以只写一个指令文件,例如 PSH, 5 是0, 5,但是这样并不易读,所以我们使用枚举器!
typedef enum { PSH, ADD, POP, SET, HLT } InstructionSet;</div>
现在我们可以将一个测试程序存储为一个数组。我们写一个简单的程序用于测试:将5和6相加,然后将他们打印出来(用POP指令)。如果你愿意,你可以定义一个指令将栈顶的值打印出来。
指令应该存储成一个数组,我将在文档的顶部定义它;但你或许会将它放在一个头文件中,下面是我们的测试程序:
const int program[] = { PSH, 5, PSH, 6, ADD, POP, HLT };</div>
上面的程序将会把5和6压入栈,调用 ADD 指令,这将会把栈顶的两个值弹出,相加后将结果压回栈中,接下来我们弹出结果,因为 POP 指令将会打印这个值,但是你不必自己再做了,我已经做好并测试过了。最后,HLT 指令结束程序。
很好,这样我们有了自己的程序。现在我们实现了虚拟机的读取,解码,求值的模式。但是要记住,我们没有解码任何东西,因为我们给出的是原始指令。也就是说我们只需要关注读取和求值!我们可以将它们简化成两个函数 fetch 和 evaluate。
取得当前指令
因为我们已经将我们的程序存成了一个数组,所以很简单的就可以取得当前指令。一个虚拟机有一个计数器,一般来说叫做程序计数器,指令指针等等,这些名字是一个意思取决于你的个人喜好。在虚拟机的代码库里,IP 或 PC 这样的简写形式也随处可见。
如果你之前有记得,我说过我们要把程序计数器以寄存器的形式存储...我们将那么做——在以后。现在,我们只是在我们代码的最顶端创建一个叫 ip 的变量,并且设置为 0。
int ip = 0;</div>
ip 变量代表指令指针。因为我们已经将程序存成了一个数组,所以使用 ip 变量去指明程序数组中当前索引。例如,如果创建了一个被赋值了程序 ip 索引的变量 x,它将存储我们程序的第一条指令。
[假设ip为0]
int ip = 0; int main() { int instr = program[ip]; return 0;</div>
如果我们打印变量 instr,本来应是 PSH 的它将显示为0,因为在他是我们枚举里的第一个值。我们也可以写一个取回函数像这样:
int fetch() { return program[ip]; }</div>
这个函数将会返回当前被调用指令。太棒了,那么如果我们想要下一条指令呢?很容易,我们只要增加指令指针就好了:
int main() { int x = fetch(); // PSH ip++; // increment instruction pointer int y = fetch(); // 5 }</div>
那么怎样让它自己动起来呢?我们知道一个程序直到它执行 HLT 指令才会停止。因此我们使用一个无限的循环持续直到当前指令为HLT。
// INCLUDE <stdbool.h>! bool running = true; int main() { while (running) { int x = fetch(); if (x == HLT) running = false; ip++; } }</div>
这工作的很好,但是有点凌乱。我们正在循环每一条指令,检查是否 HLT,如果是就停止循环,否则“吃掉”指令接着循环。
判断一条指令
因此这就是我们虚拟机的主体,然而我们想要确实的评判每一条指令,并且使它更简洁一些。好的,这个简单的虚拟机,你可以写一个“巨大”的 switch 声明。让 switch 中的每一个 case 对应一条我们定义在枚举中的指令。这个 eval 函数将使用一个简单的指令的参数来判断。我们在函数中不会使用任何指令指针递增除非我们想操作数浪费操作数。
void eval(int instr) { switch (instr) { case HLT: running = false; break; } }</div>
因此如果我们在回到主函数,就可以像这样使用我们的 eval 函数工作:
bool running = true; int ip = 0; // instruction enum here // eval function here // fetch function here int main() { while (running) { eval(fetch()); ip++; // increment the ip every iteration } }</div>
栈!
很好,那会很完美的完成这个工作。现在,在我们加入其他指令之前,我们需要一个栈。幸运的是,栈是很容易实现的,我们仅仅需要使用一个数组而已。数组会被设置为合适的大小,这样