第一个程序

你应在本节中完成以下任务
  1. 由于内容较多,务必反复仔细阅读本节内容;
  2. 跟随讲义和思考题的引导阅读项目中和本节相关的源代码,理解并掌握本节大量使用的框架函数(或宏);
  3. 对宏保持高度警惕,多通过手算学习遇到的宏定义;
  4. 实现基本指令和基本指令所使用的 RTL 指令;
  5. 实现标志寄存器及基本指令中对标志寄存器的更新;
  6. 实现 Differential Testing;
  7. 回答所有思考题(部分思考题需要在完全阅读完本节内容后回答);

在PA1中,我们已经见识到最简单的计算机 TRM 的工作方式:

while (1) {
  从EIP指示的存储器位置取出指令;
  执行指令;
  更新EIP;
}

接下来我们就来谈谈这个过程,也就是,CPU究竟是怎么执行一条指令的。对于大部分指令来说,执行它们都可以抽象成取指—译码—执行指令周期。为了使描述更加清晰,我们借助指令周期中的一些概念来说明指令执行的过程。

追本溯源

我们经常提的指令到底是从哪里来的呢?大家都知道,我们写的程序经过预处理、编译、汇编等过程之后会生成一个目标文件,这个文件一般是以二进制的形式存在我们的硬盘上。我们所谓的指令,其实是文件中存储的一系列二进制比特串。例如这里有一条指令:

10111000 00110100 00010010 00000000 00000000

这究竟是什么……不过想想,计算机也只是个巨大的数字电路,它也只能理解0和1了。但是,为了方便人们阅读与理解,我们一般以十六进制和汇编语言组合的形式来表示这一段指令:

b8 34 12 00 00             mov $0x1234,%eax

很明显,这段指令的意思是把 eax 寄存器赋值为 0x1234。其中 b8 是操作码(operation code),对应汇编代码中的 mov%eax34 12 00 00 是操作数(operand),对应的是汇编代码的立即数$0x1234部分。

我们在执行一个程序的时候,首先要把这个二进制文件加载到内存(具体的加载过程在 PA3 会有详细介绍,现在可以理解为把指令的比特串从硬盘读到了内存的相应位置),光加载到内存还不够,CPU 还需要知道从内存的什么地方开始执行指令,这时 eip 寄存器便派上了用场,它会告诉 CPU 接下来要从内存的什么地方读取指令。

回想一下,我们在做简易调试器时,用来测试的指令的形式如下:

0x100000:    b8 34 12 00 00             mov $0x1234,%eax

其中 0x100000 便是 eip 指向的地址。

有了内存中的指令与 eip 提供的地址,CPU 便可以开始它的工作了。

讲到这里,如果你还不理解指令是什么,你可以把它当做汇编代码的二进制表示,虽然这样定义有些不严谨。如果你想了解更多,可以参考这里

指令执行过程

下面我们来谈一谈一条指令的执行周期。

取指(instruction fetch, IF)

取指令要做的事情自然就是将 eip 指向的指令从内存读入到CPU中。

译码(instruction decode, ID)

在取指阶段, 计算机拿到了将要执行的指令。然而,面对已经取到的这一串令人费解的01序列,计算机是如何理解它的意思的呢?

让我们先来回想一下指令是做什么的。我们知道CPU是用来处理数据的, 指令则是用来指示CPU具体对什么数据进行什么样的处理. 也就是说, 我们只要让CPU从取到的比特串中解读出处理的对象和处理的操作, CPU就知道我们想让它做什么了. 所以相应地, CPU需要从指令中解读出操作数操作码两部分信息。

于是, 为了让计算机明白指令的含义, 先驱想到了一个办法, 那就是查找表! CPU拿到一条指令之后,可以通过查表的方式得知这条指令的操作数和操作码。这个过程叫译码。

当然, 译码逻辑实际上也并非只有一张查找表那么简单, 还需要根据不同的指令通过多路选择器选择不同的操作数. 回想一下, 计算机现在已经有存储器和寄存器了, 它们都可以存放操作数, 指令中也可以存放立即数. 也可能还有二次译码的处理……不过无论再怎么复杂, 我们只需要知道, 这个过程终究也只是一些数字电路的事情, 毕竟所有需要的信息都在指令里面了, 没什么神秘的操作.

执行(execute, EX)

经过译码之后, CPU 就知道当前指令具体要做什么了, 执行阶段就是真正完成指令的工作. 现在 TRM 只有加法器这一个执行部件, 必要的时候, 只需要往加法器输入两个源操作数, 就能得到执行的结果了. 之后还要把结果写回到目的操作数中, 可能是寄存器, 也可能是内存.

更新 eip

执行完一条指令之后, CPU就要执行下一条指令. 在这之前, CPU 需要更新 eip 的值, 让 eip 加上刚才执行完的指令的长度, 即可指向下一条指令的位置.

增加了多少

我们说在执行完下一条指令前需要更新 eip 的值。设某指令执行前 eip 值为 x1,该指令执行后 eip 值为 x2,那么 x2 - x1 的这个差值都包括了一条指令的哪些组成成分?

注:本问题不考虑使用跳转类指令(包括函数内跳转和函数调用)。


于是, 计算机不断地重复上述四个步骤, 不断地执行指令, 直到永远.

也许你会疑惑, 这个只能做加法的TRM, 究竟还能做些什么呢? 对于采用补码表示的计算机, 能做加法自然就能做减法. 如果再添加一条条件跳转指令jne r, addr :当寄存器r不为0时, eip 跳转到addr处, TRM就大不一样了. 科学家证明了, 只要有inc, dec, jne这三条指令, 就可以实现"所有"的算法! (这里的"所有"是指可计算理论中的"所有可计算的算法") 也就是说, 现代计算机可以解决的纯粹的计算问题, 这个只有三条指令的TRM也可以解决. 例如通过 jnedec 的组合可以实现循环, 循环执行 inc 可以实现任意数的加法, 循环执行加法可以实现乘法……甚至科学家还证明了, 仅仅通过这三条指令, 就可以编写一个和 NEMU 功能等价的程序! 这下可不得了了, 没想到这个弱不禁风的 TRM 竟然深藏着擎天撼地的威力! 不过, 虽然这个只有三条指令的 TRM 可以解决所有可计算的问题, 但却低效得让人无法忍受. 为此, 先驱决定往TRM中加入更多高效的指令.

RTFM

我们在上一小节中已经在概念上介绍了一条指令具体如何执行, 其中有的概念甚至显而易见得难以展开. 不过x86这一庞然大物背负着太多历史的包袱, 但当我们决定往TRM中添加各种高效的x86指令时, 也同时意味着我们无法回避这些繁琐的细节.

首先你需要了解指令确切的行为, 为此, 你需要阅读 i386手册 中指令集相关的章节. 这里有一个简单的阅读教程.

RISC: 与CISC平行的另一个世界

你是否觉得x86指令集的格式特别复杂? 这其实是 CISC 的一个特性, 不惜使用复杂的指令格式, 牺牲硬件的开发成本, 也要使得一条指令可以多做事情, 从而提高代码的密度, 减小程序的大小. 随着时代的发展, 架构师发现 CISC 中复杂的控制逻辑不利于提高处理器的性能, 于是 RISC 应运而生. RISC 的宗旨就是简单, 指令少, 指令长度固定, 指令格式统一, 这和 KISS 法则有异曲同工之妙. 这里有一篇对比 RISC 和 CISC 的小短文.

另外值得推荐的是这篇文章, 里面讲述了一个从 RISC 世界诞生, 到与 CISC 世界融为一体的故事, 体会一下 RISC 的诞生对计算机体系结构发展的里程碑意义.

RTFSC(2)

NEMU 的作用之一就是实现上述整个 CPU 的执行过程,下面我们将更详细地介绍 NEMU 的框架代码,一层一层揭开它神奇的面纱。

数据结构

首先先对这个过程中的两个重要的数据结构进行说明.

  • nemu/src/cpu/exec/exec.c 中的 opcode_table 数组. 这就是我们之前提到的译码查找表了, 这一张表通过操作码 opcode 来索引, 每一个 opcode 对应相应指令的译码函数, 执行函数, 以及操作数宽度.
  • nemu/src/cpu/decode/decode.c 中的 decoding 结构. 它用于记录一些全局译码信息供后续使用, 包括操作数的类型, 宽度, 值等信息. 其中的 src 成员, src2 成员和 dest 成员分别代表两个源操作数和一个目的操作数. nemu/include/cpu/decode.h 中定义了三个宏 id_src, id_src2id_dest, 用于方便地访问它们,分别对应于源操作数 1、源操作数 2 和目标操作数.

是什么类型?

opcode_table 数组中存放了所有指令的信息,请问表中每个表项是什么类型?NEMU 又是如何通过这个表项得知操作数长度、应该使用哪个译码函数、哪个执行函数等信息的?

操作数结构体的实现

阅读 decode.cdecode.h,思考一下操作数结构体/共同体中都包括哪些成员,分别存储什么信息?他们是如何实现协同工作的?

执行流程

exec_wrapper()

在PA1中我们知道,在 cpu-exec() 函数中调用了 exec_wrapper() 函数来进行指令执行的操作,所以下面将对exec_wrapper() 的执行过程进行简单介绍.

首先将当前的 eip 保存到全局译码信息 decoding 的成员 seq_eip 中,然后将其地址被作为参数送进exec_real() 函数中。seq 代表顺序的意思, 当代码从 exec_real() 返回时, decoding.seq_eip 将会指向下一条指令的地址.exec_real() 函数通过宏 make_EHelper 来定义:

#define make_EHelper(name) void concat(exec_, name) (vaddr_t *eip)

其含义是"定义一个执行阶段相关的helper函数", 这些函数都带有一个参数eip.(对于表达式中concat的含义,在下面会有介绍。NEMU通过不同的helper函数来模拟不同的步骤.

复现宏定义

我们在学习 C 语言时就已经接触过宏定义了,现在我们需要复习一下,便于你更好地理解项目中所使用的宏定义。下面请你算出下列宏定义最终展开以后的样子,即类似于 type func_name(type arg1, type arg2, ...) 的我们最常见到的 C 语言函数定义的形式,在展开时,希望你能写出计算步骤,不要直接写出结果。如果你现在无法算出下列的宏定义,你可以先阅读完整个本节内容,再回来思考这个题目。

  • make_EHelper(mov) //mov 指令的执行函数
  • make_EHelper(push) //push 指令的执行函数
  • make_DHelper(I2r) //I2r 类型操作数的译码函数
  • IDEX(I2a, cmp) //cmp 指令的 opcode_table 表项
  • EX(nop) //nop 指令的 opcode_table 表项
  • make_rtl_arith_logic(and) //and 运算的 RTL 指令

exec_real()

exec_real() 中:

  • 首先通过 instr_fetch() 函数(在nemu/include/cpu/exec.h中定义)进行取指, 得到指令的第一个字节, 将其解释成 opcode 并记录在全局译码信息 decoding 中;
  • 根据 opcode 查阅译码查找表, 得到操作数的宽度信息, 并通过调用 set_width() 函数将其记录在全局译码信息 decoding 中;
  • 调用 idex() 对指令进行进一步的译码和执行。

idex()

idex() 函数会调用译码查找表中的相应的译码函数进行操作数的译码. 译码函数统一通过宏 make_DHelper 来定义(在 nemu/src/cpu/decode/decode.c 中):

#define make_DHelper(name) void concat(decode_, name) (vaddr_t *eip)

它们的名字主要采用 i386 手册附录 A中的操作数表示记号, 例如 I2r 表示将立即数移入寄存器, 其中 I 表示立即数, 2 表示英文 to, r 表示通用寄存器, 更多的记号请参考 i386 手册. 译码函数会把指令中的操作数信息分别记录在全局译码信息 decoding 中。

这些译码函数会进一步分解成各种不同操作数的译码的组合, 以实现操作数译码的解耦. 操作数译码函数统一通过宏 make_DopHelper 来定义 (在 nemu/src/cpu/decode/decode.c 中, decode_op_rm() 除外):

#define make_DopHelper(name) void concat(decode_op_, name) (vaddr_t *eip, Operand *op, bool load_val)

它们的名字主要采用 i386 手册附录 A 中的操作数表示记号. 操作数译码函数会把操作数的信息记录在结构体 op 中, 如果操作数在指令中, 就会通过 instr_fetch() 将它们从 eip 所指向的内存位置取出. 为了使操作数译码函数更易于复用, 函数中的 load_val 参数会控制是否需要将该操作数读出到全局译码信息 decoding 供后续使用. 例如如果一个内存操作数是源操作数, 就需要将这个操作数从内存中读出来供后续执行阶段来使用; 如果它仅仅是一个目的操作数, 就不需要从内存读出它的值了, 因为执行这条指令并不需要这个值, 而是将新数据写入相应的内存位置.

idex() 函数中的译码过程结束之后, 会调用译码查找表中的相应的执行函数来进行真正的执行操作. 执行函数统一通过宏 make_EHelper 来定义, 它们的名字是指令操作本身. 执行函数通过 RTL 来描述指令真正的执行功能(RTL 将在下文介绍). 其中 operand_write() 函数(在 nemu/src/cpu/decode/decode.c 中定义) 会根据第一个参数中记录的类型的不同进行相应的写操作, 包括写寄存器和写内存.

idex() 返回后, exec_real() 最后会通过 update_eip()eip 进行更新.

上文已经把一条指令在 NEMU 中执行的流程进行了大概的介绍. 如果觉得上文的内容不易理解, 可以结合这个例子来 RTFSC. 但这个例子中会描述较多细节, 阅读的时候需要一定的耐心.

立即数背后的故事

decode_op_I() 函数中通过 instr_fetch() 函数获得指令中的立即数. 别看这里就这么一行代码, 其实背后隐藏着针对字节序的慎重考虑. 我们知道x86是小端机, 当你使用高级语言或者汇编语言写了一个 32 位常数 0x1234 的时候, 在生成的二进制代码中, 这个常数对应的字节序列如下(假设这个常数在内存中的起始地址是 x):

x   x+1  x+2  x+3
+----+----+----+----+
| 34 | 12 | 00 | 00 |
+----+----+----+----+

而大多数 PC 机都是小端架构(我们相信没有同学会使用 IBM 大型机来做 PA), 当 NEMU 运行的时候,

op_src->imm = instr_fetch(eip, 4);

这行代码会将 34 12 00 00 这个字节序列原封不动地从内存读入 imm 变量中, 主机的 CPU 会按照小端方式来解释这一字节序列, 于是会得到 0x1234, 符合我们的预期结果.

Motorola 68k 系列的处理器都是大端架构的. 现在问题来了, 考虑以下两种情况:

  • 假设我们需要将NEMU运行在Motorola 68k的机器上(把NEMU的源代码编译成Motorola 68k的机器码)
  • 假设我们需要编写一个新的模拟器NEMU-Motorola-68k, 模拟器本身运行在x86架构中, 但它模拟的是Motorola 68k程序的执行

在这两种情况下, 你需要注意些什么问题? 为什么会产生这些问题? 怎么解决它们?

事实上不仅仅是立即数的访问, 长度大于1字节的内存访问都需要考虑类似的问题. 我们在这里把问题统一抛出来, 以后就不再单独讨论了.

结构化程序设计

细心的你会发现以下规律:

  • 对于同一条指令的不同形式, 它们的执行阶段是相同的. 例如 add_I2Eadd_E2G 等, 它们的执行阶段都是把两个操作数相加, 把结果存入目的操作数.
  • 对于不同指令的同一种形式, 它们的译码阶段是相同的. 例如 add_I2Esub_I2E 等, 它们的译码阶段都是识别出一个立即数和一个 E 操作数.
  • 对于同一条指令同一种形式的不同操作数宽度, 它们的译码阶段和执行阶段都是非常类似的. 例如 add_I2E_b, add_I2E_wadd_I2E_l, 它们都是识别出一个立即数和一个 E 操作数, 然后把相加的结果存入 E 操作数.

这意味着, 如果独立实现每条指令不同形式不同操作数宽度的 helper 函数, 将会引入大量重复的代码. 需要修改的时候, 相关的所有 helper 函数都要分别修改, 遗漏了某一处就会造成 bug, 工程维护的难度急速上升.

一种好的做法是把译码, 执行和操作数宽度的相关代码分离开来, 实现解耦, 也就是在程序设计课上提到的结构化程序设计. 在框架代码中, 实现译码和执行之间的解耦的是 idex() 函数, 它依次调用 opcode_table 表项中的译码和执行的 helper 函数, 这样我们就可以分别编写译码和执行的 helper 函数了. 实现操作数宽度和译码, 执行这两者之间的解耦的是 id_src, id_src2id_dest 中的 width 成员, 它们记录了操作数宽度, 译码和执行的过程中会根据它们进行不同的操作, 通过同一份译码函数和执行函数实现不同操作数宽度的功能.

为了易于使用, 框架代码中使用了一些宏, 我们在这里把相关的宏整理出来, 供大家参考.

含义
nemu/include/macro.h
str(x) 字符串"x"
concat(x, y) token xy
nemu/include/cpu/reg.h
reg_l(index) 编码为 index 的 32 位 GPR
reg_w(index) 编码为 index 的 16 位 GPR
reg_b(index) 编码为 index 的 8 位 GPR
nemu/include/cpu/decode.h
id_src 全局变量 decoding源操作数成员的地址
id_src2 全局变量 decoding2号源操作数成员的地址
id_dest 全局变量 decoding目的操作数成员的地址
make_Dhelper(name) 名为 decode_name 的译码函数的原型说明
Dhelper 指向译码函数的函数指针
nemu/src/cpu/decode.c
make_Dophelper(name) 名为 decode_op_name 的操作数译码函数的原型说明
nemu/include/cpu/exec.h
make_Ehelper(name) 名为 exec_name 的执行函数的原型说明
Ehelper 指向执行函数的函数指针
print_asm(...) 将反汇编结果的字符串打印到缓冲区 decoding.assembly
suffix_char(width) 操作数宽度 width 对应的后缀字符
print_asm_template1(instr) 打印单目操作数指令 instr 的反汇编结果
print_asm_template2(instr) 打印双目操作数指令 instr 的反汇编结果
print_asm_template3(instr) 打印三目操作数指令 instr 的反汇编结果

其中,你需要在每个指令的执行函数中调用适当的反汇编结果打印函数,将该指令的反汇编信息打印到屏幕上。

强大的宏

如果你知道 C++ 的"模板"功能, 你可能会建议使用它, 但事实上在这里做不到. 我们知道宏是在编译预处理阶段进行处理的, 这意味着宏的功能不受编译阶段的约束(包括词法分析, 语法分析, 语义分析); 而 C++ 的模板是在编译阶段进行处理的, 这说明它会受到编译阶段的限制. 理论上来说, 必定有一些事情是宏能做到, 但 C++ 模板做不到. 一个例子就是框架代码中的拼接宏 concat(), 它可以把两个 token 连接成一个新的 token; 而在 C++ 模板进行处理的时候, 词法分析阶段已经结束了, 因而不可能通过 C++ 模板生成新的 token.

计算机世界处处都是 tradeoff, 有好处自然需要付出代价. 由于处理宏的时候不会进行语法检查, 因为宏而造成的错误很有可能不会马上暴露. 例如以下代码:

#define N 10;
int a[N];

在编译的时候, 编译器会提示代码的第2行有语法错误. 但如果你光看第2行代码, 你很难发现错误, 甚至会怀疑编译器有 bug. 那宏到底要不要用呢? 一种客观的观点是, 在你可以控制的范围中使用. 这就像 goto 语句一样, 当你希望在多重循环中从最内层循环直接跳出所有循环, goto 是最方便的做法. 但如果代码中到处都是 goto, 已经严重影响到代码段的可读性了, 这种情况当然是不可取的.

用 RTL 表示指令行为

NEMU 使用 RTL (寄存器传输语言)来描述 x86 指令的行为. 这样做的好处是可以提高代码的复用率, 使得指令模拟的实现更加规整. 同时 RTL 也可以作为一种 IR (中间表示)语言, 将来可以很方便地引入即时编译技术对 NEMU 进行优化, 即使你在 PA 中不一定有机会感受到这一好处.

下面我们对 NEMU 中使用的 RTL 进行一些说明, 首先是RTL寄存器的定义. RTL寄存器是RTL指令专门使用的寄存器. 在NEMU中, RTL 寄存器统一使用 rtlreg_t 来定义, 而 rtlreg_t(在nemu/include/common.h中定义)其实只是一个 uint32_t 类型:

typedef uint32_t rtlreg_t;

在 NEMU 中, RTL 寄存器只有以下这些

  • x86 的八个通用寄存器(在 nemu/include/cpu/reg.h 中定义);
  • id_src, id_src2id_dest 中的访存地址 addr 和操作数内容 val(在nemu/include/cpu/decode.h中定义). 从概念上看, 它们分别与 MAR 和 MDR 有异曲同工之妙;
  • 临时寄存器 t0~t3 (在nemu/src/cpu/decode/decode.c中定义);
  • 零寄存器 tzero (在nemu/src/cpu/decode/decode.c中定义), 它只能读出 0, 不能写入。

有了 RTL 寄存器, 我们就可以定义 RTL 指令对它们进行的操作了. 在 NEMU 中, RTL 指令有两种(在nemu/include/cpu/rtl.h 中定义). 一种是 RTL 基本指令, 它们的特点是在即时编译技术里面可以只使用一条机器指令来实现相应的功能, 同时也不需要使用临时寄存器, 可以看做是最基本的 x86 指令中的最基本的操作. RTL 基本指令包括:

  • 立即数读入 rtl_li
  • 算术运算和逻辑运算, 包括寄存器-寄存器类型 rtl_(add|sub|and|or|xor|shl|shr|sar|slt|sltu) 和立即数-寄存器类型 rtl_(add|sub|and|or|xor|shl|shr|sar|slt|sltu)i
  • 内存的访存rtl_lmrtl_sm
  • 通用寄存器的访问 rtl_lr_(b|w|l)rtl_sr_(b|w|l)

第二种 RTL 指令是 RTL 伪指令, 它们是通过 RTL 基本指令或者已经实现的 RTL 伪指令来实现的, 包括:

  • 带宽度的通用寄存器访问 rtl_lrrtl_sr
  • EFLAGS标志位的读写 rtl_set_(CF|OF|ZF|SF|IF)rtl_get_(CF|OF|ZF|SF|IF)
  • 其它常用功能, 如数据移动 rtl_mv, 符号扩展 rtl_sext 等。

其中大部分 RTL 伪指令还没有实现, 必要的时候你需要实现它们. 有了这些 RTL 指令之后, 我们就可以方便地通过若干条 RTL 指令来实现每一条 x86 指令的行为了. 现在请你再次阅读一遍本段落,确保你已经仔细阅读了 RTL 的相关知识。

如何实现新指令

对译码, 执行和操作数宽度的解耦实现以及 RTL 的引入对 NEMU 中实现一条新的 x86 指令提供了很大的便利, 为了实现一条新指令, 你只需要

  1. 根据 i386 手册的介绍,在 opcode_table 中填写正确的译码函数, 执行函数以及操作数宽度;
  2. 用 RTL 实现正确的执行函数, 需要注意使用 RTL 伪指令时不要把临时变量中有意义的值覆盖了;
  3. nemu/src/cpu/exec/all-instr.h 中声明已经实现好的执行函数

框架代码把绝大部分译码函数和执行函数都定义好了, 你可以很方便地使用它们.

如果你读过上文的扩展阅读材料中关于 RISC 与 CISC 融为一体的故事, 你也许会记得 CISC 风格的 x86 指令最终被分解成 RISC 风格的微指令在计算机中运行, 才让 x86 在这场扩日持久的性能大战中得以存活下来的故事. 如今 NEMU 在经历了第二次重构之后, 也终于引入了 RISC 风格的 RTL 来实现 x86 指令, 这也许是冥冥之中的安排吧.

运行第一个C程序

说了这么多, 现在到了动手实践的时候了. 你在 PA2 的第一个任务, 就是实现若干条指令, 使得第一个简单的 C 程序可以在 NEMU 中运行起来. 这个简单的 C 程序的代码是 nexus-am/tests/cputest/tests/dummy.c, 它什么都不做就直接返回了. 在 nexus-am/tests/cputest 目录下键入

make ARCH=x86-nemu ALL=dummy run

编译 dummy 程序, 并启动 NEMU 运行它. 事实上, 并不是每一个程序都可以在 NEMU 中运行, nexus-am/ 子项目专门用于编译出能在 NEMU 中运行的程序, 我们在下一小节中会再来介绍它.

在NEMU中运行 dummy 程序, 你会发现 NEMU 输出以下信息:

invalid opcode(eip = 0x0010000a): e8 01 00 00 00 90 55 89 ...

There are two cases which will trigger this unexpected exception:
1. The instruction at eip = 0x0010000a is not implemented.
2. Something is implemented incorrectly.
Find this eip value(0x0010000a) in the disassembling result to distinguish which case it is.

If it is the first case, see
 _ ____   ___    __    __  __                         _ 
(_)___ \ / _ \  / /   |  \/  |                       | |
 _  __) | (_) |/ /_   | \  / | __ _ _ __  _   _  __ _| |
| ||__ < > _ <| '_ \  | |\/| |/ _  | '_ \| | | |/ _  | |
| |___) | (_) | (_) | | |  | | (_| | | | | |_| | (_| | |
|_|____/ \___/ \___/  |_|  |_|\__,_|_| |_|\__,_|\__,_|_|

for more details.

If it is the second case, remember:
* The machine is always right!
* Every line of untested code is always wrong!

这是因为你还没有实现以 0xe8 为首字节的指令, 因此, 你需要开始在 NEMU 中添加指令了.

要实现哪些指令才能让 dummy 在 NEMU 中运行起来呢? 答案就在其反汇编结果( nexus-am/tests/cputest/build/dummy-x86-nemu.txt )中. 查看反汇编结果, 你发现只需要添加 call, push, sub, xor, pop, ret六条指令就可以了. 每一条指令还有不同的形式, 根据 KISS 法则, 你可以先实现只在 dummy 中出现的指令形式, 通过指令的 opcode 可以确定具体的形式.

请查阅 i386 手册

这里要再次强调, 你务必通过 i386 手册来查阅指令的功能, 不能想当然.手册中给出了指令功能的完整描述(包括做什么事, 怎么做的, 有什么影响),一定要仔细阅读其中的每一个单词, 对指令功能理解错误和遗漏都会给以后的调试带来巨大的麻烦. 同时,一定要结合勘误来看,否则你的很多努力都会前功尽弃!

编写RTL函数

因为实现指令的执行函数要用到许多 RTL 函数,请你在 nemu/include/cpu/rtl.h 中,根据提示实现每一条 RTL函数。注意:RTL 是整个框架的最底层部分,如果有一处出现差错,之后会有无数难以调试的 bug 在等待着你!请在实现的过程务必保持头脑清醒,按照提示来实现!

任务1:实现所有 RTL 指令

上文我们介绍过所有可能会用到的 RTL 指令(包括但不限于 rtl_pop, rtl_push 等),请你在本节实现所有 RTL 指令(即 rtl.h所有TODO() 占位的 RTL 指令)。

注意:

  1. 部分 RTL 指令已经实现,你需要自行阅读并理解它们是如何实现的;
  2. 有些指令实现之前需要 eflags 寄存器,所以你需要先在 cpu 结构体里实现一下 eflags 寄存器,具体实现方法下面的 sub 指令中有相关介绍,请务必严格按照讲义中给的结构来实现

实现指令

  • call: call指令有很多形式, 不过在PA中只会用到其中的几种, 现在只需要实现 CALL rel32 的形式就可以了. %eip的跳转可以通过将 decoding.is_jmp 设为 1, 并将 decoding.jmp_eip 设为跳转目标地址来实现, 这时在 update_eip() 函数中会把跳转目标地址作为新的 eip, 而不是顺序意义下的下一条指令的地址

  • push, pop: 现在只需要实现 PUSH r32POP r32 的形式就可以了, 它们可以很容易地通过 rtl_pushrtl_pop 来实现

  • sub: 在实现 sub 指令之前, 你首先需实现 EFLAGS 寄存器.你只需要在寄存器结构体中添加EFLAGS寄存器即可. EFLAGS 是一个 32 位寄存器, 它的结构如下:

     31                  23                  15               7             0
     +-------------------+-------------------+-------+-+-+-+-+-+-+-------+-+-+
     |                                               |O| |I| |S|Z|       | |C|
     |                       X                       | |X| |X| | |   X   |1| |
     |                                               |F| |F| |F|F|       | |F|
     +-------------------+-------------------+-------+-+-+-+-+-+-+-------+-+-+
    

    在 NEMU 中, 我们只会用到 EFLAGS 中以下的 5 个位: CF, ZF, SF, IF, OF,标记成 X 的位不必关心, 它们的功能可暂不实现.关于 EFLAGS 中每一位的含义, 请查阅 i386 手册.添加 EFLAGS 寄存器需要用到结构体的位域(bit field)功能, 如果你从未听说过位域, 请查阅相关资料. 关于 EFLGAS 的初值, 我们遵循 i386 手册中提到的约定,你需要在 i386 手册的第 10 章中找到这一初值, 然后在 restart() 函数中对 EFLAGS 寄存器进行初始化.实现了 EFLAGS 寄存器之后, 再实现相关的 RTL 指令, 之后你就可以通过这些 RTL 指令来实现 sub 指令了.

    具体实现可以参照 sbb 指令,实现后请解释其实现思路.

    注意: 之前不少同学在实现 eflags 的时候,利用 union 把它和 eax 寄存器共享了同一块空间,导致之后会有很难找的bug,所以请你实现的时候务必确保它和之前定义的九个32位寄存器的空间是相互独立的.

  • xor, ret: RTFM 吧,偷偷告诉你:i386手册附录B和C的内容可能对你有帮助哦.

任务2:实现 6 条基本指令

在 NEMU 中通过 RTL 指令实现上文提到的 call, push, pop, sub, xor, ret 等指令, 具体细节请务必参考 i386 手册. 实现成功后, 在 NEMU 中运行客户程序 dummy, 你将会看到 HIT GOOD TRAP 的信息. 其中,在实现其中部分指令时,会用到一个 eflags 寄存器,请参见任务 3。注意:你需要通过 si 命令的截图证明你实现了某一条指令。

实现指令的基本步骤小结:

  • 确定指令名称、操作数种类和数量并填写指令表,选用正确的译码函数;
  • 编写正确指令执行函数,又具体包括:
    • 严格按 i386 手册要求实现指令具体功能;
    • 若本指令需要更新目的操作数,则写入到 id_dest 中;
    • 若本指令影响到某些标志寄存器的位,你需要对这些位进行正确的置位/复位操作;
    • 调用合适的反汇编代码打印函数/宏打印反汇编序列。

务必尽可能使用 RTL 指令

可能你会认为:只是一个操作而已,为什么非要在 RTL 中实现而不能直接写在指令的执行函数里面?从程序设计的角度出发,使用 RTL 来实现指令更满足结构化设计思想,可以使代码充分解耦。如果你不善于使用 RTL 表示指令行为,可能会在下一节中面临很多巨大的挑战。

因此,我们建议凡是出现在用 RTL 表示指令行为小节的 RTL 指令,都应该被实现,当然,是在实现的 x86 指令需要某个的时候再实现,而不需要在此时一股脑实现它们。在编写 x86 指令时,严格遵守 i386 手册对于每个指令集的行为描述和指令格式编写,有 RTL 指令的行为调用 RTL 函数实现,没有的行为可以自行使用

任务3:实现 eflags 寄存器

你需要根据上文的描述,实现本节会用到的部分 eflags 的标志位。还记得在哪里实现吗?如果不记得了,你可以复习一下 PA1.1 的内容。hint:通过结构体位域实现。

任务4:对已实现指令增加标志寄存器行为

你需要根据 i386 手册的表述,对已经实现的几个指令增加对标志寄存器某些位的设置操作。

神奇的 eflags

x86 体系下,eflags 在程序的运行过程中发挥了巨大的作用,除了每个标志位本身的含义,标志位之前的各种组合可以为程序跳转条件的判断提供很大的依据。在这里希望你能结合实现 sub 指令的过程回答如下问题。

  • OF 的作用是判断是否有溢出, 这里的“溢出”是什么意思呢? 如果只用仅为标志位 CF 可不可以代替 OF 的功能呢?为什么?
  • 在运算的过程中如何获得 OF 的值?

任务5:运行第一个客户程序

在 NEMU 中通过 RTL 指令实现上文提到的指令, 具体细节请务必参考 i386 手册.实现成功后, 在 NEMU 中运行客户程序 dummy, 你将会看到 HIT GOOD TRAP 的信息(HIT GOOD TRAP 将作为成功运行一个测试样例的重要标志).

基础设施

用调试发现问题

理解指令的执行过程之后, 添加各种指令更多的是工程实现. 工程实现难免会碰到 bug, 实现不正确的时候如何快速进行调试, 其实也属于基础设施的范畴. 思考一下, 译码查找表中有那么多指令, 每一条指令又通过若干 RTL 指令实现, 如果其中实现有误, 我们该如何发现呢?

直觉上这貌似不是一件容易的事情, 不过让我们来讨论一下其中的缘由. 假设我们不小心把译码查找表中的某一条指令的译码函数填错了, NEMU 执行到这一条指令的时候, 就会使用错误的译码函数进行译码, 从而导致执行函数拿到了错误的源操作数, 或者是将正确的结果写入了错误的目的操作数. 这样, NEMU 执行这条指令的结果就违反了它原来的语义, 接下来就会导致跟这条指令有依赖关系的其它指令也无法正确地执行. 最终, 我们就会看到客户程序访问内存越界, 陷入死循环, 或者 HIT BAD TRAP, 甚至是 NEMU 触发了段错误.

调试的工具与原理

我们可以从上面的这个例子中抽象出一些软件工程相关的概念:

  • Fault: 实现错误的代码, 例如填写了错误的译码函数
  • Error: 程序执行时不符合预期的状态, 例如客户程序的指令没有被正确地执行
  • Failure: 能直接观测到的错误, 例如HIT BAD TRAP, 段错误等

调试其实就是从观测到的failure一步一步回溯寻找fault的过程, 找到了fault之后, 我们就很快知道应该如何修改错误的代码了. 但从上面的例子也可以看出, 调试之所以不容易, 恰恰是因为:

  • fault 不一定马上触发 error
  • 触发了 error 也不一定马上转变成可观测的 failure
  • error 会像滚雪球一般越积越多, 当我们观测到 failure 的时候, 其实已经距离 fault 非常遥远了

理解了这些原因之后, 我们就可以制定相应的策略了:

  • 尽可能把 fault 转变成 error. 这其实就是测试做的事情, 所以 nexus-am/tests/ 目录下提供了各种各样的测试用例(它们将是你在下一节遇到的敌人). 但并不是有了测试用例就能把所有 fault 都转变成 error 了, 因为这取决于测试的覆盖度. 要设计出一套全覆盖的测试并不是一件简单的事情, 越是复杂的系统, 全覆盖的测试就越难设计. 至少, 框架代码中提供的测试用例的覆盖度还是很有限的. 但是, 如何提高测试的覆盖度, 是学术界一直以来都在关注的问题.
  • 尽早观测到 error 的存在. 观测到 error 的时机直接决定了调试的难度: 如果等到触发 failure 的时候才发现 error 的存在, 调试就会比较困难; 但如果能在 error 刚刚触发的时候就观测到它, 调试难度也就大大降低了. 事实上, 你已经见识过一些有用的工具了:
    • -Wall, -Werror: 在编译时刻把潜在的 fault 直接转变成 failure. 这种工具的作用很有限, 只能寻找一些在编译时刻也觉得可疑的 fault, 例如 if (p = NULL), 但也是代价最低的.
    • assert(): 在运行时刻把 error 直接转变成 failure. assert() 是一个很简单却又非常强大的工具, 只要在代码中定义好程序应该满足的特征, 就一定能在运行时刻将不满足这些特征的 error 拦截下来. 例如链表的实现, 我们只需要在代码中插入一些很简单的 assert() (例如指针不为空), 就能够几乎告别段错误. 事实上, 客户程序之所以会 HIT BAD TRAP, 其实也是因为违背了我们设置的 nemu_assert(). 但是, 编写这些 assert() 其实需要我们对程序的行为有一定的了解, 同时在程序特征不易表达的时候, assert() 的作用也较为有限.
    • printf(): 通过输出的方式观察潜在的 error. 这是用于回溯fault时最常用的工具, 用于观测程序中的变量是否进入了错误的状态. 在NEMU中我们提供了输出更多调试信息的宏 Log(), 它实际上封装了printf() 的功能. 但由于 printf() 需要根据输出的结果人工判断是否正确, 在便利程度上相对于 assert() 的自动判断就逊色了不少.
    • GDB: 随时随地观测程序的任何状态. 调试器是最强大的工具, 但你需要在程序行为的茫茫大海中观测那些可疑的状态, 因此使用起来的代价也是最大的.

根据上面的分析, 我们就可以总结出一些调试的建议:

  • 总是使用 -Wall-Werror
  • 尽可能多地在代码中插入 assert()
  • assert() 无法捕捉到 error 时, 通过 printf() 输出可疑的变量, 期望能观测到 error
  • printf() 不易观测 error 时, 通过 GDB 理解程序的细致行为

Differential Testing

如果你在程序设计课上听说过上述这些建议, 相信你几乎不会遇到过运行时错误. 然而回过头来看上文提到的指令实现的bug, 我们会发现, 这些工具还是不够用: 我们很难通过 assert() 来表达指令的正确行为来进行自动检查, 而printf() 和 GDB 实际上并没有缩短 error 和 failure 的距离.

如果有一种方法能够表达指令的正确行为, 我们就可以基于这种方法来进行类似 assert() 的检查了. 那么, 究竟什么地方表达了指令的正确行为呢? 最直接的, 当然就是 i386 手册了, 但是我们恰恰就是根据 i386 手册中的指令行为来在 NEMU 中实现指令的, 同一套方法不能既用于实现也用于检查. 如果有一个 i386 手册的参考实现就好了. 嘿! 我们用的真机不就是根据 i386 手册实现出来的吗? 我们让在 NEMU 中执行的每条指令也在真机中执行一次, 然后对比 NEMU 和真机的状态, 如果 NEMU 和真机的状态不一致, 我们就捕捉到 error 了!

这实际上是一种非常奏效的测试方法, 在软件测试领域称为 differential testing. 我们刚才提到了"状态", 那"状态"具体指的是什么呢? 我们在 PA1 中已经认识到, 计算机就是一个数字电路. 那么, "计算机的状态"就恰恰是那些时序逻辑部件的状态, 也就是寄存器和内存的值. 其实仔细思考一下, 计算机执行指令, 就是修改这些时序逻辑部件的状态的过程. 要检查指令的实现是否正确, 只要检查这些时序逻辑部件中的值是否一致就可以了! Differential testing 可以非常及时地捕捉到 error, 第一次发现 NEMU 的寄存器或内存的值与真机不一样的时候, 就是因为当时执行的指令实现有误导致的. 这时候其实离 error 非常接近, 防止了 error 进一步传播的同时, 要回溯找到 fault 也容易得多.

多么美妙的功能啊! 背后还蕴含着计算机本质的深刻原理! 但很遗憾, 不要忘记了, 真机上是运行了操作系统 GNU/Linux 的, 而 NEMU 中的测试程序是运行在 AM 上的. 就如前文所说, 它们提供的运行时环境是不一样的, 我们无法在 GNU/Linux 中运行基于 x86-nemu 的 AM 程序. 所以, 我们需要的不仅是一个 i386 手册的正确实现, 而且需要在上面能正确运行基于 x86-nemu 的 AM 程序.我们将在下一节详细介绍 AM: 抽象计算机。

事实上, QEMU 就是一个不错的参考实现. 它是一个虚拟出来的完整的 x86 计算机系统, 而 NEMU 的目标只是虚拟出 x86 的一个子集, 能在 NEMU 上运行的程序, 自然也能在 QEMU 上运行. 因此, 为了通过 differential testing 的方法测试 NEMU 实现的正确性, 我们让 NEMU 和 QEMU 逐条指令地执行同一个客户程序. 双方每执行完一条指令, 就检查各自的寄存器和内存的状态, 如果发现状态不一致, 就马上报告错误, 停止客户程序的执行.

NEMU 的框架代码已经准备好相应的功能了, 在 nemu/include/common.h 中定义宏 DIFF_TEST 之后, 重新编译NEMU 后运行, 你会发现 NEMU 多输出了Connect to QEMU successfully 的信息. 定义了宏 DIFF_TEST 之后, monitor 会多进行以下初始化工作, 你不需要了解这些工作的具体细节, 只需要知道这是在为了让 QEMU 进入一个和 NEMU 同等的状态就可以了.

  • 调用 init_difftest() 函数(在 nemu/src/monitor/diff-test/diff-test.c 中定义)来启动 QEMU. 需要注意的是, 框架代码让 QEMU 运行在后台, 因此你将看不到 QEMU 的任何输出.
  • load_img() 的最后将客户程序拷贝一份副本到 QEMU 模拟的内存中.
  • restart() 中调用 init_qemu_reg() 函数(在 nemu/src/monitor/diff-test/diff-test.c 中定义), 来把 QEMU 的通用寄存器设置成和 NEMU 一样.

进行了上述初始化工作之后, QEMU 和 NEMU 就处于相同的状态了. 接下来就要进行逐条指令执行后的状态对比了, 实现这一功能的是 difftest_step() 函数(在 nemu/src/monitor/diff-test/diff-test.c 中定义). 它会在 exec_wrapper() 的最后被调用, 在NEMU中执行完一条指令后, 就在 difftest_step() 中让 QEMU 执行相同的指令, 然后读出 QEMU 中的寄存器. 你需要添加相应的代码, 把 NEMU 的 8 个通用寄存器和 eip 与从 QEMU 中读出的寄存器的值进行比较, 如果发现值不一样, 就输出相应的提示信息, 并将 diff 标志设置为 true. 在difftest_step() 的最后, 如果检测到 diff 标志为 true, 就停止客户程序的运行.

任务6:实现 differential testing

根据上面描述的逻辑,请你在 difftest_step() 中添加相应的代码, 实现 differential testing 的核心功能. 实现正确后, 你将会得到一款无比强大的测试工具.

体会到 differential testing 的强大之后, 不妨思考一下: 作为一种基础设施, differential testing 能帮助你节省多少调试的时间呢?

任务7:利用 differential testing 检查已实现指令

实现 differential testing 的核心功能后,请你利用这个功能,对目前已经实现的几条指令进行测试,检测他们是否正确实现,若发现有问题,请修改他们的实现,确保你的程序能够在开启 diff-test 的条件下顺利运行得到 HIT GOOD TRAP 的结果。

咦? 我们不需要对内存的状态进行比较吗?事实上, NEMU 是通过一套 GDB 协议与 QEMU 通信来获取 QEMU 的状态的,但是通过这一协议还是不好获取指令修改的内存位置, 而对比整个内存又会带来很大的开销,所以我们就不对内存的状态进行比较了.事实上, NEMU 中的简化实现也会导致某些寄存器的状态与 QEMU 的结果不一致, 例如EFLAGS, NEMU只实现了 EFLAGS 中的少量标志位, 同时也简化了某些指令对 EFLAGS 的更新.另外, 一些特殊的系统寄存器也没有完整实现.因此, 我们实现的 differential testing 并不是完整地对比 QEMU 和 NEMU 的状态,但是不管是内存还是标志位, 只要客户程序的一条指令修改了它们,在不久的将来肯定也会再次用到它们, 到时候一样能检测出状态的不同.同时框架中也准备了is_skip_nemuis_skip_qemu这两个变量,用于跳过少量不易进行对比的指令.因此, 我们其实牺牲了一些比较的精度, 来换取性能的提升,但即使这样, 由于 differential testing 需要与QEMU 进行通信, 这还是会把 NEMU 的运行速度拉低上百倍.因此除非是在进行调试, 否则不建议打开 differential testing 的功能来运行 NEMU.

性能瓶颈

由于 NEMU 和 QEMU 间通讯将产生大量开销,拖慢 NEMU 运行性能。因此,当你在运行后面一些比较大型的程序(如打字小游戏、仙剑奇侠传甚至是 Nanos-lite )时,可能需要关闭该功能,以免因性能极低造成表面假死。


以上是 PA2.1 的全部内容。

results matching ""

    No results matching ""