近日,看到一些动手写虚拟机的文章。看过之后,觉得收获很大,突然觉得原来虚拟机并没那么的深不可测,背后的思想还挺简单。因此,就想着写一篇文章来记录和分享一下,希望能给和我有着同样困扰的同学一点点帮助。
虚拟机是什么
虚拟机是什么?听起来高深莫测,其实背后的原理很简单。它就是一个应用程序
,模拟了硬件所提供的功能,比如 CPU、I/O、寄存器、堆栈等。也就是说它使用软件来实现了这一套东西,通常它会定义自己的一套指令集、寄存器、堆栈等。说的直白点,就是在软件层面定义一套规范,并提供这些能力。
常见的有 Java 虚拟机(JVM),JS 虚拟机。我们都知道 Java 是跨平台的,那为什么 Java 可以做到跨平台呢?原因就是它编译后不是直接生成具体平台的机器码,而是生成了字节码(中间码,与平台无关),然后在虚拟机上解释执行,相当于中间多了一层抽象。那么只要在每个平台上实现对应的 JVM,就可以做到跨平台。比如 Windows/Linux 下的 JVM。
这就是中间层的好处,对上抽象统一,屏蔽平台特性,对下提供具体实现。其实语言类的虚拟机都可以看成是个中间层,对上提供抽象统一的规范,对下再由它来生成对应平台的机器码。
试想如果我们能编写一个虚拟机,那么也可以定义自己的指令集,比如:
MY_ADD a, b
MY_SUB a, b
将 MY_ADD
定义为加法指令,MY_SUB
定义为减法指令等等,随你想定义啥都行,只要按规定实现对应的功能即可。
想想,这是多么酷的一件事情。
那,今天我们就来动手写一个最小的虚拟机,用 c 语言实现,只要有些 c 的基础即可。
它的功能很简单,主要实现下面几点:
- 定义并实现 4 个简单指令
- 指令的获取、解析和执行
- 栈数据的存取
不过呢,在实现上有很多地方考虑的不太完善,主要以讲述原理为主。
指令集定义
我们先只定义如下 4 个简单的指令,下一篇再来完善更多指令。
// 指令定义
typedef enum
{
PSH, // PSH 5; ::将数据放入栈中
ADD, // ADD; ::取出栈顶的两个数据相加后,结果放入栈中
POP, // POP; ::取出栈顶数据,并打印出来
HLT, // HLT; ::停止程序
} InstructionSet;
关于各指令的说明如下:
- PSH,操作码是 0。它将数据入栈,带有一个参数,
PSH xx
。比如:
// 将 5 push 到栈中
PSH 5
- POP,操作码是 1。弹出栈顶数据,并将其打印出来。
- ADD,操作码是 2。取出栈顶两个数相加,然后将结果放回栈中。
- HLT,操作码是 3。程序终止指令。
程序指令列表
一般来说,程序最终生成的指令是以二进制格式存储在文件中的。为了方便起见,我们将其直接写在虚拟机代码中,用数组存储。如下所示:
// 程序指令
const int program[] = {
PSH, 5,
PSH, 6,
ADD,
POP,
HLT
};
这个程序很简单,只包含 5 条指令,分别是:
// 将 5 入栈
PSH, 5
// 将 6 入栈
PSH, 6
// 取出 5 和 6 相加,将结果入栈
ADD
// 栈顶数据出栈
POP
// 程序终止
HLT
不过这并不是真正意义上的指令,只是借用数组的方式简单实现。通常一条完整指令是 0101...
的二进制格式,包含操作码、操作数。根据指令的不同,它可能有一个操作数,两个操作数,又或者没有操作数。
这里,我们的主要目的是为了实现最小虚拟机,所以尽量以最简单的方式。后面的文章会详细讲解如何定义和解析一条完整的指令。
栈
栈对于我们来说,应该是很熟悉了,有着先进后出
的特点。这里我们使用整形数组 stack
来模拟,长度暂且定为 256
。
int stack[256];
既然要入栈出栈,那么也就需要知道当前栈的状态。硬件中有 sp
栈顶指针寄存器,类似的,我们也可以使用全局变量 sp
来模拟记录,初始值为 -1
。
int sp = -1;
入栈时,sp 先加 1,再放入数据。
sp++;
stack[sp] = xx;
出栈时,先取数据,然后 sp 减 1。
int value = stack[sp];
sp--;
这样,就完成了一个简单的栈结构。
指令操作
要想执行某条指令,首先我们得先获取到它,然后解析它是干啥的,最后才是执行。因此,跟指令相关的操作分为如下几个步骤:
- 取出指令
- 解析指令
- 执行指令
在硬件中,有 CPU
来进行指令的处理和执行。但在虚拟机中,就需要我们来模拟这个过程。
取出指令
要想取到指令,那么得知道当前指令执行到哪了,这就需要一个计数器,也就对应着我们熟知的 Program Counter / Instruction Pointer
,也就是 pc/ip
寄存器。
同样的,我们可以用一个全局变量 ip
替代,标记当前正在执行的指令。对应上述程序代码来说,ip
就代表数组下标,初始值为 0。
// 初始值
int ip = 0;
但程序中的指令有多条,怎么才能不断的取出指令呢?简单,用循环嘛。流程如下:
// running 表示是否退出
while (running)
{
int instr = program[ip];
// 处理指令 instr
// 计数器+1
ip++;
}
那程序什么时候才退出呢?
还记得我们定义了 HLT
指令吗?它就是用于退出程序的。当执行到 HLT
指令后,将 running = false
即可。
解析指令
不同的指令有不同操作码和操作数。当取出一条指令后,我们需要对它进行解析,根据操作码做不同的事情。
比如 ADD
指令,就从栈中取出两个数据相加,结果再放回栈里;PSH
,就将跟在其后的数据放入栈中。
前面说过,指令不是真正意义上的指令。在取参数时,相当于取出数组下一个元素,那么就需要改变 ip。
执行指令
指令本身是由硬件提供的实现。但对应到虚拟机中,每种指令的功能,都得在软件层面上实现。
当解析出是哪种类型的指令后,再调用相应的功能。不同功能使用最简单的 switch/case
来跳转执行。
switch (instr)
{
case HLT:
break;
case PSH:
break;
case POP:
break;
case ADD:
break;
default:
break;
}
指令实现
PSH
PSH 的实现很简单,将跟在其后的数据入栈即可。分两步走:
- 取出跟在它后面的参数。ip 是数组元素的下标,ip + 1 即可取到参数。
int value = program[++ip];
- 将参数放到栈中。
sp++;
stack[sp] = value;
POP
POP 从栈顶取出数据,然后打印出来。
int popValue = stack[sp--];
printf("poped %d\n", popValue);
ADD
ADD 从栈顶取出 2 个数据,相加之后,将结果放回栈中。分三步走:
- 从栈中取出两个数
// 从栈中取出两个数
int a = stack[sp--];
int b = stack[sp--];
- 求和
// 相加
int sum = a + b;
- 放回栈中
// 再 push 回栈
sp++;
stack[sp] = sum;
HLT
HLT,表示程序停止,退出循环。
running = false;
运行虚拟机
这样,一个最小虚拟机就完成了,也就不过 70 来行代码。有了这一套指令集后,就可以写程序了,但是目前得手写指令,回到最原始的编程时代😭。
完整代码放到了 github 上,可点击文末链接查看。编译运行一下,好好感受下自己写的虚拟机吧🤩。
另外,代码中没有考虑一些异常场景,比如栈溢出、指令范围越界、指令格式正确性等等。
汇编器
手写指令是一个痛苦的事情,我们也可尝试编写自己的汇编器,将汇编代码转换成这一套指令,回到手写汇编的时代。
比如,可定义如下汇编代码(纯文本),;
表示注释。
PSH 5; 5 入栈
PSH 6
ADD
POP
HLT
汇编器的处理就是一行行读取代码(跳过注释),再根据如下映射关系,将其转换为指令。
// 映射关系
PSH->0
ADD->1
POP->2
HLT->3
最终生成的指令数组如下:
[0,5,0,6,1,2,3]
这样,经过汇编器生成的指令也能在虚拟机上跑起来。只要是按照虚拟机规则
生成的指令,上层无论用什么方式写,都能被识别和执行。
这下,手写汇编可比手写指令要好多了,生产力直接上了一个台阶。编程就是这样一步步演进而来,不断升级打怪,从复杂到简单,以至于到现在我们根本不用关心底层到底做了些什么,也能写出一手好程序。
总结
这篇文章主要讲述了什么是虚拟机,如何定义与实现指令集,如何执行指令,并实现了一个最小的虚拟机。
怎么样,最小虚拟机的实现是不是挺简单的?越是简单,就越清晰透明,越容易看透它的本质。因为一切复杂的事物都是在简单的基础上,不断升级演化的结果。
下一篇,将会在此基础上实现更多的指令,还会加入寄存器以及条件跳转,敬请期待。
网友评论