本教程教你如何编写一个可以运行汇编语言程序的虚拟机(VM),例如我朋友的2048或我的Roguelike。如果你了解编程,但想更深入地了解计算机内部发生的事情并更好地理解编程语言的工作方式,那么这个项目适合你。编写你自己的虚拟机可能听起来有点吓人,但我保证你会发现它出奇地简单且有启发性。
最终代码大约有250行C语言代码(适用于Unix和Windows)。你只需要知道如何阅读基本的C或C++以及如何进行二进制运算。
**什么是虚拟机?**
虚拟机(VM)是一个像计算机一样运行的程序。它模拟一个CPU以及其他一些硬件组件,使其能够执行算术运算、读写内存以及与I/O设备交互,就像一台物理计算机一样。最重要的是,它可以理解一种机器语言,你可以用它来编程。
虚拟机试图模拟的计算机硬件数量取决于其用途。一些虚拟机旨在复制某些特定计算机的行为,例如视频游戏模拟器。大多数人现在都没有NES了,但我们仍然可以通过在程序中模拟NES硬件来玩NES游戏。这些模拟器必须忠实地再现原始设备的每一个细节和主要硬件组件。
其他虚拟机并不像任何真实的计算机,而是完全虚构的!这主要是为了使软件开发更容易。假设你想创建一个可以在多个计算机架构上运行的程序。虚拟机可以提供一个标准平台,为所有这些平台提供可移植性。与其为每个CPU架构用不同的汇编语言方言重写程序,你只需要用每种汇编语言编写一个小型的虚拟机程序。然后,每个程序都只需用虚拟机的汇编语言编写一次。
**LC-3架构**
我们的虚拟机将模拟LC-3,这是一种教育性的计算机体系结构,通常用于教授大学生计算机体系结构和汇编语言。与x86相比,它具有简化的指令集,但演示了现代CPU使用的大多数思想。
为了开始,我们需要模拟机器的基本硬件组件。尝试理解每个组件是什么,但现在不必担心你不确定它如何融入更大的图景。首先创建一个C文件。本节中的每个代码片段都应该放在此文件的全局范围内。
**内存**
LC-3有65,536个内存位置(16位无符号整数2^16可以寻址的最大值),每个位置存储一个16位值。这意味着它总共只能存储128KB,这比你可能习惯的要小得多!在我们的程序中,此内存将存储在一个简单的数组中:
**寄存器**
寄存器是CPU上用于存储单个值的槽。寄存器就像CPU的“工作台”。对于CPU要处理的数据,它必须位于其中一个寄存器中。但是,由于只有几个寄存器,因此在任何给定时间只能加载最少数量的数据。程序通过将值从内存加载到寄存器、将值计算到其他寄存器,然后将最终结果存储回内存来解决此问题。
LC-3有10个寄存器,每个寄存器都是16位。其中大多数是通用寄存器,但有一些寄存器具有指定的作用。
- 8个通用寄存器(R0-R7)
- 1个程序计数器(PC)寄存器
- 1个条件标志(COND)寄存器
通用寄存器可用于执行任何程序计算。程序计数器是一个无符号整数,表示要执行的内存中下一条指令的地址。条件标志告诉我们有关先前计算的信息。
**指令集**
指令是告诉CPU执行某些基本任务(例如添加两个数字)的命令。指令既有操作码,指示要执行的任务类型,也有一组参数,为正在执行的任务提供输入。
每个操作码代表CPU“知道”如何执行的一个任务。LC-3中只有16个操作码。计算机可以计算的所有内容都是这些简单指令的某个序列。每条指令长16位,左4位存储操作码。其余位用于存储参数。
**条件标志**
R_COND寄存器存储条件标志,这些标志提供有关最近执行的计算的信息。这允许程序检查逻辑条件,例如if (x > 0) { ... }。
每个CPU都有各种条件标志来表示各种情况。LC-3仅使用3个条件标志,指示先前计算的符号。
**汇编示例**
现在让我们来看一个LC-3汇编程序,以了解虚拟机实际运行的内容。你不需要知道如何编写汇编程序或理解所有正在发生的事情。只需尝试大致了解正在发生的事情即可。这是一个简单的“Hello World”:
就像在C语言中一样,程序从顶部开始,一次执行一条语句。但是,与C语言不同,这里没有嵌套作用域{}或if或while之类的控制结构;只是一个语句的平面列表。这使得执行更容易。
请注意,一些语句具有与我们之前定义的操作码匹配的名称。之前,我们了解到每个指令都是16位,但每行看起来似乎是不同数量的字符。这种不一致是如何可能的呢?
这是因为我们正在阅读的代码是用汇编语言编写的,汇编语言是一种人类可读和可写的形式,以纯文本编码。一个名为汇编器的工具用于将每一行文本转换为虚拟机可以理解的16位二进制指令。这种二进制形式,本质上是一个16位指令数组,称为机器代码,是虚拟机实际运行的内容。
.ORIG和.STRINGZ看起来像指令,但它们不是。它们是汇编器指令,生成一段代码或数据(例如宏)。例如,.STRINGZ将字符串插入到程序二进制文件中它被写入的位置。
循环和条件是用类似于goto的指令完成的。这是一个另一个示例,它计算到10。
**执行程序**
再次强调,前面的示例只是为了让你了解虚拟机做了什么。要编写虚拟机,你不需要精通汇编语言。只要你遵循读取和执行指令的正确程序,任何LC-3程序都会正确运行,无论它多么复杂。理论上,它甚至可以运行Web浏览器或Linux之类的操作系统!
如果深入思考这个特性,这是一个在哲学上引人注目的想法。程序本身可以完成我们从未预料到的各种智能的事情,而且我们可能无法理解,但与此同时,它们可以做的一切都局限于我们将要编写的简单代码!我们同时了解程序工作方式的一切和一无所有。图灵观察到了这个奇妙的想法:
程序
以下是我们需要编写的程序:
1. 从PC寄存器地址处的内存中加载一条指令。
2. 增加PC寄存器。
3. 查看操作码以确定它应该执行哪种类型的指令。
4. 使用指令中的参数执行指令。
5. 返回步骤1。
你可能想知道,“如果循环不断增加PC,而且我们没有if或while,它会不会很快耗尽指令?”不会。正如我们之前提到的,一些类似于goto的指令通过跳转PC来更改执行流程。
让我们开始在主循环中概述此过程:
**实现指令**
你的任务现在是使用正确的实现填充每个操作码情况。这比听起来容易。项目文档中包含了每个指令的详细规范。每个规范都可以很容易地转换为几行代码。我将在这里演示如何实现其中两个。其余代码可以在下一节中找到。
**ADD**
ADD指令取两个数字,将它们加在一起,并将结果存储在一个寄存器中。它的规范在第526页。每个ADD指令看起来如下所示:
**LDI**
LDI代表“间接加载”。此指令用于将内存中某个位置的值加载到寄存器中。它的规范在第532页。
**指令备忘单**
本节包含了其余指令的完整实现,如果你遇到问题可以参考。
**陷阱例程**
LC-3提供了一些预定义的例程,用于执行常见任务和与I/O设备交互。例如,有一些例程用于从键盘获取输入以及将字符串显示到控制台上。这些被称为陷阱例程,你可以将其视为LC-3的操作系统或API。每个陷阱例程都分配了一个陷阱代码来识别它(类似于操作码)。要执行一个例程,需要使用所需例程的陷阱代码调用TRAP指令。
你可能想知道为什么陷阱代码不包含在指令中。这是因为它们实际上并没有为LC-3引入任何新功能,它们只是提供了一种方便执行任务的方式(类似于OS系统调用)。在官方的LC-3模拟器中,陷阱例程是用汇编语言编写的。当调用陷阱代码时,PC将移动到该代码的地址。CPU执行过程的指令,完成后,PC将重置到初始调用后的位置。
请注意:这就是为什么程序从地址0x3000而不是0x0开始的原因。较低的地址为空,以便为陷阱例程代码留出空间。
**陷阱例程备忘单**
本节包含了其余陷阱例程的完整实现。
**加载程序**
我们已经多次提到从内存加载和执行指令,但是指令最初是如何进入内存的呢?当汇编程序被转换为机器代码时,结果是一个包含指令和数据数组的文件。这可以通过直接将内容复制到内存中的某个地址来加载。
程序文件的头16位指定程序应该在内存中开始的地址。此地址称为原点。必须首先读取它,之后可以从文件读取其余数据到内存中,从原点地址开始。
**内存映射寄存器**
一些特殊的寄存器无法从正常的寄存器表中访问。相反,为它们在内存中保留了一个特殊的地址。要读写这些寄存器,只需读写它们的内存位置即可。这些称为内存映射寄存器。它们通常用于与特殊硬件设备交互。
LC-3有两个需要实现的内存映射寄存器。它们是键盘状态寄存器(KBSR)和键盘数据寄存器(KBDR)。KBSR指示是否按下了键,而KBDR识别按下了哪个键。
**平台细节**
本节包含一些需要访问键盘并正常运行的繁琐细节。这些对于了解虚拟机并不具有洞察力或相关性。可以随意复制粘贴!这些函数应该在你的主函数上方声明。
**运行虚拟机**
你现在可以构建和运行LC-3虚拟机了!
1. 使用你最喜欢的C编译器编译虚拟机。
2. 下载2048或Rogue的汇编版本。
3. 使用.obj文件作为参数运行虚拟机:
**调试**
如果程序无法正常工作,可能是因为你错误地编写了一个指令。这可能难以调试。我建议在同时使用调试器逐个单步执行虚拟机指令的同时,阅读LC-3程序的汇编源代码。当你阅读汇编代码时,确保虚拟机转到你预期它要执行的指令。如果发生差异,你将知道哪个指令导致了问题。重新阅读它的规范并仔细检查你的代码。
**备选C++技术**
这是一个组织指令的高级方法,它使代码大大缩短。本节完全可选。
你可能已经注意到大多数指令都重复了类似的任务。例如,一些指令使用间接寻址或对值进行符号扩展并将其添加到当前PC值。如果我们能够为所有指令只编写一次此代码不是很好吗?
**贡献**
许多程序员已经完成了本教程并在各种语言中分享了他们的实现。以前列出了其中的一些,但由于太多了,我们决定利用GitHub标签来组织它们。