本文所述的虚拟机最终由400行左右的C语言代码构成。
从零开始编写一个虚拟机,听起来或许令人心生畏惧,但通读本文后,你会发现它远比想象中简单,并能从中获得深刻的启发。
如果你已经会编程,但希望更深入地理解计算机的内部运作机制以及编程语言是如何工作的,那么本文非常适合你。
理解这些代码只需要基础的C/C++知识和二进制运算能力。这个虚拟机可以在Unix系统(包括macOS)上运行。代码中包含少量用于配置终端和显示的、与平台相关的代码,但这并非本项目的核心。
注意:这个虚拟机是“文学化编程”的产物。本文将解释每一段代码的原理,而最终的实现就是将所有这些代码片段连接起来。
什么是虚拟机?
虚拟机,顾名思义,模拟了一台计算机,它包括了诸如CPU在内的数个硬件组件,能够执行算术运算、读写内存、与I/O设备交互。最重要的是,它理解机器语言,因此我们可以用相应的语言对它进行编程。
一个虚拟机需要模拟哪些硬件,取决于其使用场景。有些虚拟机是为了模拟特定类型的计算设备而设计的,例如视频游戏模拟器。如今NES(任天堂红白机)已不常见,但我们依然可以通过NES硬件模拟器来玩NES游戏。这类模拟器必须忠实地重建原硬件的每一个细节和主要组件。

另一些虚拟机则完全是虚构的,并非用于模拟真实硬件。这类虚拟机的主要目的是简化软件开发。例如,如果你需要开发一个能在不同计算架构(如x86、ARM)上运行的程序,无需为每种架构特定的汇编方言都实现一遍,而只需使用一个跨平台虚拟机提供的统一汇编语言。
注:编译器也解决了类似的跨平台问题,它将标准高级语言编写的程序编译成能在不同CPU架构上执行的目标代码。相比之下,虚拟机的方式是创建一个标准的、虚拟的CPU架构,然后在不同的物理设备上模拟这个CPU。编译器方式的优点是没有运行时开销,但实现一个支持多平台的编译器非常困难,而实现一个虚拟机则相对简单。在实际中,人们会根据需求混合使用这两种技术,因为它们工作在计算机基础的不同层次。
Java虚拟机 就是一个非常成功的例子。JVM本身是一个中等规模、程序员能够完全理解的程序,因此很容易被移植到包括手机在内的上千种设备上。只要在设备上实现了JVM,任何Java、Kotlin或Clojure程序都无需修改即可运行。唯一的开销来自虚拟机自身及其之上的进一步抽象,这在大多数情况下都是可以接受的。
虚拟机不必庞大或适应所有场景。老式视频游戏经常使用小型虚拟机来提供简单的脚本系统。
虚拟机还适用于在安全或隔离的环境中执行代码。一个典型的例子就是垃圾回收。要在C或C++之上直接实现自动垃圾回收机制并不容易,因为程序无法“看到”自身的栈或变量。但虚拟机运行在程序“之外”,因此能够观察到内存中所有的引用关系。
另一个现代的例子是以太坊智能合约。智能合约是在区块链网络上由验证节点执行的小段程序。这就要求节点能在无法提前审查陌生人编写的代码的情况下执行它们。为了避免恶意行为,智能合约被放在一个虚拟机内执行,这个虚拟机没有权限访问文件系统、网络或磁盘等资源。这也很好地体现了虚拟机的可移植性优势。
LC-3 架构
我们的虚拟机将模拟一个名为 LC-3 的虚构计算机。LC-3在学术界中较为流行,常用于教授学生如何进行汇编编程。与x86相比,LC-3的指令集更加精简,但包含了现代CPU的主要设计思想。
我们首先需要模拟机器最基础的硬件组件。别担心,如果暂时无法将这些组件拼成一张完整的图,随着讲解的深入,一切都会变得清晰。
1 内存
LC-3拥有65,536个内存位置(这是16位无符号整数能寻址的最大值),每个位置可以存储一个16位的值。这意味着其总存储容量为128KB。在我们的C语言程序中,内存将用一个简单的数组来表示:
/* 65536 locations */
uint16_t memory[UINT16_MAX];
2 寄存器
寄存器是CPU上用于存储单个数据的“工作槽”。你可以把它们想象成CPU的“工作台”,CPU要处理数据,必须先将数据加载到寄存器中。由于寄存器数量稀少(LC-3只有10个),因此同时能处理的数据也很有限。计算机的解决方案是:先将数据从内存加载到寄存器,执行计算并将结果存入其他寄存器,最后将最终结果写回内存。
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, /* program counter */
R_COND,
R_COUNT
};
和内存一样,我们也用数组来表示这些寄存器:
uint16_t reg[R_COUNT];
3 指令集
一条指令就是一个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这样指令较少的架构则称为精简指令集。CISC指令通常能完成更多工作,但RISC在设计、制造和某些场景下的执行效率上可能更具优势。
4 条件标志位
R_COND寄存器存储条件标志位,其中记录了最近一次计算的执行结果。这使得程序可以执行诸如 if (x > 0) { ... } 的逻辑判断。
LC-3使用3个条件标志位来表示计算结果的符号:
enum {
FL_POS = 1 << 0, /* P */
FL_ZRO = 1 << 1, /* Z */
FL_NEG = 1 << 2, /* N */
};
至此,我们就完成了对虚拟机核心硬件组件的模拟。
汇编示例
在深入实现之前,我们先通过一个LC-3汇编程序来直观感受一下。你无需现在就知道如何编写汇编,只需有个大致印象。下面是一个“Hello World”程序:
.ORIG x3000 ; 程序加载到内存的起始地址
LEA R0, HELLO_STR ; 将HELLO_STR字符串的地址加载到R0
PUTs ; 输出R0指向的字符串
HALT ; 停止程序
HELLO_STR .STRINGZ "Hello World!" ; 在程序中存储这个字符串
.END ; 文件结束标记
和C语言类似,程序从上到下依次执行每条语句。但不同的是,这里没有{}作用域符号或if、while等控制结构,只有一个扁平的语句列表。这使得执行过程更加简单直接。
你可能会注意到,一些语句的名字(如LEA, HALT)和我们定义的操作码(opcodes)很像。为什么汇编文本长度不一,而机器指令却是固定的16位呢?
这是因为汇编是人类可读写的文本格式。一个叫做汇编器的工具负责将这些文本指令转换成16位的二进制指令(即机器码),后者才是虚拟机能够理解并执行的格式。机器码本质上就是一个由16位指令组成的数组。

注:虽然编译器和汇编器在开发中角色相似,但它们是不同的工具。汇编器只是简单地将程序员编写的文本“编码”成二进制格式,替换其中的符号为二进制值。
像.ORIG和.STRINGZ这样的关键字并非指令,它们被称为汇编制导命令,用于生成代码或数据。例如,.STRINGZ会在其所在位置插入一个字符串。
循环和条件判断是通过类似goto的指令实现的。下面是一个计数到10的例子:
AND R0, R0, 0 ; 清空R0
LOOP ; 循环开始标签
ADD R0, R0, 1 ; R0加1,结果存回R0
ADD R1, R0, -10 ; R0减10,结果存到R1
BRn LOOP ; 如果结果为负,跳回LOOP
... ; 此时R0的值是10!
执行程序
前面的例子让你对虚拟机的工作有了直观印象。实现一个虚拟机,并不要求你精通汇编编程,只要遵循正确的流程读取和执行指令,任何LC-3程序(无论多复杂)都能正确执行。理论上,这个虚拟机甚至可以运行一个浏览器或Linux操作系统。
思考这一特性,你会意识到一个哲学上奇特的现象:程序能完成各种智能的、甚至超乎我们想象的事情,但所有这些事情最终都是由我们编写的少量简单指令完成的!
我们将编写的执行过程(或称指令周期)描述如下:
- 取指:从PC寄存器指向的内存地址中加载一条指令。
- PC递增:将PC寄存器的值加1,指向下一条指令。
- 译码:查看指令中的opcode字段,判断指令类型。
- 执行:根据指令类型和所带参数执行该指令。
- 跳转:回到步骤1。
你可能会问:“如果循环不断递增PC,而我们没有if或while,程序不会很快执行到内存之外吗?”答案是不会。我们前面提到过,有类似goto的指令可以通过直接修改PC的值来改变执行流。
以上流程的大致代码实现如下:
int main(int argc, const char* argv[]) {
// {加载参数,初始化}
// {设置运行环境}
/* 将PC设置到起始位置 */
enum { PC_START = 0x3000 }; /* 0x3000 是默认起始地址 */
reg[R_PC] = PC_START;
int running = 1;
while (running) {
uint16_t instr = mem_read(reg[R_PC]++); /* FETCH */
uint16_t op = instr >> 12; /* 提取高4位操作码 */
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:
{ /* 错误操作码处理 */ }
break;
}
}
// {清理,关闭}
}
指令实现
现在我们需要逐一正确地实现每条指令。我们将以ADD和LDI两条指令为例进行详细讲解,其余指令的实现思路类似。
1 ADD
ADD指令将两个数相加,结果存入一个寄存器。其编码格式如下:

这里有两张图,因为ADD指令有两种“模式”。我们先看共同点:
- 都以
0001(OP_ADD的操作码)开头。
- 接着3位是
DR(目的寄存器),用于存放结果。
- 再3位是
SR1,存放第一个加数。
差异点在于第5位,它决定了操作模式:
- 寄存器模式(第5位为0):第二个数存储在寄存器
SR2中(位于第0-2位)。汇编示例:ADD R2, R0, R1。
- 立即模式(第5位为1):第二个数直接以5位有符号整数(
imm5)的形式存储在指令中。这种方式方便快速加减一个小常数。汇编示例:ADD R0, R0, 1。
对于立即模式,我们需要将5位的imm5有符号扩展到16位,以便与16位的寄存器值相加。以下是符号扩展的辅助函数:
uint16_t sign_extend(uint16_t x, int bit_count) {
if ((x >> (bit_count - 1)) & 1) { // 判断最高位(符号位)是否为1
x |= (0xFFFF << bit_count); // 如果是负数,高位全补1
}
return x;
}
根据LC-3规范,每次有值写入寄存器时,都需要更新条件标志位。我们用一个函数来实现:
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;
}
}
现在,我们可以实现ADD指令的逻辑了:
{
uint16_t r0 = (instr >> 9) & 0x7; /* destination register (DR) */
uint16_t r1 = (instr >> 6) & 0x7; /* first operand (SR1) */
uint16_t imm_flag = (instr >> 5) & 0x1; /* immediate mode flag */
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的实现要点:支持两种模式、处理有符号扩展、并在计算后更新条件标志位。这些辅助函数(sign_extend, update_flags)在实现其他指令时会被大量重用。
2 LDI
LDI是“load indirect”的缩写,用于从内存间接加载一个值到寄存器。其二进制格式如下:

LDI的操作码是1010。它包含一个3比特的DR寄存器和一个9比特的PCoffset9立即值。根据规范,该指令的执行步骤如下:
- 将
PCoffset9有符号扩展至16位。
- 将这个扩展后的值与当前
PC(注意,此时PC已指向下一条指令)相加,得到一个内存地址(记作Addr1)。
- 读取
Addr1内存位置中存储的值,这个值又是一个地址(记作Addr2)。
- 读取
Addr2内存位置中存储的值,将其加载到DR寄存器。
简单来说,PCoffset9指向一个“指针”,而这个“指针”指向最终的数据。这种方式允许程序访问距离当前PC较远的数据,而PCoffset9本身只能表示一个较小的偏移范围。
与ADD类似,加载值后需要更新条件标志位。LDI的实现如下(mem_read函数稍后介绍):
{
uint16_t r0 = (instr >> 9) & 0x7; /* destination register (DR) */
uint16_t pc_offset = sign_extend(instr & 0x1ff, 9); /* PCoffset 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);
}
以上就是两条指令的详细实现过程。你可以参考这个模式,根据LC-3指令集规范,完成其余指令的实现。在main()函数的switch-case中补全所有case后,你的虚拟机核心就完成了!
全部指令的参考实现
本节提供所有指令的参考实现。如果你在实现自己的版本时遇到问题,可以对照检查。
1 RTI & RES
这两个指令在本项目中未使用,可以直接报错或忽略。
abort(); // 或其它错误处理
2 Bitwise and(按位与)
AND指令的实现逻辑与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);
}
3 Bitwise not(按位非)
NOT指令相对简单,只有寄存器模式。
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;
reg[r0] = ~reg[r1];
update_flags(r0);
}
4 Branch(条件分支)
BR指令根据条件标志位决定是否跳转。其n、z、p位(对应NEG, ZRO, POS)与R_COND寄存器的位进行“与”操作,若结果非零则跳转。
{
uint16_t pc_offset = sign_extend((instr) & 0x1ff, 9);
uint16_t cond_flag = (instr >> 9) & 0x7; // nzp bits
if (cond_flag & reg[R_COND]) {
reg[R_PC] += pc_offset;
}
}
5 Jump(跳转)
JMP指令跳转到指定寄存器中存储的地址。RET(从子程序返回)是JMP的一个特例(当寄存器为R7时)。
{
/* Also handles RET */
uint16_t r1 = (instr >> 6) & 0x7;
reg[R_PC] = reg[r1];
}
6 Jump Register(跳转寄存器)
JSR/JSRR指令用于调用子程序。它将返回地址(当前PC)保存到R7,然后跳转。
{
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; /* JSR (使用PC偏移) */
} else {
reg[R_PC] = reg[r1]; /* JSRR (使用寄存器地址) */
}
break;
}
7 Load(加载)
LD指令从内存加载一个值到寄存器,地址为PC + PCoffset9(有符号扩展)。
{
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);
}
8 Load Register(加载寄存器)
LDR指令从内存加载一个值到寄存器,地址为基址寄存器(BR) + 偏移量(offset6)。
{
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);
}
9 Load Effective Address(加载有效地址)
LEA指令将一个地址(PC + PCoffset9)加载到寄存器,而不是该地址中的内容。
{
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);
}
10 Store(存储)
ST指令将寄存器的值存储到内存地址PC + PCoffset9处。
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1ff, 9);
mem_write(reg[R_PC] + pc_offset, reg[r0]);
}
11 Store Indirect(间接存储)
STI指令将寄存器的值存储到内存中。目标地址需要两次间接寻址(类似于LDI的反向操作)。
{
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]);
}
12 Store Register(存储寄存器)
STR指令将寄存器的值存储到内存地址基址寄存器(BR) + 偏移量(offset6)处。
{
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 Routines(中断陷入例程)
LC-3提供了一些预定义的函数(称为Trap Routines),用于执行常规任务和与I/O设备交互,例如从键盘获取输入或在控制台显示字符串。你可以将它们视为LC-3的“操作系统API”。每个例程都有一个对应的中断号(Trap Code)。
执行一次Trap,需要使用相应的Trap Code来执行TRAP指令。

定义所有Trap Code:
enum {
TRAP_GETC = 0x20, /* 从键盘获取字符(不显示) */
TRAP_OUT = 0x21, /* 输出一个字符 */
TRAP_PUTS = 0x22, /* 输出一个字符串 */
TRAP_IN = 0x23, /* 从键盘获取字符并显示 */
TRAP_PUTSP = 0x24, /* 输出一个字节字符串 */
TRAP_HALT = 0x25 /* 停止程序 */
};
在官方LC-3模拟器中,这些例程是用汇编实现的。当调用Trap时,PC会跳转到对应例程的地址,执行完后再返回。
注:这就是为什么我们的用户程序默认从0x3000开始,而不是0x0——低地址空间特意留给了Trap Routines。
在我们的虚拟机中,我们将直接用C语言实现这些Trap功能。这简化了代码,并允许我们利用操作系统提供的高效I/O接口。
TRAP指令的处理逻辑被集成在主循环的switch-case中:
case OP_TRAP:
switch (instr & 0xFF) { // 提取低8位的trapvect8
case TRAP_GETC: { /* 实现 */ } break;
case TRAP_OUT: { /* 实现 */ } break;
case TRAP_PUTS: { /* 实现 */ } break;
case TRAP_IN: { /* 实现 */ } break;
case TRAP_PUTSP: { /* 实现 */ } break;
case TRAP_HALT: { /* 实现 */ } break;
}
break;
我们以PUTS为例展示如何实现一个Trap Routine。
1 PUTS
PUTS用于输出一个以空字符结尾的字符串(类似于C的printf)。要显示的字符串起始地址需放在R0寄存器中。规范说明字符串中每个字符占用一个内存位置(16位),结束标志是0x0000。
{
/* one char per word */
uint16_t* c = memory + reg[R_R0];
while (*c) {
putc((char)*c, stdout); // 将16位值转换为char输出
++c;
}
fflush(stdout);
}
现在,你可以参考LC-3规范和PUTS的例子,动手实现其他的Trap Routine了。
Trap Routine 参考实现
本节提供所有Trap Routine的参考实现。
/* read a single ASCII char */
reg[R_R0] = (uint16_t)getchar();
update_flags(R_R0); // 可选,根据规范更新标志位
2 输出单个字符(Output Character - OUT)
putc((char)reg[R_R0], stdout);
fflush(stdout);
printf("Enter a character: ");
char c = getchar();
putc(c, stdout); // 回显
reg[R_R0] = (uint16_t)c;
update_flags(R_R0);
4 输出字节字符串(Output Byte String - PUTSP)
PUTSP用于输出一个压缩的字符串,每个内存字(16位)存储两个ASCII字符。
{
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);
}
5 暂停程序执行(Halt Program - HALT)
puts("HALT");
fflush(stdout);
running = 0; // 退出主循环
加载程序
我们已经知道如何从内存取指和执行,但程序是如何进入内存的呢?当汇编程序被转换为机器码后,我们得到一个包含指令流和数据的文件。加载程序,本质上就是将这个文件的内容复制到内存中。
程序文件的前16位规定了程序在内存中的起始地址(称为origin)。因此,加载时需要先读取这16位确定起始地址,然后依次读取和放置后续的指令及数据。
以下是将LC-3程序(.obj文件)读入内存的代码:
void read_image_file(FILE* file) {
uint16_t origin; /* the origin tells us where in memory to place the image */
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);
/* swap to little endian */
while (read-- > 0) {
*p = swap16(*p);
++p;
}
}
注意,读取后我们调用了swap16()函数。因为LC-3程序是大端格式,而现代大多数计算机是小端格式,所以需要进行转换。
uint16_t swap16(uint16_t x) {
return (x << 8) | (x >> 8);
}
注:字节序(Endianness)是指一个多字节整数在内存中的存储顺序。理解这一点对处理跨平台数据是必要的。
为了方便使用,我们再封装一个函数:

内存映射寄存器(Memory Mapped Registers)
有些特殊寄存器无法通过常规的寄存器表访问,LC-3为它们在内存中预留了特定的地址。要读写这些寄存器,只需读写相应的内存地址即可,它们被称为内存映射寄存器,常用于与特殊硬件交互。
LC-3有两个需要实现的MMR:
- KBSR:键盘状态寄存器,表示是否有键按下。
- KBDR:键盘数据寄存器,表示具体按下了哪个键。
使用GETC Trap会阻塞执行直到有输入,而KBSR/KBDR允许我们轮询设备状态,实现非阻塞输入。
enum {
MR_KBSR = 0xFE00, /* keyboard status */
MR_KBDR = 0xFE02 /* keyboard data */
};
MMR使得内存访问稍微复杂。我们不能直接读写内存位置,而需要使用专门的setter/getter函数。例如,在mem_read中检查键盘输入:
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];
}
这就是虚拟机的最后一部分核心代码了!只要你实现了前面提到的所有Trap Routine和指令,你的虚拟机就已经具备了运行能力。
平台相关的细节
本节包含一些与Unix系统键盘交互及终端设置相关的代码。如果你在其他平台(如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;
}
禁用和恢复终端行缓冲的代码(为了即时获取键盘输入):
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);
}
// 在main函数初始化部分调用
signal(SIGINT, handle_interrupt);
disable_input_buffering();
运行虚拟机
现在,你可以编译和运行这个LC-3虚拟机了!
- 编译:使用你喜欢的C编译器(如gcc)编译虚拟机源文件。
gcc lc3-vm.c -o lc3-vm
- 下载游戏:下载两个用LC-3汇编编写的小游戏(已汇编成
.obj文件)。
- 执行:
./lc3-vm path/to/2048.obj
如果一切正常,你将看到游戏界面并可以通过WASD键进行控制。
调试
如果程序不能正常工作,可能是你的实现有误。调试方法包括:仔细阅读LC-3程序的汇编源代码,使用调试器(如gdb)单步执行虚拟机指令,确保每条指令的执行结果符合LC-3规范预期。这个过程是深入理解计算机体系结构和虚拟机工作原理的绝佳实践。
通过这个近400行C代码的项目,你不仅亲手实现了一个可运行的虚拟机,更深刻地理解了CPU、内存、指令执行等核心计算概念。这种从理论到实践的综合性学习,是掌握计算机科学的有效途径。希望这个项目能激发你进一步探索系统编程和开源实战的热情。欢迎在云栈社区分享你的实现心得或遇到的问题。