在本教程中,我将向您展示如何编写自己的虚拟机(VM),以运行诸如
2048 (我的朋友)或
Roguelike (我的)的汇编程序。 如果您知道如何编程,但想更好地了解计算机内部发生的事情以及编程语言是如何工作的,那么这个项目适合您。 编写自己的虚拟机似乎有些吓人,但我保证该主题非常简单且具有启发性。
最终的代码在C中约为250行。仅了解C或C ++的基础知识(例如
二进制算术)就足够了。 任何Unix系统(包括macOS)都适合构建和运行。 几个Unix API用于配置控制台输入和显示,但是它们对于主代码不是必需的。 (感谢Windows支持的实施)。
注意:此VM是一个主管程序 。 也就是说,您已经在阅读其源代码! 每段代码都会详细显示和解释,因此您可以确定没有任何遗漏。 最终代码由一组代码块创建。 项目仓库在这里 。
1.内容
- 目录
- 引言
- 架构LC-3
- 汇编程序示例
- 程序执行
- 指令执行
- 说明备忘单
- 中断处理程序
- 备忘单备忘单
- 下载软件
- 内存映射寄存器
- 平台功能
- 虚拟机启动
- C ++中的替代方法
2.简介
什么是虚拟机?
虚拟机是一种行为类似于计算机的程序。 它用其他几个硬件组件模拟处理器,使您可以执行算术,读取和写入内存,并与诸如真实物理计算机的输入/输出设备进行交互。 最重要的是,VM可以理解可用于编程的机器语言。
特定VM模拟多少硬件取决于其用途。 一些VM重现一台特定计算机的行为。 人们不再拥有NES,但是我们仍然可以通过在软件级别模拟硬件来玩NES游戏。 这些仿真器必须
准确地重新创建原始设备的每个
细节和每个主要硬件组件。
其他VM并不对应于任何特定计算机,而是部分对应于一次! 这样做主要是为了促进软件开发。 假设您要创建一个在多种计算机体系结构上运行的程序。 虚拟机提供了可移植的标准平台。 对于每种体系结构,无需使用汇编程序的不同方言来重写程序。 每种语言仅制作一个小型VM就足够了。 之后,任何程序只能用虚拟机的汇编语言编写一次。
注意:编译器通过为不同的处理器体系结构编译标准的高级语言来解决此类问题。 VM创建一种在各种硬件设备上模拟的标准CPU体系结构 。 编译器的优点之一是没有像VM那样的运行时开销。 尽管编译器运行良好,但是为多个平台编写新的编译器非常困难,因此VM仍然有用。 实际上,VM和编译器在不同的级别上一起使用。
Java虚拟机(JVM)是一个非常成功的示例。 JVM本身的大小相对中等;它足够小,程序员可以理解。 这使您可以为数千种不同的设备(包括电话)编写代码。 在新设备上实现JVM之后,任何编写的Java,Kotlin或Clojure程序都可以在其上运行而无需进行任何更改。 唯一的成本将只是VM本身以及从计算机级别
进一步抽象的开销。 这通常是一个很好的折衷方案。
VM不必很大或无处不在即可提供类似的好处。 较早的
视频游戏通常使用小型VM创建简单的
脚本系统 。
VM对于安全隔离程序也很有用。 一种应用是垃圾收集。 由于程序看不到自己的堆栈或变量,
因此没有简单的方法可以在C或C ++之上实现自动垃圾回收。 但是,VM在正在运行的程序“外部”,并且可以观察
到对堆栈上
存储单元的所有
引用 。
以太坊智能合约证明了这种行为的另一个例子。 智能合约是由区块链中每个验证节点执行的小程序。 也就是说,操作员允许完全陌生人编写的任何程序在其计算机上执行,而无需任何提前研究的机会。 为了防止恶意操作,这些操作是在无法访问文件系统,网络,磁盘等的
VM上执行的。 以太坊也是可移植性的一个很好的例子。 借助VM,您可以编写智能合约,而无需考虑许多平台的功能。
3.架构LC-3
我们的虚拟机将模拟一台称为
LC-3的虚拟计算机。 它在教学学生组装程序中很受欢迎。
与x86相比,此处的命令简化了,但保留了现代CPU中使用的所有基本概念。
首先,您需要模拟必要的硬件组件。 尝试了解每个组件的含义,但是如果不确定如何将其放入全局中也不要担心。 让我们从用C创建文件开始。本节中的每段代码都应放在该文件的全局范围内。
记忆
LC-3具有65,536个存储单元(2
16 ),每个存储单元包含一个16位值。 这意味着它只能存储128 KB-比您习惯的要少得多! 在我们的程序中,此内存存储在一个简单的数组中:
uint16_t memory[UINT16_MAX];
寄存器
寄存器是用于在CPU中存储一个值的插槽。 寄存器就像一个CPU“工作台”。 为了能够处理某些数据,它必须位于寄存器之一中。 但是由于只有几个寄存器,因此在任何给定时间只能下载最少的数据。 程序通过将内存中的值加载到寄存器中,将值计算到其他寄存器中,然后将最终结果存储回内存中来解决此问题。
LC-3中只有10个寄存器,每个寄存器有16位。 它们大多数是通用的,但是有些被分配了角色。
- 8个通用寄存器(
R0-R7
) - 1队的柜台(
PC
) - 1个条件标志寄存器(
COND
)
通用寄存器可用于执行任何软件计算。 指令计数器是一个无符号整数,它是下一条要执行的指令的内存地址。 条件标志告诉我们有关先前计算的信息。
enum { R_R0 = 0, R_R1, R_R2, R_R3, R_R4, R_R5, R_R6, R_R7, R_PC, R_COND, R_COUNT };
像内存一样,我们将寄存器存储在数组中:
uint16_t reg[R_COUNT];
指令集
指令是告诉处理器执行某种基本任务的命令,例如,将两个数字相加。 该指令具有指示正在执行的任务类型的
操作码 (操作码),以及为正在执行的任务提供输入的一组
参数 。
每个
操作码代表处理器“知道”如何执行的一项任务。 LC-3中有16个操作码。 计算机只能计算这些简单指令的顺序。 每条指令的长度为16位,剩下的4位存储操作码。 其余的用于存储参数。
稍后我们将详细讨论每个指令的作用。 现在定义以下操作码。 确保保留此顺序以获取正确的枚举值:
enum { OP_BR = 0, OP_ADD, OP_LD, OP_ST, OP_JSR, OP_AND, OP_LDR, OP_STR, OP_RTI, OP_NOT, OP_LDI, OP_STI, OP_JMP, OP_RES, OP_LEA, OP_TRAP };
注意: Intel x86架构具有数百条指令,而其他架构(如ARM和LC-3)则很少。 小指令集称为RISC ,大指令集称为CISC 。 通常,大型指令集不会提供根本上的新功能,但通常会简化汇编代码的编写 。 一条CISC指令可以代替几条RISC指令。 然而,CISC处理器更复杂且设计和制造昂贵。 这种和其他的折衷不允许调用“最佳”设计 。
条件标记
R_COND
寄存器存储条件标志,这些标志提供有关上次执行的计算的信息。 这允许程序检查逻辑条件,例如
if (x > 0) { ... }
。
每个处理器都有许多状态标志来发出各种情况的信号。 LC-3仅使用三个条件标志来显示先前计算的符号。
enum { FL_POS = 1 << 0, FL_ZRO = 1 << 1, FL_NEG = 1 << 2, };
注意:(字符<<
被称为左移位运算符 。 (n << k)
将n
位向左移位 k
位置。因此1 << 2
等于4
如果您不熟悉此概念,请阅读此处 。这将非常重要。
我们已经完成了虚拟机硬件组件的配置! 添加标准包含项(请参见上面的链接)后,您的文件应如下所示:
{Includes, 12} {Registers, 3} {Opcodes, 3} {Condition Flags, 3}
这里是文章编号部分的链接,相应的代码片段来自这些文章。 有关完整列表,请参见工作程序 -大约。 反式4.汇编程序示例
现在,让我们看一下LC-3汇编程序,以了解虚拟机的实际功能。 您无需了解如何在汇编器中编程,也无需了解此处的所有内容。 只需尝试了解正在发生的事情即可。 这是一个简单的“ 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不同,没有嵌套区域
{}
或控制结构,例如
if
或
while
; 只是一个简单的运算符列表。 因此,它更容易执行。
请注意,某些运算符的名称与我们之前定义的操作码相对应。 我们知道指令是16位的,但是每一行看起来好像字符数不同。 这样的不匹配怎么可能?
这是因为我们正在阅读的代码是用
汇编语言编写的,即纯文本,可读可写的形式。 一种称为
汇编程序的工具将文本的每一行转换为虚拟机可以理解的16位二进制指令。 这种二进制形式本质上是一个16位指令的数组,称为
机器代码 ,实际上是由虚拟机执行的。
注意:尽管编译器和汇编器在开发中扮演相似的角色,但它们并不相同。 汇编器仅对程序员在文本中编写的内容进行编码,将字符替换为二进制表示形式并将其打包为指令。
.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程序。
5.程序执行
再一次,前面的示例仅说明了VM的功能。 要编写VM,您不需要完全了解汇编程序。 只要您遵循阅读和执行指令的适当步骤,
任何 LC-3程序都可以正常运行,无论其复杂程度如何。 从理论上讲,VM甚至可以运行浏览器或像Linux这样的操作系统!
如果您深思熟虑,那么这是一个哲学上的绝妙主意。 程序本身可以产生我们从未期望并且可能无法理解的任意复杂的动作。 但是同时,它们的所有功能都限于简单的代码,我们将编写这些代码! 同时,我们对每个程序的工作原理一无所知。 图灵提到了这个好主意:
“我认为,机器无法使任何人惊讶的观点是基于一个错误,数学家和哲学家特别容易犯这种错误。 我的意思是这样的假设:既然某个事实已经成为心灵的财产,那么这个事实的所有后果将立即成为心灵的财产。” - 艾伦·图灵
程序
这是编写过程的确切描述:
- 从
PC
寄存器地址的存储器中下载一条指令。 - 增加
PC
寄存器。 - 查看操作码以确定要遵循的指令类型。
- 按照说明使用其参数。
- 返回步骤1。
您可能会问一个问题:“但是如果在没有
if
或
while
的情况下循环继续增加计数器,指令是否不会结束?” 答案是否定的。 正如我们已经提到的,一些类似于goto的指令通过在
PC
跳转来改变执行流程。
我们以主要周期为例开始研究此过程:
int main(int argc, const char* argv[]) { {Load Arguments, 12} {Setup, 12} enum { PC_START = 0x3000 }; reg[R_PC] = PC_START; int running = 1; while (running) { uint16_t instr = mem_read(reg[R_PC]++); uint16_t op = instr >> 12; switch (op) { case OP_ADD: {ADD, 6} break; case OP_AND: {AND, 7} break; case OP_NOT: {NOT, 7} break; case OP_BR: {BR, 7} break; case OP_JMP: {JMP, 7} break; case OP_JSR: {JSR, 7} break; case OP_LD: {LD, 7} break; case OP_LDI: {LDI, 6} break; case OP_LDR: {LDR, 7} break; case OP_LEA: {LEA, 7} break; case OP_ST: {ST, 7} break; case OP_STI: {STI, 7} break; case OP_STR: {STR, 7} break; case OP_TRAP: {TRAP, 8} break; case OP_RES: case OP_RTI: default: {BAD OPCODE, 7} break; } } {Shutdown, 12} }
6.指令的执行
现在,您的任务是为每个操作码进行正确的实现。 每个说明的详细说明包含在
项目文档中 。 从规范中,您需要找出每个指令的工作方式并编写实现。 这比听起来容易。 在这里,我将演示如何实现其中的两个。 其余代码可在下一部分中找到。
新增
ADD
指令采用两个数字,将它们相加并将结果存储在寄存器中。 该规范在第526页的文档中。每条
ADD
指令如下:
图中有两行,因为该指令有两种不同的“模式”。 在解释这些模式之前,让我们尝试找出它们之间的相似之处。 它们都以四个相同的位
0001
开始。 这是
OP_ADD
的操作码值。 接下来的三位标记为输出寄存器的
DR
。 输出寄存器是存储金额的地方。 以下三个位是:
SR1
。 这是一个包含要添加的第一个数字的寄存器。
因此,我们知道将结果保存到何处,并且知道要添加的第一个数字。 剩下的只是找出第二个要加的数字。 这两条线开始有所不同。 请注意,第5位在顶部是0,在底部是1.,该位对应于
直接模式或
寄存器模式 。 在寄存器模式下,第二个数字与第一个数字一样存储在寄存器中。 它被标记为
SR2
,并包含在第二到零位中。 不使用位3和4。 在汇编器中,将这样编写:
ADD R2 R0 R1 ; add the contents of R0 to R1 and store in R2.
在立即模式下,立即值嵌入在指令本身中,而不是添加寄存器的内容。 这很方便,因为该程序不需要其他指令即可将该编号从内存加载到寄存器中。 相反,当我们需要它时,它已经在指令内。 折衷方案是只能在其中存储少量数字。 确切地说,最大2
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页)
这类似于我们讨论的内容。 但是什么是“意义的延伸”? 尽管在直接模式下该值只有5位,但需要将其与16位数字相加。 这5位应扩展为16以对应另一个数字。 对于正数,我们可以用零填充缺失的位,并获得相同的值。 但是,对于负数,此操作无效。 例如,五位中的-1为
1 1111
。 如果仅用零填充,则得到
0000 0000 0001 1111
,即32! 扩展值可以通过用零填充正数和填充负数来避免此问题。
uint16_t sign_extend(uint16_t x, int bit_count) { if ((x >> (bit_count - 1)) & 1) { x |= (0xFFFF << bit_count); } return x; }
注意:如果您对二进制负数感兴趣,则可以阅读有关其他代码的信息 。 但这不是必需的。 只需复制上面的代码,并在规范要求扩展值时使用即可。
规范的最后一句话:
根据结果是负数,零还是正数来设置条件码。 (第526页)
前面我们定义了标志枚举条件,现在是时候使用这些标志了。 每次将值写入寄存器时,我们都需要更新标志以指示其符号。 我们编写了一个可重用的函数:
void update_flags(uint16_t r) { if (reg[r] == 0) { reg[R_COND] = FL_ZRO; } else if (reg[r] >> 15) { reg[R_COND] = FL_NEG; } else { reg[R_COND] = FL_POS; } }
现在我们准备编写
ADD
的代码:
{ 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); }
本节包含很多信息,因此让我们进行总结。
ADD
取两个值并将它们存储在寄存器中。- 在寄存器模式下,要添加的第二个值在寄存器中。
- 在直接模式下,第二个值嵌入指令的右5位。
- 小于16位的值应扩展。
- 每次指令更改大小写时,条件标志都应更新。
您可能还会再写15条说明,这会让您不知所措。 但是,此处获得的信息可以重复使用。 大多数指令结合使用值扩展,各种模式和标志更新。
LDI
LDI表示“间接”或“间接”加载(间接加载)。 该指令用于将值从存储位置加载到寄存器中。 规格在第532页。
二进制布局如下所示:
与
ADD
不同,它没有模式且参数较少。 这次,操作码是
1010
,它对应于枚举值
OP_LDI
。 同样,我们看到了一个三位的
DR
(输出寄存器),用于存储加载的值。 其余位标记为
PCoffset9
。 这是嵌入在指令中的立即值(类似于
imm5
)。 由于指令是从内存中加载的,因此我们可以猜测该数字是一种地址,用于指示从何处加载值。 规范更详细地说明:
通过将值[8:0]
的位扩展为16位并将此值添加到扩展的PC
。 内存中此地址存储的是将要加载到DR
中的数据的地址。 (第532页)
和以前一样,您需要扩展此9位值,但是这次将其添加到当前
PC
。 (如果查看执行周期,则加载该指令后
PC
会立即增加)。 结果和是内存中的位置地址,并且该地址
包含另一个值,即加载值的地址。
这似乎是从内存读取的一种round回方式,但这是必需的。
LD
指令的地址偏移量限制为9位,而存储器则需要16位的地址。
LDI
对于加载存储在当前计算机外部某处的值很有用,但要使用它们,最终位置的地址应存储在附近。 您可以将其视为C中的局部变量,它是指向某些数据的指针:
和以前一样,将值写入
DR
,应更新标志:
根据结果是负数,零还是正数来设置条件码。 (第532页)
这是这种情况的代码:(
mem_read
下一节中讨论):
{ uint16_t r0 = (instr >> 9) & 0x7; uint16_t pc_offset = sign_extend(instr & 0x1ff, 9); reg[r0] = mem_read(mem_read(reg[R_PC] + pc_offset)); update_flags(r0); }
就像我说的那样,对于本指令,我们使用了前面编写
ADD
时获得的大部分代码和知识。 其余说明相同。
现在,您需要执行其余的说明。 请遵循
规范并使用已编写的代码。 本文末尾给出了所有说明的代码。 不需要前面提到的两个操作码:
OP_RTI
和
OP_RES
。 您可以忽略它们或在调用它们时给出错误。 完成后,可以认为您的VM的大部分已经完成!
7.按照说明婴儿床
如果您遇到困难,本节包含其余说明的完整实现。
信息技术
(未使用)
abort();
{ 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); }
{ uint16_t r0 = (instr >> 9) & 0x7; uint16_t r1 = (instr >> 6) & 0x7; reg[r0] = ~reg[r1]; update_flags(r0); }
分行
{ 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; } }
跳
RET
在规范中被指示为单独的指令,因为这是汇编程序中的另一条命令。 这实际上是
JMP
的特例。 只要
R1
为7,就会发生
RET
。
{ uint16_t r1 = (instr >> 6) & 0x7; reg[R_PC] = reg[r1]; }
跳转寄存器
{ uint16_t r1 = (instr >> 6) & 0x7; uint16_t long_pc_offset = sign_extend(instr & 0x7ff, 11); uint16_t long_flag = (instr >> 11) & 1; reg[R_R7] = reg[R_PC]; if (long_flag) { reg[R_PC] += long_pc_offset; } else { reg[R_PC] = reg[r1]; } break; }
负荷
{ 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); }
加载寄存器
{ 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); }
有效负载地址
{ 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); }
店面
{ uint16_t r0 = (instr >> 9) & 0x7; uint16_t pc_offset = sign_extend(instr & 0x1ff, 9); mem_write(reg[R_PC] + pc_offset, reg[r0]); }
间接存储
{ 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]); }
店铺登记
{ 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]); }
8.中断处理程序
LC-3提供了一些预定义的例程,用于执行常见任务并与I / O设备进行交互。 例如,有一些过程用于接收键盘输入和向控制台输出线路。 它们被称为陷阱例程,您可以将其视为LC-3的操作系统或API。 每个子程序都分配了一个识别它的中断代码(陷阱代码)(类似于操作码)。
为了执行它,使用TRAP
所需子程序的代码调用一条指令。为每个中断代码设置枚举: enum { TRAP_GETC = 0x20, TRAP_OUT = 0x21, TRAP_PUTS = 0x22, TRAP_IN = 0x23, TRAP_PUTSP = 0x24, TRAP_HALT = 0x25 };
您可能想知道为什么指令中不包含中断代码。这是因为它们实际上并未向LC-3添加任何新功能,而只是提供了一种方便的方式来完成任务(例如C中的系统功能)。在官方的LC-3仿真器中,中断代码是用汇编器编写的。调用中断代码时,计算机将移至该代码的地址。CPU执行该过程的指令,并在完成后PC
复位到触发中断的位置。: 0x3000
0x0
. , .
没有关于如何实现中断例程的规范:它们应该做什么。在我们的虚拟机中,我们将用C编写它们的行为有所不同,当调用中断代码时,将调用函数C,在其操作之后,指令将继续。尽管可以用汇编程序编写过程,而物理计算机LC-3可以这样编写,但这对于VM而言不是最佳选择。您可以使用我们操作系统上可用的程序来代替编写自己的原始输入输出程序。这将改善我们计算机上的虚拟机,简化代码,并为可移植性提供更高级别的抽象。注意:一个特定的示例是键盘输入。汇编器版本使用循环来连续检查键盘输入。但是浪费了很多处理器时间!使用适当的OS功能,程序可以在输入信号之前安静地进入睡眠状态。
在操作码的多项选择运算符中,TRAP
添加另一个开关: switch (instr & 0xFF) { case TRAP_GETC: {TRAP GETC, 9} break; case TRAP_OUT: {TRAP OUT, 9} break; case TRAP_PUTS: {TRAP PUTS, 8} break; case TRAP_IN: {TRAP IN, 9} break; case TRAP_PUTSP: {TRAP PUTSP, 9} break; case TRAP_HALT: {TRAP HALT, 9} break; }
与说明一样,我将向您展示如何实现一个过程,然后自己完成其余的过程。推杆
中断代码PUTS
用于返回以零结尾的字符串(类似于printf
C)。在第543页的规范。要显示一个字符串,我们必须给中断例程一个字符串以显示。通过R0
在处理开始之前存储第一个字符的地址来完成此操作。从规格:在控制台显示屏中显示ASCII字符串。字符包含在连续的存储单元中,每个单元一个字符,从中指定的地址开始R0
。当在内存中遇到一个值时,输出结束x0000
。(第543页)
请注意,与C字符串不同,此处的字符不是存储在一个字节中,而是存储在内存中的一个位置。LC-3的存储位置为16位,因此字符串中的每个字符均为16位。要在C函数中显示此内容,您需要将每个值转换为字符并分别打印。 { uint16_t* c = memory + reg[R_R0]; while (*c) { putc((char)*c, stdout); ++c; } fflush(stdout); }
此过程不需要任何其他操作。如果您了解C,则中断例程非常简单。现在返回规格并实施其余部分。与说明一样,完整的代码可以在本指南的末尾找到。9.中断程序备忘单
本节包含其余中断例程的完整实现。角色输入
reg[R_R0] = (uint16_t)getchar();
字符输出
putc((char)reg[R_R0], stdout); fflush(stdout);
字符输入要求
printf("Enter a character: "); reg[R_R0] = (uint16_t)getchar();
线路输出
{ 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); }
程序终止
puts("HALT"); fflush(stdout); running = 0;
10.下载程序
我们讨论了很多关于从内存中加载和执行指令的问题,但是指令通常如何进入内存?将汇编程序转换为机器代码时,结果是一个包含指令和数据数组的文件。只需将内容直接复制到内存中的地址即可下载。程序文件的前16位指示程序应在内存中启动的地址。此地址称为Origin。必须先读取它,然后将其余数据从文件读取到内存中。这是将程序加载到LC-3存储器中的代码: void read_image_file(FILE* file) { uint16_t origin; fread(&origin, sizeof(origin), 1, file); origin = swap16(origin); uint16_t max_read = UINT16_MAX - origin; uint16_t* p = memory + origin; size_t read = fread(p, sizeof(uint16_t), max_read, file); while (read-- > 0) { *p = swap16(*p); ++p; } }
请注意,对于每个加载的值,都会调用swap16
。LC-3程序以直接字节顺序编写,但是大多数现代计算机使用相反的顺序。结果,我们需要翻转每个加载的对象uint16
。(如果您不小心使用了PPC这样的陌生计算机,则无需进行任何更改)。 uint16_t swap16(uint16_t x) { return (x << 8) | (x >> 8); }
注意: 字节顺序是指如何解释整数的字节。相反,第一个字节是最低有效位,反之亦然。据我所知,决定主要是任意的。不同的公司做出不同的决定,所以现在我们有不同的实现。对于此项目,您不再需要有关字节顺序的任何信息。
还为添加一个方便的函数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; }
11.映射寄存器
常规寄存器表中不提供某些特殊寄存器。而是为它们在内存中保留一个特殊地址。要读取和写入这些寄存器,只需读取和写入它们的内存即可。它们被称为内存映射寄存器。通常,它们用于与特殊的硬件设备进行交互。对于我们的LC-3,我们需要实现两个可映射的寄存器。这是键盘状态寄存器(KBSR
)和键盘数据寄存器(KBDR
)。第一个指示是否已按下该键,第二个指示确定哪个键被按下。尽管可以使用来请求键盘输入GETC
,但它会阻止执行直到收到输入。KBSR
并KBDR
允许在继续运行程序的同时询问设备的状态,以便在等待输入时保持响应。 enum { MR_KBSR = 0xFE00, MR_KBDR = 0xFE02 };
映射的寄存器使存储器访问复杂化了一点。我们不能直接读取和写入内存阵列,而必须调用特殊功能-setter和getter。从KBSR寄存器读取内存后,getter会检查键盘并更新内存中的两个位置。 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]; }
这是虚拟机的最后一个组件!如果您已经实现了其余的中断例程和指令,则几乎可以尝试了!写入的所有内容均应按以下顺序添加到C文件中: {Memory Mapped Registers, 11} {TRAP Codes, 8} {Memory Storage, 3} {Register Storage, 3} {Functions, 12} {Main Loop, 5}
12.平台功能
本节包含访问键盘和正常工作所必需的一些繁琐细节。关于虚拟机的操作,没有任何有趣或有用的信息。随意复制粘贴!如果尝试在非Unix的操作系统(例如Windows)中启动VM,则必须将这些功能替换为相应的Windows功能。 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; }
从程序参数中提取路径的代码,如果缺少,则输出用法示例。 if (argc < 2) { 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); } }
特定于Unix的终端输入配置代码。 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); }
当程序中断时,我们想将控制台恢复为其正常设置。 void handle_interrupt(int signal) { restore_input_buffering(); printf("\n"); exit(-2); }
signal(SIGINT, handle_interrupt); disable_input_buffering();
restore_input_buffering();
{Sign Extend, 6} {Swap, 10} {Update Flags, 6} {Read Image File, 10} {Read Image, 10} {Check Key, 12} {Memory Access, 11} {Input Buffering, 12} {Handle Interrupt, 12}
#include <stdio.h> #include <stdlib.h> #include <stdint.h> #include <string.h> #include <signal.h> #include <unistd.h> #include <fcntl.h> #include <sys/time.h> #include <sys/types.h> #include <sys/termios.h> #include <sys/mman.h>
虚拟机启动
现在您可以构建并运行LC-3虚拟机!- 编译程序最喜欢的编译器。
- 下载2048或Rogue的编译版本。
- 使用obj文件作为参数运行程序:
lc3-vm path/to/2048.obj
- 玩2048!
Control the game using WASD keys. Are you on an ANSI terminal (y/n)? y +--------------------------+ | | | | | | | 2 | | | | 2 | | | | | | | +--------------------------+
侦错
如果程序无法正常运行,则很可能是您对某种指令进行了错误编码。可能很难调试。我建议您同时阅读程序的汇编代码-并在调试器的帮助下逐步遵循虚拟机的说明。读取代码时,请确保VM遵循预期的指令。如果发生不匹配,您将找出引起问题的指令。重新阅读规格并重新检查代码。14. C ++中的替代方法
这是一种执行指令的高级方法,可以大大减少代码大小。这是一个完全可选的部分。由于C ++在编译过程中支持强大的泛型,因此我们可以使用编译器来创建指令的一部分。这种方法减少了代码重复,实际上更接近于计算机的硬件级别。想法是重用每个指令的通用步骤。例如,某些指令使用值的间接寻址或扩展并将其添加到当前值PC
。同意,为所有指令编写一次此代码会很好吗?将指令视为一系列步骤,我们看到每条指令只是几个较小步骤的重新排列。我们将使用位标志来指示每个指令应遵循的步骤。1
指令编号位中的值表示,对于该指令,编译器应包括此代码段。 template <unsigned op> void ins(uint16_t instr) { uint16_t r0, r1, r2, imm5, imm_flag; uint16_t pc_plus_off, base_plus_off; uint16_t opbit = (1 << op); if (0x4EEE & opbit) { r0 = (instr >> 9) & 0x7; } if (0x12E3 & opbit) { r1 = (instr >> 6) & 0x7; } if (0x0022 & opbit) { r2 = instr & 0x7; imm_flag = (instr >> 5) & 0x1; imm5 = sign_extend((instr) & 0x1F, 5); } if (0x00C0 & opbit) {
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 ++使用已编写的代码。完整版本在这里。 {Includes, 12} {Registers, 3} {Condition Flags, 3} {Opcodes, 3} {Memory Mapped Registers, 11} {TRAP Codes, 8} {Memory Storage, 3} {Register Storage, 3} {Functions, 12} int running = 1; {Instruction C++, 14} {Op Table, 14} int main(int argc, const char* argv[]) { {Load Arguments, 12} {Setup, 12} enum { PC_START = 0x3000 }; reg[R_PC] = PC_START; while (running) { uint16_t instr = mem_read(reg[R_PC]++); uint16_t op = instr >> 12; op_table[op](instr); } {Shutdown, 12} }