美文网首页
听说你想写个虚拟机(一)?

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

作者: 微微笑的蜗牛 | 来源:发表于2021-01-30 07:17 被阅读0次

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

    虚拟机是什么

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

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

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

    总结

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

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

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

    参考资料

    相关文章

      网友评论

          本文标题:听说你想写个虚拟机(一)?

          本文链接:https://www.haomeiwen.com/subject/mtjhtltx.html