穿越时空的旅程
异常是指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三个寄存器的值保存到堆栈上.
于是, 触发异常后硬件的处理如下:
- 依次将EFLAGS, CS, EIP寄存器的值压入堆栈
- 从IDTR中读出IDT的首地址
- 根据异常(中断)号在IDT中进行索引, 找到一个门描述符
- 将门描述符中的offset域组合成目标地址
- 跳转到目标地址
需要注意的是, 这些工作都是硬件自动完成的, 不需要程序员编写指令来完成相应的内容. 事实上, 这只是一个简化后的过程, 在真实的计算机上还要处理很多细节问题, 在这里我们就不深究了. 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:
- 代码定义了一个结构体数组
idt
, 它的每一项是一个门描述符结构体 - 在相应的数组元素中填写有意义的门描述符, 例如编号为
0x80
的门描述符就是将来系统调用的入口地址. 需要注意的是, 框架代码中还是填写了完整的门描述符(包括上文中提到的don't care的域), 这主要是为了在QEMU中进行differential testing时也能跳转到正确的入口地址. QEMU实现了完整的中断机制, 如果只填写简化版的门描述符, 就无法在QEMU中正确运行. 但我们无需了解其中的细节, 只需要知道代码已经填写了正确的门描述符即可. - 在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: 不用想太多, 其实都是你学过的知识.
你的任务如下:
- 实现
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
程序, 它触发了一个号码为0
的SYS_none
系统调用.
我们约定, 这个系统调用什么都不用做, 直接返回1
.
处理系统调用的最后一件事就是设置系统调用的返回值.
我们约定系统调用的返回值存放在系统调用号所在的寄存器中,
所以我们只需要通过SYSCALL_ARG1()
来进行设置就可以了.
恢复现场
系统调用处理结束后, 代码将会一路返回到trap.S
的asm_trap()
中.
接下来的事情就是恢复用户进程的现场.
asm_trap()
将根据之前保存的trap frame中的内容, 恢复用户进程的通用寄存器
(注意trap frame中的%eax
已经被设置成系统调用的返回值了),
并直接弹出一些不再需要的信息,
最后执行iret
指令.
iret
指令用于从异常处理代码中返回, 它将栈顶的三个元素来依次解释成EIP, CS, EFLAGS, 并恢复它们.
用户进程可以通过%eax
寄存器获得系统调用的返回值, 进而得知系统调用执行的结果.
在它看来, 这次时空之旅就好像没有发生过一样.
你需要:
- 在
do_event()
中识别出系统调用事件_EVENT_SYSCALL
, 然后调用do_syscall()
. - 在
nexus-am/am/arch/x86-nemu/include/arch.h
中实现正确的SYSCALL_ARGx()
宏, 让它们从作为参数的现场reg
中获得正确的系统调用参数寄存器. - 添加
SYS_none
系统调用. - 设置系统调用的返回值.
- 实现
popa
和iret
指令.
重新运行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到此结束.