原文地址: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架构各写一遍汇编程序,你只需要为每种汇编语言写一个虚拟机程序,然后你的每个程序用虚拟机特有的汇编语言写一次就够了。
编译器解决的问题是把一种标准化的高级语言翻译成各种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
指令都按照以下形式编码:
编码显示为两行,因为该指令有两种不同的“模式”。在解释模式之前,我们先试着找出它们之间的相似之处。可以发现这两行的高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页。
这是指令的二进制编码:
相较于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_RTI
和OP_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码写一个枚举:
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虚拟机了!
- 用你喜欢的编译器编译代码。 (Final source for unix, windows).
For gcc:$ gcc lc3.c -o lc3
- 下载LC-3机器码版本的2048或者Rogue。
- 将obj文件作为参数,运行编译得到的文件
$ lc3-vm path/to/2048.obj
- 开玩!
Control the game using WASD keys.
Are you on an ANSI terminal (y/n)? y
+--------------------------+
| |
| |
| |
| 2 |
| |
| 2 |
| |
| |
| |
+--------------------------+