NEMU

简易的计算机系统

你说得对,但是NEMU是一个基于X86-64处理器模拟的IA-32操作系统。NEMU运行在一个被称作Docker的容器,在这里,被容器选中的人将被授予gcc,导引C语言之力。你将扮演一位名为Debugger的神秘用户,编写众多C语言程序,在调试中找出FAIL的原因,同时逐步发掘Hit Bad Trap的真相。

目标:制作一个32位的操作系统

什么是NEMU

在X86-64处理器的机器上模拟一个32位操作系统(一个用来执行其它程序的程序!),它包括4个连贯的实验内容:

阶段 任务
PA1 简易调试器
PA2 指令系统
PA3 存储管理
PA4 中断与I/O

认识NEMU

NEMU的结构

调试器操作指令集

nemu/src/monitor/debug/ui.c中定义了调试器的结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
static struct {
    char *name;
    char *description;
    // 函数指针,可指向*name,*description
    int (*handler) (char *);     
} cmd_table [] = {
    { "help", "Display informations about all supported commands", cmd_help },
    { "c", "Continue the execution of the program", cmd_c },
    { "q", "Exit NEMU", cmd_q },

    /* TODO: Add more commands */
    /* 接下来若想定义新的操作,格式为
    { "name", "description", function},
    */
};

几个有用的函数

函数 作用
Log() printf()的升级版,专门用来输出调试信息,同时还会输出使用Log()所在的源文件,行号和函数,当输出的调试信息过多的时候,可以很方便地定位到代码中的相关位置
Assert() assert()的升级版,当测试条件为假时,在assertion fail之前可以输出一些信息
panic() 用于输出信息并结束程序,相当于无条件的assertion fail
swaddr_read() / swaddr_write() 访问模拟的内存
strtok() 一个简单的字符串分割工具,用于解析命令
sscanf() 可以从字符串中读入格式化的内容, 使用它有时候可以很方便地实现字符串的解析

NEMU的基本操作

📝 备注

磨刀不误砍柴工!!!

切换用户程序

修改工程目录下的Makefile文件,更换NEMU的用户程序:

1
2
- USERPROG := obj/testcase/mov
+ USERPROG := obj/testcase/mov-c

同理,将mov-c更换为testcase/src目录下的其他文件,例如addbubble-sort等,即可执行对应的用户程序。

NEMU的指令集

X86系列处理器采用变长指令字结构,各种指令长度随指令功能而异。

要实现一条指令,首先你需要知道这条指令的格式和功能。格式决定如何解释,功能决定如何执行。这些信息都在 instruction set page(i386 手册第17 章)。

i386 手册中的汇编语言格式都是 Intel 格式,而objdump(反汇编)的默认格式是AT&T格式,两者的源操作数和目的操作数位置不一样,千万不要把它们混淆了!

指令格式

x86指令的一般格式如下:

x86指令的一般格式

  1. Opcode(操作码)必定出现,其余组成部分可能不出现。Opcode决定是否出现ModR/MSIBDisplacementImmediateModR/M决定。
  2. 对于某些组成部分,其长度并不是固定的。
  3. 给定一条具体指令的二进制形式,其组成部分的划分是有办法确定的,不会产生歧义

例如对于以下指令:

指令示例

它的划分如下:

示例指令划分

ModR/M部分为什么解析出了disp32[--][--]

ModR/M内部这一个字节的组成,分为了三个部分,具体每个部分的编码都对应着右边表内的含义,以ModR/M内的编码为基准进行查表就能够解析出对应的含义。

例如,例子中的ModR/M部分编码是84(十六进制),对应二进制就是10000100,对应回格式中的部分就是Mod部分为10R/M部分为100,二者在右边表中可以对应出一行,这一行的对应指令就是disp32[--][--]disp32代表偏移量(displacement)为32位的,两个[--][--]就代表我们需要解析SIB中的编码部分才能得到偏移量的具体数值。

Opcode Table

mov指令的第一种形式为例:

mov指令

列数 具体含义
Description 将一个8位寄存器中的数据传送到8位的寄存器或者内存中,其中r/m表示“寄存器或内存”。
Opcode 88表示这条指令的opcode的首字节是0x88/r表示后面跟一个ModR/M字节,并且 ModR/M字节中的reg/opcode域解释成通用寄存器的编码。
Instruction r8表示8位寄存器;r/m8表示8位寄存器或内存,具体由mod字段决定。

通用寄存器编码

接下来的两种形式也就不难看懂了:但这两种形式的Opcode都是一样的,难道不会出现歧义吗?

x86是通过指令一般格式中的 operand-size prefix来区分上面这两种形式的。

Operand-size prefix的编码是0x66,作用是指示当前指令需要改变操作数的长度。在 IA-32 中,通常如果这个前缀没有出现,操作数长度默认是 32 位;当这个前缀出现的时候,操作数长度就改变成16位。

换句话说,如果把一个开头为89 ...的比特串解释成指令,它就应该被解释成MOV r/m32, r32的形式;如果比特串的开头是66 89...,它就应该被解释成MOV r/m16, r16

C语言宏定义

在C语言中,可以使用命令#define来定义宏。宏不负责检查语法的正确性。

1
2
3
4
5
6
7
// 常规的宏
#define PI 3.1415926
// 带参数的宏
#define putchar(x) putc(x, stdout)

// 使用宏进行连接
#define concat(x, y)   x ## y

例如:

1
#define make_helper(name) int name(swaddr_t eip)

这个宏定义就代表了以下的两行代码是同一个代码,我们使用的时候看到的是上面部分函数,但是程序执行的时候实际上是在执行下面的函数(实际上就是同一个函数):

1
2
make_helper(exec)
int exec(swaddr_t eip)

在C语言中,宏必须定义在一行中。

若需通过换行提高代码可读性,可使用\附在每行末尾。

NEMU的指令周期

取指

核心流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
void cpu_exec(volatile uint32_t n) {
    ...

    for(; n>0; n--) {
        int instr_len = exec(cpu.eip);  // 执行当前%eip所指向的指令
        cpu.eip += instr_len;           // %eip指向下一条指令
    }

    ...
}

函数exec

1
2
3
4
make_helper(exec) {     //等价于 int exec(swaddr_t eip)
    ops_decoded.opcode = instr_fetch(eip, 1);       // 表示读取一个字节的操作码
    return opcode_table[ops_decoded.opcode](eip);   // 返回一个函数 
}

其中,函数instr_fetch()负责取指:

1
2
3
static inline uint32_t instr_fetch(swaddr_t addr, size_t len) {
    return swaddr_read(addr, len);
}

instr_fetch()eip处提取Opcode,存储到ops_decoded.opcode中。

译码

所有指令的规则如下:

内容 规则
指令 指令名称,具体由i386手册确定
形式 i2r,将立即数移动到寄存器
i2rm,将立即数移动到寄存器或内存
r2rm,将寄存器移动到寄存器或内存
操作数后缀 b表示操作数长度为8,v表示无法确定操作数长度,可能是16或32

通过opcode_table,根据提取到的Opcode找到对应的指令处理函数(如mov_i2r_v)。

执行

  1. 对于同一指令的不同形式,它们的执行阶段是相同的。如mov_i2rmmov_rm2r,它们的执行阶段都是将源操作数存储到目标操作数中。
  2. 对于不同指令的同一种形式,它们的译码阶段是相同的。如mov_i2rmsub_rm2r,它们的译码阶段都是识别出一个立即数和一个rm操作数。
  3. 对于同一条指令同一种形式的不同长度,它们的译码阶段和执行阶段都非常类似。如mov_i2rm_bmov_i2rm_wmov_i2rm_l。它们都是识别出一个立即数和一个rm操作数,然后把立即数存入rm操作数。

访存

访存指令

写回

指令执行完后,eip指向下一条指令。

编写指令的流程

指令模板文件

指令模板文件:xxx-template.h

  1. 在文件头尾分别包含cpu/exec/template-start.hcpu/exec/template-end.h
  2. 定义宏instr为指令名称。
  3. 定义函数static void do_execute(),实现该指令的通用执行过程。
  4. 定义helper函数
    (1)若指令的译码方式在nemu/include/cpu/decode/decode.h中已经存在,那么可以考虑使用宏 make_instr_helper()来构造helper函数(大部分 helper 函数都可以通过这种方式构造)。
    (2)否则可以考虑添加相应的译码函数或者不使用make_instr_helper(), 而是直接使用make_helper()来定义helper函数,在函数体中直接进行译码,并调用do_execute()(可以参考nemu/src/cpu/exec/data-mov/xchg-template.h中的xchg_a2r指令类型。

指令实例化文件

指令实例化文件:xxx.c

  1. 包含cpu/exec-helper.h
  2. 通过分别将宏DATA_BYTE定义成 1, 2, 4, 分别对指令模板文件xxx-template.h进行实例化。
  3. 若一个helper函数只会在某些操作数长度中用到,可以在xxx-template.h中通过条件编译的功能来指定(可以参考nemu/src/cpu/exec/data-mov/xchg-template.h中的xchg_a2r指令类型)
  4. 必要时通过宏make_helper_v()定义相应的重载函数,根据指令的操作数长度前缀确定调用哪一个helper函数。

编写指令头文件

编写指令头文件:xxx.h

  1. nemu/src/cpu/exec/all-instr.h中包含xxx.h
  2. nemu/src/cpu/exec/exec.c中的opcode_table中填写相应的helper函数

编写指令的流程

具体实现

网站总访客数:Loading

使用 Hugo 构建
主题 StackJimmy 设计