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

2021-02-10  本文已影响0人  微微笑的蜗牛

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

这是虚拟机系列的第五篇文章,主要介绍 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 来打印单个字符。由于此处是个字符串,只需循环打印即可。

我们来拆解一下实现步骤:

  1. 取出 R0 中的地址。
  2. 初始化 uint16_t 类型的指针,指向该地址。由于指针是指向 uint16_t 类型,那么意味着,通过指针取出的值是 16 位;同时指针自增时,会移动 16 字节
  3. 不断循环,指针指向下一个字符,直至遇到空字符结束。

实现代码如下:

// 将 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 的汇编用法,那时候就知道到如何进行数据定义了。

所以,我们先构造一些字符串放入内存,包括两种存储模式:

假设内存区域 [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"
  1. 对于 "de" 来说,它由 'd' 和 'e' 两个字符组成。而数据在内存区是从低地址往高地址存储,也就是说 'd' → 0x64 在低地址,'e' → 0x65 在高地址。再根据小端的特性,可推导出 0x64 在低位,0x65 在高位。最后得到的十六进制数为 0x6564。如下图所示:

  2. "fg",分析类似,十六进制表示为 0x6766。

  3. "h",属于落单的字符,再加上末尾的空字符,正好两个字符。十六进制表示为 0x0068。

"defgh" 在内存中的布局如下:

功能

设计的功能点如下,总共十条指令:

指令和数据在内存中的布局如下。为了方便查看,图中将代码和数据分开展示。

完整代码可查看:https://github.com/silan-liu/virtual-machine/blob/master/mac/vm_lc_3_3.c

总结

这篇文章,我们讲述了 6 个系统调用的实现,大都跟关输入输出有关,并对这些调用进行了实践。实现都比较简单,只是在构造数据部分,需要注意字节序的问题。

下篇文章,我们将会讲 LC-3 汇编代码的编写,数据的定义,以及如何将汇编代码转换为二进制指令。到时候,就不用这么麻烦的手写指令和构造数据了。

上一篇下一篇

猜你喜欢

热点阅读