穿越时空的旅程
有了强大的硬件保护机制, 用户程序将无法把执行流切换到操作系统的任意代码了. 但为了实现最简单的操作系统, 硬件还需要提供一种可以限制入口的执行流切换方式. 这种方式就是自陷指令, 程序执行自陷指令之后, 就会陷入到操作系统预先设置好的跳转目标.
i386提供int
指令作为自陷指令, 它的工作过程十分特别, 整个过程是由i386中断机制支撑的.
i386中断机制不具体区分CPU异常和自陷, 甚至是将在PA4最后介绍的硬件中断, 而是对它们进行统一的处理.
在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用来指示跳转目标. 有了门描述符, 用户程序就只能跳转到门描述符中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 | | |
|-------+-------| |
| | offset|-----+
index--->+---------------+
| |
|Gate Descriptor|
| |
IDT--->+---------------+
| |
| |
不过, 我们将来还是有可能需要返回到程序的当前状态来继续执行的,
比如通过int3
触发的断点异常.
这意味着, 我们需要在进行异常处理之前保存好程序当前的状态.
于是, 触发异常后硬件的处理如下:
- 依次将EFLAGS, CS(代码段寄存器), EIP寄存器的值压栈
- 从IDTR中读出IDT的首地址
- 根据异常号在IDT中进行索引, 找到一个门描述符
- 将门描述符中的offset域组合成目标地址
- 跳转到目标地址
需要注意的是, 这些工作都是硬件自动完成的, 不需要程序员编写指令来完成相应的内容. 事实上, 这只是一个简化后的过程, 在真实的计算机上还要处理很多细节问题, 在这里我们就不深究了. i386手册中还记录了处理器对中断号和异常号的分配情况, 并列出了各种异常的详细解释, 需要了解的时候可以进行查阅.
特殊的原因? (建议二周目思考)
当前的EFLAGS, CS, EIP必须由硬件来保存吗? 能否通过软件来保存? 为什么?
由于IDT中的目标地址是硬件和操作系统约定好的, 接下来的处理过程将会由操作系统来接管,
操作系统将视情况决定是否终止当前程序的运行(例如触发段错误的程序将会被杀死).
若决定不杀死当前程序, 等到异常处理结束之后, 就根据之前保存的信息恢复程序的状态.
iret
指令用于从异常处理过程中返回, 它将栈顶的三个元素来依次解释成EIP, CS, EFLAGS, 并恢复它们.
在计算机和谐社会中, 大部分门描述符都不能让用户进程随意使用,
否则恶意程序就可以通过int
指令欺骗操作系统.
例如恶意程序执行int $0x2
来谎报电源掉电, 扰乱其它进程的正常运行.
因此执行int
指令也需要进行特权级检查, 但PA中就不实现这一保护机制了,
具体的检查规则我们也就不展开讨论了, 需要了解时RTFM即可.
将上下文管理抽象成CTE
我们刚才提到了程序的状态, 在操作系统中有一个等价的术语, 叫"上下文". 因此, 硬件提供的上述在操作系统和用户程序之间切换执行流的功能, 在操作系统看来, 都可以划入上下文管理的一部分.
与IOE一样, 上下文管理的具体实现也是机器相关的:
比如MIPS中会通过syscall
指令来进行自陷,
native
中也可以通过一些神奇的库函数来模拟相应的功能,
而上下文的具体内容, 在不同的机器上也显然不一样(比如寄存器就已经不一样了).
于是, 我们可以将上下文管理的功能划入到AM的一类新的API中, 名字叫CTE(ConText Extension).
接下来的问题是, 如何将不同机器的上下文管理功能抽象成统一的API呢? 换句话说, 我们需要思考, 操作系统的处理过程其实需要哪些信息?
- 首先当然是引发这次执行流切换的原因, 是程序除0, 非法指令, 还是触发断点, 又或者是程序自愿陷入操作系统? 根据不同的原因, 操作系统都会进行不同的处理.
- 然后就是程序的上下文了, 在处理过程中, 操作系统可能会读出上下文中的一些寄存器, 根据它们的信息来进行进一步的处理. 例如操作系统读出EIP所指向的非法指令, 看看其是否能被模拟执行. 事实上, 通过这些上下文, 操作系统还能实现一些神奇的功能, 你将会在PA4中了解更详细的信息.
用软件模拟指令
在一些嵌入式场景中, 处理器对低功耗的要求非常严格, 很多时候都会去掉浮点处理单元FPU. 这时候如果软件要指令一条浮点指令, 处理器就会抛出一个非法指令的异常. 有了异常处理的机制, 我们就可以在异常处理的程序中模拟这条非法指令的执行了, 原理和PA2中的指令执行过程非常类似. 在不带FPU的MIPS, ARM和RISC-V处理器中, 都可以通过这种方式来执行浮点指令.
所以, 我们只要把这两点信息抽象成一种统一的表示方式, 就可以定义出CTE的API了.
对于切换原因, 我们只需要定义一种统一的描述原因的方式即可.
CTE定义了名为"事件"的如下数据结构(见nexus-am/am/am.h
):
typedef struct _Event {
int event;
uintptr_t cause, ref;
const char *msg;
} _Event;
其中event
表示事件编号, cause
和ref
是一些描述事件的补充信息,
msg
是事件信息字符串. 在PA中, 目前只会用到event
.
然后, 我们只要定义一些统一的事件编号(见nexus-am/am/am.h
), 让每个机器在实现各自的CTE API时,
都统一通过上述结构体来描述执行流切换的原因, 就可以实现切换原因的抽象了.
对于上下文, 我们只能将描述上下文的结构体类型名统一成_Context
,
至于其中的具体内容, 就无法进一步进行抽象了.
这主要是因为不同机器之间上下文信息的差异过大, 比如MIPS有32个通用寄存器,
就从这一点来看, MIPS和x86的_Context
注定是无法抽象成完全统一的结构的.
所以在AM中, _Context
的具体成员也是由不同的机器自己定义的,
比如x86-nemu
的_Context
结构体在nexus-am/am/arch/x86-nemu/include/arch.h
中定义.
因此, 在操作系统中, 对_Context
成员的直接引用,
都属于机器相关的行为, 会损坏操作系统的可移植性.
不过大多数情况下, 操作系统并不需要单独访问_Context
结构中的成员.
必要的时候, CTE也可以提供一些统一的接口,
来让操作系统通过这些接口来访问, 从而保证操作系统的相关代码与机器无关.
最后还有另外两个统一的API:
int _cte_init(_Context* (*handler)(_Event ev, _Context *ctx))
用于进行CTE相关的初始化操作. 其中它还接受一个来自操作系统的事件处理回调函数的指针, 当发生事件时, CTE将会把事件和相关的上下文作为参数, 来调用这个回调函数, 交由操作系统进行后续处理.void _yield()
用于进行自陷操作, 会触发一个编号为_EVENT_YIELD
事件. 在x86-nemu
中, 我们约定自陷操作通过int $0x81
触发.
CTE中还有其它的API, 目前不使用, 故暂不介绍它们.
接下来, 我们将尝试在Nanos-lite中触发一次自陷操作, 来梳理过程中的细节.
准备IDT
首先是准备一个有意义的IDT, 将来切换执行流时才能跳转到正确的目标地址.
这显然是机器相关的行为(IDT是x86特有的结构), 因此我们把这一行为放入CTE中, 而不是让Nanos-lite直接来准备IDT.
你需要在nanos-lite/include/common.h
中定义宏HAS_CTE
,
这样以后, Nanos-lite会多进行一项初始化工作: 调用init_irq()
函数,
这最终会调用位于nexus-am/am/arch/x86-nemu/src/cte.c
中的_cte_init()
函数.
_cte_init()
函数会做两件事情, 第一件就是初始化IDT:
- 代码定义了一个结构体数组
idt
, 它的每一项是一个门描述符结构体 - 在相应的数组元素中填写有意义的门描述符, 例如编号为
0x81
的门描述符中就包含自陷操作的入口地址. 需要注意的是, 框架代码中还是填写了完整的门描述符(包括上文中提到的don't care的域), 这主要是为了在QEMU中进行DiffTest时也能跳转到正确的入口地址. QEMU实现了完整的中断机制, 如果只填写简化版的门描述符, 就无法在QEMU中正确运行. 但我们无需了解其中的细节, 只需要知道代码已经填写了正确的门描述符即可. - 通过
lidt
指令在IDTR中设置idt
的首地址和长度
_cte_init()
函数做的第二件事是注册一个事件处理回调函数,
这个回调函数由Nanos-lite提供, 更多信息会在下文进行介绍.
触发自陷操作
为了测试是否已经准备正确的IDT, 我们还需要真正触发一次自陷操作, 看是否正确地跳转到目标地址.
定义了宏HAS_CTE
后, Nanos-lite会在panic()
前调用_yield()
来触发自陷操作.
为了支撑这次自陷操作, 你需要在NEMU中实现raise_intr()
函数(在nemu/src/cpu/intr.c
中定义)
来模拟上文提到的i386中断机制的处理过程:
void raise_intr(uint8_t NO, vaddr_t ret_addr) {
/* TODO: Trigger an interrupt/exception with ``NO''.
* That is, use ``NO'' to index the IDT.
*/
}
需要注意的是:
- PA不涉及特权级的切换, RTFM的时候你不需要关心和特权级切换相关的内容.
- 通过IDTR中的地址对IDT进行索引的时候, 需要使用
vaddr_read()
. - PA中不实现分段机制, 没有CS寄存器的概念. 但为了在QEMU中顺利进行DiffTest,
我们还是需要在cpu结构体中添加一个CS寄存器, 并在
restart()
函数中将其初始化为8
. - 由于i386中断机制需要对EFLAGS进行压栈, 为了配合DiffTest,
我们还需要在
restart()
函数中将EFLAGS初始化为0x2
. - 执行
int
指令后保存的EIP指向的是int
指令的下一条指令, 这有点像函数调用, 具体细节请RTFM. - 你需要在
int
指令的helper函数中调用raise_intr()
, 而不要把中断机制的代码放在int
指令的helper函数中实现, 因为在后面我们会再次用到raise_intr()
函数.
实现i386中断机制
你需要实现上文提到的lidt
指令和int
指令, 并实现raise_intr()
函数.
实现正确后, 重新运行Nanos-lite, 如果你看到在vectrap()
(在nexum-am/am/arch/x86-nemu/src/trap.S
中定义)附近触发了未实现指令,
说明你实现的i386中断机制已经跳转到正确的自陷入口.
保存上下文
成功跳转到自陷入口函数vectrap()
之后, 我们就要在软件上开始真正的异常处理过程了.
但是, 进行异常处理的时候不可避免地需要用到通用寄存器,
然而看看现在的通用寄存器, 里面存放的都是执行流切换之前的内容.
这些内容也是上下文的一部分, 如果不保存就覆盖它们, 将来就无法恢复这一上下文了.
但硬件并不负责保存它们, 因此需要通过软件代码来保存它们的值.
i386提供了pusha
指令, 用于把通用寄存器的值压栈.
vectrap()
会压入错误码和异常号#irq
, 然后跳转到asm_trap()
.
在asm_trap()
中, 代码将会把当前的通用寄存器保存到栈上,
并通过一条pushl $0
指令在栈上占位, 它是为PA4准备的, 目前可以忽略它.
这些内容连同之前保存的错误码, #irq
, 以及硬件保存的EFLAGS, CS, EIP,
形成了完整的上下文, 将来恢复上下文的时候就靠它了.
对比异常处理与函数调用
我们知道进行函数调用的时候也需要保存调用者的状态: 返回地址, 以及calling convention中需要调用者保存的寄存器. 而CTE在保存上下文的时候却要保存更多的信息. 尝试对比它们, 并思考两者保存信息不同是什么原因造成的.
注意到上下文是在栈上构造的. 接下来代码将会把当前的%esp
压栈,
并调用C函数irq_handle()
(在nexus-am/am/arch/x86-nemu/src/cte.c
中定义).
诡异的代码
trap.S
中有一行pushl %esp
的代码, 乍看之下其行为十分诡异.
你能结合前后的代码理解它的行为吗?
Hint: 不用想太多, 其实都是你学过的知识.
重新组织_Context结构体
你的任务如下:
- 实现
pusha
指令, 你需要注意压栈的顺序, 详情请RTFM. - 理解上下文形成的过程, 然后重新组织
nexus-am/am/arch/x86-nemu/include/arch.h
中定义的_Context
结构体的成员, 使得这些成员的定义顺序和nexus-am/am/arch/x86-nemu/src/trap.S
中构造的上下文保持一致.
实现之后, 你可以在irq_handle()
中通过printf
输出上下文tf
的内容,
然后通过简易调试器观察触发自陷时的寄存器状态, 从而检查你的_Context
实现是否正确.
事件分发
irq_handle()
的代码会把执行流切换的原因打包成事件,
然后调用在_cte_init()
中注册的事件处理回调函数, 将事件交给Nanos-lite来处理.
在Nanos-lite中, 这一回调函数是nanos-lite/src/irq.c
中的do_event()
函数.
do_event()
函数会根据事件类型再次进行分发.
不过我们在这里会触发一个未处理的1号事件:
[src/irq.c,5,do_event] {kernel} system panic: Unhandled event ID = 1
这是因为CTE的irq_handle()
函数并未正确识别出自陷事件.
根据_yield()
的定义, irq_handle()
函数需要将自陷事件打包成编号为_EVENT_YIELD
的事件.
实现正确的事件分发
你需要:
- 在
irq_handle()
中通过异常号识别出自陷事件, 并打包成编号为_EVENT_YIELD
的事件. - 在
do_event()
中识别出自陷事件_EVENT_YIELD
, 然后输出一句话即可, 无需进行其它操作.
重新运行Nanos-lite, 如果你的实现正确, 你会看到识别到自陷事件之后输出的信息,
恢复上下文
代码将会一路返回到trap.S
的asm_trap()
中, 接下来的事情就是恢复程序的上下文.
asm_trap()
将根据之前保存的上下文内容, 恢复通用寄存器,
并直接弹出一些不再需要的信息, 最后执行iret
指令,
返回到Nanos-lite触发自陷的代码位置, 然后继续执行.
在它看来, 这次时空之旅就好像没有发生过一样.
恢复上下文
你需要实现popa
和iret
指令. 重新运行Nanos-lite, 如果你的实现正确,
你会看到在do_event()
中输出的信息, 并且最后仍然触发了main()
函数末尾设置的panic()
.
温馨提示
PA3阶段1到此结束.