运行第一个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 专门定义了一些用于测试的宏:

#define HIT_GOOD_TRAP \ asm volatile(".byte 0xd6" : : "a" (0)) #define HIT_BAD_TRAP \ asm volatile(".byte 0xd6" : : "a" (1)) #define nemu_assert(cond) \ do { \ if( !(cond) ) HIT_BAD_TRAP; \ } while(0)

其中 HIT_GOOD_TRAP 是一条内联汇编语句, 内联汇编语句允许我们在C代码中嵌入汇编语句. 这条指令和我们常见的汇编指令不一样(例如 movl $1, %eax ), 它是直接通过指令的编码给出的, 它只有一个字节, 就是 0xd6 . 如果你在 nemu/src/cpu/exec/exec.c 中查看 opcode_table , 你会发现, 这条指令正是那条特殊的 nemu_trap ! 这其实也说明了为什么要通过编码来给出这条指令, 如果你使用

asm volatile("nemu_trap" : : "a" (0))

的方式来给出指令, 汇编器将会报错, 因为这条特殊的指令是我们人为添加的, 标准的汇编器并不能识别它. 如果你查看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函数

为什么目前让用户程序从 main 函数返回就会发生错误? 这个错误具体是怎么发生的?

运行时环境与交叉编译

在让NEMU运行用户程序之前, 我们先来讨论NEMU需要为用户程序的运行提供什么. 在你运行hello world程序时, 你敲入一条命令(或者点击一下鼠标), 程序就成功运行了, 但这背后其实隐藏着操作系统开发者和库函数开发者的无数汗水. 一个事实是, 应用程序的运行都需要运行时环境的支持, 包括加载, 销毁程序, 以及提供程序运行时的各种动态链接库(你经常使用的库函数就是运行时环境提供的)等. 现在轮到你来为用户程序提供运行时环境的支持了, 不用担心, 由于NEMU目前的功能并不完善, 我们必定无法向用户程序提供GNU/Linux般的运行时环境. 目前, 我们约定NEMU提供的运行时环境有:

  1. 物理内存有128MB(当然, 这是我们模拟出来的物理内存), 所有内存地址都是物理地址
  2. 程序入口位于地址 0x100000 , 程序总是从这里开始执行
  3. %ebp 的初值为 0 , %esp 的初值为 0x8000000 . 需要注意的是, 这个地址是物理内存的最大值, 是一个非法的物理地址, 不能直接访问
  4. 程序通过 nemu_trap 结束运行
  5. 不提供库函数的动态链接, 但提供静态链接, 故实际上对用户程序来说, 库函数的使用与运行时环境无关. 库函数的静态链接是通过框架代码中提供的函数库newlib实现的, 相应的文件有 lib-common/newlib/libc.alib-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的用户程序:

--- Makefile +++ Makefile @@ -55,2 +55,2 @@ -USERPROG = obj/testcase/mov +USERPROG = obj/testcase/mov-c ENTRY = $(USERPROG)

修改后, 键入

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 可以确定具体的形式.

这里要再次强调, 你务必通过i386手册来查阅指令的功能, 不能想当然. 手册中给出了指令功能的完整描述(包括做什么事, 怎么做的, 有什么影响), 一定要仔细阅读其中的每一个单词, 对指令功能理解错误和遗漏都会给以后的调试带来巨大的麻烦.
  • sub , cmp : 要注意被减数和减数的位置. 但在实现它们之前, 你首先需实现EFLAGS寄存器, 你只需要在寄存器结构体中添加EFLAGS寄存器即可. EFLAGS是一个32位寄存器, 它的结构如下:
    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|
    +-------------------+---------------+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
    
    关于EFLAGS中每一位的含义, 请查阅i386手册. 在NEMU中, 我们只会用到EFLAGS中以下的7个位: CF , PF , ZF , SF , IF , DF , OF . 其余位的功能可暂不实现. 添加EFLAGS寄存器需要用到结构体的位域(bit field)功能, 如果你从未听说过位域, 请查阅相关资料. 关于EFLGAS的初值, 我们遵循i386手册中提到的约定, 你需要在i386手册的第10章中找到这一初值, 然后在 restart() 函数中对EFLAGS寄存器进行初始化. 实现了EFLAGS寄存器之后, 你就可以实现 subcmp 指令了.
  • call : call 指令有很多形式, 不过在PA中只会用到其中的几种, 现在只需要实现 CALL rel32 的形式就可以了
  • push : 现在只需要实现 PUSH r32 的形式就可以了
  • test : RTFM吧
  • je : je 指令是 jcc 的一种形式

运行用户程序mov-c

编写相应的helper函数实现上文提到的指令, 具体细节请务必参考i386手册. 实现成功后, 在NEMU中运行用户程序mov-c, 你将会看到 HIT GOOD TRAP 的信息.

温馨提示

PA2阶段1到此结束.

results matching ""

    No results matching ""