手把手教你用js实现一个虚拟机
什么是虚拟机?虚拟机就是使用编程的方式在计算机中虚拟出一个计算机。什么是计算机呢?我想起小学二年级时,第一次上微机课,微机老师是这样给我解释的:计算机本质就是一台做计算的机器。这句话,给我小小的脑袋里留下了大大的问号。直到长大后,我才知道,这分明就是:听君一席话,如听一席话的废话解释。为什么呢?老师说的没错,计算机本质上就是一个计算的机器。但是,我们日常接触到的计算机,如:笔记本、台式机、手机、树
什么是虚拟机?
虚拟机就是使用编程的方式在计算机中虚拟出一个计算机。什么是计算机呢?我想起小学二年级时,第一次上微机课,微机老师是这样给我解释的:计算机本质就是一台做计算的机器。这句话,给我小小的脑袋里留下了大大的问号。直到长大后,我才知道,这分明就是:听君一席话,如听一席话的废话解释。为什么呢?老师说的没错,计算机本质上就是一个计算的机器。但是,我们日常接触到的计算机,如:笔记本、台式机、手机、树莓派等,可都是生龙活虎、绘声绘色的。上能设计造飞机,下能摸鱼打游戏。看起来和我们买菜、乘车等常用的二位数加减乘除计算没有一毛钱关系。这是因为现代计算机发展了七八十年,无数伟大的科学家和杰出的工程师,给计算机的性能、功能和外观带来了翻天覆地的改变。最初的电子计算机的功能确实是用于弹道计算的。给定了指定的弹道方程,然后将炮弹的初速度、射击角度参数代入,即可精确计算出炮弹的着陆点。反之,也可以根据测量的炮弹的末速度和角度,计算出炮弹发射点和速度。这些都是专用计算机。插一句,这个过程中,计算机是存在输入和显示设备的。只是显示设备有点让现代的人感觉有点简陋,它仅仅使用几个灯泡或者纸带将结果输出来给人来阅读。输入设备也很简陋,不是键盘,也是用纸带。它的程序存储器更是难以想象,直接使用磁珠的极性来表示二进制的0和1,一个指令八个位,是由一串的八个磁珠来表示。后来,随着技术的升级,计算机变得性能更强大也更加便宜,科学家和工程师们开始给计算机添加上图形显示器、鼠标等设备。同样,工程师们也配套开发了更加强大的软件,以解决更多领域的问题。如:银行记账核算软件,超市收银软件,办公软件。随着软件的更加复杂和多样,伴随而生的是需要管理软件的软件来管理他们。这就是操作系统。
回到计算本身,我们举一个简单的计算实例:假设我们现在是小学一年级学生,正在上数学课,数学老师安排同学们计算“ 3 + 9 = ”。然后,同学们就开始拿出笔记本演算,最后将答案告诉老师。想一想在这个场景中,我们每个同学是不是就像是一个个计算机,努力计算出老师给的数学式子,并将结果报告给老师。老师是不是像一个程序员?他将程序 “ 3 + 9 = ” 输入“计算机”,然后等待“计算机”做出反馈。当然,这个过程中可能有些计算机出了毛病,计算出一些啼笑皆非的答案。然后程序员就会抓住这个计算机,开始对他进行手把手的debug,直到他计算出正确答案为止。当然有些计算机可能放学之后还需要留在教室继续计算,直到家长找到学校,把他接回为止。
分析这个过程,一个正常的计算机是需要几个关键组件的。我们要有程序存储器-通常是你的算术本,因为我们要将老师输入的内容抄下来,便于计算。还需要一个演草本,你总不能把演算过程直接写到算术本上。这个本子可以理解为就是RAM。还需要一个具有正常计算能力的运算器–此处是我们的大脑。
算术本
演草本
我们的大脑,从程序存储器(算术本)中一个一个读取字符。首先读出来的是3,我们在演草本上画了三个点。然后读出来了一个加号,我们知道是要做加法运算。然后再读出来的是9,同样在演草本上画了九个点。结合前面判断是做加法运算,我们在下一行先画三个点,再画九个点,最后数了数是12个点。这样我们便得出了“3 + 9 = 12”。你屁颠屁颠的跑到老师旁边,一脸得意的大声告诉老师你的答案。然后老师夸你真聪明,奖励给你一朵小红花,并宣布你是班里最靓的仔。
一个典型的计算机应该由运算器、存储器、控制器、输入设备和输出设备五大基本部件组成。对应上面的场景,运算器和控制器是我们的大脑,存储器是我们算术本和演草本、输入设备是我们的眼睛,输出设备是我们的嘴巴。
而虚拟机就是虚拟一个计算机,因此也需要包含上述五大组件。
使用JS实现一个简单的支持二目运算的虚拟机
存储器
我们的虚拟机结构也需要符合计算机的要求。但是为了便于理解,我们先实现运算器、控制器和存储器功能。输入和输出,我们稍后支持。运算器我们使用js本身的计算能力来实现。存储器分为程序存储器和运算存储器,用于存储程序代码或运算,我们使用数组来实现。控制器的作用是控制程序的读取和运算结果数据的读写,我们也是用js代码来模拟。
存储器你可以理解为:由一个个抽屉组成的大柜子,不理解可以看看上面的快递柜,它也是由一个个小抽屉组成的。每个抽屉里面只能放一件快递盒子。多个抽屉装载了你双11买的所有的快递(也许只是小部分)。我们的存储器和快递柜的不同就是,所有的抽屉排列成一条直线,而不是分成多个副柜。
程序存储器
程序存储器存放的是代码。代码可以分为操作码和操作数。回到上面的例子中,操作码就是式子中的“+”,操作数有两个是:3和9。上面的例子中,我们使用的是数学语言来描述一个计算。如果用操作数加操作码的方式来表达的话,那么上面的式子,就写为:操作数(3) + 操作码(+) + 操作数(9) 。我们的计算机在描述一个计算时,方式略有不同。通常是:操作码 + 操作数 或者 单独一个操作码 的方式。那么,我们的程序存储器中存储的内容就是:一个或多个 操作码+操作数 或 操作码 的有序排列。
不好理解?放个图看看。
我们的程序在运行时,需要一步一步的加载存储器中的代码。首先加载的是操作码。如果操作码对应需要操作一个数据,后面通常跟随着操作数。这时,我们需要加载其后的操作数。加载之后,我们根据操作码,执行相应的计算。还是以上面的例子为例,式子中的操作码是“+”,这表明我们需要对两个操作数3和9进行加法运算。
数据存储器
程序在计算过程中,可能会产生一些中间结果,我们需要把这些中间结果保存起来,用于后面的计算。这就需要一个数据存储器,存储这些中间数据。
控制器
在程序运行过程中,我们要一步一步的从程序存储器中加载操作数和操作码,一点都不能出错。因此,我们使用了一个变量来记录下一个需要读取的代码的位置。这个变量我们叫做PC,它的完整名称是Program Counter。
程序运行过程中的数据存储读取不能是无规则的。为什么?举个例子,有些小朋友计算数学题时,经常因为在演算时,没有认真标记进位符号,最后记加时,忘了加上,导致计算错误。这就是一种典型的没有按照规则存入中间数据导致读取中间结果错误,从而得出错误的答案的场景。所以需要有一个规则来管理这些数据。我们定义了一个叫做栈的东西来规范的保存这些数据。简单理解,栈是一种存储在数据存储器中的内存结构。它是由一系列连续的内存小抽屉组成的大柜子。为什么是多个小抽屉呢?因为复杂的程序可能有多个中间计算结果,所以需要多个小抽屉分别保存。不好理解?比如,我们计算:12 + 23 * (123 / (12+1)) - 6 。这个式子就有点复杂,需要分段计算。那么如何保证这些中间数据存储到正确的位置呢?这就需要一个变量来指向当前数据存储的位置。这个变量我们取名叫做SP,全称是Stack Pointer。另外,这个栈还有点特殊,它存储数据时,总是先从高的地址开始存储,然后依次向下存储。读取数据时,先从低的地址读取,然后向上读取。注意:理解上面这句话很重要。如果不能理解,还是举个例子:有个有强迫症的快递员存快递到快递柜时,他首先把快递放在最高的那个抽屉里,然后依次向下放快递。你取快递时,也有强迫症,先从最低的地方打开抽屉,然后一直向上直到最上面一个快递。
运算器
说到运算,我们前面说了,我们的虚拟机支持的是二目运算。啥是二目运算?简单理解就是二目运算是对两个数的运算。既然是两个数,那肯定需要两个内存分别存储这两个数字了。为啥?还是看上面例子,第二个截图中,我们在运算时,分别点了三个点和九个点,这不是用两个位置来分别保存了?实际上,上述举例的计算中,我们使用了三个位置,分别存储两个操作数和运算结果。这多少有点浪费内存了。其实可以仅仅使用两个内存就可以完成计算。为了实现这个目的,添加了一个变量,这个变量叫做AX,一般存放计算结果。
然后就是如何实现运算功能了。运算功能我们使用的是js代码的运算功能来模拟,分别支持:逻辑运算、布尔运算、移位运算和算术运算。
再次提醒,我们支持的是二目运算,需要使用操作码操作两个操作数来得到结果。
运算功能怎么实现呢?以加法运算为例,我们先将一个操作数放入到栈中,然后将第二个操作数放入AX中,然后使用js的运算功能,对这两个数进行运算,最后将运算结果放入AX中。其它运算流程类似,也是使用栈顶的数据和AX中的数据运算,然后将结果存入AX中。
操作码
如前面所说,我们已经设计了存储器、控制器和运算器的功能。可是如何使用他们来完成计算功能呢?还是上面的例子,我们知道程序是由操作码和操作数组成的。操作码是指挥控制器和运算器来完成对操作数的操作的功能。
那么需要哪些操作码才能实现基础的计算功能呢?
数据存储与加载
通过上面描述的运算器的功能,我们知道,运算器的功能就是对AX中的数据和栈顶中的数据进行运算。所以,我们需要一些指令,将程序中的操作数加载到AX和栈顶。
首先,我们定义一个 IMM 指令,将立即数加载到AX中;
然后,我们定义一个 PUSH 指令,将AX中的值,加载到栈中;
这样,我们便拥有了将一个操作数放入栈顶的能力。只需要使用IMM将立即数放入AX中,然后使用PUSH指令,即可将数据放入栈顶。
问题来了,我们想将某一个值存储在栈中的指定位置(地址)该怎么办呢?
简单,设计一个新的指令,叫做SI,它的功能是以栈顶的值为目的地址,将AX的值加在到栈的目的地址。注意,这一步中,我们需要将这个地址移出栈。为什么?因为对于本次运算它已经没有用了。如果后面用到,只需要使用IMM+PUSH将地址重新入栈即可。
可是还有一个问题,我们如何去访问我们存储的这些值呢?
可以通过定义一个LI指令,将栈中的值加载到AX中。可是栈是一段内存,我们要加载的是栈中某一个地址的数据,怎么办?我们将LI这样设计:LI指令以AX中存储的值为地址,从栈中将AX中存储的地址指向的内存的值加载到AX中。
如上,我们使用了IMM,PUSH,SI 和 LI 这四个指令便实现了将立即数放入AX、将立即数放入栈顶、将立即数放入栈指定位置,从指定栈的指定地址加载数据到AX。
数据运算
使用上面的指令,我们可以二目运算中的两个操作数分别放入栈顶和AX。如何将其进行运算呢?根据我们需要支持的四种类型的运算,需要设计16种指令,分别是:
- 逻辑运算
- OR 或
- XOR 异或
- AND 与
- 布尔运算
- EQ 相等
- NE 不相等
- LT 小于 less than
- GT 大于 greater than
- LE 小于等于
- GE 大于等于
- 移位运算
- SHL shift left
- SHR shift right
- 算术运算
- ADD 加
- SUB 减
- MUL 乘
- DIV 除
- MOD 取余
需要说明一下,运算器每次运算都需要读取栈顶的数据,这一步还伴随着数据的出栈。很好理解,每个栈顶的数据都只参与一次数据运算,数据参与过计算之后,保留毫无意义,直接出栈。有点像使用过的演算本都逃不过被丢弃的命运。
程序退出
我们的程序运行完成之后需要明确一个退出指令,以告知虚拟机停止运行。
- EXIT
- 这个虚拟机执行到这个指令,便会停止移动PC读取下一条指令
总结一下,我们定一个了一个CPU,它目前支持23条指令。至此,我们只需要使用代码实现我们上述指令,便实现了一个超级简单但能用的虚拟机。
实现CPU的代码与CPU的测试
关门放码!!!
//only support minimum function
class CPU {
constructor(stackSize) {
this.stackSize = stackSize
this.pc = 0
this.sp = this.stackSize - 1
this.bp = this.sp
this.ax = 0
this.stack = new Array(this.stackSize)
const opcodes = [
"IMM", "LI", "LC", "SI", "SC", "PUSH",
"OR", "XOR", "AND", "EQ", "NE", "LT", "GT", "LE", "GE", "SHL", "SHR", "ADD", "SUB", "MUL", "DIV", "MOD",
"EXIT"
]
this.OPCodes = {}
let i = 0
opcodes.forEach(prop => {
this.OPCodes[prop] = i++;
})
}
loadProgram(code) {
this.code = code
}
run() {
this.pc = 0
this.sp = this.stackSize - 1
this.bp = this.sp
this.ax = 0
let exit = false
while (!exit) {
let opcode = this.code[this.pc++]
switch (opcode) {
// 数据操作
case this.OPCodes.IMM:
// load immediate number to ax
this.ax = this.code[this.pc++]
break
case this.OPCodes.LI:
// load a int value to ax, the value stored in stack while it's address stored in ax
this.ax = this.stack[this.ax]
break
case this.OPCodes.LC:
// the same as above, but load a char value
this.ax = (byte)(this.stack[this.ax])
break
case this.OPCodes.SI:
{
// store a value to stack at the specified address, the value contained by ax, the specified address stored in the top of stack
let address = this.stack[this.sp++]
this.stack[address] = this.ax
}
break
case this.OPCodes.SC:
{
// the same as above, but store a char value
let address = this.stack[this.sp++]
this.stack[address] = (byte)(this.ax)
}
break
case this.OPCodes.PUSH:
{
// push ax's value to the top of stack
this.stack[--this.sp] = this.ax
}
break
// 数据计算
case this.OPCodes.OR:
//
this.ax = this.stack[this.sp++] | this.ax
break
case this.OPCodes.XOR:
//
this.ax = this.stack[this.sp++] ^ this.ax
break
case this.OPCodes.AND:
//
this.ax = this.stack[this.sp++] & this.ax
break
case this.OPCodes.EQ:
//
this.ax = this.stack[this.sp++] == this.ax ? 1 : 0
break
case this.OPCodes.NE:
//
this.ax = this.stack[this.sp++] != this.ax ? 1 : 0
break
case this.OPCodes.LT:
//
this.ax = this.stack[this.sp++] < this.ax ? 1 : 0
break
case this.OPCodes.LE:
//
this.ax = this.stack[this.sp++] <= this.ax ? 1 : 0
break
case this.OPCodes.GT:
//
this.ax = this.stack[this.sp++] > this.ax ? 1 : 0
break
case this.OPCodes.GE:
//
this.ax = this.stack[this.sp++] > this.ax ? 1 : 0
break
case this.OPCodes.SHL:
//
this.ax = this.stack[this.sp++] << this.ax
break
case this.OPCodes.SHR:
//
this.ax = this.stack[this.sp++] >> this.ax
break
case this.OPCodes.ADD:
//
this.ax = this.stack[this.sp++] + this.ax
break
case this.OPCodes.SUB:
//
this.ax = this.stack[this.sp++] - this.ax
break
case this.OPCodes.MUL:
//
this.ax = this.stack[this.sp++] * this.ax
break
case this.OPCodes.DIV:
//
this.ax = this.stack[this.sp++] / this.ax
break
case this.OPCodes.MOD:
//
this.ax = this.stack[this.sp++] % this.ax
break
// 内置函数
case this.OPCodes.EXIT:
// print ax
exit = true
console.log(this)
break
}
}
}
}
如何测试这个CPU呢?可以使用下面的代码
// make a cpu which is so poor,only has a 16 length stack
let cpu16 = new CPU(16)
// write 2 case for test
// case 1 : calculate 3 + 9
let count = 0;
let code1 = [];
code1[count++] = cpu16.OPCodes.IMM
code1[count++] = 3
code1[count++] = cpu16.OPCodes.PUSH
code1[count++] = cpu16.OPCodes.IMM
code1[count++] = 9
code1[count++] = cpu16.OPCodes.ADD
code1[count++] = cpu16.OPCodes.EXIT
cpu16.loadProgram(code1)
cpu16.run()
// case 2 :a litte complexed case, calculate 12 * 8 * 6
cpu16 = new CPU(16)
count = 0;
let code2 = [];
code2[count++] = cpu16.OPCodes.IMM
code2[count++] = 12
code2[count++] = cpu16.OPCodes.PUSH
code2[count++] = cpu16.OPCodes.IMM
code2[count++] = 8
code2[count++] = cpu16.OPCodes.PUSH
code2[count++] = cpu16.OPCodes.IMM
code2[count++] = 6
code2[count++] = cpu16.OPCodes.MUL
code2[count++] = cpu16.OPCodes.MUL
code2[count++] = cpu16.OPCodes.EXIT
cpu16.loadProgram(code2)
cpu16.run()
// case 3 :a slightly more complexed case, calculate 12 * 8 + 6 / 2
// (as you see, in order to example LI、SI opcode,i make this calculate a litter more complexed)
cpu16 = new CPU(16)
count = 0;
let code3 = [];
// store 12 * 8 in stack address 0
code3[count++] = cpu16.OPCodes.IMM
code3[count++] = 0
code3[count++] = cpu16.OPCodes.PUSH
code3[count++] = cpu16.OPCodes.IMM
code3[count++] = 12
code3[count++] = cpu16.OPCodes.PUSH
code3[count++] = cpu16.OPCodes.IMM
code3[count++] = 8
code3[count++] = cpu16.OPCodes.MUL
// code3[count++] = cpu16.OPCodes.PUSH
code3[count++] = cpu16.OPCodes.SI
// store 6 / 2 in stack address 1
code3[count++] = cpu16.OPCodes.IMM
code3[count++] = 1
code3[count++] = cpu16.OPCodes.PUSH
code3[count++] = cpu16.OPCodes.IMM
code3[count++] = 6
code3[count++] = cpu16.OPCodes.PUSH
code3[count++] = cpu16.OPCodes.IMM
code3[count++] = 2
code3[count++] = cpu16.OPCodes.DIV
code3[count++] = cpu16.OPCodes.SI
// code3[count++] = cpu16.OPCodes.ADD
// load result 1 to stack top
code3[count++] = cpu16.OPCodes.IMM
code3[count++] = 0
code3[count++] = cpu16.OPCodes.LI
code3[count++] = cpu16.OPCodes.PUSH
// load result 1 to ax
code3[count++] = cpu16.OPCodes.IMM
code3[count++] = 1
code3[count++] = cpu16.OPCodes.LI
code3[count++] = cpu16.OPCodes.ADD
code3[count++] = cpu16.OPCodes.EXIT
cpu16.loadProgram(code3)
cpu16.run()
从前面的CPU的代码中,我们可以看到在EXIT指令中,我们打印了整个CPU的状态。另外从代码中也可以看到,我们每次计算的结果都存在ax中。在chrome浏览器中运行的话,它将打印如下图所示的结果。
更多推荐
所有评论(0)