编写您自己的虚拟机

2020-08-15 15:27:48

在本教程中,我将教您如何编写可以运行汇编语言程序的虚拟机(VM),比如我的朋友2048或我的无赖。如果您知道如何编程,但又想更深入地了解计算机内部发生的事情,并更好地了解编程语言是如何工作的,那么这个项目就适合您。编写您自己的VM可能听起来有点吓人,但我保证您会发现它出人意料地简单和有启发性。

最后的代码大约是250行C(Unix,Windows),你所需要知道的就是如何阅读基本的C或C++,以及如何做二进制算术。

注意:此VM是识字程序。这意味着您现在正在阅读源代码!项目中的每段代码都将被完整地显示和解释,因此您可以确保没有遗漏任何内容。最后的代码是将代码块编织在一起创建的。

VM是一种运行方式类似于计算机的程序。它模拟CPU和其他一些硬件组件,允许它执行运算、读取和写入内存,并与I/O设备交互,就像物理计算机一样。最重要的是,它能理解一种机器语言,你可以用它来编程。

VM尝试模拟的计算机硬件数量取决于其用途。某些VM旨在重现某些特定计算机的行为,例如视频游戏仿真器。大多数人不再拥有NES,但是我们仍然可以通过在程序中模拟NES硬件来玩NES游戏。这些仿真器必须忠实地重新创建原始设备的每个细节和主要硬件组件。

其他虚拟机的行为不像任何真正的计算机,完全是虚构的!这样做主要是为了简化软件开发。假设您想要创建一个在多个计算机体系结构上运行的程序。VM可以提供一个标准平台,为所有这些组件提供可移植性。您不需要为每个CPU体系结构用不同的汇编语言重写程序,而只需要用每种汇编语言编写小的VM程序。然后,每个程序将只用VM的汇编语言编写一次。

注意:编译器通过将标准高级语言编译成几种CPU架构来解决类似的问题。VM创建一个标准CPU体系结构,该体系结构在各种硬件设备上进行模拟。编译器的一个优点是,与VM相比,它没有运行时开销。尽管编译器的工作做得很好,但是编写一个面向多个平台的新编译器是非常困难的,所以VM在这里仍然很有帮助。实际上,VM和编译器在不同级别混合在一起。

Java虚拟机(JVM)就是一个非常成功的例子。JVM本身是一个中等大小的程序,小到足以让一个程序员理解。这使得为包括电话在内的数千种设备编写代码成为可能。一旦在新设备上实现了JVM,所编写的任何Java、Kotlin或Clojure程序都可以在其上运行,无需修改。唯一的成本是VM本身的开销和对机器的进一步抽象。在大多数情况下,这是一个相当好的权衡。

虚拟机并不一定要很大或很普遍才能提供类似的好处。旧的视频游戏通常使用小型VM来提供简单的脚本系统。

VM对于以安全或隔离的方式执行代码也很有用。这方面的一个应用是垃圾收集。没有简单的方法可以在C或C++之上实现自动垃圾收集,因为程序看不到自己的堆栈或变量。但是,VM在它正在运行的程序的“外部”,并且可以观察堆栈上的所有内存引用。

这种行为的另一个例子是以太智能合约。智能合约是由区块链网络中的每个验证节点执行的小程序。这要求节点操作员在他们的机器上运行由完全陌生的人编写的程序,而没有任何机会事先仔细检查这些程序。为了防止合同进行恶意操作,它们在无法访问文件系统、网络、磁盘等的虚拟机中运行。Etherum也很好地应用了使用虚拟机时产生的可移植性功能。由于以太节点可以在多种类型的计算机和操作系统上运行,因此使用VM可以编写智能合同,而无需考虑它们运行在哪些平台上。

我们的虚拟机将模拟一台名为LC-3的虚拟计算机。LC-3是流行的教授大学生如何用汇编语言编程的工具。与x86相比,它具有简化的指令集,但包含了现代CPU中使用的所有主要思想。

首先,我们需要模拟机器的基本硬件组件。试着了解每一个组成部分是什么,但是如果你不确定它如何适应更大的图景,现在不要担心。首先创建一个C文件。本节中的每个代码片段都应该放在此文件的全局范围内。

LC-3有65,536个内存位置(可由16位无符号整数2^16寻址的最大值),每个位置存储一个16位值。这意味着它总共只能存储128KB,比您可能习惯的要小得多!在我们的程序中,该内存将存储在一个简单的数组中:

寄存器是用于在CPU上存储单个值的槽。寄存器类似于CPU的工作台。要让CPU处理一段数据,它必须位于其中一个寄存器中。但是,由于只有几个寄存器,所以在任何给定时间只能加载极少量的数据。程序通过将值从内存加载到寄存器、将值计算到其他寄存器,然后将最终结果存储回内存来解决此问题。

LC-3共有10个寄存器,每个寄存器为16位。他们中的大多数是通用的,但也有少数有指定的角色。

通用寄存器可用于执行任何程序计算。程序计数器是一个无符号整数,它是内存中要执行的下一条指令的地址。条件标志告诉我们有关先前计算的信息。

枚举{R_R0=0,R_R1,R_R2,R_R3,R_R4,R_R5,R_R6,R_R7,R_PC,/*程序计数器*/R_Cond,R_count};

指令是告诉CPU执行一些基本任务的命令,例如将两个数字相加。指令既有指示要执行的任务类型的操作码,也有为正在执行的任务提供输入的一组参数。

每个操作码代表CPU知道如何执行的一个任务。LC-3中只有16个操作码。计算机所能计算的一切都是这些简单指令的序列。每条指令为16位长,左侧4位存储操作码。其余位用于存储参数。

我们稍后将详细讨论每条指令的作用。目前,定义以下操作码。确保它们保持此顺序,以便为它们分配正确的枚举值:

枚举{OP_BR=0,/*BRANCH*/OP_ADD,/*ADD*/OP_LD,/*LOAD*/OP_ST,/*STORE*/OP_JSR,/*JUMP寄存器*/OP_AND,/*按位AND*/OP_LDR,/*LOAD寄存器*/OP_STR,/*存储寄存器*/OP_RTI,/*UNUSED*/OP_NOT,/*BITED NOT*/OP_LDI,/*LOAD INDIRECT*/OP_。/*存储间接*/OP_JMP,/*JUMP*/OP_RES,/*保留(未使用)*/OP_LEA,/*加载有效地址*/OP_TRAP/*EXECUTE TRAP*/};

注:Intel x86体系结构有数百条指令,而其他如ARM和LC-3的指令很少。较小的指令集称为RISC,而较大的指令集称为CISC。更大的指令集通常不会从根本上提供任何新的可能性,但它们通常会使编写汇编语言变得更加方便。CISC中的一条指令可能会取代RISC中的几条指令。然而,对于工程师来说,它们往往更复杂,更昂贵的设计和制造。这一点和其他权衡会导致设计时好时坏。

R_COND寄存器存储提供有关最近执行的计算的信息的条件标志。这允许程序检查逻辑条件,如IF(x>;0){...}。

每个CPU都有各种条件标志来通知各种情况。LC-3仅使用3个条件标志来指示前一次计算的符号。

枚举{FL_POS=1<;<;0,/*P*/FL_ZRO=1<;<;1,/*Z*/FL_NEG=1<;<;2,/*N*/};

备注:(<;<;符号称为左位移运算符。(n<;<;k)将n的位向左移位k位。因此,1<;<;2将等于4。如果您不熟悉该链接,请阅读该链接。这将是重要的。)。

我们已经完成了虚拟机硬件组件的设置!添加标准包含后(请参阅参考),您的文件应该如下所示:

现在,让我们看一个LC-3汇编程序,以了解VM实际运行的内容。您不需要知道如何编写汇编语言,也不需要了解正在发生的一切。只要试着对正在发生的事情有个大概的了解就行了。这里有一个简单的Hello World";:

.ORIG x3000;这是内存中将加载程序的地址LEA R0,HELLO_STR;将HELLO_STR字符串的地址加载到R0PUTS;将R0指向的字符串输出到控制台HALT;停止程序HELLO_STR.STRINGZ";Hello World!";将此字符串存储在程序中。标记文件的结尾。

就像在C中一样,程序从顶部开始,一次执行一条语句。但是,与C不同的是,它没有嵌套的作用域{}或控制结构(如if或while);只有一个语句的平面列表。这使得它更容易执行。

请注意,有些语句的名称与我们先前定义的操作码匹配。以前,我们了解到每条指令都是16位的,但是每行看起来都是不同数量的字符。这种不一致是怎么可能的呢?

这是因为我们正在阅读的代码是用汇编语言编写的,汇编语言是一种人类可读可写的形式,以纯文本编码。使用一种称为汇编器的工具将每行文本转换为VM可以理解的16位二进制指令。这种二进制形式本质上是一个16位指令数组,称为机器码,是VM实际运行的代码。

注意:尽管编译器和汇编器在开发中扮演着相似的角色,但它们并不相同。汇编器简单地将程序员用文本编写的内容编码成二进制,用它们的二进制表示替换符号,并将它们打包成指令。

命令.ORIG和.STRINGZ看起来像指令,但它们不是。它们是生成一段代码或数据(如宏)的汇编指令。例如,.STRINGZ将字符串插入到程序二进制文件中的写入位置。

循环和条件是通过类似于Goto的指令来完成的。这是另一个数到10的例子。

和R0,R0,0;清除R0LOOP;循环顶部的标签ADD R0,R0,1;将1加到R0并存储回R0ADD R1,R0,-10;从R0减去10并存储回R1BRn循环;如果结果为负,则返回循环...。;R0现在是10!

注意:本教程不需要学习编写汇编。但是,如果您感兴趣,您可以使用LC-3工具编写和汇编您自己的LC-3程序。

同样,前面的示例只是让您了解VM的作用。要编写VM,您不需要流利地使用汇编。只要您遵循正确的指令读取和执行过程,任何LC-3程序都将正确运行,无论它有多复杂。从理论上讲,它甚至可以运行网络浏览器或像Linux这样的操作系统!

如果你深入思考这一特性,你会发现它是一个哲学上不同凡响的想法。程序本身可以做各种我们从未预料到也可能无法理解的智能事情,但同时,它们所能做的一切都局限于我们将要编写的简单代码!我们同时知道每个程序的工作原理,但对此一无所知。图灵观察到了这个奇妙的想法:

因此,我们知道要将结果存储在哪里,也知道要添加的第一个数字。我们需要的最后一点信息是要添加的第二个数字。在这一点上,这两行开始看起来不同。请注意,在顶行,第5位是0,在第二行是1。此位表示它是立即模式还是寄存器模式。在寄存器模式下,第二个数字存储在与第一个相同的寄存器中。这被标记为SR2,并且包含在位2-0中。位3和4未使用。在汇编中,这将写成:

添加R2 R0 R1;将R0的内容添加到R1中并存储在R2中。

立即模式是一种减少典型程序长度的便利方式。不是将存储在单独寄存器中的两个值相加,而是将第二个值嵌入指令本身,在图中标记为imm5。这样就不需要编写指令来从内存中加载值。折衷的是,指令只有很小的空间,准确地说,最大可达2^5=32(无符号),这使得立即模式主要用于递增和递减。在汇编中,它可以写成:

如果位[5]为0,则从SR2获取第二个源操作数。如果位[5]为1,则通过将imm5字段符号扩展到16位来获得第二个源操作数。在这两种情况下,第二个源操作数加到SR1的内容中,结果存储在DR中。(PG.。526)。

这听起来就像我们讨论过的行为,但是什么是符号扩展?立即模式值只有5位,但需要加到16位数字上。要进行加法,需要将这5位扩展到16以匹配其他数字。对于正数,我们可以简单地填写0';来表示额外的位。对于负数,这会导致问题。例如,5位中的-1是1111。如果我们只将其扩展为0&39;s,则这是0000 0000 0001 1111,等于31。符号扩展通过为正数填写0#39;为负数填写1';来更正此问题,以便保留原始值。

Uint16_t sign_EXTEND(uint16_t x,int bit_count){if(x>;>;(bit_count-1))&;1){x|=(0xFFFF<;<;bit_count);}返回x;}。

注意:如果你对负数究竟是如何用二进制表示感兴趣的,你可以阅读关于2的补码。然而,这并不是必须的。您只需复制上面的代码,并在规范要求对扩展号进行签名时使用它。

根据结果是负、零还是正来设置条件代码。(PG.。526)

前面我们定义了一个条件标志枚举,现在是使用它们的时候了。任何时候将值写入寄存器时,我们都需要更新标志以指示其符号。我们将编写一个函数,以便可以重用该函数:

Void UPDATE_FLAGS(Uint16_T R){if(reg[r]==0){reg[R_Cond]=FL_ZRO;}Else if(reg[r]>;>;15)/*最左位的1表示负*/{reg[R_Cond]=FL_NEG;}Else{reg[R_Cond]=FL_POS;}}。

{/*目标寄存器(DR)*/uint16_t R0=(instr>;>;9)&;0x7;/*第一个操作数(SR1)*/uint16_t r1=(instr>;>;6)&;0x7;/*我们是否处于立即模式*/uint16_t imm_flag=(instr>;>;5)&;0x1;if(IMM。Reg[r0]=reg[r1]+imm5;}否则{uint16_t r2=instr&;0x7;reg[r0]=reg[r1]+reg[r2];}更新标志(R0);}。

在立即模式下,第二个值嵌入指令的最右侧5位。

你可能会因为多写15条说明而感到不知所措。但是,您在这里学到的所有内容都将被重复使用。大多数指令使用符号扩展、不同模式和更新标志的某种组合。

LDI代表";LOAD INDIRECT。";此指令用于将值从内存中的某个位置加载到寄存器中。规格请参见第532页。

与Add相比,它没有模式,参数也更少。这一次,操作码是1010,它对应于op_LDI枚举值。就像ADD一样,它包含一个3位DR(目标寄存器),用于存储加载的值。其余位标记为PCoffset9。这是嵌入在指令中的立即值(类似于imm5)。因为这条指令是从内存加载的,所以我们可以猜测这个数字是某种地址,它告诉我们从哪里加载。该规范提供了更多详细信息:

地址是通过将位[8:0]符号扩展到16位,然后将该值加到递增的PC上来计算的。此地址存储在内存中的是日期的地址

.