穿越时空的旅程
有了强大的硬件保护机制, 用户程序将无法把执行流切换到操作系统的任意代码了. 但为了实现最简单的操作系统, 硬件还需要提供一种可以限制入口的执行流切换方式. 这种方式就是自陷指令, 程序执行自陷指令之后, 就会陷入到操作系统预先设置好的跳转目标. 这个跳转目标也称为异常入口地址.
这一过程是ISA规范的一部分, 称为中断/异常响应机制. 大部分ISA并不区分CPU的异常和自陷, 甚至是将在PA4最后介绍的硬件中断, 而是对它们进行统一的响应. 目前我们并未加入硬件中断, 因此先把这个机制简称为"异常响应机制"吧.
x86
x86提供int
指令作为自陷指令, 但其异常响应机制和其它ISA相比会复杂一些.
在x86中, 上述的异常入口地址是通过门描述符(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所指定的位置, 再也不能随心所欲地跳转到操作系统的任意代码了.
为了方便管理各个门描述符, x86把内存中的某一段数据专门解释成一个数组,
叫IDT(Interrupt Descriptor Table, 中断描述符表), 数组的一个元素就是一个门描述符.
为了从数组中找到一个门描述符, 我们还需要一个索引.
对于CPU异常来说, 这个索引由CPU内部产生(例如除零异常为0号异常),或者由int
指令给出(例如int $0x80
).
最后, 为了在内存中找到IDT, x86使用IDTR寄存器来存放IDT的首地址和长度.
操作系统的代码事先把IDT准备好, 然后执行一条特殊的指令lidt
,
来在IDTR中设置好IDT的首地址和长度, 这一异常响应机制就可以正常工作了.
现在是万事俱备, 等到程序执行自陷指令或者触发异常的时候, CPU就会按照设定好的IDT跳转到异常入口地址:
| |
| Entry Point |<----+
| | |
| | |
| | |
+---------------+ |
| | |
| | |
| | |
+---------------+ |
|offset | | |
|-------+-------| |
| | offset|-----+
index--->+---------------+
| |
|Gate Descriptor|
| |
IDT--->+---------------+
| |
| |
不过, 我们将来还是有可能需要返回到程序的当前状态来继续执行的,
比如通过int3
触发的断点异常.
这意味着, 我们需要在进行响应异常的时候保存好程序当前的状态.
于是, 触发异常后硬件的响应过程如下:
- 依次将eflags, cs(代码段寄存器), eip(也就是PC)寄存器的值压栈
- 从IDTR中读出IDT的首地址
- 根据异常号在IDT中进行索引, 找到一个门描述符
- 将门描述符中的offset域组合成异常入口地址
- 跳转到异常入口地址
在计算机和谐社会中, 大部分门描述符都不能让用户进程随意使用,
否则恶意程序就可以通过int
指令欺骗操作系统.
例如恶意程序执行int $0x2
来谎报电源掉电, 扰乱其它进程的正常运行.
因此执行int
指令也需要进行特权级检查, 但PA中就不实现这一保护机制了,
具体的检查规则我们也就不展开讨论了, 需要了解时RTFM即可.
mips32
mips32提供syscall
指令作为自陷指令, 它的工作过程十分简单.
mips32约定, 上述的异常入口地址总是0x80000180
.
为了保存程序当前的状态, mips32提供了一些特殊的系统寄存器,
这些寄存器位于0号协处理器(Co-Processor 0), 因此也称CP0寄存器.
在PA中, 我们只使用如下3个CP0寄存器:
- epc寄存器 - 存放触发异常的PC
- status寄存器 - 存放处理器的状态
- cause寄存器 - 存放触发异常的原因
mips32触发异常后硬件的响应过程如下:
- 将当前PC值保存到epc寄存器
- 在cause寄存器中设置异常号
- 在status寄存器中设置异常标志, 使处理器进入内核态
- 跳转到
0x80000180
riscv32
riscv32提供ecall
指令作为自陷指令, 并提供一个stvec寄存器来存放异常入口地址.
为了保存程序当前的状态, riscv32提供了一些特殊的系统寄存器, 叫控制状态寄存器(CSR寄存器).
在PA中, 我们只使用如下3个CSR寄存器:
- sepc寄存器 - 存放触发异常的PC
- sstatus寄存器 - 存放处理器的状态
- scause寄存器 - 存放触发异常的原因
riscv32触发异常后硬件的响应过程如下:
- 将当前PC值保存到sepc寄存器
- 在scause寄存器中设置异常号
- 从stvec寄存器中取出异常入口地址
- 跳转到异常入口地址
riscv32的特权模式简化
如果你RTFSC, 你会发现上述CSR寄存器都是S-mode(supervisor-mode, 监控者模式)的, 而标准的riscv32在开机后应该位于M-mode, 因此应该使用M-mode的CSR. 在这里我们选择S-mode, 是为了配合PA4的分页机制而进行的简化, 因此我们在NEMU中实现的并不是标准的riscv32(当然x86也不是), 不过这并不影响你的这些知识的理解. 如果你不理解这一段话的内容, 你可以忽略它.
需要注意的是, 上述保存程序状态以及跳转到异常入口地址的工作, 都是硬件自动完成的, 不需要程序员编写指令来完成相应的内容. 事实上, 这只是一个简化后的过程, 在真实的计算机上还要处理很多细节问题, 比如x86和riscv32的特权级切换等, 在这里我们就不深究了. ISA手册中还记录了处理器对中断号和异常号的分配情况, 并列出了各种异常的详细解释, 需要了解的时候可以进行查阅.
特殊的原因? (建议二周目思考)
这些程序状态(x86的eflags, cs, eip; mips32的epc, status, cause; riscv32的sepc, sstatus, scause)必须由硬件来保存吗? 能否通过软件来保存? 为什么?
由于异常入口地址是硬件和操作系统约定好的, 接下来的处理过程将会由操作系统来接管, 操作系统将视情况决定是否终止当前程序的运行(例如触发段错误的程序将会被杀死). 若决定不杀死当前程序, 等到异常处理结束之后, 就根据之前保存的信息恢复程序的状态, 并从异常处理过程中返回到程序触发异常之前的状态. 具体地:
- x86通过
iret
指令从异常处理过程中返回, 它将栈顶的三个元素来依次解释成eip, cs, eflags, 并恢复它们. - mips32通过
eret
指令从异常处理过程中返回, 它将清除status寄存器中的异常标志, 并根据epc寄存器恢复PC. - riscv32通过
sret
指令从异常处理过程中返回, 它将根据sepc寄存器恢复PC.
将上下文管理抽象成CTE
我们刚才提到了程序的状态, 在操作系统中有一个等价的术语, 叫"上下文". 因此, 硬件提供的上述在操作系统和用户程序之间切换执行流的功能, 在操作系统看来, 都可以划入上下文管理的一部分.
与IOE一样, 上下文管理的具体实现也是架构相关的:
例如上文提到, x86/mips32/riscv32中分别通过int
/syscall
/ecall
指令来进行自陷,
native
中甚至可以通过一些神奇的库函数来模拟相应的功能;
而上下文的具体内容, 在不同的架构上也显然不一样(比如寄存器就已经不一样了).
于是, 我们可以将上下文管理的功能划入到AM的一类新的API中, 名字叫CTE(ConText Extension).
接下来的问题是, 如何将不同架构的上下文管理功能抽象成统一的API呢? 换句话说, 我们需要思考, 操作系统的处理过程其实需要哪些信息?
- 首先当然是引发这次执行流切换的原因, 是程序除0, 非法指令, 还是触发断点, 又或者是程序自愿陷入操作系统? 根据不同的原因, 操作系统都会进行不同的处理.
- 然后就是程序的上下文了, 在处理过程中, 操作系统可能会读出上下文中的一些寄存器, 根据它们的信息来进行进一步的处理. 例如操作系统读出PC所指向的非法指令, 看看其是否能被模拟执行. 事实上, 通过这些上下文, 操作系统还能实现一些神奇的功能, 你将会在PA4中了解更详细的信息.
用软件模拟指令
在一些嵌入式场景中, 处理器对低功耗的要求非常严格, 很多时候都会去掉浮点处理单元FPU来节省功耗. 这时候如果软件要执行一条浮点指令, 处理器就会抛出一个非法指令的异常. 有了异常响应机制, 我们就可以在异常处理的过程中模拟这条非法指令的执行了, 原理和PA2中的指令执行过程非常类似. 在不带FPU的MIPS, ARM和RISC-V处理器中, 都可以通过这种方式来执行浮点指令.
在AM中执行浮点指令是UB
换句话说, AM的运行时环境不支持浮点数. 这听上去太暴力了. 之所以这样决定, 是因为IEEE 754是个工业级标准, 为了逻辑上的soundness和completeness, 标准里面可能会有各种奇怪的设定, 例如不同的舍入方式, inf和nan的引入等等, 作为教学其实没有必要去理解它们的所有细节; 但如果要去实现一个正确的FPU, 你就没法摆脱这些细节了.
和PA2中的定点指令不同, 浮点指令用到的场合会比较少, 而且我们有别的方式可以绕开, 所以就怎么简单怎么来了, 于是就UB吧. 当然, 如果你感兴趣, 你也可以考虑实现一个简化版的FPU. 毕竟是UB, 如果你的FPU行为正确, 也不算违反规定.
另一个UB
另一种你可能会碰到的UB是栈溢出, 对, 就是stackoverflow的那个. 检测栈溢出需要一个更强大的运行时环境, AM肯定是无能为力了, 于是就UB吧.
不过, AM究竟给程序提供了多大的栈空间呢? 事实上, 你确实应该了解一下这个问题的答案, 而答案已经隐藏在代码中了, 所以RTFSC吧.
所以, 我们只要把这两点信息抽象成一种统一的表示方式, 就可以定义出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
,
至于其中的具体内容, 就无法进一步进行抽象了.
这主要是因为不同架构之间上下文信息的差异过大, 比如mips32有32个通用寄存器,
就从这一点来看, mips32和x86的_Context
注定是无法抽象成完全统一的结构的.
所以在AM中, _Context
的具体成员也是由不同的架构自己定义的,
比如x86-nemu
的_Context
结构体在nexus-am/am/include/arch/x86-nemu.h
中定义.
因此, 在操作系统中对_Context
成员的直接引用,
都属于架构相关的行为, 会损坏操作系统的可移植性.
不过大多数情况下, 操作系统并不需要单独访问_Context
结构中的成员.
必要的时候, CTE也可以提供一些统一的接口,
来让操作系统通过这些接口来访问, 从而保证操作系统的相关代码与架构无关.
最后还有另外两个统一的API:
int _cte_init(_Context* (*handler)(_Event ev, _Context *ctx))
用于进行CTE相关的初始化操作. 其中它还接受一个来自操作系统的事件处理回调函数的指针, 当发生事件时, CTE将会把事件和相关的上下文作为参数, 来调用这个回调函数, 交由操作系统进行后续处理.void _yield()
用于进行自陷操作, 会触发一个编号为_EVENT_YIELD
事件. 不同的ISA会使用不同的自陷指令来触发自陷操作, 具体实现请RTFSC.
CTE中还有其它的API, 目前不使用, 故暂不介绍它们.
接下来, 我们将尝试在Nanos-lite中触发一次自陷操作, 来梳理过程中的细节.
设置异常入口地址
首先是按照ISA的约定来设置异常入口地址, 将来切换执行流时才能跳转到正确的异常入口.
这显然是架构相关的行为, 因此我们把这一行为放入CTE中, 而不是让Nanos-lite直接来设置异常入口地址.
你需要在nanos-lite/include/common.h
中定义宏HAS_CTE
,
这样以后, Nanos-lite会多进行一项初始化工作: 调用init_irq()
函数,
这最终会调用位于nexus-am/am/src/$ISA/nemu/cte.c
中的_cte_init()
函数.
_cte_init()
函数会做两件事情, 第一件就是设置异常入口地址:
- 对x86来说, 就是要准备一个有意义的IDT
- 代码定义了一个结构体数组
idt
, 它的每一个元素是一个门描述符结构体 - 在相应的数组元素中填写有意义的门描述符, 例如编号为
0x81
的门描述符中就包含自陷操作的入口地址. 需要注意的是, 框架代码中还是填写了完整的门描述符(包括上文中提到的don't care的域), 这主要是为了在QEMU中进行DiffTest时也能跳转到正确的入口地址. QEMU实现了完整的x86异常响应机制, 如果只填写简化版的门描述符, 代码就无法在QEMU中正确运行. 但我们无需了解其中的细节, 只需要知道代码已经填写了正确的门描述符即可. - 通过
lidt
指令在IDTR中设置idt
的首地址和长度
- 代码定义了一个结构体数组
- 对于mips32来说, 由于异常入口地址是固定在
0x80000180
, 因此我们需要在0x80000180
放置一条无条件跳转指令, 使得这一指令的跳转目标是我们希望的真正的异常入口地址即可. - 对于riscv32来说, 直接将异常入口地址设置到stvec寄存器中即可.
_cte_init()
函数做的第二件事是注册一个事件处理回调函数,
这个回调函数由Nanos-lite提供, 更多信息会在下文进行介绍.
触发自陷操作
为了测试异常入口地址是否已经设置正确, 我们还需要真正触发一次自陷操作.
定义了宏HAS_CTE
后, Nanos-lite会在panic()
前调用_yield()
来触发自陷操作.
为了支撑这次自陷操作, 你需要在NEMU中实现raise_intr()
函数(在nemu/src/isa/$ISA/intr.c
中定义)
来模拟上文提到的异常响应机制.
需要注意的是:
- PA不涉及特权级的切换, RTFM的时候你不需要关心和特权级切换相关的内容.
- 你需要在自陷指令的helper函数中调用
raise_intr()
, 而不要把异常响应机制的代码放在自陷指令的helper函数中实现, 因为在后面我们会再次用到raise_intr()
函数.
如果你选择的是x86, 你还需要注意:
- 通过IDTR中的地址对IDT进行索引的时候, 需要使用
vaddr_read()
. - NEMU中不实现分段机制, 没有cs寄存器的概念. 但为了在QEMU中顺利进行DiffTest,
我们还是需要在cpu结构体中添加一个cs寄存器, 并在
restart()
函数中将其初始化为8
. - 由于x86的异常响应机制需要对eflags进行压栈, 为了配合DiffTest,
我们还需要在
restart()
函数中将eflags初始化为0x2
.
实现异常响应机制
你需要实现上文提到的新指令, 并实现raise_intr()
函数.
然后阅读_cte_init()
的代码, 找出相应的异常入口地址.
实现后, 重新运行Nanos-lite, 如果你发现NEMU确实跳转到你找到的异常入口地址, 说明你的实现正确(NEMU也可能因为触发了未实现指令而终止运行).
保存上下文
成功跳转到异常入口地址之后, 我们就要在软件上开始真正的异常处理过程了.
但是, 进行异常处理的时候不可避免地需要用到通用寄存器,
然而看看现在的通用寄存器, 里面存放的都是执行流切换之前的内容.
这些内容也是上下文的一部分, 如果不保存就覆盖它们, 将来就无法恢复这一上下文了.
但通常硬件并不负责保存它们, 因此需要通过软件代码来保存它们的值.
x86提供了pusha
指令, 用于把通用寄存器的值压栈;
而mips32和riscv32则通过sw
指令将各个通用寄存器依次压栈.
除了通用寄存器之外, 上下文还包括:
- 触发异常时的PC和处理器状态. 对于x86来说就是eflags, cs和eip, x86的异常响应机制已经将它们保存在堆栈上了; 对于mips32和riscv32来说, 就是epc/sepc和status/sstatus寄存器, 异常响应机制把它们保存在相应的系统寄存器中, 我们还需要将它们从系统寄存器中读出, 然后保存在堆栈上.
- 异常号. 对于x86, 异常号由软件保存; 而对于mips32和riscv32, 异常号已经由硬件保存在cause/scause寄存器中, 我们还需要将其保存在堆栈上.
- 地址空间. 这是为PA4准备的, x86通过一条
pushl $0
指令在堆栈上占位, mips32和riscv32则是将地址空间信息与0号寄存器共用存储空间, 反正0号寄存器的值总是0, 也不需要保存和恢复. 不过目前我们暂时不使用地址空间信息, 你可以忽略它.
异常号的保存
x86通过软件来保存异常号, 没有类似cause的寄存器. mips32和riscv32也可以这样吗? 为什么?
于是, 这些内容构成了完整的上下文信息, 异常处理过程可以根据上下文来诊断并尝试从异常中恢复, 同时, 将来恢复上下文的时候也需要这些信息.
对比异常处理与函数调用
我们知道进行函数调用的时候也需要保存调用者的状态: 返回地址, 以及calling convention中需要调用者保存的寄存器. 而CTE在保存上下文的时候却要保存更多的信息. 尝试对比它们, 并思考两者保存信息不同是什么原因造成的.
接下来代码会调用C函数__am_irq_handle()
(在nexus-am/am/src/$ISA/nemu/cte.c
中定义),
来进行异常的处理.
诡异的x86代码
x86的trap.S
中有一行pushl %esp
的代码, 乍看之下其行为十分诡异.
你能结合前后的代码理解它的行为吗?
Hint: 不用想太多, 其实都是你学过的知识.
重新组织_Context结构体
你的任务如下:
- 实现这一过程中的新指令, 详情请RTFM.
- 理解上下文形成的过程并RTFSC, 然后重新组织
nexus-am/am/include/arch/$ISA-nemu.h
中定义的_Context
结构体的成员, 使得这些成员的定义顺序和nexus-am/am/src/$ISA/nemu/trap.S
中构造的上下文保持一致.
实现之后, 你可以在__am_irq_handle()
中通过printf
输出上下文c
的内容,
然后通过简易调试器观察触发自陷时的寄存器状态, 从而检查你的_Context
实现是否正确.
给一些提示吧
"实现新指令"没什么好说的, 你已经在PA2中实现了很多指令了.
"重新组织结构体"是一个非常有趣的题目, 如果你不知道要做什么, 不妨从读懂题目开始.
题目大概的意思就是, 根据trap.S
里面的内容, 来定义$ISA-nemu.h
里面的一个结构体.
trap.S
明显是汇编代码, 而$ISA-nemu.h
里面则是一个用C语言定义的结构体.
汇编代码和C语言... 等等, 你好像想起了ICS课本的某些内容...
我乱改一通, 居然过了, 嘿嘿嘿
如果你还抱着这种侥幸心态, 你在PA3中会过得非常痛苦. 事实上, "明白如何正确重新组织结构体"是PA3中非常重要的内容. 所以我们还是加一道必答题吧.
必答题(需要在实验报告中回答) - 理解上下文结构体的前世今生
你会在__am_irq_handle()
中看到有一个上下文结构指针c
,
c
指向的上下文结构究竟在哪里? 这个上下文结构又是怎么来的?
具体地, 这个上下文结构有很多成员, 每一个成员究竟在哪里赋值的?
$ISA-nemu.h
, trap.S
, 上述讲义文字, 以及你刚刚在NEMU中实现的新指令,
这四部分内容又有什么联系?
如果你不是脑袋足够灵光, 还是不要眼睁睁地盯着代码看了,
来用纸笔模拟一下__am_asm_trap()
的执行过程吧.
事件分发
__am_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的__am_irq_handle()
函数并未正确识别出自陷事件.
根据_yield()
的定义, __am_irq_handle()
函数需要将自陷事件打包成编号为_EVENT_YIELD
的事件.
实现正确的事件分发
你需要:
- 在
__am_irq_handle()
中通过异常号识别出自陷异常, 并打包成编号为_EVENT_YIELD
的自陷事件. - 在
do_event()
中识别出自陷事件_EVENT_YIELD
, 然后输出一句话即可, 目前无需进行其它操作.
重新运行Nanos-lite, 如果你的实现正确, 你会看到识别到自陷事件之后输出的信息,
恢复上下文
代码将会一路返回到trap.S
的__am_asm_trap()
中, 接下来的事情就是恢复程序的上下文.
__am_asm_trap()
将根据之前保存的上下文内容, 恢复程序的状态,
最后执行"异常返回指令"返回到程序触发异常之前的状态.
不过这里需要注意之前自陷指令保存的PC, 对于x86的int
指令,
保存的是指向其下一条指令的PC, 这有点像函数调用;
而对于mips32的syscall
和riscv32的ecall
, 保存的是自陷指令的PC,
因此需要在适当的地方对保存的PC加上4, 使得将来返回到自陷指令的下一条指令.
从加4操作看CISC和RISC
事实上, 自陷只是其中一种异常类型. 有一种故障类异常, 它们返回的PC和触发异常的PC是同一个, 例如缺页异常, 在系统将故障排除后, 将会重新执行相同的指令进行重试, 因此异常返回的PC无需加4. 所以根据异常类型的不同, 有时候需要加4, 有时候则不需要加.
这时候, 我们就可以考虑这样的一个问题了: 决定要不要加4的, 是硬件还是软件呢? CISC和RISC的做法正好相反, CISC都交给硬件来做, 而RISC则交给软件来做. 思考一下, 这两种方案各有什么取舍? 你认为哪种更合理呢? 为什么?
返回到Nanos-lite触发自陷的代码位置, 然后继续执行. 在它看来, 这次时空之旅就好像没有发生过一样.
恢复上下文
你需要实现这一过程中的新指令. 重新运行Nanos-lite, 如果你的实现正确,
你会看到在do_event()
中输出的信息, 并且最后仍然触发了main()
函数末尾设置的panic()
.
必答题(需要在实验报告中回答) - 理解穿越时空的旅程
从Nanos-lite调用_yield()
开始, 到从_yield()
返回的期间, 这一趟旅程具体经历了什么?
软(AM, Nanos-lite)硬(NEMU)件是如何相互协助来完成这趟旅程的? 你需要解释这一过程中的每一处细节,
包括涉及的每一行汇编代码/C代码的行为, 尤其是一些比较关键的指令/变量.
事实上, 上文的必答题"理解上下文结构体的前世今生"已经涵盖了这趟旅程中的一部分,
你可以把它的回答包含进来.
别被"每一行代码"吓到了, 这个过程也就大约50行代码, 要完全理解透彻并不是不可能的. 我们之所以设置这道必答题, 是为了强迫你理解清楚这个过程中的每一处细节. 这一理解是如此重要, 以至于如果你缺少它, 接下来你面对bug几乎是束手无策.
mips32延迟槽和异常
我们在PA2中提到, 标准的mips32处理器采用了分支延迟槽技术. 思考一下, 如果标准的mips32处理器在执行延迟槽指令的时候触发了异常, 从异常返回之后可能会造成什么问题? 该如何解决? 尝试RTFM对比你的解决方案.
温馨提示
PA3阶段1到此结束.