如何编写您自己的虚拟机
在本教程中,我将教您如何编写自己的虚拟机 (VM),该虚拟机可以运行汇编语言程序,例如我朋友的 2048 或我的 Roguelike。如果您知道如何编程,但想更深入地了解计算机内部发生的事情并更好地了解编程语言的工作原理,那么这个项目适合您。编写自己的 VM 听起来可能有点可怕,但我保证您会发现它非常简单且富有启发性。 最终代码大约是 250 行 C 语言(unix、windows)。 您需要知道的只是如何阅读基本的 C 或 C++ 以及如何进行二进制运算。 - 什么是虚拟机?
- LC-3 架构
- 装配示例
- 执行程序
- 实施说明
- 说明书备忘单
- 陷阱例程
- Trap 例程备忘单
- 加载程序
- 内存映射寄存器
- 平台细节
- 运行 VM
- 替代 C++ 技术
- 贡献
注意:本教程是一个识字程序。 这意味着您现在正在阅读源代码! VM 项目中的每一段代码都将得到全面展示和解释,因此您可以确保没有遗漏任何内容。 最终代码是通过将代码块 “缠结” 在一起来创建的。 什么是虚拟机?VM 是一个像计算机一样的程序。它模拟 CPU 以及其他一些硬件组件,使其能够执行算术、读取和写入内存以及与 I/O 设备交互,就像物理计算机一样。最重要的是,它可以理解一种机器语言,您可以使用它来对其进行编程。 VM 尝试模拟的计算机硬件数量取决于其用途。某些 VM 旨在重现某些特定计算机(如视频游戏仿真器)的行为。大多数人已经没有 NES 了,但我们仍然可以通过在程序中模拟 NES 硬件来玩 NES 游戏。这些仿真器必须忠实地再现原始设备的每个细节和主要硬件组件。 其他 VM 的行为与任何真实计算机都不同,完全是虚构的!这样做主要是为了使软件开发更容易。假设您想创建一个在多个计算机体系结构上运行的程序。VM 可以提供一个标准平台,为所有这些 VM 提供可移植性。无需为每个 CPU 体系结构使用不同的汇编方言重写程序,只需使用每种汇编语言编写小型 VM 程序。然后,每个程序将只用 VM 的汇编语言编写一次。
如何编写您自己的虚拟机
如何编写您自己的虚拟机
注意:编译器通过将标准高级语言编译为多个 CPU 体系结构来解决类似的问题。VM 创建一个标准 CPU 体系结构,该体系结构在各种硬件设备上进行模拟。编译器的一个优点是它没有运行时开销,而 VM 则有。尽管编译器做得很好,但编写一个针对多个平台的新编译器非常困难,因此 VM 在这里仍然很有用。在实践中,VM 和编译器在不同级别混合使用。
Java 虚拟机 (JVM) 就是一个非常成功的例子。JVM 本身是一个中等大小的程序,它足够小,一个程序员也能理解。这使得为包括手机在内的数千种设备编写成为可能。在新设备上实现 JVM 后,任何编写的 Java、Kotlin 或 Clojure 程序都可以在其上运行,而无需修改。唯一的成本是 VM 本身的开销以及从机器中进一步抽象出来。大多数情况下,这是一个非常好的权衡。 VM 不必很大或无处不在才能提供类似的好处。旧视频游戏通常使用小型 VM 来提供简单的脚本系统。 VM 还可用于以安全或隔离的方式执行代码。其中一个应用是垃圾回收。在 C 或 C++ 之上实现自动垃圾回收没有简单的方法,因为程序无法看到自己的堆栈或变量。但是,VM 位于它正在运行的程序的“外部”,并且可以观察堆栈上的所有内存引用。 以太坊智能合约演示了这种行为的另一个示例。智能合约是由区块链网络中的每个验证节点执行的小程序。这要求节点运营商在他们的机器上运行由完全陌生人编写的程序,而没有任何机会事先仔细检查它们。为了防止合约执行恶意操作,它们在无法访问文件系统、网络、磁盘等的 VM 中运行。Ethereum 也是使用 VM 时产生的可移植性功能的良好应用。由于以太坊节点可以在多种计算机和操作系统上运行,因此使用 VM 允许编写智能合约,而无需考虑它们运行的许多平台。 LC-3 架构我们的 VM 将模拟 LC-3,这是一种教育计算机架构,通常用于教授大学生计算机架构和组装。与 x86 相比,它具有简化的指令集,但演示了现代 CPU 使用的主要思想。 注意:LC-3在耶鲁· N·帕特和桑杰·J·帕特尔的“计算系统导论:从比特和门到C/C++及其他”中被定义。本教程基于第二版。
首先,我们需要模拟机器的基本硬件组件。尝试了解每个组件是什么,但如果您不确定它如何适应更大的图景,请不要担心。首先创建一个 C 文件。本节中的每个代码片段都应放置在此文件的全局范围内。 记忆 MemoryLC-3 有 65,536 个内存位置(16 位无符号整数可寻址的最大值),每个位置存储一个 16 位值。这意味着它总共只能存储 128KB,这比您可能习惯的要小得多!在我们的程序中,此内存将存储在一个简单的数组中:2^16 - #define MEMORY_MAX (1 << 16)
- uint16_t memory[MEMORY_MAX]; /* 65536 locations */
复制代码 寄存 器 Registers寄存器是用于在 CPU 上存储单个值的插槽。寄存器就像 CPU 的 “工作台”。要使 CPU 处理一段数据,它必须位于其中一个 registers 中。但是,由于只有几个 registers,因此在任何给定时间只能加载极少量的数据。程序通过将值从内存加载到 registers,将值加载到其他 registers,然后将最终结果存储回 memory 来解决这个问题。 LC-3 总共有 10 个寄存器,每个寄存器为 16 位。它们中的大多数是通用的,但也有少数具有指定的角色。 - 8 个通用寄存器 (-) - 1 个程序计数器 () 寄存器 - 1 个条件标志 () 寄存器R0R7PCCOND 通用寄存器可用于执行任何程序计算。程序计数器是一个无符号整数,它是内存中要执行的下一条指令的地址。条件标志告诉我们有关上一个计算的信息。 - enum
- {
- R_R0 = 0,
- R_R1,
- R_R2,
- R_R3,
- R_R4,
- R_R5,
- R_R6,
- R_R7,
- R_PC, /* program counter */
- R_COND,
- R_COUNT
- };
复制代码 就像内存一样,我们将寄存器存储在一个数组中:指令集 Instruction set指令是告诉 CPU 执行一些基本任务的命令,例如将两个数字相加。指令既有一个操作码,用于指示要执行的任务类型,也具有一组参数,用于为正在执行的任务提供输入。 每个操作码代表 CPU“知道”如何执行的一项任务。LC-3 中只有 16 个操作码。计算机可以计算的一切都是这些简单指令的某个序列。每条指令长 16 位,左边 4 位存储操作码。其余位用于存储参数。 我们稍后将详细讨论每条指令的作用。现在,定义以下操作码。确保它们保持以下顺序,以便为它们分配正确的枚举值: - enum
- {
- OP_BR = 0, /* branch */
- OP_ADD, /* add */
- OP_LD, /* load */
- OP_ST, /* store */
- OP_JSR, /* jump register */
- OP_AND, /* bitwise and */
- OP_LDR, /* load register */
- OP_STR, /* store register */
- OP_RTI, /* unused */
- OP_NOT, /* bitwise not */
- OP_LDI, /* load indirect */
- OP_STI, /* store indirect */
- OP_JMP, /* jump */
- OP_RES, /* reserved (unused) */
- OP_LEA, /* load effective address */
- OP_TRAP /* execute trap */
- };
复制代码 注意:Intel x86 架构有数百条指令,而 ARM 和 LC-3 等其他架构则很少。较小的指令集称为 RISC,而较大的指令集称为 CISC。较大的指令集通常不会提供任何全新的可能性,但它们通常会使编写 assembly 更加方便。CISC 中的单个指令可能会代替 RISC 中的多个指令。但是,对于工程师来说,设计和制造它们往往更加复杂和昂贵。这种权衡和其他权衡会导致设计过时。
条件标志 Condition flagsregister 存储条件标志,这些标志提供有关最近执行的计算的信息。这允许程序检查逻辑条件,如 .R_CONDif (x > 0) { ... } 每个 CPU 都有各种条件标志来指示各种情况。LC-3 仅使用 3 个条件标志,这些标志指示先前计算的标志。 - enum
- {
- FL_POS = 1 << 0, /* P */
- FL_ZRO = 1 << 1, /* Z */
- FL_NEG = 1 << 2, /* N */
- };
复制代码注意:(该符号称为 left bitshift 运算符。 将 的位移动到左侧位置。因此将等于 。如果您不熟悉,请阅读该链接。这将很重要。<<(n << k)nk1 << 24
我们已完成 VM 的硬件组件设置!添加标准包含后(参见参考资料),您的文件应如下所示: - @{Includes}
- @{Registers}
- @{Condition Flags}
- @{Opcodes}
复制代码 装配示例 Assembly examples现在让我们看一个 LC-3 汇编程序,以了解 VM 实际运行什么。您不需要知道如何编程、汇编或了解正在发生的一切。试着大致了解发生了什么。下面是一个简单的 “Hello World”: - .ORIG x3000 ; this is the address in memory where the program will be loaded
- LEA R0, HELLO_STR ; load the address of the HELLO_STR string into R0
- PUTs ; output the string pointed to by R0 to the console
- HALT ; halt the program
- HELLO_STR .STRINGZ "Hello World!" ; store this string here in the program
- .END ; mark the end of the file
复制代码就像在 C 语言中一样,程序从顶部开始,一次执行一个语句。但是,与 C 不同的是,没有嵌套范围或控制结构,例如 or ;只是一个简单的语句列表。这使得执行起来要容易得多。{}ifwhile 请注意,某些语句的名称与我们之前定义的操作码匹配。以前,我们了解到每条指令都是 16 位,但每行看起来都是不同数量的字符。这种不一致怎么可能呢? 这是因为我们正在读取的代码是用汇编编写的,汇编是一种人类可读和可写的形式,以纯文本编码。使用一种称为汇编程序的工具将每行文本转换为 VM 可以理解的 16 位二进制指令。这种二进制形式本质上是一个 16 位指令数组,称为机器代码,是 VM 实际运行的内容。
如何编写您自己的虚拟机
注意:尽管编译器和汇编器在开发中扮演着相似的角色,但它们并不相同。汇编器只需将程序员在文本中写入的内容编码为二进制,用它们的二进制表示形式替换符号并将它们打包到指令中。
命令 和 看起来像说明,但事实并非如此。它们是生成一段代码或数据(如宏)的汇编器指令。例如,将字符串插入到程序二进制文件的写入位置。.ORIG.STRINGZ.STRINGZ 循环和条件是通过类似 goto 的指令完成的。这是另一个计数为 10 的示例。 - AND R0, R0, 0 ; clear R0
- LOOP ; label at the top of our loop
- ADD R0, R0, 1 ; add 1 to R0 and store back in R0
- ADD R1, R0, -10 ; subtract 10 from R0 and store back in R1
- BRn LOOP ; go back to LOOP if the result was negative
- ... ; R0 is now 10!
复制代码注意:在本教程中,学习编写程序集不是必需的。但是,如果您有兴趣,您可以使用 LC-3 工具(附件中包含此工具)编写和组装自己的 LC-3 程序。 执行程序 Executing programs同样,前面的示例只是为了让您了解 VM 的作用。要编写 VM,您无需流利地使用程序集。只要您遵循正确的程序来读取和执行指令,任何 LC-3 程序都会正确运行,无论它多么复杂。理论上,它甚至可以运行 Web 浏览器或 Linux 等操作系统! 如果你深入思考这个属性,这是一个哲学上非凡的想法。程序本身可以做各种我们从未预料到、可能无法理解的智能事情,但与此同时,它们所能做的一切都仅限于我们将要编写的简单代码!我们同时对每个程序的工作原理一无所知。图灵观察到了这个奇妙的想法: “我认为,机器不会产生意外的观点是由于哲学家和数学家特别受制于一个谬误。这是一个假设,即一旦一个事实被呈现给一个头脑,这个事实的所有后果就会同时涌入头脑。在许多情况下,这是一个非常有用的假设,但人们很容易忘记它是错误的。 程序 Procedure这是我们需要编写的过程: - 从 register 地址的内存中加载一条指令。PC
- 递增寄存器。PC
- 查看操作码以确定它应该执行哪种类型的指令。
- 使用指令中的参数执行指令。
- 返回步骤 1。
您可能想知道,“如果循环不断递增 ,而我们没有 或 ,它是否很快就会用完指令?不。正如我们之前提到的,一些类似 goto 的指令通过跳转来改变执行流程。PCifwhilePC 让我们开始在主循环中概述此过程: - int main(int argc, const char* argv[])
- {
- @{Load Arguments}
- @{Setup}
- /* since exactly one condition flag should be set at any given time, set the Z flag */
- reg[R_COND] = FL_ZRO;
- /* set the PC to starting position */
- /* 0x3000 is the default */
- enum { PC_START = 0x3000 };
- reg[R_PC] = PC_START;
- int running = 1;
- while (running)
- {
- /* FETCH */
- uint16_t instr = mem_read(reg[R_PC]++);
- uint16_t op = instr >> 12;
- switch (op)
- {
- case OP_ADD:
- @{ADD}
- break;
- case OP_AND:
- @{AND}
- break;
- case OP_NOT:
- @{NOT}
- break;
- case OP_BR:
- @{BR}
- break;
- case OP_JMP:
- @{JMP}
- break;
- case OP_JSR:
- @{JSR}
- break;
- case OP_LD:
- @{LD}
- break;
- case OP_LDI:
- @{LDI}
- break;
- case OP_LDR:
- @{LDR}
- break;
- case OP_LEA:
- @{LEA}
- break;
- case OP_ST:
- @{ST}
- break;
- case OP_STI:
- @{STI}
- break;
- case OP_STR:
- @{STR}
- break;
- case OP_TRAP:
- @{TRAP}
- break;
- case OP_RES:
- case OP_RTI:
- default:
- @{BAD OPCODE}
- break;
- }
- }
- @{Shutdown}
- }
复制代码当我们在主循环中时,让我们处理命令行输入以使我们的程序可用。 我们需要一个或多个 VM 映像路径,如果未提供任何路径,则会显示使用字符串。 - if (argc < 2)
- {
- /* show usage string */
- printf("lc3 [image-file1] ...\n");
- exit(2);
- }
- for (int j = 1; j < argc; ++j)
- {
- if (!read_image(argv[j]))
- {
- printf("failed to load image: %s\n", argv[j]);
- exit(1);
- }
- }
复制代码 实施说明 Implementing instructions您现在的任务是用正确的实现填充每个操作码大小写。这比听起来容易。项目文档中(附件中包含此文档)包含每个指令的详细规范。每个的特异性很容易转换为几行代码。我将在此处演示如何实现其中两个。其余部分的代码可以在下一节中找到。 加 ADD该指令采用两个数字,将它们相加,并将结果存储在寄存器中。其规格见第 526 页。每条指令如下所示:ADDADD
如何编写您自己的虚拟机
编码显示两行,因为此指令有两种不同的 “模式”。在我解释模式之前,让我们试着找出它们之间的相似之处。在这两行中,我们可以看到我们从 4 位 .这是 的操作码值。接下来的 3 位标记为 。这代表 destination register。目标 register 是存储添加的总和的位置。接下来的 3 位是 。这是包含要添加的第一个数字的 register。0001OP_ADDDRSR1 所以我们知道我们想要将结果存储在哪里,并且我们知道要添加的第一个数字。我们需要的最后一点信息是要添加的第二个数字。此时,两行开始看起来不同。请注意,在第一行,第 5 位是 a,在第二行是 。此位指示它是即时模式还是寄存器模式。在 register 模式下,第二个数字与第一个数字一样存储在 register 中。这被标记并包含在位 2-0 中。位 3 和 4 未使用。在 assembly 中,这将写成:01SR2 - ADD R2 R0 R1 ; add the contents of R0 to R1 and store in R2.
复制代码Immediate 模式是一种便利,可以减少典型程序的长度。 第二个值不是添加存储在单独寄存器中的两个值,而是嵌入在指令本身中,并在图中标记。 这样就无需编写指令来从内存中加载值。 权衡是该指令只留有少量空间,确切地说是 (unsigned), 使 immediate 模式主要用于递增和递减。在 assembly 中,它可以写成:imm52^5=32 - ADD R0 R0 1 ; add 1 to R0 and store back in R0
复制代码以下是规范中的摘要: 如果位 [5] 为 0,则从 SR2 获取第二个源操作数。如果位 [5] 为 1,则通过将 imm5 字段符号扩展为 16 位来获得第二个源操作数。在这两种情况下,第二个源操作数都被添加到 SR1 的内容中,结果存储在 DR 中(第 526 页)
这听起来就像我们讨论的行为,但什么是“符号扩展”?immediate mode 值只有 5 位,但需要将其与 16 位数字相加。要进行加法,需要将这 5 位扩展为 16 以匹配另一个数字。对于正数,我们只需为额外的位填写 0 即可。对于负数,这会导致问题。例如,5 位中的 -1 是 。如果我们只是用 0 扩展它,那就等于 31。符号扩展通过为正数填写 0 和负数填写 1 来纠正此问题,以便保留原始值。1 11110000 0000 0001 1111 - uint16_t sign_extend(uint16_t x, int bit_count)
- {
- if ((x >> (bit_count - 1)) & 1) {
- x |= (0xFFFF << bit_count);
- }
- return x;
- }
复制代码注意:如果你对负数如何用二进制表示感兴趣,你可以阅读 2 的补码。但是,这不是必需的。您可以复制上面的代码,并在规范说对扩展数字进行签名时使用它。
规范中还有最后一句话: 条件代码是根据结果是负数、零还是正数来设置。(第 526 页)
之前我们定义了一个条件 flags enum,现在是时候使用它们了。每当将值写入 register 时,我们都需要更新标志以指示其符号。我们将编写一个函数,以便可以重用它: - void update_flags(uint16_t r)
- {
- if (reg[r] == 0)
- {
- reg[R_COND] = FL_ZRO;
- }
- else if (reg[r] >> 15) /* a 1 in the left-most bit indicates negative */
- {
- reg[R_COND] = FL_NEG;
- }
- else
- {
- reg[R_COND] = FL_POS;
- }
- }
复制代码现在我们准备好为该案例编写代码:ADD - {
- /* destination register (DR) */
- uint16_t r0 = (instr >> 9) & 0x7;
- /* first operand (SR1) */
- uint16_t r1 = (instr >> 6) & 0x7;
- /* whether we are in immediate mode */
- uint16_t imm_flag = (instr >> 5) & 0x1;
- if (imm_flag)
- {
- uint16_t imm5 = sign_extend(instr & 0x1F, 5);
- reg[r0] = reg[r1] + imm5;
- }
- else
- {
- uint16_t r2 = instr & 0x7;
- reg[r0] = reg[r1] + reg[r2];
- }
- update_flags(r0);
- }
复制代码这部分包含了很多信息,所以让我们总结一下。 - 获取两个值并将它们存储在 register 中。 - 在 register 模式下,要添加的第二个值位于 register. - 在 immediate 模式下,第二个值嵌入在指令最右侧的 5 位中。 - 短于 16 位的值需要进行符号扩展。 - 每当 instruction 修改 register 时,都需要更新 condition flags。ADD 您可能会对再写 15 条说明感到不知所措。但是,您在此处学到的所有知识都将重复使用。大多数说明都使用了符号扩展、不同模式和更新标志的某种组合。 LDILDI代表 “Load Indirect”。此指令用于将值从内存中的某个位置加载到 register 中。该规范位于第 532 页。 以下是二进制布局的样子:
如何编写您自己的虚拟机
与 相反,没有模式,参数较少。这一次,操作码是与 enum 值对应的 opcode。就像 ,它包含一个 3 位(目标寄存器)用于存储加载的值。其余位标记为 。这是嵌入在指令中的立即值(类似于 )。由于这条指令是从内存中加载的,我们可以猜测这个数字是某种地址,它告诉我们从哪里加载。该规范提供了更多详细信息:ADD1010OP_LDIADDDRPCoffset9imm5 通过将符号扩展位限制为 16 位并将此值添加到递增的 .此地址处存储在内存中的内容是要加载到 的数据的地址。(第 532 页)[8:0]PCDR
就像以前一样,我们需要对这个 9 位值进行符号扩展,但这次将其添加到当前的 .(如果您回顾一下执行循环,则 this 在加载此指令后立即递增。结果总和是内存中某个位置的地址,该地址包含另一个值,该值是要加载的值的地址。PCPC 这似乎是一种迂回的记忆阅读方式,但它是必不可少的。该指令仅限于 9 位的地址偏移量,而内存需要 16 位来寻址。 对于加载存储在远离当前 PC 的位置的值非常有用,但要使用它,最终位置的地址需要存储在附近的社区中。你可以把它想象成在 C 中有一个局部变量,它是指向某些数据的指针:LDLDI - // the value of far_data is an address
- // of course far_data itself (the location in memory containing the address) has an address
- char* far_data = "apple";
- // In memory it may be layed out like this:
- // Address Label Value
- // 0x123: far_data = 0x456
- // ...
- // 0x456: string = 'a'
- // if PC was at 0x100
- // LDI R0 0x023
- // would load 'a' into R0
复制代码和以前一样,将值放入 :DR 条件代码根据加载的值是负数、零还是正数进行设置。(第 532 页)
这是这种情况的代码:( 将在后面的部分中讨论。mem_read - {
- /* destination register (DR) */
- uint16_t r0 = (instr >> 9) & 0x7;
- /* PCoffset 9*/
- uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
- /* add pc_offset to the current PC, look at that memory location to get the final address */
- reg[r0] = mem_read(mem_read(reg[R_PC] + pc_offset));
- update_flags(r0);
- }
复制代码正如我所说,该指令分享了很多从 中学到的代码和知识。您会发现其余说明就是这种情况。ADD 您现在需要返回并实现其余的 switch case 以获取说明。按照规范(附件中包含此规范文档)并使用此处列出的代码完成其他操作。本教程末尾列出了所有指令的代码。之前指定的两个操作码将不会被使用,它们是 和 。您可以忽略这些情况,或者在执行这些情况时引发错误。完成后,VM 的大部分将完成!OP_RTIOP_RES 说明书备忘单如果您遇到困难,本节包含其余说明的完整实现。 RTI & RES(这些是未使用的) Bitwise and- {
- uint16_t r0 = (instr >> 9) & 0x7;
- uint16_t r1 = (instr >> 6) & 0x7;
- uint16_t imm_flag = (instr >> 5) & 0x1;
- if (imm_flag)
- {
- uint16_t imm5 = sign_extend(instr & 0x1F, 5);
- reg[r0] = reg[r1] & imm5;
- }
- else
- {
- uint16_t r2 = instr & 0x7;
- reg[r0] = reg[r1] & reg[r2];
- }
- update_flags(r0);
- }
复制代码 Bitwise not- {
- uint16_t r0 = (instr >> 9) & 0x7;
- uint16_t r1 = (instr >> 6) & 0x7;
- reg[r0] = ~reg[r1];
- update_flags(r0);
- }
复制代码 Branch- {
- uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
- uint16_t cond_flag = (instr >> 9) & 0x7;
- if (cond_flag & reg[R_COND])
- {
- reg[R_PC] += pc_offset;
- }
- }
复制代码 JumpRET在规范中作为单独的指令列出,因为它在 assembly 中是不同的关键字。但是,它实际上是 的一个特例。 每当 7 时发生。JMPRETR1 - {
- /* Also handles RET */
- uint16_t r1 = (instr >> 6) & 0x7;
- reg[R_PC] = reg[r1];
- }
复制代码 Jump register- {
- uint16_t long_flag = (instr >> 11) & 1;
- reg[R_R7] = reg[R_PC];
- if (long_flag)
- {
- uint16_t long_pc_offset = sign_extend(instr & 0x7FF, 11);
- reg[R_PC] += long_pc_offset; /* JSR */
- }
- else
- {
- uint16_t r1 = (instr >> 6) & 0x7;
- reg[R_PC] = reg[r1]; /* JSRR */
- }
- }
复制代码 Load- {
- uint16_t r0 = (instr >> 9) & 0x7;
- uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
- reg[r0] = mem_read(reg[R_PC] + pc_offset);
- update_flags(r0);
- }
复制代码 Load register- {
- uint16_t r0 = (instr >> 9) & 0x7;
- uint16_t r1 = (instr >> 6) & 0x7;
- uint16_t offset = sign_extend(instr & 0x3F, 6);
- reg[r0] = mem_read(reg[r1] + offset);
- update_flags(r0);
- }
复制代码 Load effective address- {
- uint16_t r0 = (instr >> 9) & 0x7;
- uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
- reg[r0] = reg[R_PC] + pc_offset;
- update_flags(r0);
- }
复制代码 Store- {
- uint16_t r0 = (instr >> 9) & 0x7;
- uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
- mem_write(reg[R_PC] + pc_offset, reg[r0]);
- }
复制代码 Store indirect- {
- uint16_t r0 = (instr >> 9) & 0x7;
- uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
- mem_write(mem_read(reg[R_PC] + pc_offset), reg[r0]);
- }
复制代码 Store register- {
- uint16_t r0 = (instr >> 9) & 0x7;
- uint16_t r1 = (instr >> 6) & 0x7;
- uint16_t offset = sign_extend(instr & 0x3F, 6);
- mem_write(reg[r1] + offset, reg[r0]);
- }
复制代码 陷阱例程 Trap routinesLC-3 提供了一些预定义的例程,用于执行常见任务和与 I/O 设备交互。例如,有一些例程用于从键盘获取输入和向控制台显示字符串。这些称为陷阱例程,您可以将其视为 LC-3 的操作系统或 API。每个 trap 例程都分配有一个标识它的 trap 代码(类似于操作码)。要执行一个命令,请使用所需例程的陷阱代码调用该指令。TRAP
如何编写您自己的虚拟机
为每个 trap 代码定义一个枚举: - enum
- {
- TRAP_GETC = 0x20, /* get character from keyboard, not echoed onto the terminal */
- TRAP_OUT = 0x21, /* output a character */
- TRAP_PUTS = 0x22, /* output a word string */
- TRAP_IN = 0x23, /* get character from keyboard, echoed onto the terminal */
- TRAP_PUTSP = 0x24, /* output a byte string */
- TRAP_HALT = 0x25 /* halt the program */
- };
复制代码您可能想知道为什么说明中没有包含陷阱代码。 这是因为它们实际上并没有为 LC-3 引入任何新功能,它们只是提供了一种执行任务的便捷方式(类似于操作系统调用)。 在官方的 LC-3 模拟器中,trap 例程是用汇编语言编写的。 调用 trap 代码时,将移动到该代码的地址。 CPU 执行过程的指令,完成后,PC 将重置到初始调用后的位置。PC 注意:这就是为什么程序从 address 开始而不是 .较低的地址留空,以便为 trap 例程代码留出空间。0x30000x0
没有说明必须如何实现 trap 例程,只有它们应该做什么。 在我们的 VM 中,我们将通过用 C 语言编写它们来做一些不同的事情。 当 trap 代码被调用时,将调用 C 函数。完成后,执行将返回到指令。 (如果您对汇编中的陷阱代码感到好奇,请参阅 Ryan 的实现。 尽管陷阱例程可以用汇编语言编写,而且这是物理 LC-3 计算机可以做的事情,但它并不是最适合 VM 的。我们可以利用操作系统上可用的例程,而不是编写自己的原始 I/O 例程。这将使 VM 在我们的计算机上更好地运行,简化代码,并为可移植性提供更高级别的抽象。 注意:从键盘获取输入就是一个具体的例子。汇编版本使用循环来持续检查键盘的输入。这会白白消耗大量 CPU 时间!使用适当的 OS 输入功能可以让程序休眠,直到收到输入。
在操作码的 switch case 中,添加另一个 switch:TRAP - reg[R_R7] = reg[R_PC];
- switch (instr & 0xFF)
- {
- case TRAP_GETC:
- @{TRAP GETC}
- break;
- case TRAP_OUT:
- @{TRAP OUT}
- break;
- case TRAP_PUTS:
- @{TRAP PUTS}
- break;
- case TRAP_IN:
- @{TRAP IN}
- break;
- case TRAP_PUTSP:
- @{TRAP PUTSP}
- break;
- case TRAP_HALT:
- @{TRAP HALT}
- break;
- }
复制代码与说明一样,我将向您展示如何实现单个陷阱例程,其余的交给您。 PUTS陷阱代码用于输出以 null 结尾的字符串(类似于 C 语言)。该规范位于第 543 页。PUTSprintf 要显示字符串,我们必须为 trap 例程提供要显示的字符串。这是通过在开始陷阱之前存储第一个字符的地址来完成的。R0 规范说: 将一串 ASCII 字符写入控制台显示。包含字符 在连续内存位置中,每个内存位置 1 个字符,从 中指定的地址开始。写入终止,并在内存位置出现 。(第 543 页)R0x0000
请注意,与 C 字符串不同,字符不是存储在单个字节中,而是存储在单个内存位置中。LC-3 中的内存位置为 16 位,因此字符串中的每个字符都是 16 位宽。要使用 C 函数显示它,我们需要将每个值转换为 char 并单独输出它们。 - {
- /* one char per word */
- uint16_t* c = memory + reg[R_R0];
- while (*c)
- {
- putc((char)*c, stdout);
- ++c;
- }
- fflush(stdout);
- }
复制代码这就是这个例程的全部内容。如果您熟悉 C,trap 例程非常简单。返回规范并立即实施其他规范。与说明一样,完整的代码可以在本教程的末尾找到。 Trap routine cheat sheet本节包含其余 trap 例程的完整实现。 输入字符 - /* read a single ASCII char */
- reg[R_R0] = (uint16_t)getchar();
- update_flags(R_R0);
复制代码输出字符 - putc((char)reg[R_R0], stdout);
- fflush(stdout);
复制代码提示输入字符 - {
- printf("Enter a character: ");
- char c = getchar();
- putc(c, stdout);
- fflush(stdout);
- reg[R_R0] = (uint16_t)c;
- update_flags(R_R0);
- }
复制代码输出字符串 - {
- /* one char per byte (two bytes per word)
- here we need to swap back to
- big endian format */
- uint16_t* c = memory + reg[R_R0];
- while (*c)
- {
- char char1 = (*c) & 0xFF;
- putc(char1, stdout);
- char char2 = (*c) >> 8;
- if (char2) putc(char2, stdout);
- ++c;
- }
- fflush(stdout);
- }
复制代码Halt Program - puts("HALT");
- fflush(stdout);
- running = 0;
复制代码 加载程序我们已经提到了很多关于从内存中加载和执行指令的信息,但是指令首先是如何进入内存的呢?当汇编程序转换为机器代码时,结果是一个包含指令和数据数组的文件。只需将内容直接复制到内存中的地址即可加载。 程序文件的前 16 位指定程序应在内存中启动的地址。此地址称为源。必须先读取它,然后才能将其余数据从文件读取到内存中,从源地址开始。 以下是将 LC-3 程序读入内存的代码: - void read_image_file(FILE* file)
- {
- /* the origin tells us where in memory to place the image */
- uint16_t origin;
- fread(&origin, sizeof(origin), 1, file);
- origin = swap16(origin);
- /* we know the maximum file size so we only need one fread */
- uint16_t max_read = MEMORY_MAX - origin;
- uint16_t* p = memory + origin;
- size_t read = fread(p, sizeof(uint16_t), max_read, file);
- /* swap to little endian */
- while (read-- > 0)
- {
- *p = swap16(*p);
- ++p;
- }
- }
复制代码请注意,对每个加载的值调用 that。LC-3 程序是 big-endian,但大多数现代计算机是 little-endian。因此,我们需要交换每个加载的内容。(如果您碰巧使用的是一台不起眼的计算机,例如旧的 PPC Mac,请不要交换。swap16uint16 - uint16_t swap16(uint16_t x)
- {
- return (x << 8) | (x >> 8);
- }
复制代码注意:字节序是指如何解释整数的字节。在 little-endian 中,第一个字节是最低有效数字,而在 big-endian 中,它是相反的。据我所知,这个决定大多是武断的。不同的公司做出了不同的决定,所以现在我们剩下的实施方式也各不相同。对于此项目,您不需要了解任何其他有关字节序的知识。
我们还添加一个便捷函数,它接受一个 path 一个 string;read_image_file - int read_image(const char* image_path)
- {
- FILE* file = fopen(image_path, "rb");
- if (!file) { return 0; };
- read_image_file(file);
- fclose(file);
- return 1;
- }
复制代码 内存映射寄存器某些特殊 register 无法从普通 register table 访问。相反,在内存中为他们保留了一个特殊地址。要读取和写入这些 registers,您只需读取和写入它们的 memory 位置。这些称为 memory mapped registers。它们通常用于与特殊硬件设备交互。 LC-3 有两个需要实现的 memory mapped registers。它们是键盘状态寄存器 () 和键盘数据寄存器 ()。指示是否已按下某个键,标识按下的键。KBSRKBDRKBSRKBDR 虽然您可以使用 请求键盘输入,但这会阻止执行,直到收到输入为止。 并允许您轮询设备的状态并继续执行,以便程序可以在等待输入时保持响应。GETCKBSRKBDR - enum
- {
- MR_KBSR = 0xFE00, /* keyboard status */
- MR_KBDR = 0xFE02 /* keyboard data */
- };
复制代码Memory mapped registers 使 memory access 稍微复杂一些。我们不能直接读取和写入内存数组,而必须调用 setter 和 getter 函数。当从 中读取内存时,getter 将检查键盘并更新两个内存位置。KBSR - void mem_write(uint16_t address, uint16_t val)
- {
- memory[address] = val;
- }
- uint16_t mem_read(uint16_t address)
- {
- if (address == MR_KBSR)
- {
- if (check_key())
- {
- memory[MR_KBSR] = (1 << 15);
- memory[MR_KBDR] = getchar();
- }
- else
- {
- memory[MR_KBSR] = 0;
- }
- }
- return memory[address];
- }
复制代码VM 的最后一个组件到此完成!只要您实现了其余的陷阱例程和说明,您几乎可以尝试一下了! 平台细节本节包含一些访问键盘和良好行为所需的繁琐细节。 这些没有洞察力,也与了解 VM 无关。随意复制粘贴! 这些函数应该在你的 main 函数之上声明。 Linux / macOS / UNIX的注意:请跳至下一部分,了解这些功能的 Windows 版本。 - struct termios original_tio;
- void disable_input_buffering()
- {
- tcgetattr(STDIN_FILENO, &original_tio);
- struct termios new_tio = original_tio;
- new_tio.c_lflag &= ~ICANON & ~ECHO;
- tcsetattr(STDIN_FILENO, TCSANOW, &new_tio);
- }
- void restore_input_buffering()
- {
- tcsetattr(STDIN_FILENO, TCSANOW, &original_tio);
- }
- uint16_t check_key()
- {
- fd_set readfds;
- FD_ZERO(&readfds);
- FD_SET(STDIN_FILENO, &readfds);
- struct timeval timeout;
- timeout.tv_sec = 0;
- timeout.tv_usec = 0;
- return select(1, &readfds, NULL, NULL, &timeout) != 0;
- }
复制代码- #include <stdio.h>
- #include <stdint.h>
- #include <signal.h>
- /* unix only */
- #include <stdlib.h>
- #include <unistd.h>
- #include <fcntl.h>
- #include <sys/time.h>
- #include <sys/types.h>
- #include <sys/termios.h>
- #include <sys/mman.h>
复制代码 窗户注意:如果您已经包含 Unix 版本,请不要添加这些! - HANDLE hStdin = INVALID_HANDLE_VALUE;
- DWORD fdwMode, fdwOldMode;
- void disable_input_buffering()
- {
- hStdin = GetStdHandle(STD_INPUT_HANDLE);
- GetConsoleMode(hStdin, &fdwOldMode); /* save old mode */
- fdwMode = fdwOldMode
- ^ ENABLE_ECHO_INPUT /* no input echo */
- ^ ENABLE_LINE_INPUT; /* return when one or
- more characters are available */
- SetConsoleMode(hStdin, fdwMode); /* set new mode */
- FlushConsoleInputBuffer(hStdin); /* clear buffer */
- }
- void restore_input_buffering()
- {
- SetConsoleMode(hStdin, fdwOldMode);
- }
- uint16_t check_key()
- {
- return WaitForSingleObject(hStdin, 1000) == WAIT_OBJECT_0 && _kbhit();
- }
复制代码- #include <stdio.h>
- #include <stdint.h>
- #include <signal.h>
- /* windows only */
- #include <Windows.h>
- #include <conio.h> // _kbhit
复制代码 所有平台为了正确处理终端的输入,我们需要调整一些缓冲设置。 这些的实现因平台而异,应该在上面定义。 我们在程序的开头(main 的开头)包含此设置代码。 - signal(SIGINT, handle_interrupt);
- disable_input_buffering();
复制代码当程序中断时,我们希望将终端设置恢复正常。 这应该在程序结束时完成。 - restore_input_buffering();
复制代码如果我们收到结束程序的信号,也应该恢复设置。 - void handle_interrupt(int signal)
- {
- restore_input_buffering();
- printf("\n");
- exit(-2);
- }
复制代码到目前为止,我们编写的所有内容都应该按以下顺序添加到 C 文件中: - @{Memory Mapped Registers}
- @{TRAP Codes}
- @{Memory Storage}
- @{Register Storage}
- @{Input Buffering}
- @{Handle Interrupt}
- @{Sign Extend}
- @{Swap}
- @{Update Flags}
- @{Read Image File}
- @{Read Image}
- @{Memory Access}
- @{Main Loop}
复制代码 运行 VM您现在可以构建和运行 LC-3 VM! 调试如果程序无法正常工作,则可能是因为您错误地编写了指令。调试起来可能很棘手。我建议通读 LC-3 程序的汇编源代码,同时使用调试器一次单步执行一个 VM 指令。在阅读程序集时,请确保 VM 转到您期望的指令。如果出现差异,您将知道是哪条指令导致了问题。重新阅读其规范并仔细检查您的代码。 替代 C++ 技术这是一种组织指令的高级方法,它使代码更短。 此部分完全是可选的。 您可能注意到,大多数指令都重复了类似的任务。 例如,一些使用间接寻址或符号扩展值并将其添加到当前 PC 值。 如果我们能一劳永逸地编写这段代码,那不是很好吗? 棘手的部分是,目前尚不清楚如何将这些重复压缩为额外的功能,因为它们需要周围的上下文。 但是,我们可以做的是利用 C++ 泛型的强大功能,在编译时将功能片段拼接在一起。 这个想法是将 INSTRUCTION EXECUTION 视为一个由较小的 proccessing 步骤组成的管道。 每种指令都只是可用步骤的子集或 “排列”。 我们将使用按位标志实现这一点。 与指令号对应的位中的 1 表示编译器应包含该指令的这段代码。 而 0 则省略了它。 - template <unsigned op>
- void ins(uint16_t instr)
- {
- uint16_t r0, r1, r2, imm5, imm_flag;
- uint16_t pc_plus_off, base_plus_off;
- constexpr uint16_t opbit = (1 << op);
- if (0x4EEE & opbit) { r0 = (instr >> 9) & 0x7; }
- if (0x12F3 & opbit) { r1 = (instr >> 6) & 0x7; }
- if (0x0022 & opbit)
- {
- imm_flag = (instr >> 5) & 0x1;
- if (imm_flag)
- {
- imm5 = sign_extend(instr & 0x1F, 5);
- }
- else
- {
- r2 = instr & 0x7;
- }
- }
- if (0x00C0 & opbit)
- { // Base + offset
- base_plus_off = reg[r1] + sign_extend(instr & 0x3F, 6);
- }
- if (0x4C0D & opbit)
- {
- // Indirect address
- pc_plus_off = reg[R_PC] + sign_extend(instr & 0x1FF, 9);
- }
- if (0x0001 & opbit)
- {
- // BR
- uint16_t cond = (instr >> 9) & 0x7;
- if (cond & reg[R_COND]) { reg[R_PC] = pc_plus_off; }
- }
- if (0x0002 & opbit) // ADD
- {
- if (imm_flag)
- {
- reg[r0] = reg[r1] + imm5;
- }
- else
- {
- reg[r0] = reg[r1] + reg[r2];
- }
- }
- if (0x0020 & opbit) // AND
- {
- if (imm_flag)
- {
- reg[r0] = reg[r1] & imm5;
- }
- else
- {
- reg[r0] = reg[r1] & reg[r2];
- }
- }
- if (0x0200 & opbit) { reg[r0] = ~reg[r1]; } // NOT
- if (0x1000 & opbit) { reg[R_PC] = reg[r1]; } // JMP
- if (0x0010 & opbit) // JSR
- {
- uint16_t long_flag = (instr >> 11) & 1;
- reg[R_R7] = reg[R_PC];
- if (long_flag)
- {
- pc_plus_off = reg[R_PC] + sign_extend(instr & 0x7FF, 11);
- reg[R_PC] = pc_plus_off;
- }
- else
- {
- reg[R_PC] = reg[r1];
- }
- }
- if (0x0004 & opbit) { reg[r0] = mem_read(pc_plus_off); } // LD
- if (0x0400 & opbit) { reg[r0] = mem_read(mem_read(pc_plus_off)); } // LDI
- if (0x0040 & opbit) { reg[r0] = mem_read(base_plus_off); } // LDR
- if (0x4000 & opbit) { reg[r0] = pc_plus_off; } // LEA
- if (0x0008 & opbit) { mem_write(pc_plus_off, reg[r0]); } // ST
- if (0x0800 & opbit) { mem_write(mem_read(pc_plus_off), reg[r0]); } // STI
- if (0x0080 & opbit) { mem_write(base_plus_off, reg[r0]); } // STR
- if (0x8000 & opbit) // TRAP
- {
- @{TRAP}
- }
- //if (0x0100 & opbit) { } // RTI
- if (0x4666 & opbit) { update_flags(r0); }
- }
复制代码- static void (*op_table[16])(uint16_t) = {
- ins<0>, ins<1>, ins<2>, ins<3>,
- ins<4>, ins<5>, ins<6>, ins<7>,
- NULL, ins<9>, ins<10>, ins<11>,
- ins<12>, NULL, ins<14>, ins<15>
- };
复制代码这种方法不仅减少了代码重复,而且更接近计算机在硬件中的实际连接方式。 其中每个处理步骤都必须占用芯片上的物理空间。 注意:我从 Bisqwit 的 NES 仿真器中学到了这项技术。 如果您对仿真或 NES 感兴趣,我强烈推荐他的视频。
C++ 版本的其余部分使用我们已经编写的代码! 完整的源代码在附件中。
游客,本帖隐藏的内容需要积分高于 2 才可浏览,您当前积分为 0 提取码下载:
|