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

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

虚拟机是什么

虚拟机是什么?听起来高深莫测,其实背后的原理很简单。它就是一个应用程序,模拟了硬件所提供的功能,比如 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]

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

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

总结

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

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

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

参考资料

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,826评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,968评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,234评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,562评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,611评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,482评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,271评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,166评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,608评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,814评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,926评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,644评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,249评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,866评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,991评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,063评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,871评论 2 354

推荐阅读更多精彩内容

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