400行代码,搞定了一个虚拟机,女神直夸666

共 14576字,需浏览 30分钟

 ·

2022-04-24 16:31


来源 |  爱码有道(ID:aimayoudao)

如若转载请联系原公众号

 

大家好,我是道哥。

从事软件开发多年,经常自己动手写一些小东西,从实践的过程中,学到很多东西。软件开发就是这样,必须多动手,多实践,多总结。

纸上得来终觉浅,绝知此事要躬行今天,分享如果用代码写虚拟机,大家如有疑问,可以随时找我交流和沟通,也欢迎在评论区讨论。


1. 引言

本文将带你编写一个自己的虚拟机(VM),这个虚拟机能够运行汇编语言编写的程序, 例如我朋友编写的 2048 或者我自己的 Roguelike。如果你会编程,但希望 更深入地了解计算机的内部原理以及编程语言是如何工作的,那本文很适合你。从零开始 写一个虚拟机听起来可能让人有点望而生畏,但读完本文之后你会惊讶于这件事原来如此简 单,并从中深受启发。

本文所说的虚拟机最终由 400 行左右 C 代码组成。理解这些代码只需要基本的 C/C++ 知识和二进制运算。这个虚拟机可以在 Unix 系统(包括 macOS)上执行。代码中包含少 量平台相关的配置终端(terminal)和显示(display)的代码,但这些并不是本项目的核 心。(欢迎大家添加对 Windows 的支持。)

什么是虚拟机?

虚拟机就像计算机(computer),它模拟包括 CPU 在内的几个硬件组件,能够执行 算术运算、读写内存、与 I/O 设备交互。最重要的是,它能理解机器语言(machine language),因此可以用相应的语言来对它进行编程。

一个虚拟机需要模拟哪些硬件要看它的使用场景。有些虚拟机是设计用来模拟特定类型的计算设备 的,例如视频游戏模拟器。现在 NES 已经不常见了,但我们还是可以用 NES 硬件模拟器来玩 NES 游戏。这些模拟器必须能忠实地 重建每一个细节,以及原硬件的每个主要组件。

另外一些虚拟机则完全是虚构的,而非用来模拟硬件。这类虚拟机的主要用途是使软件开发 更容易。例如,要开发一个能运行在不同计算架构上的程序,你无需使用每种架构特定的汇 编方言来实现一遍自己的程序,而只需要使用一个跨平台的虚拟机提供的汇编语言。


Java Virtual Machine (JVM) 就是一个非常成功的例子。JVM 本身是一个中等大小、程序员完全能够看懂的程序,因此很 容易将它移植到包括手机在内的上千种设备上。只要在设备上实现了 JVM,接下来任何 Java、Kotlin 或 Clojure 程序都无需任何修改就可以直接运行在这个设备上。唯一的开销 来自虚拟机自身以及机器之上的 进一步抽象。大部分情况下,这完全是可以接受的。

虚拟机不必很大或者能适应各种场景,老式的视频游戏 经常使用很小的虚拟机来提 供简单的脚本系统(scripting systems)。

虚拟机还适用于在一个安全的或隔离的环境中执行代码。一个例子就是垃圾回收(GC)。要 在 C 或 C++ 之上实现一个自动垃圾回收机制并不容易 ,因为程序无法看到它自身的栈或变量。但是,虚拟机是在它运行的程序“之外”的,因此它能够看到栈上所有的内存引用 。

另一个例子是以太坊智能合约 (Ethereum smart contracts)。智能合约是在区块链网络中被验证节点(validating node)执行的小段程序。这就要求 人们在无法提前审查这些由陌生人编写的代码的情况下,直接他们的机器上执行这些代码。

为避免合约执行一些恶意行为,智能合约将它们放到一个 虚拟机 内执行,这个虚拟机没有权限访问文件系统、网络、磁盘等等资源。以太坊也很好地展现了虚拟机的可移植性特性,因为以太坊节点可以运行在多种计算机和操作系统上。使用虚拟机 使得智能合约的编写无需考虑将在什么平台运行。

2. LC-3 架构

我们的虚拟机将会模拟一个虚构的称为 LC-3 的计算机。LC-3 在学校中比较流行,用于教学生如何用汇编编程。与 x86 相比 ,LC-3 的指令集更 加简化,但现代 CPU 的主要思想其中都包括了。

我们首先需要模拟机器最基础的硬件组件,尝试来理解每个组件是做什么的,如果 现在无法将这些组件拼成一张完整的图也不要着急。

2.1 内存

LC-3 有 65,536 个内存位置(16 bit 无符号整形能寻址的最大值),每个位置可以存储一 个 16-bit 的值。这意味着它总共可以存储 128KB 数据(64K * 2 Byte),比我们平时接触 的计算机内存小多了!在我们的程序中,这个内存会以简单数组的形式存放数据:

/* 65536 locations */
uint16_t memory[UINT16_MAX];

2.2 寄存器

一个寄存器就是 CPU 上一个能够存储单个数据的槽(slot)。寄存器就像是 CPU 的 “工作台”(workbench),CPU 要对一段数据进行处理,必须先将数据放到某个寄存器中。但 因为寄存器的数量很少,因此在任意时刻只能有很少的数据加载到寄存器。计算机的解决办 法是:首先将数据从内存加载到寄存器,然后将计算结果放到其他寄存器,最后将最终结果 再写回内存。

LC-3 总共有 10 个寄存器,每个都是 16 比特。其中大部分都是通用目的寄存器,少数几 个用于特定目的。

  • 8 个通用目的寄存器(R0-R7)
  • 1 个程序计数器(program counter, PC)寄存器
  • 1 个条件标志位(condition flags,COND)寄存器

通用目的寄存器可以用于执行任何程序计算。程序计数器(PC)是一个无符号整数,表示内 存中将要执行的下一条指令的地址。条件标记寄存器记录前一次计算结果的正负符号。

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];

2.3 指令集

一条指令就是一条 CPU 命令,它告诉 CPU 执行什么任务,例如将两个数相加。一条指令包 含两部分:

  • 操作码(opcode):表示任务的类型
  • 执行任务所需的参数

每个操作码代表 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 */
};

2.4 条件标志位

R_COND 寄存器存储条件标记,其中记录了最近一次计算的执行结果。这使得程序可以完成诸如 if (x > 0) { ... } 之类的逻辑条件。

每个 CPU 都有很多条件标志位来表示不同的情形。LC-3 只使用 3 个条件标记位,用来 表示前一次计算结果的符号:

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

注:<< 和 >> 表示移位操作。

至此,我们就完成了虚拟机的硬件组件的模拟。

3. 汇编示例

下面通过一个 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 类似,这段程序从最上面开始,每次执行一条声明(statement)。但和 C 不同的是, 这里没有作用域符号 {} 或者控制结构(例如 if 和 while),仅仅是一个扁平的声 明列表(a flat list of statements)。这样的程序更容易执行。

注意,其中一些声明中的名字和我们前面的定义的操作码(opcodes)是一样的。前面 介绍到,每条指令都是 16 比特,但这里的汇编程序看起来每行的字符数都是不一样的。为什么会有这种不一致呢?

这是因为这些汇编声明都是以人类可读写的格式编写的,以纯文本的形式表示。一种称为 汇编器(assembler)的工具会将这些文本格式的指令转换成 16 比特的二进制指令, 后者是虚拟机可以理解的。这种二进制格式称为机器码(machine code),是虚拟机可以 执行的格式,其本质上就是一个 16 比特指令组成的数组。

.ORIG 和 .STRINGZ 看起来像是指令,但其实不是,它们称为汇编制导命令 (assembler directives),可以生成一段代码或数据。例如,.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!

4. 执行程序

前面的例子是给大家一个直观印象来理解虚拟机在做什么。实现一个虚拟机不必精通汇编编 程,只要遵循正确的流程来读取和执行指令,任何 LC-3 程序都能够正确执行,不管这些程 序有多么复杂。理论上,这样的虚拟机甚至可以运行一个浏览器或者 Linux 这样的操作系 统。

如果深入地思考这个特性,你就会意识到这是一个在哲学上非常奇特的现象:程序能完成各种智能的事情,其中一些我们甚至都很难想象;但同时,所有这些程序最终都是用我们编 写的这些少量指令来执行的!我们既了解 —— 又不了解 —— 那些和程序执行相关的的事情。图灵 曾经讨探讨过这种令人惊叹的思想:

过程(Procedure)

我们将编写的这个过程(procedure)描述如下:

  • 1. 从 PC 寄存器指向的内存地址中加载一条指令


  • 2. 递增 PC 寄存器


  • 3. 查看指令中的 opcode 字段,判断指令类型


  • 4. 根据指令类型和指令中所带的参数执行该指令


  • 5. 跳转到步骤 1


你可能会有疑问:“如果这个循环不断递增 PC,而我们没有 if 或 while,那程序不会 很快运行到内存外吗?”答案是不会,我们前面提到过,有类似 goto 的指令会通过修改 PC 来改变执行流。

下面是以上流程的大致代码实现:

int main(int argc, const char* argv[]) {
    {Load Arguments, 12}
    {Setup, 12}

    /* set the PC to starting position */
    enum { PC_START = 0x3000 }; /* 0x3000 is the default */
    reg[R_PC] = PC_START;

    int running = 1;
    while (running) {
        uint16_t instr = mem_read(reg[R_PC]++); /* FETCH */
        uint16_t op = instr >> 12;

        switch (op) {
            case OP_ADD: {ADD, 6break;
            case OP_AND: {AND, 7break;
            case OP_NOT: {NOT, 7break;
            case OP_BR: {BR, 7break;
            case OP_JMP: {JMP, 7break;
            case OP_JSR: {JSR, 7break;
            case OP_LD: {LD, 7break;
            case OP_LDI: {LDI, 6break;
            case OP_LDR: {LDR, 7break;
            case OP_LEA: {LEA, 7break;
            case OP_ST: {ST, 7break;
            case OP_STI: {STI, 7break;
            case OP_STR: {STR, 7break;
            case OP_TRAP: {TRAP, 8break;
            case OP_RES:
            case OP_RTI:
            default:
                {BAD OPCODE, 7}
                break;
        }
    }
    {Shutdown, 12}
}

5. 指令实现

现在需要做的就是正确地实现每一条指令。每条指令的详细描述见 GitHub Repo 中附录的 PDF 文档。你需要 照着文档的描述自己实现这些指令。这项工作做起来其实比听起来要容易。下面我会拿其中 的两个作为例子来展示如何实现,其余的见下一章。

5.1 ADD

ADD 指令将两个数相加,然后将结果存到一个寄存器中。关于这条指令的描述见 526 页。ADD 指令的编码格式如下:

这里给出了两张图是因为 ADD 指令有两种不同的“模式”。在解释模式之前,先来看看两张 图的共同点:

  • 1. 两者都是以 0001 这 4 个比特开始的,这是 OP_ADD 的操作码(opcode)


  • 2. 后面 3 个比特名为 DR(destination register),即目的寄存器,相加的结果会放到 这里


  • 3. 再后面 3 个比特是 SR1,这个寄存器存放了第一个将要相加的数字


至此,我们知道了相加的结果应该存到哪里,以及相加的第一个数字。只要再知道第二个数 在哪里就可以执行加法操作了。从这里开始,这两者模式开始不同:注意第 5 比特 ,这个标志位表示的是操作模式是立即模式(immediate mode)还是寄存器模式 (register mode)。在寄存器模式中,第二个数是存储在寄存器中的,和第一个数类似。这个寄存器称为 SR2,保存在第 0-2 比特中。第 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

什么是 “sign-extending”(有符号扩展)?虽然立即 模式中存储的值只有 5 比特,但这个值需要加到一个 16 比特的值上。因此,这些 5 比 特的数需要扩展到 16 比特才能和另一个数相匹配。对于正数,我们可以在前面填充 0, 填充之后值是不变的。但是,对于负数,这样填充会导致问题。例如, -1 的 5 比特表示 是 11111。如果我们用 0 填充,那填充之后的 0000 0000 0001 1111 等于 32!这种 情况下就需要使用有符号扩展( sign extension),对于正数填充 0,对负数填充 1。

uint16_t sign_extend(uint16_t x, int bit_count) {
    if ((x >> (bit_count - 1)) & 1) {
        x |= (0xFFFF << bit_count);
    }
    return x;
}

前面我们定义的那个条件标记枚举类型现在要派上用场了。每次有值写到寄存器时,我们 需要更新这个标记,以标明这个值的符号。为了方便,我们用下面的函数来实现这个功能:

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 的逻辑了:

{
    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/* whether we are in immediate mode */

    if (imm_flag) {
        uint16_t imm5 = sign_extend(instr & 0x1F5);
        reg[r0] = reg[r1] + imm5;
    } else {
        uint16_t r2 = instr & 0x7;
        reg[r0] = reg[r1] + reg[r2];
    }

    update_flags(r0);
}

本节包含了大量信息,这里再总结一下:

  • ADD 接受两个值作为参数,并将计算结果写到一个寄存器中
  • 在寄存器模式中,第二个值存储在某个寄存器中
  • 在立即模式中,第二个值存储在指令最右边的 5 个比特中
  • 短于 16 比特的值需要执行有符号扩展
  • 每次指令修改了寄存器后,都需要更新条件标志位(condition flags)

以上就是 ADD 的实现,你可能会觉得以这样的方式实现另外 15 个指令将会是一件非常繁 琐的事情。好消息是,前面的这些函数基本都是可以重用的,因为另外 15 条指令中,大部 分都会组合有符号扩展、不同的模式和更新条件标记等等。

5.2 LDI

LDI 是 load indirect 的缩写,用于从内存加载一个值到寄存器,规范见 532 页。LDI 的二进制格式如下:

与 ADD 相比,LDI 只有一种模式,参数也更少。LDI 的操作码是 1010,对应 OP_LDI 枚举类型。和 ADD 类似,它包含一个 3 比特的 DR(destination register)寄存器,用 于存放加载的值。剩余的比特组成 PCoffset9 字段,这是该指令内嵌的一个立即值( immediate value),和 imm5 类似。由于这个指令是从内存加载值,因此我们可以猜测 ,PCoffset9 是一个加载值的内存地址。

和前面一样,我们需要将这个 9 比特的 PCoffset9 以有符号的方式扩展到 16 比特,但 这次是将扩展之后的值加到当前的程序计数器 PC(如果回头去看前面的 while 循 环,就会发现这条指令加载之后 PC 就会递增)。相加得到的结果(也就是 PC 加完之后的 值)表示一个内存地址,这个地址中存储的值表示另一个地址,后者中存储的是需要加载到 DR 中的值。

这种方式听上去非常绕,但它确是不可或缺的。LD 指令只能加载 offset 是 9 位的地址, 但整个内存是 16 位的。LDI 适用于加载那些远离当前 PC 的地址内的值,但要加载这 些值,需要将这些最终地址存储在离 PC 较近的位置。可以将它想想成 C 中有一个局部变 量,这变量是指向某些数据的指针:

// 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

和 ADD 类似,将值放到 DR 之后需要更新条件标志位:

The condition codes are set based on whether the value loaded is negative, zero, or positive. (Pg. 532)

下面是我对 LDI 的实现(后面章节中会介绍 mem_read):

{
    uint16_t r0 = (instr >> 9) & 0x7/* destination register (DR) */
    uint16_t pc_offset = sign_extend(instr & 0x1ff9); /* 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);
}

后面会看到,这些指令的实现中,大部分辅助功能函数都是可以复用的。

以上是两个例子,接下来就可以参考这两个例子实现其他的指令。注意本文中有两个指令是 没有用到的:OP_RTI 和 OP_RES。你可以忽略这两个指令,如果执行到它们直接报错。将 main() 函数中未实现的 switch case 补全后,你的虚拟机主体就完成了!

6. 全部指令的参考实现

本节给出所有指令的实现。如果你自己的实现遇到问题,可以参考这里给出的版本。

6.1 RTI & RES

这两个指令本文没用到。

abort();

6.2 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 & 0x1F5);
        reg[r0] = reg[r1] & imm5;
    } else {
        uint16_t r2 = instr & 0x7;
        reg[r0] = reg[r1] & reg[r2];
    }
    update_flags(r0);
}

6.3 Bitwise not(按位非)

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t r1 = (instr >> 6) & 0x7;

    reg[r0] = ~reg[r1];
    update_flags(r0);
}

6.4 Branch(条件分支)

{
    uint16_t pc_offset = sign_extend((instr) & 0x1ff9);
    uint16_t cond_flag = (instr >> 9) & 0x7;
    if (cond_flag & reg[R_COND]) {
        reg[R_PC] += pc_offset;
    }
}

6.5 Jump(跳转)

RET 在规范中作为一个单独的指令列出,因为在汇编中它是一个独立的关键字。但是,RET 本质上是 JMP 的一个特殊情况。当 R1 为 7 时会执行 RET。

{
    /* Also handles RET */
    uint16_t r1 = (instr >> 6) & 0x7;
    reg[R_PC] = reg[r1];
}

6.6 Jump Register(跳转寄存器)

{
    uint16_t r1 = (instr >> 6) & 0x7;
    uint16_t long_pc_offset = sign_extend(instr & 0x7ff11);
    uint16_t long_flag = (instr >> 11) & 1;

    reg[R_R7] = reg[R_PC];
    if (long_flag) {
        reg[R_PC] += long_pc_offset;  /* JSR */
    } else {
        reg[R_PC] = reg[r1]; /* JSRR */
    }
    break;
}

6.7 Load(加载)

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t pc_offset = sign_extend(instr & 0x1ff9);
    reg[r0] = mem_read(reg[R_PC] + pc_offset);
    update_flags(r0);
}

6.8 Load Register(加载寄存器)

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t r1 = (instr >> 6) & 0x7;
    uint16_t offset = sign_extend(instr & 0x3F6);
    reg[r0] = mem_read(reg[r1] + offset);
    update_flags(r0);
}

6.9 Load Effective Address(加载有效地址)

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t pc_offset = sign_extend(instr & 0x1ff9);
    reg[r0] = reg[R_PC] + pc_offset;
    update_flags(r0);
}

6.10 Store(存储)

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t pc_offset = sign_extend(instr & 0x1ff9);
    mem_write(reg[R_PC] + pc_offset, reg[r0]);
}

6.11 Store Indirect(间接存储)

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t pc_offset = sign_extend(instr & 0x1ff9);
    mem_write(mem_read(reg[R_PC] + pc_offset), reg[r0]);
}

6.12 Store Register(存储寄存器)

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t r1 = (instr >> 6) & 0x7;
    uint16_t offset = sign_extend(instr & 0x3F6);
    mem_write(reg[r1] + offset, reg[r0]);
}

7. Trap Routines

LC-3 提供了几个预定于的函数(过程),用于执行常规任务以及与 I/O 设备交换, 例如,用于从键盘接收输入的函数,在控制台上显示字符串的函数。这些都称为 trap routines,你可以将它们当做操作系统或者是 LC-3 的 API。每个 trap routine 都有一个对应的 trap code(中断号)。要执行一次捕获, 需要用相应的 trap code 执行 TRAP 指令。

定义所有 trap code:

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 */
};

你可能会觉得奇怪:为什么 trap code 没有包含在指令编码中?这是因为它们没有给 LC-3 带来任何新功能,只是提供了一种方便地执行任务的方式(和 C 中的系统函数类似 )。在官方 LC-3 模拟器中,trap routines 是用汇编实现的。当调用到 trap code 时,PC 会移动到 code 对应的地址。CPU 执行这个函数( procedure)的指令流,函数结束后 PC 重置到 trap 调用之前的位置。

规范只定义了 trap routine 的行为,并没有规定应该如何实现。在我们这个虚拟机中, 将会用 C 实现。当触发某个 trap code 时,会调用一个相应的 C 函数。这个函数执行 完成后,执行过程会返回到原来的指令流。

虽然 trap routine 可以用汇编实现,而且物理的 LC-3 计算机也确实是这样做的,但对虚 拟机来说并不是非常合适。相比于实现自己的 primitive I/O routines,我们可以利用操 作系统上已有的。这样可以使我们的虚拟机运行更良好,还简化了代码,提供了一个便于移 植的高层抽象。

TRAP 处理逻辑:

switch (instr & 0xFF) {
    case TRAP_GETC: {TRAP GETC, 9break;
    case TRAP_OUT: {TRAP OUT, 9break;
    case TRAP_PUTS: {TRAP PUTS, 8break;
    case TRAP_IN: {TRAP IN, 9break;
    case TRAP_PUTSP: {TRAP PUTSP, 9break;
    case TRAP_HALT: {TRAP HALT, 9break;
}

和前面几节类似,我会拿一个 trap routine 作为例子展示如何实现,其他的留给读者自己 完成。

7.1 PUTS

PUT trap code 用于输出一个以空字符结尾的字符串(和 C 中的 printf 类似)。规 范见 543 页。

显示一个字符串需要将这个字符串的地址放到 R0 寄存器,然后触发 trap。注意这里和 C 中的字符串有所不同:C 中每个字符占用一个 byte;LC-3 中内存寻找是 16 位的,每个字符都是 16 位,占用 两个 byte。因此要用 C 函数打印这些字符,需要将每个值先转换成 char 类型再输出:

{
    /* one char per word */
    uint16_t* c = memory + reg[R_R0];
    while (*c) {
        putc((char)*c, stdout);
        ++c;
    }
    fflush(stdout);
}

这就是 PUTS trap routine 的实现了。如果熟悉 C 的话,这个函数应该很容易理解。现 在你可以按照 LC-3 规范,自己动手实现其他的 trap routine 了。

8. Trap Routine 参考实现

本节给出所有 trap routine 的一份参考实现。

8.1 输入单个字符(Input Character)

/* read a single ASCII char */
reg[R_R0] = (uint16_t)getchar();

8.2 输出单个字符(Output Character)

putc((char)reg[R_R0], stdout);
fflush(stdout);

8.3 打印输入单个字符提示(Prompt for Input Character)

printf("Enter a character: ");
char c = getchar();
putc(c, stdout);
reg[R_R0] = (uint16_t)c;

8.4 输出字符串(Output String)

{
    /* 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);
}

8.5 暂停程序执行(Halt Program)

puts("HALT");
fflush(stdout);
running = 0;

9. 加载程序

前面提到了从内存加载和执行指令,但指令是如何进入内存的呢?将汇编程序转换为 机器码时,得到的是一个文件,其中包含一个指令流和相应的数据。只需要将这个文件的内 容复制到内存就算完成加载了。

程序的前 16 比特规定了这个程序在内存中的起始地址,这个地址称为 origin。因此 加载时应该首先读取这 16 比特,确定起始地址,然后才能依次读取和放置后面的指令及数 据。

下面是将 LC-3 程序读到内存的代码:

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);

    /* we know the maximum file size so we only need one fread */
    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;
    }
}

注意读取前 16 比特之后,对这个值执行了 swap16()。这是因为 LC-3 程序是大端 (big-endian),但现在大部分计算机都是小端的(little-endian),因此需要做大小端 转换。如果你是在某些特殊的机器 (例如 PPC)上运行,那就不 需要这些转换了。

uint16_t swap16(uint16_t x) {
    return (x << 8) | (x >> 8);
}

我们再封装一下前面加载程序的函数,接受一个文件路径字符串作为参数,这样更加方便:

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;
}

10. 内存映射寄存器

某些特殊类型的寄存器是无法从常规寄存器表(register table)中访问的。因此,在内 存中为这些寄存器预留了特殊的地址。要读写这些寄存器,只需要读写相应的内存地址。这些称为 内存映射寄存器(MMR)。内存映射寄存器通常用于处理与特殊硬件的交互。

LC-3 有两个内存映射寄存器需要实现,分别是:

  • KBSR:键盘状态寄存器(keyboard status register),表示是否有键按下
  • KBDR:键盘数据寄存器(keyboard data register),表示哪个键按下了 虽然可以用 GETC 来请求键盘输入,但这个 trap routine 会阻塞执行,知道从键盘获得 了输入。KBSR 和 KBDR 使得我们可以轮询设备的状态然后继续执 行,因此程序不会阻塞。
enum {
    MR_KBSR = 0xFE00/* keyboard status */
    MR_KBDR = 0xFE02  /* keyboard data */
};

内存映射寄存器使内存访问稍微复杂了一些。这种情况下不能直接读写内存位置,而要使 用 setter 和 getter 辅助函数。当获取输入时,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];
}

这就是我们的虚拟机的最后一部分了!只要你实现了前面提到的 trap routine 和指令,你 的虚拟机就即将能够运行了!

11. 平台相关的细节

本节包含一些与键盘交互以及显示相关的代码。如果不感兴趣可以直接复制粘贴。

如果不是在 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, NULLNULL, &timeout) != 0;
}

下面是特定于 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();

12. 运行虚拟机

现在你可以编译和运行这个 LC-3 虚拟机了!

用如下命令执行:lc3-vm path/to/2048.obj。

Play 2048!
{2048 Example 13}
Control the game using WASD keys.
Are you on an ANSI terminal (y/n)? y
+--------------------------+
| |
| |
| |
| 2 |
| |
| 2 |
| |
| |
| |
+--------------------------+

调试

如果程序不能正常工作,那可能是你的实现有问题。调试程序就有点麻烦了。我建议通读 LC-3 程序的汇编源代码,然后使用一个调试器单步执行虚拟机指令,确保虚拟机执行到 的指令是符合预期的。如果发现了不符合预期的行为,就需要重新查看 LC-3 规范,确认你 的实现是否有问题。

13. C++ 实现(可选)

使用 C++ 会使代码更简短。本节介绍 C++ 的一些实现技巧。

C++ 有强大的编译时泛型(compile-time generics)机制,可以帮我们自动生成部分指令 的实现代码。这里的基本思想是重用每个指令的公共部分。例如,好几条指令都用到了间接 寻址或有符号扩展然后加到当前寄存器的功能。模板如下:

{Instruction C++ 14}
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) & 0x1F5);
    }
    if (0x00C0 & opbit) {   // Base + offset
        base_plus_off = reg[r1] + sign_extend(instr & 0x3f6);
    }
    if (0x4C0D & opbit) { // Indirect address
        pc_plus_off = reg[R_PC] + sign_extend(instr & 0x1ff9);
    }
    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;
        pc_plus_off = reg[R_PC] +  sign_extend(instr & 0x7ff11);
        reg[R_R7] = reg[R_PC];
        if (long_flag) {
            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, 8}
    }
    //if (0x0100 & opbit) { } // RTI
    if (0x4666 & opbit) { update_flags(r0); }
}
{Op Table 14}
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>
};



浏览 39
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报