穿越时空的旅程

异常是指CPU在执行过程中检测到的不正常事件, 例如除数为零, 无效指令, 权限不足等. i386还向软件提供int指令, 让软件可以手动产生异常, 因此前面提到的系统调用也算是一种特殊的异常. 那触发异常之后都发生了些什么呢? 我们先来对这一场神秘的时空之旅作一些简单的描述.

我们之前提到, CPU检测到异常之后, 就会跳转到一个地方, 这个过程是由位于硬件层次i386中断机制支撑的. 在i386中, 上述跳转的目标通过门描述符(Gate Descriptor)来指示. 门描述符是一个8字节的结构体, 里面包含着不少细节的信息, 我们在NEMU中简化了门描述符的结构, 只保留存在位P和偏移量OFFSET:

   31                23                15                7                0
  +-----------------+-----------------+---+-------------------------------+
  |           OFFSET 31..16           | P |          Don't care           |4
  +-----------------------------------+---+-------------------------------+
  |             Don't care            |           OFFSET 15..0            |0
  +-----------------+-----------------+-----------------+-----------------+

P位来用表示这一个门描述符是否有效, OFFSET用来指示跳转目标.

为了方便管理各个门描述符, i386把内存中的某一段数据专门解释成一个数组, 叫IDT(Interrupt Descriptor Table, 中断描述符表), 数组的一个元素就是一个门描述符. 为了从数组中找到一个门描述符, 我们还需要一个索引. 对于CPU异常来说, 这个索引由CPU内部产生(例如除零异常为0号异常), 或者由int指令给出(例如int $0x80). 最后, 为了在内存中找到IDT, i386使用IDTR寄存器来存放IDT的首地址和长度. 我们需要通过软件代码事先把IDT准备好, 然后通过一条特殊的指令lidt在IDTR中设置好IDT的首地址和长度, 这一中断处理机制就可以正常工作了. 现在是万事俱备, 等到异常的东风一刮, CPU就会按照设定好的IDT跳转到目标地址:

           |               |
           |   Entry Point |<----+
           |               |     |
           |               |     |
           |               |     |
           +---------------+     |
           |               |     |
           |               |     |
           |               |     |
           +---------------+     |
           |offset |       |     |
           |-------+-------|     |
Exception  |       | offset|-----+
   ID----->+---------------+
           |               |
           |Gate Descriptor|
           |               |
 IDT------>+---------------+
           |               |
           |               |

但感觉有什么不太对劲? 异常处理结束之后, 我们要怎么返回异常之前的状态呢? 为了方便叙述, 我们称触发异常之前状态为S. 为了以后能够完美地恢复到S, 在开始真正的处理异常之前应该先把S保存起来, 等到异常处理结束之后, 才能根据之前保存的信息把计算机恢复到S的样子. 哪些内容表征了S? 首先当然是EIP了, 它指示了S正在执行的指令(或者下一条指令); 然后就是EFLAGS(各种标志位)和CS(代码段寄存器, 里面包含CPL的信息). 由于一些特殊的原因, 这三个寄存器的内容必须由硬件来保存. 要将这些信息保存到哪里去呢? 一个合适的地方就是进程的堆栈. 触发异常时, 硬件会自动将EFLAGS, CS, EIP三个寄存器的值保存到堆栈上.

于是, 触发异常后硬件的处理如下:

  1. 依次将EFLAGS, CS, EIP寄存器的值压入堆栈
  2. 从IDTR中读出IDT的首地址
  3. 根据异常(中断)号在IDT中进行索引, 找到一个门描述符
  4. 将门描述符中的offset域组合成目标地址
  5. 跳转到目标地址

需要注意的是, 这些工作都是硬件自动完成的, 不需要程序员编写指令来完成相应的内容. 事实上, 这只是一个简化后的过程, 在真实的计算机上还要处理很多细节问题, 在这里我们就不深究了. i386手册中还记录了处理器对中断号和异常号的分配情况, 并列出了各种异常的详细解释, 需要了解的时候可以进行查阅.

在计算机和谐社会中, 大部分门描述符都不能让用户进程随意使用, 否则恶意程序就可以通过int指令欺骗操作系统. 例如恶意程序执行int $0x2来谎报电源掉电, 扰乱其它进程的正常运行. 因此执行int指令也需要进行特权级检查, 但PA中就不实现这一保护机制了, 具体的检查规则我们也就不展开讨论了, 需要了解的时候请查阅i386手册.

加入ASYE

在AM的模型中, 异常处理的能力被划分到ASYE模块中. 老规矩, 我们还是分别从NEMU和AM两个角度来体会硬件和软件如何相互协助来支持ASYE的功能.

准备IDT

首先是要准备一个有意义的IDT, 这样以后触发异常时才能跳转到正确的目标地址. 具体的, 你需要在NEMU中添加IDTR寄存器和lidt指令. 然后在nanos-lite/src/main.c中定义宏HAS_ASYE, 这样以后, Nanos-lite会多进行一项初始化工作: 调用init_irq()函数, 这最终会调用位于nexus-am/am/arch/x86-nemu/src/asye.c中的_asye_init()函数. _asye_init()函数会做两件事情, 第一件就是初始化IDT:

  1. 代码定义了一个结构体数组idt, 它的每一项是一个门描述符结构体
  2. 在相应的数组元素中填写有意义的门描述符, 例如编号为0x80的门描述符就是将来系统调用的入口地址. 需要注意的是, 框架代码中还是填写了完整的门描述符(包括上文中提到的don't care的域), 这主要是为了在QEMU中进行differential testing时也能跳转到正确的入口地址. QEMU实现了完整的中断机制, 如果只填写简化版的门描述符, 就无法在QEMU中正确运行. 但我们无需了解其中的细节, 只需要知道代码已经填写了正确的门描述符即可.
  3. 在IDTR中设置idt的首地址和长度

_asye_init()函数做的第二件事是注册一个事件处理函数, 这个事件处理函数由_asyn_init()的调用者提供. 关于事件处理函数, 我们会在下文进行更多的介绍.

触发异常

为了测试是否已经成功准备IDT, 我们还需要真正触发一次异常, 看是否正确地跳转到目标地址. 具体的, 你需要在NEMU中实现raise_intr()函数(在nemu/src/cpu/intr.c中定义) 来模拟上文提到的i386中断机制的处理过程:

void raise_intr(uint8_t NO, vaddr_t save_addr) {
  /* TODO: Trigger an interrupt/exception with ``NO''.
   * That is, use ``NO'' to index the IDT.
   */

}

需要注意的是:

  • PA不涉及特权级的切换, 查阅i386手册的时候你不需要关心和特权级切换相关的内容.
  • 通过IDTR中的地址对IDT进行索引的时候, 需要使用vaddr_read().
  • PA中不实现分段机制, 没有CS寄存器的概念. 但为了在QEMU中顺利进行differential testing, 我们还是需要在cpu结构体中添加一个CS寄存器, 并在restart()函数中将其初始化为8.
  • 由于中断机制需要对EFLAGS进行压栈, 为了配合differential testing, 我们还需要在restart()函数中将EFLAGS初始化为0x2.
  • 执行int指令后保存的EIP指向的是int指令的下一条指令, 这有点像函数调用, 具体细节可以查阅i386手册.
  • 你需要在int指令的helper函数中调用raise_intr(), 而不要把中断机制的代码放在int指令的helper函数中实现, 因为在后面我们会再次用到raise_intr()函数.

实现中断机制

你需要实现上文提到的lidt指令和int指令, 并实现raise_intr()函数.

实现正确后, 重新在Nanos-lite上运行dummy程序, 如果你看到在vecsys()(在nexum-am/am/arch/x86-nemu/src/trap.S中定义)附近触发了未实现指令, 说明你的中断机制实现正确.

保存现场

成功跳转到入口函数vecsys()之后, 我们就要在软件上开始真正的异常处理过程了. 但是, 进行异常处理的时候不可避免地需要用到通用寄存器, 然而看看现在的通用寄存器, 里面存放的都是异常触发之前的内容. 这些内容也是现场的一部分, 如果不保存就覆盖它们, 将来就无法恢复异常触发之前的状态了. 但硬件并不负责保存它们, 因此需要通过软件代码来保存它们的值. i386提供了pusha指令, 用于把通用寄存器的值压入堆栈.

vecsys()会压入错误码和异常号#irq, 然后跳转到asm_trap(). 在asm_trap()中, 代码将会把用户进程的通用寄存器保存到堆栈上. 这些寄存器的内容连同之前保存的错误码, #irq, 以及硬件保存的EFLAGS, CS, EIP, 形成了trap frame(陷阱帧)的数据结构. 我们知道栈帧记录了函数调用时的状态, 而相应地, 陷阱帧则完整记录了用户进程触发异常时现场的状态, 将来恢复现场就靠它了.

对比异常与函数调用

我们知道进行函数调用的时候也需要保存调用者的状态: 返回地址, 以及调用约定(calling convention)中需要调用者保存的寄存器. 而进行异常处理之前却要保存更多的信息. 尝试对比它们, 并思考两者保存信息不同是什么原因造成的.

注意到trap frame是在堆栈上构造的. 接下来代码将会把当前的%esp压栈, 并调用C函数irq_handle()(在nexus-am/am/arch/x86-nemu/src/asye.c中定义).

诡异的代码

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

重新组织TrapFrame结构体

你的任务如下:

  • 实现pusha指令, 你需要注意压栈的顺序, 更多信息请查阅i386手册.
  • 理解trap frame形成的过程, 然后重新组织nexus-am/am/arch/x86-nemu/include/arch.h中定义的_RegSet结构体的成员, 使得这些成员声明的顺序和nexus-am/am/arch/x86-nemu/src/trap.S中构造的trap frame保持一致.

实现正确之后, irq_handle()以及后续代码就可以正确地使用trap frame了. 重新在Nanos-lite上运行dummy程序, 你会看到在nanos-lite/src/irq.c中的do_event()函数中触发了BAD TRAP:

[src/irq.c,5,do_event] {kernel} system panic: Unhandled event ID = 8

事件分发

irq_handle()的代码会把异常封装成事件, 然后调用在_asye_init()中注册的事件处理函数, 将事件交给它来处理. 在Nanos-lite中, 这一事件处理函数是nanos-lite/src/irq.c中的do_event()函数. do_event()函数会根据事件类型再次进行分发. 我们刚才触发了一个未处理的8号事件, 这其实是一个系统调用事件_EVENT_SYSCALL(在nexus-am/am/am.h中定义). 在识别出系统调用事件后, 需要调用do_syscall()(在nanos-lite/src/syscall.c中定义)进行处理.

系统调用处理

我们终于正式进入系统调用的处理函数中了. do_syscall()首先通过宏SYSCALL_ARG1()从现场r中获取用户进程之前设置好的系统调用参数, 通过第一个参数 - 系统调用号 - 进行分发. 但目前Nanos-lite没有实现任何系统调用, 因此触发了panic.

添加一个系统调用比你想象中要简单, 所有信息都已经准备好了. 我们只需要在分发的过程中添加相应的系统调用号, 并编写相应的系统调用处理函数sys_xxx(), 然后调用它即可.

回过头来看dummy程序, 它触发了一个号码为0SYS_none系统调用. 我们约定, 这个系统调用什么都不用做, 直接返回1.

处理系统调用的最后一件事就是设置系统调用的返回值. 我们约定系统调用的返回值存放在系统调用号所在的寄存器中, 所以我们只需要通过SYSCALL_ARG1()来进行设置就可以了.

恢复现场

系统调用处理结束后, 代码将会一路返回到trap.Sasm_trap()中. 接下来的事情就是恢复用户进程的现场. asm_trap()将根据之前保存的trap frame中的内容, 恢复用户进程的通用寄存器 (注意trap frame中的%eax已经被设置成系统调用的返回值了), 并直接弹出一些不再需要的信息, 最后执行iret指令. iret指令用于从异常处理代码中返回, 它将栈顶的三个元素来依次解释成EIP, CS, EFLAGS, 并恢复它们. 用户进程可以通过%eax寄存器获得系统调用的返回值, 进而得知系统调用执行的结果. 在它看来, 这次时空之旅就好像没有发生过一样.

实现系统调用

你需要:

  1. do_event()中识别出系统调用事件_EVENT_SYSCALL, 然后调用do_syscall().
  2. nexus-am/am/arch/x86-nemu/include/arch.h中实现正确的SYSCALL_ARGx()宏, 让它们从作为参数的现场reg中获得正确的系统调用参数寄存器.
  3. 添加SYS_none系统调用.
  4. 设置系统调用的返回值.
  5. 实现popairet指令.

重新运行dummy程序, 如果你的实现正确, 你会看到dummy程序又触发了一个号码为4的系统调用. 查看nanos-lite/src/syscall.h, 你会发现它是一个SYS_exit系统调用. 这说明之前的SYS_none已经成功返回, 触发SYS_exit是因为dummy已经执行完毕, 准备退出了.

你需要实现SYS_exit系统调用, 它会接收一个退出状态的参数, 用这个参数调用_halt()即可. 实现成功后, 再次运行dummy程序, 你会看到GOOD TRAP的信息.

需要提醒的是, ASYE还有其它的API, 但我们暂时不会用到, 现在可以先忽略它们.

温馨提示

PA3阶段1到此结束.

results matching ""

    No results matching ""