听说你想写个虚拟机(六)?

大家好,我是微微笑的蜗牛,。

这是虚拟机系列的最后一篇文章,终于要写完了。前五篇文章可点击下方链接进行查看。

  • 听说你想写个虚拟机(一)?
  • 听说你想写个虚拟机(二)?
  • 听说你想写个虚拟机(三)?
  • 听说你想写个虚拟机(四)?
  • 听说你想写个虚拟机(五)?

今天主要介绍 LC-3 汇编格式、汇编器、如何加载可执行文件。

汇编示例

我们先来看一段 LC-3 下实际的汇编代码:

.ORIG x3000                        
LEA R0, HELLO_STR                  
PUTs                               
HALT                               
HELLO_STR .STRINGZ "Hello World!"  
.END

一眼看上去,是不是跟我们之前介绍的指令类似?实际上,每种指令有着它自己的助记符,大部分指令的助记符就是它本身。

比如 LEA 指令,对应助记符 LEA。HALT 原本是 TRAP 指令中定义的一个系统调用,这里给它设定了单独的助记符 HALT。PUTs 也是。

固定格式

它有着固定格式,开头 .ORIG 和结尾 .END

.ORIG 后面跟的地址 0x3000,指定了程序的装载地址,表示程序加载进内存后,会放到 0x3000 起始地址处。但这个地址也不能随意指定,因为在 LC-3 中 0x0000-0x2fff 是系统内核区,程序禁止访问。

.END 没啥作用,仅作为结束标识。

汇编解析

先看第一条汇编,LEA R0, HELLO_STR

LEA,前面我们也介绍过了,用来取地址。HELLO_STR 是一个标签,继续往后看,会发现它定义了一个字符串,"Hello World!"。

那么这行代码的意思是,将 "Hello World!" 字符串的地址放入 R0 中。

PUTs,取出 R0 中的字符串地址,将字符串输出到屏幕。这里,也就是将 "Hello World!" 打印出来。它其实也就是 TRAP 指令中的一个系统调用,用于打印字符串。

HALT,退出程序。

以上几行汇编的功能,千言万语汇成一句话:将 "Hello World!" 打印到屏幕。

相比起来,写汇编还是要比起手写指令简单高效的多。至少我们不需要计算出指令最终的数值表示了。手写指令可谓是错之毫厘,谬以千里,得非常细心谨慎,容不得半点差错。

数据定义

之前我们在手写指令时,都是在内存区预先构造好数据,提供给指令使用。但在具体运用时,我们不大可能像这样操作。

那 LC-3 中有没有提供像高级语言一样,能直接定义数据的写法呢。当然有啦,它分为字符串和数值定义。

字符串

字符串的定义,在上边我们其实已经接触过了。看看如下栗子。

HELLO_STR .STRINGZ "Hello World!"  

HELLO_STR 表示标签,.STRINGZ 表示字符串类型,后面跟着真正的字符串数据。

它的含义是,在当前内存地址处,存储一个字符串 "Hello World!"。

还是拿这个汇编实例来说:

.ORIG x3000                        
LEA R0, HELLO_STR                  
PUTs                               
HALT                               
HELLO_STR .STRINGZ "Hello World!"
.END

HELLO_STR 前面有 3 条指令,它表示从第 4 个存储单元开始,写入字符串 "Hello World!"。注意末尾有空结束字符。

一个字符占两字节

整段汇编的内存布局如下:

数值

数值的定义,与字符串定义类似。同样是标签形式,只是类型不同,为 .FILL,最后跟上具体数值。如下所示:

WIDTH .FILL x4000

它表示在当前内存地址中,存入 0x4000 这个数值。

注意,汇编中不能写成 0x4000,只能是 x4000。

假设还是上边那段汇编,只是将字符串定义换成数值定义,此时的内存布局如下:

标签

标签可以用来定义数据(上边我们刚介绍的),也可以用来定义函数。比如下面这段代码:

WELCOME
    LEA R0, WELCOME_MESSAGE
    PUTs    

WELCOME_MESSAGE .STRINGZ "Welcome to LC3 Rogue."

WELCOME 定义了一个函数,WELCOME_MESSAGE 定义了一个字符串。

指令中是没有标签这个说法的,它只是用在汇编中,方便书写和阅读。在将汇编代码转换为指令时,汇编器会按照指令定义,将标签转换为具体的参数。

在了解标签含义,数据定义,指令定义后,我们就可写出更加丰富的汇编代码了。

若想了解更多汇编写法,可参考实例。

不过,这个实例涉及到了键盘相关操作,我们并没有在虚拟机中实现。若有兴趣,可自己参照原有实现补充完成。

汇编器

当我们写了汇编代码后,如何将其转换为二进制指令呢?

别着急,lc3tool 提供了配套的汇编器,可通过命令将汇编转换成指令,生成可执行文件。下面,我们来介绍一下。

安装

下载 lc3tool 源码编译安装。下载链接可在文末查看。

下载完成后,按如下步骤操作,注意将安装路径替换为自己本机路径。

// 解压
unzip lc3tools_v12.zip

// 进入目录
cd lc3tools

// 将路径替换成要安装的本机路径
./configure --installdir /path/to/install/dir

// 编译
make
make install

编译完成后,在命令行输入 lc3as,如果看到如下输出,表示安装成功。

另外,建议将 lc3as 路径添加到 PATH 中。这样就不用每次都跑到 lc3tools 目录下执行命令,一劳永逸。

我使用的是 zsh,编辑 ~/.zshrc,添加要导出的路径。

export LC3TOOL_HOME="/Users/liusilan/Downloads/package/lc3tools"
export PATH=$LC3TOOL_HOME:$PATH

最后执行 source ~/.zshrc,使更改生效。

转换

lc3as 工具准备好之后,我们先写段简单的代码转换一下。

  1. 将以下汇编代码存储为 test.asm。
.ORIG x3000                        
LEA R0, HELLO_STR                  
PUTs                               
HALT                               
HELLO_STR .STRINGZ "Hello World!"  
WIDTH .FILL x4000
.END

这段汇编包括代码和数据,数据部分定义了字符串 HELLO_STR 和数值 WIDTH

  1. 使用如下命令将其转换为指令。
lc3as test.asm

若转换成功,控制台会输出 0 errors,同时在同级目录下会生成 test.obj 可执行文件。虽说后缀 .obj 是目标文件的意思,但这里我们就将其称为可执行文件吧。

文件内容

接着,我们来看看 test.obj 中的内容。可见,汇编代码都被转换成了指令。

3000 e002 f022 f025 0048 0065 006c 006c
006f 0020 0057 006f 0072 006c 0064 0021
0000 4000

在上面的指令中,喵一眼就能看到我们熟知的 f025,也就是 HALT,程序停止指令。

我们再来详细介绍一下文件内容的构成。

  1. 载入地址

文件的头两个字节是 0x3000,也就是 .ORIG 指定的地址。那我们可以得知,.ORIG 这个标识的作用就是将程序载入地址写入到文件开头两字节

另外,还需注意一点,这里生成文件的数据存储方式是大端字节序,高位在低地址,低位在高地址,符合人类阅读习惯。

为什么说是大端序?再简单解释一下。

因为文件数据存储是从低地址到高地址,而文件中写入的内容是 0x3000,那么表明 30 存储在低地址,00 存储在高地址。

而 0x3000 这个数值,30 是高位,00 是低位。再根据上一段的分析,也就对应着高位 30 在低地址,低位 00 在高地址,所以是大端存储。

如果换作是小端存储,那么文件内容应该是 0030,大家若有疑问可以实践一下。

  1. 指令

再来看 e002 这条指令,它对应着 LEA R0, HELLO_STR 这条汇编代码。我们来详细分析一下,指令是如何生成的。

首先我们回顾一下 LEA 的指令格式:

操作码是 1110;第一个参数是 R0,下标为 0,用三位表示是 000;第二个参数是相对于 PC 的偏移量。

那偏移量如何计算呢?看如下分析,就能明白了。

  • LEA 是第一条指令,假设它在地址 x 处,PC 指向下一条指令,值为 x+1
  • HELLO_STR 在地址 x+3 处,因为它与 LEA 相差 3 个存储单元。
  • 偏移量为 (x+3)-(x+1)=2。第二个参数 pc_offset 用 9 位表示为:000000010

这是常规计算偏移量的方式。其实,大可不必这么复杂,在算出两者相差多少个存储单元后,再减一即可。

最后,将操作码、两个操作数拼起来形成完整指令,得到:1110000000000010,转换为十六进制表示:e002。✌️,正好可以对应得上。

f022,可以自己试着分析一下。

f025,程序退出指令,不必多说。

  1. 数据

接下来,就是我们定义的数据。

0048,刚好是字符 'H' 的 ASIIC 码表示。那么,我们可以猜到,从它开始,存储的是字符串 "Hello World!",对应着 0048 ~ 0000 这一段数据。

最后的 4000,也就是我们定义的数值 WIDTH

复杂些的栗子

在上面的栗子中,代码和数据看似是分开的,其实是可混合在一起的,这取决于汇编如何编写。

下面这个例子,定义了 MAIN 和 FUNC 标签,表示函数。MAIN 中有代码和数据,FUNC 中只有代码。那么转换之后,数据仍在两段代码之间,还是按照原有顺序。

.ORIG x3000 ; OS is <3000

MAIN
    LEA R0, HELLO_STR                  
    PUTs                                       
    JSR FUNC                     
    HELLO_STR .STRINGZ "Hello World!"  
    WIDTH .FILL x4000

FUNC
    LD  R5, WIDTH
    ADD R5, R5, #1
    HALT
.END

转换后的指令和数据如下:

3000 e002 f022 480e 0048 0065 006c 006c
006f 0020 0057 006f 0072 006c 0064 0021
0000 4000 2bfe 1b61 f025

不难看出,最后三条指令,2bfe 1b61 f025,刚好对应着 FUNC 标签中的代码。前面的 4000 则是数据 WIDTH,再往前,就是字符串 "Hello World!",这也就印证了代码和数据是混合的

另外,标签不会进行转换,因为它只是为了方便编写代码,没有真正含义。但它会被用来计算偏移量,可认为它代表着函数第一条指令的地址,或者是数据的地址。

举个栗子。JSR FUNC,表示跳转到函数 FUNC 去执行。这里假设指令从地址 0 开始存放。

  • JSR 是第三条指令,地址为 2,那么 PC = 3。
  • FUNC 的地址,也就是第一条指令 LD 的地址,为 17。
  • 由此得出的偏移量为 17 - 3 = 14。
  • 480e 是转换后指令的十六进制表示,最后 11 位是偏移,数值是 e,刚好等于 14。

至于 FUNC 的地址 17 是怎么计算出来的,可看下图,也就是依照 FUNC 前面的指令和数据所占空间来推导。

因此,汇编中有效转换部分是代码和数据定义,并且是按顺序转换,代码和数据的相对位置不变。也可以认为去除标签后,将代码和数据按序依次放到内存中。

加载可执行文件

既然使用工具生成了可执行文件,那就只剩最后一步,将其加载到内存,运行程序。可执行文件的路径可通过参数指定。

不过,有几个问题需要处理:

  1. 指令和数据存储是大端序,而我们的机器是小端序,这就涉及到大端转小端的问题。
  2. 文件前两个字节是程序载入地址,需要把它提取出来。
  3. 由于指定了载入地址,而内存空间有限,那么可读入的最大指令数是有限制的。

大端转小端

大端和小端的顺序恰好是相反的,因此,只需将高 8 位 和低 8 位交换即可。

uint16_t swap16(uint16_t x)
{
  // 两个字节交换
  return (x << 8) | (x >> 8);
}

提取载入地址

载入地址在开头两字节,先从文件中读取两字节数据,然后转换为小端表示。

// 载入地址
uint16_t origin;

// 读取 2 字节
fread(&origin, sizeof(origin), 1, file);

// 大端转小端
origin = swap16(origin);

读取指令和数据到内存

由于载入地址的指定,可读取最大指令数有限。这里简单处理:

最大读取指令数 = 最大内存空间 - 载入地址

a. 根据载入地址,计算出可读取最大指令数。

uint16_t max_read = UINT16_MAX - origin;

b. 将指针指向载入地址:

uint16_t *p = mem + origin;

c. 读取指令到载入地址:

size_t read = fread(p, sizeof(uint16_t), max_read, file);

d. 指令逐条转换为小端序:

// 大端转小端
while (read-- > 0)
{
    *p = swap16(*p);
    ++p;
}

这样,可执行文件的处理与载入就完成了。

指令执行

现在指令和数据已经放入内存,一切准备就绪。那就开始执行指令吧~

不过,别忘了将 PC 的初始值修改为载入地址哟。这样,程序才会从第一条指令开始执行。

// 设置初始值
PC = origin;

再啰嗦一下载入地址。假设载入地址 origin = 0x3000。这时,内存布局和 PC 指向如下所示,载入地址处存放第一条指令。

最后,运行虚拟机,参数带上可执行文件路径,程序就能跑起来啦。

gcc -o vm_lc_3_all vm_lc_3_all.c
./vm_lc_3_all test.obj

从此,我们从手工劳作时代跨越到了机器时代,可以更加欢快的写代码了~

LC-3 实现的完整代码可点此查看。

总结

至此,虚拟机系列就落下帷幕,全部完结,总共六篇文章。

从一个最小虚拟机开始介绍,到完成 LC-3 这个更加贴近真实意义的虚拟机。一步步从简单,到复杂,由浅入深,慢慢掀开了虚拟机的神秘面纱。当然,现代虚拟机要复杂的多,这里我们主要是讲述它的大体思想。

同时,这也是我自主学习过程的记录。通过思考与实践,慢慢发现原来虚拟机并没有那么难。不过话又说回来,手写指令,着实让我深深的体会到了老一辈程序员的痛苦。相比起来,现在的程序员简直幸福得不要不要的。

另外,这个系列虽已完结,但是我还会继续学习,输出其他系列,比如《动手写 shell》、《动手写浏览器渲染引擎》等等,欢迎关注~

参考资料

  • lc3tool:http://highered.mheducation.com/sites/0072467509/student_view0/lc-3_simulator.html
  • 汇编完整实例:https://github.com/justinmeiners/lc3-rogue/blob/master/rogue.asm
  • lc-3 完整代码:https://github.com/silan-liu/virtual-machine/blob/master/mac/vm_lc_3_all.c

你可能感兴趣的:(听说你想写个虚拟机(六)?)