实现更多的指令

为了让NEMU支持大部分程序的运行, 目前你需要实现以下指令(一些例外会在备注中说明):

  • Data Movement Instructions: mov xchg push pop leave movsx movzx
  • Binary Arithmetic Instructions: add adc sub sbb cmp inc dec neg mul imul div idiv
  • Logical Instructions: not and or xor sal(shl) shr sar setcc test
  • Control Transfer Instructions: jmp call ret jcc
  • String and Character Translation Instructions: movs stos rep
  • Miscellaneous Instructions: lea nop

你只需要实现上述红色字体的指令, 它们不多不少都和以下的某些内容相关: EFLAGS, 堆栈, 整数扩展, 加减溢出判断. 框架代码已经把黑色字体的指令实现好了, 但并没有填写 opcode_table . 此外, 某些需要更新EFLAGS的指令, 以及有符号立即数的译码函数(在 nemu/src/cpu/decode/decode-template.h 中定义)并没有完全实现好(框架代码中已经插入了 panic() 作为提示), 你还需要编写相应的功能.

测试用例

未测试代码永远是错的, 你需要足够多的测试用例来测试你的NEMU. 我们在 testcase 目录下准备了一些测试用例, 需要更换测试用例时, 修改工程目录下 Makefile 中的 USERPROG 变量, 改成测试用例的可执行文件( obj/testcase/xxx , 不是C源文件)即可.

testcase 目录下的大部分测试用例都可以直接在NEMU上运行, 除了以下几个测试用例:

  • hello-inline-asm
  • hello
  • hello-str
  • integral
  • quadratic-eq

其中运行hello-inline-asm和hello需要系统调用的支持, 在PA2中我们无法提供系统调用的功能, 这两个测试用例将会在PA4中用到, 目前你可以忽略它们; 要运行hello-str需要使一些和ELF文件组织相关的小技巧, 我们在PA2的最后再来讨论如何运行它; 而integral和quadratic-eq涉及到浮点数的使用, 我们先来讨论如何处理浮点数.

实现binary scaling

要在NEMU中实现浮点指令也不是不可能的事情. 但实现浮点指令需要涉及x87架构的很多细节, 而且我们并不打算在用户程序中直接使用浮点指令. 为了在保持程序逻辑的同时不引入浮点指令, 我们通过整数来模拟实数的运算, 这样的方法叫binary scaling.

我们先来说明如何用一个32位整数来表示一个实数. 为了方便叙述, 我们称用binary scaling方法表示的实数的类型为 FLOAT . 我们约定最高位为符号位, 接下来的15位表示整数部分, 低16位表示小数部分, 即约定小数点在第15和第16位之间(从第0位开始). 从这个约定可以看到, FLOAT 类型其实是实数的一种定点表示.

31  30                  16                    0
+----+-------------------+--------------------+
|sign|      integer      |      fraction      |
+----+-------------------+--------------------+

这样, 对于一个实数 a , 它的 FLOAT 类型表示 A = a * 2^16 (截断结果的小数部分). 例如实数 1.25.6FLOAT 类型来近似表示, 就是

1.2 * 2^16 = 78643 = 0x13333
+----+-------------------+--------------------+
| 0  |         1         |        3333        |
+----+-------------------+--------------------+


5.6 * 2^16 = 367001 = 0x59999
+----+-------------------+--------------------+
| 0  |         5         |        9999        |
+----+-------------------+--------------------+

而实际上, 这两个 FLOAT 类型数据表示的数是:

0x13333 / 2^16 = 1.19999695
0x59999 / 2^16 = 5.59999084

对于负实数, 我们用相应正数的相反数来表示, 例如 -1.2FLOAT 类型表示为:

-(1.2 * 2^16) = -0x13333 = 0xfffecccd
比较FLOAT和float

FLOATfloat 类型的数据都是32位, 它们都可以表示2^32个不同的数, 但由于表示方法不一样, FLOATfloat 能表示的数集是不一样的. 思考一下, 我们用 FLOAT 来模拟表示 float , 这其中隐含着哪些取舍?

</div></div>

接下来我们来考虑 FLOAT 类型的常见运算, 假设实数 a , bFLOAT 类型表示分别为 A , B .

  • 由于我们使用整数来表示 FLOAT 类型, FLOAT 类型的加法可以直接用整数加法来进行:
    A + B = a * 2^16 + b * 2^16 = (a + b) * 2^16
    
  • 由于我们使用补码的方式来表示FLOAT类型数据, 因此FLOAT类型的减法用整数减法来进行.
    A - B = a * 2^16 - b * 2^16 = (a - b) * 2^16
    
  • FLOAT 类型的乘除法和加减法就不一样了:
    A * B = a * 2^16 * b * 2^16 = (a * b) * 2^32 != (a * b) * 2^16
    
    也就是说, 直接把两个 FLOAT 数据相乘得到的结果并不等于相应的两个浮点数乘积的 FLOAT 表示. 为了得到正确的结果, 我们需要对相乘的结果进行调整: 只要将结果除以 2^16 , 就能得出正确的结果了. 除法也需要对结果进行调整, 至于如何调整, 当然难不倒聪明的你啦.
  • 如果把 A = a * 2^16 看成一个映射, 那么在这个映射的作用下, 关系运算是保序的, 即 a <= b 当且仅当 A <= B , 故 FLOAT 类型的关系运算可以用整数的关系运算来进行.

有了这些结论, 要用 FLOAT 类型来模拟实数运算就很方便了. 除了乘除法需要额外实现之外, 其余运算都可以直接使用相应的整数运算来进行. 例如

float a = 1.2; float b = 10; int c = 0; if(b > 7.9) { c = (a + 1) * b / 2.3; }

FLOAT 类型来模拟就是

FLOAT a = f2F(1.2); FLOAT b = int2F(10); int c = 0; if(b > f2F(7.9)) { c = F2int(F_div_F(F_mul_F((a + int2F(1)), b), f2F(2.3))); }

其中还引入了一些类型转换函数来实现和 FLOAT 相关的类型转换.

实现binary scaling

框架代码已经将测试用例中涉及浮点数的部分用 FLOAT 类型来模拟, 你需要实现一些和 FLOAT 类型相关的函数:

/* lib-common/FLOAT.h */ int32_t F2int(FLOAT a); FLOAT int2F(int a); FLOAT F_mul_int(FLOAT a, int b); FLOAT F_div_int(FLOAT a, int b); /* lib-common/FLOAT.c */ FLOAT f2F(float a); FLOAT F_mul_F(FLOAT a, FLOAT b); FLOAT F_div_F(FLOAT a, FLOAT b); FLOAT Fabs(FLOAT a);

其中 F_mul_int()F_div_int() 用于计算一个 FLOAT 类型数据和一个整型数据的积/商, 这两种特殊情况可以快速计算出结果, 不需要将整型数据先转化成 FLOAT 类型再进行运算. lib-common/FLOAT.c 中的 pow() 函数目前不会用到, 我们会在PA4再提到它. 实现成功后, 你还需要在 lib-common/Makefile.part 中编写用于生成 FLOAT.o 的规则, 要求如下:

  • 只编译不链接
  • 使用 -m32-fno-builtin 编译选项
  • 添加 lib-common 目录作为头文件的搜索路径
  • FLOAT.o 生成到在 obj/lib-common 目录下, 若目录不存在, 可以通过 mkdir -p 目录 创建

编写规则后, 修改工程目录下的 Makefile 文件:

--- Makefile +++ Makefile @@ -11,2 +11,2 @@ NEWLIBC = $(NEWLIBC_DIR)/libc.a -#FLOAT = obj/$(LIB_COMMON_DIR)/FLOAT.a +FLOAT = obj/$(LIB_COMMON_DIR)/FLOAT.a

FLOAT.a 参与链接, 这样你就可以在NEMU中运行integral和quadratic-eq这两个测试用例了.

事实上, 我们并没有考虑计算结果溢出的情况, 不过我们的测试用例中的浮点数结果都可以在 FLOAT 类型中表示, 所以你可以不关心溢出的问题. 如果你不放心, 你可以在上述函数的实现中插入assertion来捕捉溢出错误.

</div></div>

编写自己的测试用例

从测试的角度来说, testcase 目录下的测试用例还不够完备, 很多指令可能都没有被覆盖到. 想象一下你编写了一个 if 语句, 但程序运行的时候根本就没有进入过这个 if 块中, 你怎么好意思说你写的这个 if 语句是对的呢? 因此我们鼓励你编写自己的测试用例, 尽可能地覆盖到你写的所有代码. 用户程序的来源有很多, 例如程序设计作业中的小程序, 或者是已经完成的数据结构作业等等. 但你需要注意NEMU提供的运行时环境, 用户程序不能输出, 只能通过 nemu_assert() 来进行验证. 你可以按照以下步骤编写一个测试用例:

  • 先使用 printf() 根据数组的格式输出测试结果, 此时你编写的是一个运行在GNU/Linux下的程序, 直接用gcc编译即可.
  • 运行程序, 得到了数组格式的输出, 然后把这些输出结果作为全局数组添加到源代码中, 你可以通过 >> 将输出重定向追加到源代码中.
  • 去掉代码中的 printf() 和头文件, 包含 trap.h , 使用 nemu_assert() 进行验证, 并注意在 main 函数返回之前使用 HIT_GOOD_TRAP 结束程序的运行.
  • 把修改后的.c文件放到 testcase/src 目录下即可.

这样你就成功添加了一个测试用例了, 按照上文提到的步骤更换测试用例, 就可以使用你的测试用例进行测试了.

我们还鼓励你把测试用例分享给大家, 我们在提交网站中创建了一个"测试用例分享"的讨论版, 你可以在讨论版中分享你的测试用例, 同时也可以使用其它同学提供的测试用例进行测试. 你的程序通过越多的测试, 程序的健壮性就越好, 越有希望通过"在NEMU上运行仙剑奇侠传"的终极考验.

实现更多的指令

你需要实现上表提到的更多指令, 以支持 testcase 目录下更多程序的运行. 实现的时候尽可能使用框架代码中的宏(参考 include/cpu/exec/helper.hinclude/cpu/exec/template-start.h ), 它们可以帮助你编写出简洁的代码. 你可以自由选择按照什么顺序来实现指令.

你需要使用 testcase 目录下的测试用例来测试你的实现. 你不需要实现所有指令的所有形式, 只需要通过 testcase 目录下的所有测试就可以了(hello-inline-asm和hello除外).

</div></div>

NEMU的本质

你已经知道, NEMU是一个用来执行其它程序的程序. 在可计算理论中, 这种程序有一个专门的名词, 叫通用程序(Universal Program), 它的通俗含义是: 其它程序能做的事情, 它也能做. 通用程序的存在性有专门的证明, 我们在这里不做深究, 但是, 我们可以写出NEMU, 可以用虚拟机做实验, 乃至我们可以在计算机上做各种各样的事情, 其背后都蕴含着通用程序的思想: NEMU和VirtualBox只不过是通用程序的实例化, 我们也可以毫不夸张地说, 计算机就是一个通用程序的实体化. 通用程序的存在性为计算机的出现奠定了理论基础, 是可计算理论中一个极其重要的结论, 如果通用程序的存在性得不到证明, 我们就没办法放心地使用计算机, 同时也不能义正辞严地说"机器永远是对的".

我们编写的NEMU最终会被编译成x86机器代码, 用x86指令来模拟x86程序的执行. 事实上在30多年前(1983年), Martin Davis教授就在他出版的"Computability, complexity, and languages: fundamentals of theoretical computer science"一书中提出了一种仅有三种指令的程序设计语言L语言, 并且证明了L语言和其它所有编程语言的计算能力等价. L语言中的三种指令分别是:

V = V + 1
V = V - 1
IF V != 0 GOTO LABEL

用x86指令来描述, 就是 inc , decjnz 三条指令. 假设除了输入变量之外, 其它变量的初值都是0, 并且假设程序执行到最后一条指令就结束, 你可以仅用这三种指令写一个计算两个正整数相加的程序吗?

    # Assume a = 0, x and y are initialized with some positive integers.
    # Other temporary variables are initialized with 0.
    # Let "jnz" carries a variable: jnz v, label.
    # It means "jump to label if v != 0".
    # Compute a = x + y used only these three instructions: inc, dec, jnz.
    # No other instructions can be used.
    # The result should be stored in variable "a".
    # Have a try?

令人更惊讶的是, Martin Davis教授还证明了, 在不考虑物理限制的情况下(认为内存容量无限多, 每一个内存单元都可以存放任意大的数), 用L语言也可以编写出一个和NEMU类似的通用程序! 而且这个用L语言编写的通用程序的框架, 竟然还和NEMU中的 cpu_exec() 函数如出一辙: 取指, 译码, 执行... 这其实并不是巧合, 而是模拟(Simulation)在计算机科学中的应用.

早在Martin Davis教授提出L语言之前, 科学家们就已经在探索什么问题是可以计算的了. 回溯到19世纪30年代, 为了试图回答这个问题, 不同的科学家提出并研究了不同的计算模型, 包括Gödel, HerbrandKleen研究的递归函数, Church提出的λ-演算, Turing提出的图灵机, 后来发现这些模型在计算能力上都是等价的; 到了40年代, 计算机就被制造出来了. 后来甚至还有人证明了, 如果使用无穷多个算盘拼接起来进行计算, 其计算能力和图灵机等价! 我们可以从中得出一个推论, 通用程序在不同的计算模型中有不同的表现形式. NEMU作为一个通用程序, 在19世纪30年代有着非凡的意义, 如果你能在80年前设计出NEMU, 说不定"图灵奖"就要用你的名字来命名了. 计算的极限这一篇科普文章叙述了可计算理论的发展过程, 我们强烈建议你阅读它, 体会人类的文明(当然一些数学功底还是需要的). 如果你对可计算理论感兴趣, 可以选修宋方敏老师的计算理论导引课程.

把思绪回归到PA中, 通用程序的性质告诉我们, NEMU的潜力是无穷的. 但NEMU现在连输出一句话的功能都无法向用户程序提供, 为了创造出一个缤纷多彩的世界, 你觉得NEMU还缺少些什么呢?

</div></div>

捕捉死循环(有点难度)

NEMU除了作为模拟器之外, 还具有简单的调试功能, 可以设置断点, 查看程序状态. 如果让你为NEMU添加如下功能

当用户程序陷入死循环时, 让用户程序暂停下来, 并输出相应的提示信息

你觉得应该如何实现? 如果你感到疑惑, 在互联网上搜索相关信息.

</div></div>

温馨提示

PA2阶段3到此结束. 此阶段需要实现较多指令, 你不必在一周内完成此阶段的所有内容, 但注意合理分配时间, 不要影响到后续阶段的实验.

</div></div>

results matching ""

    No results matching ""