听说你想写个虚拟机(五)?
大家好,我是微微笑的蜗牛,🐌。
这是虚拟机系列的第五篇文章,主要介绍 TRAP 指令,系统调用。前四篇文章可点击下方链接进行查看。
TRAP 的意思是陷阱,当程序需要请求操作系统资源时,比如读写文件,就会从用户态「陷入」内核态,根据调用号,找到相应的程序进行处理。处理完毕后,再从内核态切回用户态。
细细一想,陷阱这个词,真的很贴切,就好像掉入了预先设好的机关一样。
接下来,我将带着大家一起实现几个系统调用,比如从键盘读入单个字符 GETC、输出字符串到屏幕 PUTS、输出单个字符到屏幕 OUT 等等。
指令格式
TRAP 指令比较特殊,操作码为 1111。低 8 位为系统调用号,用于标识不同的系统调用。
这里,我们一共定义了 6 种系统调用。系统调用号的定义如下所示:
// 类型
typedef enum
{
TRAP_GETC = 0x20, // 从键盘输入
TRAP_OUT = 0x21, //输出字符
TRAP_PUTS = 0x22, // 输出字符串
TARP_IN = 0x23, // 打印输入提示,读取单个字符
TRAP_PUTSP = 0x24,// 输出字符串
TRAP_HALT = 0x25, // 退出程序
} TrapSet;
注意 TRAP_HALT 这个系统调用。在之前的文章中,我们都是简单处理,将 TRAP 指令直接视为程序退出,并没有考虑它的参数。这里终于要揭示它的真身了,程序退出其实也是一个系统调用,调用号是 0x25。
所以,从现在开始,实例代码中,将会使用它的真正定义 0xf025,来表示退出程序。
系统调用
GETC
系统调用号是 0x20,表示从键盘读取单个字符,并将字符放入 R0 中。
指令布局如下:
我们可以直接使用 c 标准库中的 getchar 来进行模拟输入。
// 等待输入一个字符,最后存入 r0
void trap_getc()
{
// 清空输入缓冲区
fflush(stdin);
reg[R_R0] = (uint16_t)getchar();
}
OUT
系统调用号是 0x21,表示将 R0 中的字符打印到屏幕。
指令布局如下:
这里,可使用 putc 函数来模拟打印字符。
// 将 r0 中的字符打印出来
void trap_out()
{
putc((char)reg[R_R0], stdout);
// 刷新输出缓冲区
fflush(stdout);
}
PUTS
系统调用号是 0x22,表示打印 ASCII 字符串。从 R0 寄存器取出字符串的起始地址,将字符串打印到屏幕。
每个存储单元存储 1 个字符,存储单元大小为 2 字节,也就是 1 个字符占 2 字节。
指令布局如下:
我们仍然使用 putc 来打印单个字符。由于此处是个字符串,只需循环打印即可。
我们来拆解一下实现步骤:
- 取出 R0 中的地址。
- 初始化 uint16_t 类型的指针,指向该地址。由于指针是指向 uint16_t 类型,那么意味着,通过指针取出的值是 16 位;同时指针自增时,会移动 16 字节。
- 不断循环,指针指向下一个字符,直至遇到空字符结束。
实现代码如下:
// 将 r0 寄存器中地址处的字符串打印出来。1 个字符占 2 字节。
void trap_puts()
{
// 1. 取出 R0 中的地址。
uint16_t address = reg[R_R0];
// 2. 初始化指针,指向该地址
uint16_t *c = mem + address;
// 3. 不断循环,指针指向下一个字符,直至遇到空字符结束
while (*c)
{
putc((char)*c, stdout);
++c;
}
fflush(stdout);
}
IN
系统调用号是 0x23。和 GETC 类似,只不过多了两个打印的步骤。一是打印输入提示,二是打印输入的字符。
指令布局如下:
实现代码如下:
// 提示输入一个字符,将字符打印,并放入 R0
void trap_in()
{
// 清空输入缓冲区
fflush(stdin);
// 打印提示
printf("Enter a character:");
char c = getchar();
// 打印输入的字符
putc(c, stdout);
reg[R_R0] = (uint16_t)c;
}
PUTSP
系统调用号是 0x24,与 PUTS 类似,同样是打印 ASCII 字符串。
唯一区别是:每个存储单元存储 2 个字符,即 1 个字符占 1 字节。
指令布局如下:
由于 1 个字符占 1 字节,但指向 uint16_t 类型的指针每次取值会取出 2 字节的数据。因此,两个字符需分别打印。先打印低 8 位,再打印高 8 位。
注意:当字符串长度为奇数时,字符串最后一个存储单元的高 8 位是 0,代表空结束字符。因为数据是 2 字节一组。
// 将 r0 地址处的字符串出来,一个字符一字节
void trap_put_string()
{
uint16_t *c = mem + reg[R_R0];
while (*c)
{
// 低 8 位
char char1 = (*c) & 0xff;
putc(char1, stdout);
// 高 8 位
char char2 = (*c) >> 8;
// 当为奇数时的判断处理,此时 char2 = 0
if (char2)
{
putc(char2, stdout);
}
++c;
}
fflush(stdout);
}
HALT
系统调用号是 0x25。表示程序停止,并打印一条退出消息。
指令布局如下:
它的实现最为简单:
puts("Halt");
running = 0;
实践
到这里,我们的兵器库中又多了一件好物件,当然得拿出来练练。
关于输入/输出单字符的很好写指令,但对于输出字符串来说,现在还没有写入字符串数据的方式,需要预先构造数据。不过,不用担心。下篇文章,我们会讲到 LC-3 的汇编用法,那时候就知道到如何进行数据定义了。
所以,我们先构造一些字符串放入内存,包括两种存储模式:
- 1 字符占 1 字节。
- 1 字符占 2 字节。
假设内存区域 [0,9] 存储单元是代码段,[10,20] 区间是数据段。
大小端
在构造数据之前,我们先简单讲下大小端的概念,因为在写入数据时需要注意字节顺序。
字节的存储方式分为大端和小端。
- 大端:数据的高位在低地址,低位在高地址,符合人类阅读习惯。
- 小端:数据的高位在高地址,低位在低地址,符合计算机从低位开始处理。
比如数据 0x12345678
,大小端的存储顺序如下,两者刚好相反。
由于我们的机器一般都是小端字节序,与人类的阅读顺序是相反的。所以在构造数据时,要特别注意一下字节顺序问题。
在理解了大小端之后,我们接着来讲讲不同模式下数据存储的差异。
模式一
1 字符占 2 字节。
假设字符串为 "abc",长度是 4(加上末尾空结束字符)。从内存单元 10 开始存储。
对于字符 'a',它的 ASCII 码是 97,转换为 16 进制为 0x61。由于一个字符占 2 字节,那么扩展一下就变为 0x0061。
在小端字节序中,高位在高地址,低位在低地址。那么对于 0x0061 来说,0x00 是高位,应该在高地址;0x61 是低位,应该放在低地址。如下图所示:
其余字符的分析类似,不再赘述。
"abc" 整个字符串在内存中的布局为(注意,字符串末尾还有一个结束符 0x0):
模式二
1 字符占 1 字节。
假设字符串为 "defgh",长度是 6。从内存单元 14 开始存储。
由于一个存储单元是 2 字节,首先我们把字符串分为 2 个字符一组。分组如下:
"de", "fg", "h"
-
对于 "de" 来说,它由 'd' 和 'e' 两个字符组成。而数据在内存区是从低地址往高地址存储,也就是说
'd' → 0x64
在低地址,'e' → 0x65
在高地址。再根据小端的特性,可推导出 0x64 在低位,0x65 在高位。最后得到的十六进制数为 0x6564。如下图所示: -
"fg",分析类似,十六进制表示为 0x6766。
-
"h",属于落单的字符,再加上末尾的空字符,正好两个字符。十六进制表示为 0x0068。
"defgh" 在内存中的布局如下:
功能
设计的功能点如下,总共十条指令:
- 调用 GETC 等待从键盘输入字符,使用 OUT 打印。
- 调用 IN 等待从键盘输入字符。
- R0 清零,再赋值为 10,指向 "abc" 的起始地址。
- 调用 PUTS 打印 "abc"。
- R0 清零,再赋值为 14,指向 "defgh" 的起始地址。
- 调用 PUTSP 打印 "defgh"。
指令和数据在内存中的布局如下。为了方便查看,图中将代码和数据分开展示。
完整代码可查看:https://github.com/silan-liu/virtual-machine/blob/master/mac/vm_lc_3_3.c。
总结
这篇文章,我们讲述了 6 个系统调用的实现,大都跟关输入输出有关,并对这些调用进行了实践。实现都比较简单,只是在构造数据部分,需要注意字节序的问题。
下篇文章,我们将会讲 LC-3 汇编代码的编写,数据的定义,以及如何将汇编代码转换为二进制指令。到时候,就不用这么麻烦的手写指令和构造数据了。