实现更多的指令
为了让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.2
和 5.6
用 FLOAT
类型来近似表示, 就是
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.2
的 FLOAT
类型表示为:
-(1.2 * 2^16) = -0x13333 = 0xfffecccd
FLOAT
和 float
类型的数据都是32位, 它们都可以表示2^32个不同的数, 但由于表示方法不一样, FLOAT
和 float
能表示的数集是不一样的. 思考一下, 我们用 FLOAT
来模拟表示 float
, 这其中隐含着哪些取舍?
</div></div>
接下来我们来考虑 FLOAT
类型的常见运算, 假设实数 a
, b
的 FLOAT
类型表示分别为 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
类型来模拟就是
其中还引入了一些类型转换函数来实现和 FLOAT
相关的类型转换.
框架代码已经将测试用例中涉及浮点数的部分用 FLOAT
类型来模拟, 你需要实现一些和 FLOAT
类型相关的函数:
其中 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
文件:
让 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.h
和 include/cpu/exec/template-start.h
), 它们可以帮助你编写出简洁的代码. 你可以自由选择按照什么顺序来实现指令.
你需要使用 testcase
目录下的测试用例来测试你的实现. 你不需要实现所有指令的所有形式, 只需要通过 testcase
目录下的所有测试就可以了(hello-inline-asm和hello除外).
</div></div>
你已经知道, 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
, dec
和 jnz
三条指令. 假设除了输入变量之外, 其它变量的初值都是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, Herbrand和Kleen研究的递归函数, Church提出的λ-演算, Turing提出的图灵机, 后来发现这些模型在计算能力上都是等价的; 到了40年代, 计算机就被制造出来了. 后来甚至还有人证明了, 如果使用无穷多个算盘拼接起来进行计算, 其计算能力和图灵机等价! 我们可以从中得出一个推论, 通用程序在不同的计算模型中有不同的表现形式. NEMU作为一个通用程序, 在19世纪30年代有着非凡的意义, 如果你能在80年前设计出NEMU, 说不定"图灵奖"就要用你的名字来命名了. 计算的极限这一篇科普文章叙述了可计算理论的发展过程, 我们强烈建议你阅读它, 体会人类的文明(当然一些数学功底还是需要的). 如果你对可计算理论感兴趣, 可以选修宋方敏老师的计算理论导引课程.
把思绪回归到PA中, 通用程序的性质告诉我们, NEMU的潜力是无穷的. 但NEMU现在连输出一句话的功能都无法向用户程序提供, 为了创造出一个缤纷多彩的世界, 你觉得NEMU还缺少些什么呢?
</div></div>
NEMU除了作为模拟器之外, 还具有简单的调试功能, 可以设置断点, 查看程序状态. 如果让你为NEMU添加如下功能
当用户程序陷入死循环时, 让用户程序暂停下来, 并输出相应的提示信息
你觉得应该如何实现? 如果你感到疑惑, 在互联网上搜索相关信息.
</div></div>
PA2阶段3到此结束. 此阶段需要实现较多指令, 你不必在一周内完成此阶段的所有内容, 但注意合理分配时间, 不要影响到后续阶段的实验.
</div></div>