你说得对,但是
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中定义了调试器的结构:
|
|
几个有用的函数
| 函数 | 作用 |
|---|---|
Log() |
printf()的升级版,专门用来输出调试信息,同时还会输出使用Log()所在的源文件,行号和函数,当输出的调试信息过多的时候,可以很方便地定位到代码中的相关位置 |
Assert() |
assert()的升级版,当测试条件为假时,在assertion fail之前可以输出一些信息 |
panic() |
用于输出信息并结束程序,相当于无条件的assertion fail |
swaddr_read() / swaddr_write() |
访问模拟的内存 |
strtok() |
一个简单的字符串分割工具,用于解析命令 |
sscanf() |
可以从字符串中读入格式化的内容, 使用它有时候可以很方便地实现字符串的解析 |
NEMU的基本操作
备注磨刀不误砍柴工!!!
切换用户程序
修改工程目录下的Makefile文件,更换NEMU的用户程序:
|
|
同理,将mov-c更换为testcase/src目录下的其他文件,例如add、bubble-sort等,即可执行对应的用户程序。
NEMU的指令集
X86系列处理器采用变长指令字结构,各种指令长度随指令功能而异。
要实现一条指令,首先你需要知道这条指令的格式和功能。格式决定如何解释,功能决定如何执行。这些信息都在 instruction set page(i386 手册第17 章)。
i386 手册中的汇编语言格式都是 Intel 格式,而
objdump(反汇编)的默认格式是AT&T格式,两者的源操作数和目的操作数位置不一样,千万不要把它们混淆了!
指令格式
x86指令的一般格式如下:

x86指令的一般格式
Opcode(操作码)必定出现,其余组成部分可能不出现。Opcode决定是否出现ModR/M;SIB、Displacement、Immediate由ModR/M决定。- 对于某些组成部分,其长度并不是固定的。
- 给定一条具体指令的二进制形式,其组成部分的划分是有办法确定的,不会产生歧义
例如对于以下指令:

指令示例
它的划分如下:

示例指令划分
ModR/M部分为什么解析出了disp32[--][--]?
ModR/M内部这一个字节的组成,分为了三个部分,具体每个部分的编码都对应着右边表内的含义,以ModR/M内的编码为基准进行查表就能够解析出对应的含义。例如,例子中的
ModR/M部分编码是84(十六进制),对应二进制就是10000100,对应回格式中的部分就是Mod部分为10,R/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来定义宏。宏不负责检查语法的正确性。
|
|
例如:
|
|
这个宏定义就代表了以下的两行代码是同一个代码,我们使用的时候看到的是上面部分函数,但是程序执行的时候实际上是在执行下面的函数(实际上就是同一个函数):
|
|
在C语言中,宏必须定义在一行中。
若需通过换行提高代码可读性,可使用
\附在每行末尾。
NEMU的指令周期
取指
核心流程
|
|
函数exec:
|
|
其中,函数instr_fetch()负责取指:
|
|
instr_fetch()从eip处提取Opcode,存储到ops_decoded.opcode中。
译码
所有指令的规则如下:
| 内容 | 规则 |
|---|---|
| 指令 | 指令名称,具体由i386手册确定 |
| 形式 | i2r,将立即数移动到寄存器 i2rm,将立即数移动到寄存器或内存 r2rm,将寄存器移动到寄存器或内存 |
| 操作数后缀 | b表示操作数长度为8,v表示无法确定操作数长度,可能是16或32 |
通过opcode_table,根据提取到的Opcode找到对应的指令处理函数(如mov_i2r_v)。
执行
- 对于同一指令的不同形式,它们的执行阶段是相同的。如
mov_i2rm和mov_rm2r,它们的执行阶段都是将源操作数存储到目标操作数中。 - 对于不同指令的同一种形式,它们的译码阶段是相同的。如
mov_i2rm和sub_rm2r,它们的译码阶段都是识别出一个立即数和一个rm操作数。 - 对于同一条指令同一种形式的不同长度,它们的译码阶段和执行阶段都非常类似。如
mov_i2rm_b,mov_i2rm_w和mov_i2rm_l。它们都是识别出一个立即数和一个rm操作数,然后把立即数存入rm操作数。
访存

访存指令
写回
指令执行完后,eip指向下一条指令。
编写指令的流程
指令模板文件
指令模板文件:xxx-template.h
- 在文件头尾分别包含
cpu/exec/template-start.h和cpu/exec/template-end.h。 - 定义宏
instr为指令名称。 - 定义函数
static void do_execute(),实现该指令的通用执行过程。 - 定义
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
- 包含
cpu/exec-helper.h。 - 通过分别将宏
DATA_BYTE定义成 1, 2, 4, 分别对指令模板文件xxx-template.h进行实例化。 - 若一个
helper函数只会在某些操作数长度中用到,可以在xxx-template.h中通过条件编译的功能来指定(可以参考nemu/src/cpu/exec/data-mov/xchg-template.h中的xchg_a2r指令类型) - 必要时通过宏
make_helper_v()定义相应的重载函数,根据指令的操作数长度前缀确定调用哪一个helper函数。
编写指令头文件
编写指令头文件:xxx.h
- 在
nemu/src/cpu/exec/all-instr.h中包含xxx.h。 - 在
nemu/src/cpu/exec/exec.c中的opcode_table中填写相应的helper函数
