动手写个虚拟机

原文地址:https://justinmeiners.github.io/lc3-vm/ 这是个水平挺低的翻译版
作者:Justin Meiners and Ryan Pendleton
文中的最终代码和其他一些资源在这里可以找到:GitHub repo

1.简介

在这篇教程里,我将会教你们如何自己写一个虚拟机(VM),可以运行特定的汇编语言程序,例如我朋友的2048或者我的Roguelike。如果你知道怎么编程,但是想对电脑内部发生的事情作更深的了解,更好的理解编程语言是运作的,那么这篇文章就是为你而作的。自己写一台虚拟机听起来可能有些吓人,但我保证你会发现这其实惊人的简单,而且能给你带来启发。

最终的代码是大约250行的C代码(unix, windows)。(本翻译版只提供unix用代码。)你只需要知道基本的C语言编程和二进制位运算。

这个VM使用了文学式编程。这意味着你在看这篇文章的同时也在阅读源码。我会展示并详细解释项目中的每一段代码,确保没有遗漏。

什么是虚拟机?

虚拟机就是像一台电脑那样运行的程序。它主要模拟了CPU以及其他一小部分硬件,让被模拟的CPU就做算术运算,读写内存,与I/O设备交互,就像一台真正的电脑一样。更重要的一点,它能理解一种机器语言,这样你就可以利用它进行编程。

虚拟机需要模拟的电脑硬件数量取决于虚拟机自己的目的。一部分虚拟机是为了再现特定的某种计算机的行为,比如游戏模拟器。大多数人现在身边没有NES游戏机,但是我们可以通过程序模拟NES的硬件从而玩到NES游戏。这些模拟器必须完完全全的重现原机器的每一个细节。

另一些虚拟机完完全全是人造的,它们不模拟任何真实存在的电脑。它们是为了让软件开发变得更加方便而造出来的。想象一下你打算写一个能在各种架构上运行的程序。这时候虚拟机就可以提供一个标准化的平台。相比于为各种CPU架构各写一遍汇编程序,你只需要为每种汇编语言写一个虚拟机程序,然后你的每个程序用虚拟机特有的汇编语言写一次就够了。

不使用VM运行程序
使用VM运行程序

编译器解决的问题是把一种标准化的高级语言翻译成各种CPU架构的汇编语言。而虚拟机则创造了一种标准化的CPU架构,虚拟机本身可以在各种硬件环境下运行。编译器的优点之一是编译出来的程序在运行时不会带来更多的花费,然而虚拟机会。但是即使编译器做的很好了,为了新的架构重写一个编译器的难度还是非常大的,因此虚拟机在这方面还是很有用。实际上,虚拟机和编译器是被混在一起使用的。

Java虚拟机(JVM)就是一个十分成功的例子。JVM本身是一个大小合理的让一个程序员容易理解的程序。这让它能够在数千种架构上运行,包括手机。只要JVM在新的设备被成功实现,任何Java、Kotlin或者Clojure程序都能够不加修改地直接运行起来。唯一的花费是虚拟机本身带来的。大多数情况下,这种交换是十分划得来的。

一个VM不需要特别大或者十分普遍。老游戏经常会使用小型VM来提供简单的脚本系统。

对于安全地,或者说隔离地执行代码,VM也相当有用。这样的例子之一就是垃圾收集程序(GC)。对于C/C++之上程序而言,没有常见的方法来实现自动垃圾收集器,因为程序没法知道自己的栈和变量。然而,在程序“外部”运行的VM可以知道栈上所有的内存引用。

关于VM的另一个例子就是以太坊智能合约(Ethereum smart contracts)。所谓智能合约就是在区块链网络中由每个区块执行的一段小程序。这需要节点管理员在他们的电脑上运行完全陌生的程序,没有任何机会去提前检查它们。为了防止这种程序做出恶意行为,它们都被运行在VM中,这样就无法访问主机的文件系统、网络等。因为使用VM,以太坊也是一个成功的可移植程序。由于以太坊节点可以在多种硬件和操作系统上运行,VM的使用可以让智能合约的编写无需考虑运行平台的种种要素。

2.LC-3架构

我们的VM将会模拟一种虚构的计算机:LC-3。在大学生的汇编语言课程中,LC-3比较流行。相比于x86,它拥有更加简化的指令集,但是能很好的反映现代CPU的主要思想。

首先,我们需要模拟出机器所必需的硬件组件。试着去理解这些组件是什么,但如果你不知道它们有什么用的话,不要着急。我们先来创建一个C文件。接下来展示的每一段代码都应该放在文件的全局变量部分。

内存

LC-3 有 65,536 个内存单元(16位无符号数能寻址的最大值2^16),每个单元存储一个16位的值。这表示它能存储的数据最多只有128KB,可能比你所习惯的内存大小要小很多。在我们的程序中,内存存放在一个数组中:

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

寄存器

寄存器是CPU中用来存储一个单一值的存储器。它们好比CPU的“工作区”。为了让CPU操作数据,它必须首先放在寄存器中。然而,由于寄存器的数量很少,只有很少的数据能被存入其中。一个程序通常把数据从内存加载到寄存器中,将计算得到的值存储在其他的寄存器中,然后把最终的结果存回内存中。

LC-3一共有10个寄存器,每个都是16bit。它们大部分是通用寄存器,但也有些有设定好的用途。

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

指令集

一条指令是让CPU执行一些基本操作的命令,比如让两个数相加。指令有一个操作码(opcode)来告诉CPU做哪种操作,以及一系列参数来为操作提供更详细的信息。

每个操作码代表着CPU“知道”怎么去做的一个任务。LC-3只有16种操作码。CPU所能做的只是执行一系列指令而已。每条指令都是16位长,高4位用来存放操作码,剩下的12位存放参数。

我们在后面再来讨论每条指令做了啥,现在先定义好这些操作码。确保它们是按照这个顺序放的,这样才能让他们得到正确的枚举值。

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。较大的指令集不提供任何新的基本功能,但它们通常让编写汇编变得更方便。CISC的一条指令可能相当于RISC好几条指令。然而,CISC也会使得CPU的设计和制造变得更加复杂和昂贵。这个以及别的代价使得CPU设计的流行趋势来回变动。

标志位寄存器

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

至此我们已经完成了VM的硬件设定。然后我们再在文件开头加上include:

#include 
#include 
#include 
#include 
#include 
/* unix */
#include 
#include 

#include 
#include 
#include 
#include 

3. 汇编语言例子

现在我们来看一个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不同的是,汇编程序没有代码块{}或者像if和while这样的控制流;就只是一些指令按顺序排列。这使得执行程序变得十分简单。

注意到有些代码包含着与我们先前定义的操作码名字一样的内容。之前也说过每条指令都是16bit,但是每行代码看起来都是由不同的字母组成的。这个矛盾真的是对的吗?

这是因为我们阅读的汇编代码是方便人类阅读和书写的形式,按照文本文件的方式。汇编器(assembler)可以把这样的汇编代码转换成VM能够理解的16bit指令序列。这种16bit的指令序列是二进制形式叫做机器码,它是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 Tools

4. 执行程序

再说一遍,上面关于汇编语言的例子只是为了让你大致了解VM做的事情。对于写VM而言,没有必要十分擅长汇编编程。只要你按照正确的顺序读入并执行指令,任何 LC-3程序都能够正确的执行,无论它们有多复杂。理论上,它甚至可以跑一个浏览器或者像Linux这样的操作系统!

如果你仔细思考这些特点的话,从哲学层面来说确实是不错的点子。程序本身可以做许多我们预想不到甚至不能理解的聪明的事情,但是与此同时,通过我们写的小程序,我们几乎可以做到任何事!我们既知道程序如何运行,又不知道程序如何运行。正如图灵所言:

"The view that machines cannot give rise to surprises is due, I believe, to a fallacy to which philosophers and mathematicians are particularly subject. This is the assumption that as soon as a fact is presented to a mind all consequences of that fact spring into the mind simultaneously with it. It is a very useful assumption under many circumstances, but one too easily forgets that it is false." — Alan M. Turing

主流程

这是我们需要实现的大致流程:

  • 从PC寄存器指向的内存中加载一条指令
  • 令PC寄存器自加1
  • 通过操作码决定需要执行哪一种指令
  • 结合参数执行这条指令
  • 回到第一步

你可能会想,“如果就像这样一直增加PC的话,我们又没有if和while,它难道不会很快就把指令运行完了吗?”并不是这样,像我们之前说过的那样,一些类似goto的指令可以通过修改PC来改变控制流。

让我们先在主循环中大概描述一下这个过程:

int main(int argc, char **argv) {
    // Load arguments
    // Setup

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

在主循环开始之前,我们需要先加载VM映像来使这台VM能够运行。如果命令行参数没有给出镜像路径的话就打印一条错误信息。

if (argc < 2)
{
    /* show usage string */
    printf("usage: %s [image-file1] ...\n", argv[0]);
    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);
    }
}

5.实现每一条指令

现在你的任务是正确实现每一条指令。这比听上去要更容易些。有关每条指令的详细信息,请参见LC-3指令集文档。我将在此实现两个操作码,剩下的代码放在下一节。建议先自己尝试实现其他的操作码。

ADD

ADD指令取两个数,将它们加在一起,结果存储在寄存器中。更详细的信息在文档的526页。每条ADD指令都按照以下形式编码:

ADD的二进制编码

编码显示为两行,因为该指令有两种不同的“模式”。在解释模式之前,我们先试着找出它们之间的相似之处。可以发现这两行的高4位都是0001,这是OP_ADD的枚举值。接下来的3位标记为DR,这代表目标寄存器(Destination Register),加法的结果就存储在目标寄存器。接下来的3位是SR1(Source Register 1),这是存放第一个加数的寄存器。

现在我们已经知道了目标寄存器和存放第一个加数的寄存器,还需要知道第二个加数。这时两行开始出现不同,第一行第5位为0,而第二行为1。这个位表示ADD指令是立即模式还是寄存器模式。在寄存器模式下,第二个加数与第一个数字一样存储在寄存器中,标记为SR2并包含在位2-0中。位3和4未使用。在汇编代码中可以这么写:

ADD R2, R0, R1  ; R2 = R0 + R1

立即模式比较方便,它可以减少程序的长度。相比于将两个寄存器中的数相加,立即模式将第二个操作数直接嵌入到指令中,在图中被标记为imm5(immediate 5,5位立即数)。这使得在做加法前不用先写一条从内存中加载数字到寄存器的指令,但代价是指令只能存放很小一部分数字,准确的说是2^5=32(无符号),因此立即模式主要用来递增和递减。在汇编代码中可以这么写:

ADD R0, R0, 1 ; R0 = R0 + 1

这是文档里面对ADD指令的概括:

如果位 [5] 为 0,则从 SR2 获得第二个源操作数。 如果位 [5] 为 1,则通过将 imm5 字段符号扩展为 16 位来获得第二个源操作数。 在这两种情况下,都将第二个源操作数加到 SR1 的数字上,并将结果存储在 DR 中。 (第526页)

这和我们上边讨论的内容基本一样,但是“符号扩展”是什么?立即模式下的值只有5位,但是要加给一个16位的数字,对于正数而言,我们只需要在高位加上0就行,而负数就会出问题。举个例子,-1的5位形式是1 1111,如果我们只是扩展到0的话,0000 0000 0001 1111 = 31。符号拓展解决了这个问题,它将正数前面补上0,负数前面补上1,这样得到的16位数字依然和我们最初赋的值相等。

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

在文档中的最后一句话是:

根据相加得到的结果设置标志位,取决于结果是正数,负数,或者是0。

之前我们定义的标志位寄存器现在可以派上用场了。任何时候,当值被存入寄存器时,都需要更新标志位,所以我们可以为标志位更新写一个函数:

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

还要实现15条指令,你可能会感觉这难以做到,但是我们在这里学到的都会在之后再次用到。绝大部分指令都用到了符号扩展,模式位,更新标志位。

LDI

LDI代表“间接寻址”(load indirect)。这条指令将某内存指向的内存中存储的数据加载到寄存器中。具体分析见文档的532页。

这是指令的二进制编码:

LDI Encoding

相较于ADD,这个指令没有模式位,并且参数数量也更少。这次的操作码是1010,等于OP_LDI的枚举值。像ADD那样,它包括着一个3-bit的DR(目标寄存器)来存储被加载的值。剩下的位被标记为PCoffset9,这是被嵌入到指令中的一个立即数(与imm5相似)。因为这条指令从内存中加载数据,我们可以猜测这个数字可能告诉着我们需要加载的数据的地址。文档上给出了更加详细的说明:

首先将符号扩展到16位的[8-0]位的数字与已经递增过的PC相加计算出一个地址。存储在那个地址对应的内存中的数据就是要加载到DR的数据的地址。(第532页)

像之前那样,我们需要先将9位数的值进行符号扩展,但是这次增加到当前的PC中。(回看主流程,在执行指令之前PC已经增加过了)相加的结果是一个内存地址,这个地址中存储的值就是我们要加载的值的地址。

这个看上去像是绕了一个大圈子,但却是必要的。从文档可以知道LD指令可以直接从内存中加载数据,但是这条指令最多只能接受9位数偏移,而内存地址是16位的。LDI就可以加载距离PC较远的值,但是目标地址需要先保存在距离PC比较近的地方。设想你在C程序中定义了一个指针:

// far_data的值是一个地址
// 当然far_data本身(存放地址的内存)也有一个地址
char* far_data = "apple";

// 内存可能像这样排布:

// 地址      名称          值
// 0x123:  far_data = 0x456
// ...
// 0x456:  string   = 'a'

// 如果PC在0x100
// LDI R0 0x023
// 会把'a'加载到R0

和以前一样,将数据加载到DR后需要更新标志位。

以下是LDI的代码:(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是差不多的。在剩下的指令中你会更加深刻的认识到这一点。

现在你需要返回C程序中,动手完成余下的指令。按照文档中对于每条指令的描述,参考这里给出的两个例子完成其余的。每条指令的详细代码列在下一节。有两条之前定义了的指令不会被用到——OP_RTIOP_RES。你大可忽略它们或者在执行它们的时候输出错误信息。当你完成这些后,VM的主要部分就完成了!

6.指令代码速查

本节包括了所有指令的实现,如果你卡壳了可以看看。

RTI & RES

(未使用)

abort();

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

NOT

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

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

BR

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

JMP

RET在文档中被单独列出来了,但它和JMP是同一个操作码,当R1为7时执行的是RET

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

JSR

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

LD

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

LDR

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

LEA

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

ST

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

STI

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

STR

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

7.TRAP

LC-3提供了少量的预定义好的过程来完成一般的任务以及与I/O设备交互。比如有的过程可以读取键盘输入,有的可以在控制台上显示字符串。这些过程叫Trap过程,你可以理解为LC-3的操作系统或者API。每个Trap过程都有一个自己的编码(跟操作码类似)。为了执行Trap过程,需要用到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 */
};

你可能想知道为什么trap码不在指令集中。这是因为它们并没有给LC-3增加新的功能,它们只是给执行常见的任务一个方便的办法(就像系统调用那样)。在官方的LC-3模拟器中,trap过程是用汇编语言写的。当执行一个trap code后,PC跳转到相关代码的地址,在过程执行完成后,PC被重设为之前的位置。

这也是程序之所以从0x3000而不是0x0开始的原因。更低的地址是留给trap过程的代码的。

文档中没有提到Trap过程如何实现,只说了它们需要做些啥。在我们的VM中,我们将会用C编写Trap过程。当执行TRAP指令时,会执行对应的C代码。执行完成后,继续执行原有的指令。(如果你对汇编实现的trap过程感到好奇的话,可以看看Ryan的实现。)

即使trap过程可以用汇编编写(如果存在LC-3物理机的话确实应该这样做),这并不是写VM的最好方式。与其自己手撸这些I/O代码,我们可以直接借用操作系统已经准备好的函数。这可以让VM在我们的电脑上运行的更好,简化我们的代码,并且提供更高层次的抽象。

从键盘获取输入就是一个很好的例子。汇编版本使用了一个循环来不断确认键盘有没有输入,这会白白浪费大量的CPU时间。而使用一个合适的OS函数的话,程序会进入休眠直到接收到来自键盘的输入。

TRAP操作码的switch结构里,再添加一个新的:

switch (instr & 0xFF) {
    case TRAP_GETC:
    // do something...
    // ......
}

像指令那样,我将展示如何实现一个简单的trap过程,剩下的交给你们自己。

PUTS

PUTS trap码用来输出一个以'\0'结尾的字符串(和C语言的puts类似),可以在文档的543页查到。

为了显示字符串,我们必须给trap过程需要显示的字符串。而在执行trap之前,字符串的地址应该已经被存储在R0里面。

文档是这么说的:

将一个ASCII字符串写入到控制台中。这个字符串保存在连续的内存单元中,每个单元保存一个字符,起始地址为R0。当遇到内容为0x0000的内存单元时停止写入。 (Pg. 543)

注意到和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过程的代码在下一节列出。

8. Trap过程速查表

This section contains the full implementations of the remaining trap routines.

TRAP_GETC

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

TRAP_OUT

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

TRAP_IN

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

TRAP_PUTSP

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

TRAP_HALT

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

9.加载程序

至此我们完成了从内存中加载并执行指令,但是程序是如何加载到内存的?当一个汇编程序被转换为机器码,转换出来的结果是一个包含着一系列指令和数据的二进制文件。把文件的内容直接复制到内存里面,就相当于加载了程序了。

程序文件的最初16位是一个内存地址,指示了程序应该从何处启动。这个地址被称为origin。它必须被首先读取,然后文件的剩下部分被读取到内存的从origin开始的位置。

这是将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 = 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程序是大端序的,但是绝大多数现代CPU是小端序的,所以我们需要每读入一个uint16就交换它们的高8位和低8位。(如果你正用着一台奇怪的电脑,比如PPC,那么你就不应该交换。)

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

为了方便,我们再添加read_image函数,它接收的参数是程序文件的路径。

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.内存访问

内存访问就是读写对应位置存储的值,所以:

(注:详见原文,但是这么写能跑起来例子程序了)

void mem_write(uint16_t address, uint16_t val) {
    memory[address] = val;
}

uint16_t mem_read(uint16_t address) {
    return memory[address];
}

至此虚拟机的所有功能都完成了。

11.键盘相关

这一节包含着一些无聊的细枝末节的代码,为了键盘正常工作,这些代码是必需的。它与VM的核心内容没啥关系,所以随便复制粘贴罢。

(Windows系统相关代码见原文,此处只贴出Unix用的)

全局部分:

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函数中:

// Setup部分
signal(SIGINT, handle_interrupt);
disable_input_buffering();

// ...

// Shutdown部分
restore_input_buffering();

12.运行VM

现在你可以编译运行自己写的LC-3虚拟机了!

  1. 用你喜欢的编译器编译代码。 (Final source for unix, windows).
    For gcc: $ gcc lc3.c -o lc3
  2. 下载LC-3机器码版本的2048或者Rogue。
  3. 将obj文件作为参数,运行编译得到的文件
    $ lc3-vm path/to/2048.obj
  4. 开玩!
Control the game using WASD keys.
Are you on an ANSI terminal (y/n)? y
+--------------------------+
|                          |
|                          |
|                          |
|                     2    |
|                          |
|   2                      |
|                          |
|                          |
|                          |
+--------------------------+

你可能感兴趣的:(动手写个虚拟机)