运行第一个C程序
说了这么多, 现在到了动手实践的时候了, 你在PA2的第一个任务, 就是编写几条指令的helper函数, 使得第一个简单的C程序可以在NEMU中运行起来. 这个简单的C程序的代码是 testcase/src/mov-c.c
, 它做的事情十分简单, 对数组的某些元素进行赋值, 然后马上读出这些元素的值, 检查它们是否被正确赋值.
使用assertion进行验证
要怎么证明mov-c程序正确运行了呢? 你可能马上想到把元素的值输出到屏幕上看看. 但是, 输出一句话
是一件很复杂的事情(没错! 的确是一件很复杂的事情, 尽管你天天都在用), 由于现在NEMU的功能十分简陋, 不足以支持用户程序进行输出. 事实上, 做PA的最终目标之一, 就是让用户程序成功输出一句话, 回过头来你就能够理解, 程序要输出一句话其实也不容易.
既然用户程序不能输出数组元素, 那就用简易调试器中的扫描内存功能, 把数组元素所在的内存区域打印出来看看吧! 这是一个可行的方法, 但你很快就会因为把时间花费在人工检查当而感到厌倦了.
有没有一种方法能够让程序自动进行检查呢? 当然有! 那不就是帮你拦截了无数bug的assertion吗? assertion的功能就是当检查条件为假时, 马上终止程序的执行, 并汇报违反assertion的地方. 先别着急, 终止程序是需要操作系统的帮助的, 目前NEMU中并没有运行操作系统, 是不能直接使用标准库中的assertion功能的. 幸运的是, 框架代码早就已经考虑到这点了, 还记得在PA1中提到的 nemu_trap
这条特殊的指令吗? 我们只需要对这条特殊的指令稍作包装, 就可以把assertion的功能移植到用户程序中了!
移植后的assertion通过 nemu_assert()
来使用, 它是个宏, 在 lib-common/trap.h
中定义. lib-common/trap.h
专门定义了一些用于测试的宏:
其中 HIT_GOOD_TRAP
是一条内联汇编语句, 内联汇编语句允许我们在C代码中嵌入汇编语句. 这条指令和我们常见的汇编指令不一样(例如 movl $1, %eax
), 它是直接通过指令的编码给出的, 它只有一个字节, 就是 0xd6
. 如果你在 nemu/src/cpu/exec/exec.c
中查看 opcode_table
, 你会发现, 这条指令正是那条特殊的 nemu_trap
! 这其实也说明了为什么要通过编码来给出这条指令, 如果你使用
的方式来给出指令, 汇编器将会报错, 因为这条特殊的指令是我们人为添加的, 标准的汇编器并不能识别它. 如果你查看objdump的反汇编结果, 你会看到 nemu_trap
指令被标识为 (bad)
, 原因是类似的: objdump并不能识别我们人为添加的 nemu_trap
指令. "a"(0)
表示在执行内联汇编语句给出的汇编代码之前, 先将 0
读入 %eax
寄存器. 这样, 这段汇编代码的功能就和 nemu/src/cpu/exec/special/special.c
中的helper函数 nemu_trap()
对应起来了. 此外, volatile
是C语言的一个关键字, 如果你想了解关于 volatile
的更多信息, 请查阅相关资料. HIT_BAD_TRAP
的功能是类似的, 这里就不再进行叙述了.
最后来看看 nemu_assert()
, 它做的事情十分简单, 当条件为假时, 就执行 HIT_BAD_TRAP
. 这样几行代码就实现了assertion的功能, 我们就可以在用户程序中使用assertion了.
上述三个宏都有相应的汇编版本, 在汇编代码中包含头文件trap.h, 你就可以使用它们了. 不过汇编版本的 nemu_assert()
功能比较简陋, 它只能判断某个通用寄存器是否与给定的一个立即数相等.
另外唯一一点要注意的是, 目前我们不能让用户程序从 main
函数返回, 否则将会产生错误, 因此我们在用户程序从 main
函数返回之前, 使用 HIT_GOOD_TRAP
强行结束用户程序的运行, 同时也提示我们用户程序通过了所有的assertion.
为什么目前让用户程序从 main
函数返回就会发生错误? 这个错误具体是怎么发生的?
运行时环境与交叉编译
在让NEMU运行用户程序之前, 我们先来讨论NEMU需要为用户程序的运行提供什么. 在你运行hello world程序时, 你敲入一条命令(或者点击一下鼠标), 程序就成功运行了, 但这背后其实隐藏着操作系统开发者和库函数开发者的无数汗水. 一个事实是, 应用程序的运行都需要运行时环境的支持, 包括加载, 销毁程序, 以及提供程序运行时的各种动态链接库(你经常使用的库函数就是运行时环境提供的)等. 现在轮到你来为用户程序提供运行时环境的支持了, 不用担心, 由于NEMU目前的功能并不完善, 我们必定无法向用户程序提供GNU/Linux般的运行时环境. 目前, 我们约定NEMU提供的运行时环境有:
- 物理内存有128MB(当然, 这是我们模拟出来的物理内存), 所有内存地址都是物理地址
- 程序入口位于地址
0x100000
, 程序总是从这里开始执行 %ebp
的初值为0
,%esp
的初值为0x8000000
. 需要注意的是, 这个地址是物理内存的最大值, 是一个非法的物理地址, 不能直接访问- 程序通过
nemu_trap
结束运行 - 不提供库函数的动态链接, 但提供静态链接, 故实际上对用户程序来说, 库函数的使用与运行时环境无关. 库函数的静态链接是通过框架代码中提供的函数库newlib实现的, 相应的文件有
lib-common/newlib/libc.a
和lib-common/newlib/include
目录下的头文件,Makefile
中已经有相应的设置了. newlib是专门为嵌入式系统提供的, 库中的函数对运行时环境的要求极低, 其中一些函数甚至不需要任何运行时环境的支持(例如memcpy
等), 这正好符合NEMU的情况. 这样, 你就可以在用户程序中使用一些不需要运行时环境支持的库函数了. 但类似于printf()
这种需要运行时环境支持的库函数目前还是无法使用, 否则将会发生链接错误.
在让NEMU运行mov-c用户程序之前, 我们需要将用户程序的代码 mov-c.c
编译成可执行文件. 需要说明的是, 我们不能使用gcc的默认选项直接编译 mov-c.c
, 因为默认选项会根据GNU/Linux的运行时环境将代码编译成运行在GNU/Linux下的可执行文件. 但此时的NEMU并不能为用户程序提供GNU/Linux的运行时环境, 在NEMU中运行上述可执行文件会产生错误, 因此我们不能使用gcc的默认选项来编译用户程序.
解决这个问题的方法是交叉编译, 我们需要在GNU/Linux下根据NEMU提供的运行时环境编译出能够在NEMU中运行的可执行文件. 框架代码已经把相应的配置准备好了.
修改工程目录下的 Makefile
文件, 更换NEMU的用户程序:
修改后, 键入
make run
使用新的用户程序运行NEMU, 你会发现NEMU输出以下信息:
invalid opcode(eip = 0x0010000a): 83 ec 10 e8 00 00 00 00 ...
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!
nemu: nemu/src/cpu/exec/special/special.c:24: inv: Assertion `0' failed.
这是因为你还没有实现以 0x83
为首字节的指令, 因此, 你需要开始在NEMU中添加指令了.
实现最少的指令
要实现哪些指令才能让mov-c在NEMU中运行起来呢? 答案就在其反汇编结果( obj/testcase/mov-c.txt
)中. 查看反汇编结果, 你发现只需要添加 sub
, call
, push
, test
, je
, cmp
六条指令就可以了. 每一条指令还有不同的形式, 根据KISS法则, 你可以先实现只在mov-c中出现的指令形式, 通过指令的 opcode
可以确定具体的形式.
sub
,cmp
: 要注意被减数和减数的位置. 但在实现它们之前, 你首先需实现EFLAGS寄存器, 你只需要在寄存器结构体中添加EFLAGS寄存器即可. EFLAGS是一个32位寄存器, 它的结构如下:
关于EFLAGS中每一位的含义, 请查阅i386手册. 在NEMU中, 我们只会用到EFLAGS中以下的7个位:31 23 15 7 0 +-------------------+---------------+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | |V|R| |N|I O|O|D|I|T|S|Z| |A| |P| |C| | 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 | | |0| | | | | | | | |0| |0| |1| | | |M|F| |T|P L|F|F|F|F|F|F| |F| |F| |F| +-------------------+---------------+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
CF
,PF
,ZF
,SF
,IF
,DF
,OF
. 其余位的功能可暂不实现. 添加EFLAGS寄存器需要用到结构体的位域(bit field)功能, 如果你从未听说过位域, 请查阅相关资料. 关于EFLGAS的初值, 我们遵循i386手册中提到的约定, 你需要在i386手册的第10章中找到这一初值, 然后在restart()
函数中对EFLAGS寄存器进行初始化. 实现了EFLAGS寄存器之后, 你就可以实现sub
和cmp
指令了.call
:call
指令有很多形式, 不过在PA中只会用到其中的几种, 现在只需要实现CALL rel32
的形式就可以了push
: 现在只需要实现PUSH r32
的形式就可以了test
: RTFM吧je
:je
指令是jcc
的一种形式
编写相应的helper函数实现上文提到的指令, 具体细节请务必参考i386手册. 实现成功后, 在NEMU中运行用户程序mov-c, 你将会看到 HIT GOOD TRAP
的信息.
PA2阶段1到此结束.