听说你想写个虚拟机(一)?

近日,看到一些动手写虚拟机的文章。看过之后,觉得收获很大,突然觉得原来虚拟机并没那么的深不可测,背后的思想还挺简单。因此,就想着写一篇文章来记录和分享一下,希望能给和我有着同样困扰的同学一点点帮助。

虚拟机是什么

虚拟机是什么?听起来高深莫测,其实背后的原理很简单。它就是一个应用程序,模拟了硬件所提供的功能,比如 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 的实现很简单,将跟在其后的数据入栈即可。分两步走:

  1. 取出跟在它后面的参数。ip 是数组元素的下标,ip + 1 即可取到参数。
int value = program[++ip];
  1. 将参数放到栈中。
sp++;
stack[sp] = value;

POP

POP 从栈顶取出数据,然后打印出来。

int popValue = stack[sp--];
printf("poped %d\n", popValue);

ADD

ADD 从栈顶取出 2 个数据,相加之后,将结果放回栈中。分三步走:

  1. 从栈中取出两个数
// 从栈中取出两个数
int a = stack[sp--];
int b = stack[sp--];
  1. 求和
// 相加
int sum = a + b;
  1. 放回栈中
// 再 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]

这样,经过汇编器生成的指令也能在虚拟机上跑起来。只要是按照虚拟机规则生成的指令,上层无论用什么方式写,都能被识别和执行。

这下,手写汇编可比手写指令要好多了,生产力直接上了一个台阶。编程就是这样一步步演进而来,不断升级打怪,从复杂到简单,以至于到现在我们根本不用关心底层到底做了些什么,也能写出一手好程序。

总结

这篇文章主要讲述了什么是虚拟机,如何定义与实现指令集,如何执行指令,并实现了一个最小的虚拟机。

怎么样,最小虚拟机的实现是不是挺简单的?越是简单,就越清晰透明,越容易看透它的本质。因为一切复杂的事物都是在简单的基础上,不断升级演化的结果。

下一篇,将会在此基础上实现更多的指令,还会加入寄存器以及条件跳转,敬请期待。

参考资料

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

  • 标签: PHP 本篇文章旨在提供一个对PHP7版本中Zend虚拟机的概述,不会做到面面俱到的详细叙述,但尽力包含大...
    JUTSSAM阅读 5,610评论 1 3
  • 夜莺2517阅读 127,828评论 1 9
  • 版本:ios 1.2.1 亮点: 1.app角标可以实时更新天气温度或选择空气质量,建议处女座就不要选了,不然老想...
    我就是沉沉阅读 11,861评论 1 6
  • 我是一名过去式的高三狗,很可悲,在这三年里我没有恋爱,看着同龄的小伙伴们一对儿一对儿的,我的心不好受。怎么说呢,高...
    小娘纸阅读 8,759评论 4 7
  • 这些日子就像是一天一天在倒计时 一想到他走了 心里就是说不出的滋味 从几个月前认识他开始 就意识到终究会发生的 只...
    栗子a阅读 5,544评论 1 3

友情链接更多精彩内容