美文网首页
听说你想写个虚拟机(五)?

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

作者: 微微笑的蜗牛 | 来源:发表于2021-02-10 21:12 被阅读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 的汇编用法,那时候就知道到如何进行数据定义了。

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

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

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

    3. "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 汇编代码的编写,数据的定义,以及如何将汇编代码转换为二进制指令。到时候,就不用这么麻烦的手写指令和构造数据了。

    相关文章

      网友评论

          本文标题:听说你想写个虚拟机(五)?

          本文链接:https://www.haomeiwen.com/subject/ypnkxltx.html