时空之旅大揭秘

让我们通过 obj/testcase/hello-inline-asm 这个具体的例子, 亲自体验一趟神秘的旅程. 我们会跟随 hello-inline-asm 程序一同探索这一时空之旅, 当我们从时空之旅结束归来, hello-inline-asm 程序也已经将信息传达出去, 我们的第一个任务也就完成了.

添加传送门

在踏上旅程之前, 你还需要在NEMU中实现IA-32中断机制, 只有这样才能敲开神奇的传送门.

一方面, 你需要在kernel中加入相关的代码, 你只需要在 kernel/include/common.h 中定义宏 IA32_INTR , 然后重新编译kernel就可以了. 重新编译后, kernel会在 init_cond() 函数中多进行两项初始化工作:

  • 重新设置GDT.
  • 设置IDT, 具体来说就是填写IDT中每一个门描述符, 设置完毕后通过 lidt 指令装载IDTR.

其它的工作和之前一样, 没有变化.

另一方面, 你需要在NEMU中添加中断机制的功能, 以便让上述代码成功执行. 具体的, 你需要:

  • 添加IDTR和 lidt 指令, 注意IDTR中存放的IDT首地址是线性地址.
  • 添加如下的 raise_intr() 函数来模拟IA-32中断机制的处理过程:
    #include <setjmp.h> extern jmp_buf jbuf;

void raise_intr(uint8_t NO) { /* TODO: Trigger an interrupt/exception with ``NO''.

 * That is, use ``NO&#39;&#39; to index the IDT.
 */


/* Jump back to cpu_exec() */
longjmp(jbuf, 1);

}
</div></div>

其中 longjmp() 的功能相当于跨越函数的goto语句, 执行 longjmp() 会跳转到 cpu_exec()setjmp() 的下一条指令, 这样从 raise_intr() 中"返回"之后就会马上继续执行 cpu.eip 所指向的指令, 也就是异常处理入口指令.

  • 添加 int , iret , cli , pusha , popa 等指令. 关于 intiret 指令, 实验中不涉及特权级的切换, 查阅i386手册的时候你不需要关心和特权级切换相关的内容. 要注意的是
    • 执行 int 指令后保存的 EIP 指向的是 int 指令的下一条指令, 这有点像函数调用, 具体细节可以查阅i386手册.
    • 你需要在 int 指令的helper函数中调用 raise_intr() , 而不要把IA-32中断处理的代码放在 int 指令的helper函数中实现, 因为在后面我们会再次用到 raise_intr() 函数.

实现IA-32中断机制

你需要在NEMU中添加IA-32中断机制的支持, 如有疑问, 请查阅i386手册. 实现成功后在NEMU中运行 hello-inline-asm 程序, 你会看到在 kernel/src/irq/irq_handle.c 中的 irq_handle() 函数中触发了BAD TRAP. 这说明用户进程已经成功通过IA-32中断机制陷入内核, 你将会在下面的任务中修复"触发BAD TRAP"的问题.

神奇的longjmp

你可能会认为没有必要在 raise_intr() 函数中使用 longjmp , 直接一步一步返回到 cpu_exec() 中就可以了. 但如果需要实现处理器异常的识别和处理, longjmp 是最好的选择. 假设你打算在页级转换过程中, 当检测到页表项的present位为0时, 通过 raise_intr(14) 抛出缺页异常, 如果不使用 longjmp , 你将如何让代码从 raise_intr() 返回到 cpu_exec() ? 如果还需要实现无效指令异常的处理, 你又会怎么办?

你可以通过

man setjmp
man longjmp

查阅 setjmplongjmp 的相关信息. 思考一下, 如果让你实现 setjmplongjmp , 你将如何实现?

重新设置GDT

为什么要重新设置GDT? 尝试把 init_cond() 函数中的 init_segment() 注释掉, 编译后重新运行, 你会发现运行出错, 你知道为什么吗?

曲折的旅途

我们的第一次时空之旅被迫止于半路之中, 看来旅途并非我们想象中的那么顺利. 为了找到问题的原因, 我们需要仔细推敲途中的每一处细节.

用户进程执行 int $0x80 之后, CPU将会保存现场, 查阅kenrel设置好的IDT, 跳转到入口函数 vecsys() , 压入错误码和异常号 #irq , 跳转到 asm_do_irq . 在 asm_do_irq 中, 代码将会把用户进程的通用寄存器保存到堆栈上, 这些寄存器的内容连同之前保存的错误码, #irq , 以及硬件保存的EFLAGS, CS, EIP形成了trap frame(陷阱帧)的数据结构, 它记录了用户进程陷入内核时的状态, 注意到trap frame是在堆栈上构造的. 保存好用户进程的信息之后, kernel就可以随意使用通用寄存器了. 接下来代码将会把当前的 %esp 压栈, 并调用C函数 irq_handle() .

诡异的代码

do_irq.S 中有一行 pushl %esp 的代码, 乍看之下其行为十分诡异, 你能结合前后的代码理解它的行为吗? Hint: 不用想太多, 其实都是你学过的知识.

重新组织TrapFrame结构体

框架代码在 irq_handle() 函数的开始处设置了 panic() , 你的任务是理解trap frame形成的过程, 然后重新组织 kernel/include/irq.h 中定义的 TrapFrame 结构体的成员, 使得这些成员声明的顺序和 kernel/src/irq/do_irq.S 中构造的trap frame保持一致. 实现正确之后, 去掉上述的 panic() , irq_handle() 以及后续代码就可以正确地使用trap frame了. 重新运行 hello-inline-asm 程序, 你会看到在 kernel/src/syscall/do_syscall.c 中的 do_syscall() 函数中触发了BAD TRAP.

irq_handle() 将会根据 #irq 确定异常事件的类型, 从而进行不同的处理. 用户进程执行的 int $0x80 会被识别成系统调用请求, 于是kernel会调用 do_syscall() 对相应的请求进行处理. 由于用户进程的所有现场信息都已经保存在trap frame中了, kernel很容易获取它们, do_syscall() 将根据用户进程之前设置好的系统调用号和系统调用参数进行处理. 我们找到了第二次触发BAD TRAP的原因: kernel并没有实现 SYS_write 系统调用的处理. 为了再次踏上旅程, 你需要在 do_syscall() 中添加 SYS_write 的系统调用.

添加一个系统调用比你想象中要简单, 所有信息都已经准备好了. 根据 write 系统调用的函数声明(参考 man 2 write ), 在 do_syscall() 中识别出系统调用号是 SYS_write 之后, 检查 fd 的值, 如果 fd12 (分别代表 stdoutstderr ), 则将 buf 为首地址的 len 字节输出到屏幕上, 你需要思考如何从trap frame中获取这些信息.

现在又回到了最根本的问题了: 要怎么才能把内容输出到屏幕上? 在真实的计算机中, 这些内容最终是由设备来负责输出的, 但NEMU中还没有添加设备, 所以我们还是先借助NEMU的功能来完成输出吧. 我们使用以下内联汇编来"陷入"到NEMU中:

asm volatile (".byte 0xd6" : : "a"(2), "c"(buf), "d"(len));

这正是我们在NEMU中手工加入的 nemu_trap 指令. 接下来修改 nemu/cpu/src/exec/special/special.cnemu_trap() 的helper函数, 如果 %eax 为2, 则输出 %ecx 为首地址的 %edx 字节的内容. 在NEMU中输出就是一件易如反掌的事情了, 不过需要注意, %ecx 表示的地址是虚拟地址, 聪明的你知道应该怎么做了吧.

回到kernel中, 假设执行完上述的 nemu_trap 指令后, "输出"成功了, 处理系统调用的最后一件事就是设置系统调用的返回值. GNU/Linux约定系统调用的返回值存放在 %eax 中, 所以我们只需要修改 tf->eax 的值就可以了. 至于 write 系统调用的返回值是什么, 请查阅 man 2 write .

系统调用处理结束后, 代码将会一路返回到 do_irq.Sasm_do_irq() 中. 接下来的事情就是恢复用户进程的现场, kernel将根据之前保存的trap frame中的内容, 恢复用户进程的通用寄存器(注意trap frame中的 %eax 已经被设置成系统调用的返回值了), 并直接弹出一些不再需要的信息, 最后通过 iret 指令恢复用户进程的EIP, CS, EFLAGS. 用户进程可以通过 %eax 寄存器获得系统调用的返回值, 进而得知系统调用执行的结果.

这样, 一次系统调用就执行完了, 时空之旅圆满结束.

实现系统调用

你需要在kernel中添加 SYS_write 系统调用, 让 hello-inline-asm 程序成功输出"Hello, world!"的信息.

运行hello程序

实现 SYS_write 系统调用之后, 我们已经为"使用 printf() "扫除了最大的障碍了, 因为 printf() 进行字符串格式化之后, 最终会通过 write 系统调用进行输出. 至于格式化的功能, newlib已经为我们实现好了.

我们这次的任务是在NEMU中执行 obj/testcase/hello 程序. 查看其源代码 testcase/src/hello.c , 除了使用 printf() 输出之外, 源文件中还加入了一些系统调用函数的代码, 这是因为newlib进行链接的时候会用到它们. 但执行流不会到达其中的某些函数, 为了避免疏忽, 我们在那些不会执行到的函数中插入 nemu_assert(0) . SYS_brk 系统调用用于调整用户进程堆区的大小, kernel中已经实现了这个系统调用了. 另外 syscall() 是一个陷入内核的接口函数, 它会设置好正确的系统调用参数, 然后执行 int $0x80 陷入内核. 我们用到的系统调用的参数不会超过4个, 上述 syscall() 的实现已经能满足我们的需求了.

hello 程序编译通过之后, 你就可以在NEMU中运行它了. 不过在运行过程中, 你会发现又遇到了调皮的浮点指令, 你可以按照PA2中黑客小练习的方式去掉它们.

在NEMU中输出"Hello world"

在NEMU中运行 obj/testcase/hello 程序.

温馨提示

PA4阶段1到此结束.

results matching ""

    No results matching ""