穿越时空的旅程
穿越时空的旅程
异常是指CPU在执行过程中检测到的不正常事件, 例如除数为零, 无效指令, 权限不足等. i386还向软件提供int
指令, 让软件可以手动产生异常, 因此前面提到的系统调用也算是一种特殊的异常. 那触发异常之后都发生了些什么呢? 我们先来对这一场神秘的时空之旅作一些简单的描述.
我们之前提到, CPU检测到异常之后, 就会跳转到一个地方, 这个过程是由位于硬件层次i386中断机制支撑的. 在i386中, 上述跳转的目标通过门描述符(Gate Descriptor)来指示. 门描述符是一个8字节的结构体, 里面包含着不少细节的信息, 我们在NEMU中简化了门描述符的结构, 只保留存在位P和偏移量OFFSET:
P位来用表示这一个门描述符是否有效, OFFSET用来指示跳转目标.
为了方便管理各个门描述符, i386把内存中的某一段数据专门解释成一个数组, 叫IDT(Interrupt Descriptor Table, 中断描述符表), 数组的一个元素就是一个门描述符. 为了从数组中找到一个门描述符, 我们还需要一个索引. 对于CPU异常来说, 这个索引由CPU内部产生(例如除零异常为0号异常), 或者由int
指令给出(例如int $0x80
). 最后, 为了在内存中找到IDT, i386使用IDTR寄存器来存放IDT的首地址和长度. 我们需要通过软件代码事先把IDT准备好, 然后通过一条特殊的指令lidt
在IDTR中设置好IDT的首地址和长度, 这一中断处理机制就可以正常工作了. 现在是万事俱备, 等到异常的东风一刮, CPU就会按照设定好的IDT跳转到目标地址:
但感觉有什么不太对劲? 异常处理结束之后, 我们要怎么返回异常之前的状态呢? 为了方便叙述, 我们称触发异常之前状态为S. 为了以后能够完美地恢复到S, 在开始真正的处理异常之前应该先把S保存起来, 等到异常处理结束之后, 才能根据之前保存的信息把计算机恢复到S的样子. 哪些内容表征了S? 首先当然是EIP了, 它指示了S正在执行的指令(或者下一条指令); 然后就是EFLAGS(各种标志位)和CS(代码段寄存器, 里面包含CPL的信息). 由于一些特殊的原因, 这三个寄存器的内容必须由硬件来保存. 要将这些信息保存到哪里去呢? 一个合适的地方就是进程的堆栈. 触发异常时, 硬件会自动将EFLAGS, CS, EIP三个寄存器的值保存到堆栈上.
于是, 触发异常后硬件的处理如下: 1. 依次将EFLAGS, CS, EIP寄存器的值压入堆栈 1. 从IDTR中读出IDT的首地址 1. 根据异常(中断)号在IDT中进行索引, 找到一个门描述符 1. 将门描述符中的offset域组合成目标地址 1. 跳转到目标地址
需要注意的是, 这些工作都是硬件自动完成的, 不需要程序员编写指令来完成相应的内容. 事实上, 这只是一个简化后的过程, 在真实的计算机上还要处理很多细节问题, 在这里我们就不深究了. 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
, 它的每一项是一个门描述符结构体 1. 在相应的数组元素中填写有意义的门描述符, 例如编号为0x80
的门描述符就是将来系统调用的入口地址. 需要注意的是, 框架代码中还是填写了完整的门描述符(包括上文中提到的don't care的域), 这主要是为了在QEMU中进行differential testing时也能跳转到正确的入口地址. QEMU实现了完整的中断机制, 如果只填写简化版的门描述符, 就无法在QEMU中正确运行. 但我们无需了解其中的细节, 只需要知道代码已经填写了正确的门描述符即可. 1. 在IDTR中设置idt
的首地址和长度
_asye_init()
函数做的第二件事是注册一个事件处理函数, 这个事件处理函数由_asyn_init()
的调用者提供. 关于事件处理函数, 我们会在下文进行更多的介绍.
触发异常
为了测试是否已经成功准备IDT, 我们还需要真正触发一次异常, 看是否正确地跳转到目标地址. 具体的, 你需要在NEMU中实现raise_intr()
函数(在nemu/src/cpu/intr.c
中定义) 来模拟上文提到的i386中断机制的处理过程:
需要注意的是:
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()
函数.
保存现场
成功跳转到入口函数vecsys()
之后, 我们就要在软件上开始真正的异常处理过程了. 但是, 进行异常处理的时候不可避免地需要用到通用寄存器, 然而看看现在的通用寄存器, 里面存放的都是异常触发之前的内容. 这些内容也是现场的一部分, 如果不保存就覆盖它们, 将来就无法恢复异常触发之前的状态了. 但硬件并不负责保存它们, 因此需要通过软件代码来保存它们的值. i386提供了pusha
指令, 用于把通用寄存器的值压入堆栈.
vecsys()
会压入错误码和异常号#irq
, 然后跳转到asm_trap()
. 在asm_trap()
中, 代码将会把用户进程的通用寄存器保存到堆栈上. 这些寄存器的内容连同之前保存的错误码, #irq
, 以及硬件保存的EFLAGS, CS, EIP, 形成了trap frame(陷阱帧)的数据结构. 我们知道栈帧记录了函数调用时的状态, 而相应地, 陷阱帧则完整记录了用户进程触发异常时现场的状态, 将来恢复现场就靠它了.
注意到trap frame是在堆栈上构造的. 接下来代码将会把当前的%esp
压栈, 并调用C函数irq_handle()
(在nexus-am/am/arch/x86-nemu/src/asye.c
中定义).
事件分发
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
寄存器获得系统调用的返回值, 进而得知系统调用执行的结果. 在它看来, 这次时空之旅就好像没有发生过一样.
需要提醒的是, ASYE还有其它的API, 但我们暂时不会用到, 现在可以先忽略它们.
Last updated
Was this helpful?