使用Rust编写操作系统 - 2.1 - CPU异常
CPU异常发生在多种错误场景中,如在访问无效的内存地址时或是在除零运算时。为了对错误作出反应,我们需要建立一个提供处理函数的中断描述符表。在本文的结尾,我们的内核将能够捕获断点异常并在处理后恢复正常运行。
这个博客是在GitHub上公开开发的。如果你有任何问题或疑问,请在那里开一个issue。你也可以在底部留言。这篇文章的完整源代码可以在post-05分支中找到。
概述
异常,意味着当前执行的指令有问题。例如,如果当前指令试图除以0,则CPU会发出异常。发生异常时,CPU会中断其当前工作,并根据具体异常的类型,立即调用相应的异常处理函数。
在x86平台上,大概有20种不同的CPU异常类型。其中最重要的是:
- 页面错误:在执行非法的内存访问时将发生页面错误。例如,若当前指令试图从未映射的页面读取或试图向只读页面写入时。
- 无效操作码:在当前指令无效时(例如,当我们尝试在不支持的旧CPU上使用较新的SSE指令时)将发生此异常。
- 常规保护故障:这是异常最常见的诱因。它发生在各种访问冲突中,例如试图在用户级代码中执行特权指令或是向配置寄存器中保留字段进行写入。
- 双重故障:发生异常时,CPU尝试调用相应的处理函数。如果在调用异常处理函数时又发生另一个异常,则CPU会引发双重故障异常。此外,当没有为某异常注册相应的处理函数时,也会引发这个异常。
- 三重故障:如果在CPU尝试调用双重故障异常处理函数时又发生异常,则将引发致命的三重故障。我们无法捕捉或处理三重故障。大多数处理器通过复位并重新引导操作系统来对此故障作出反应。
有关异常的完整列表,请查看OSDev Wiki。
中断描述符表
为了捕获和处理异常,我们必须建立一个所谓的中断描述符表(IDT)。在此表中,我们可以为每个CPU异常指定一个处理函数。硬件会直接使用此表,所以我们需要遵循预定义的格式。每个条目必须具有以下的16字节结构:
类型 | 名称 | 描述 |
---|---|---|
u16 | 函数指针 [0:15] | 处理函数指针的低(16)位。 |
u16 | GDT 选择子 | 全局描述符表中的代码段的选择器 |
u16 | 选项字段 | 参见下表 |
u16 | 函数指针 [16:31] | 处理函数(handler function)指针的中(16)位。 |
u32 | 函数指针 [32:63] | 处理函数(handler function)指针的高(32)位。 |
u32 | 保留位 |
选项字段具有以下格式:
位 | 名称 | 描述 |
---|---|---|
0-2 | 中断栈表索引 | 0:不换栈,1-7:当处理函数被调用时,切换到中断栈表的第n个栈。 |
3-7 | 保留位 | |
8 | 0:中断门,1:陷阱门 | 若此位设置为0,则处理函数被调用的时,中断会被禁用。 |
9-11 | 必须为1 | |
12 | 必须为0 | |
13‑14 | 特权等级描述符(DPL) | 允许调用该处理函数的最小特权等级。 |
15 | 条目是否存在 |
每个异常都有一个预定义的IDT索引。例如,无效操作码异常的表索引为6,而页面错误异常的表索引为14。因此,硬件可以为每个异常自动加载相应的IDT条目。 OSDev wiki中的异常表在”Vector nr.”列展示了所有异常的IDT索引。
发生异常时,CPU大致将执行以下操作:
- 将某些寄存器压栈,包括指令指针和RFLAGS寄存器。(本文接下来将会用到这些值。)
- 从中断描述符表(IDT)中读取相应的条目。例如,发生页面错误时,CPU读取第14个条目。
- 检查是否存在该条目。若没有则引发双重故障。
- 如果该条目是中断门(第40位未置为1),则禁用硬件中断。
- 将指定的GDT选择器加载到CS段中。
- 跳转到指定的处理函数。
现在不必担心第4步和第5步,我们将在以后的文章中了解全局描述符表和硬件中断。
IDT类
我们无需自己创建IDT类,可以直接使用x86_64
crate的InterruptDescriptorTable
结构体,看起来像这样:
1 |
|
这些字段的类型为idt::Entry<F>
,这是一个用于表示IDT条目字段的结构体(请参见上面的表格)。泛型参数F定义了预期的处理函数的类型。我们看到有的条目需要HandlerFunc
,有的则需要HandlerFuncWithErrCode
。而页面错误甚至有其自己的特殊类型:PageFaultHandlerFunc
。
首先让我们看一下HandlerFunc
类型:
1 | type HandlerFunc = extern "x86-interrupt" fn(_: &mut InterruptStackFrame); |
这是extern "x86-interrupt" fn
类型的别名。extern
关键字定义了一个具有外部调用约定的函数,最常见的是与C代码进行通信(extern "C" fn
)的调用约定。不过,此处的x86-interrupt
调用约定又是什么?
中断调用约定
异常与函数调用非常相似,都是CPU跳转到被调用函数的第一条指令并执行它。之后,CPU跳转到返回地址并继续执行父函数。
但是,异常和函数调用之间存在主要区别在于,函数调用是被编译器插入的call
指令会主动调用的,而异常可能发生在任何指令中。为了了解这种差异的后果,我们需要更详细地研究函数调用。
调用约定明确了函数调用的细节。例如,调用约定指定了函数参数的放置位置(例如,在寄存器中还是在栈中)以及如何返回结果。在x86_64的Linux上,以下规则适用于C函数(在System V ABI中指定):
- 前六个整型参数放在在寄存器
rdi
、rsi
、rdx
、rcx
、r8
、r9
中传递 - 其他参数放在栈上传递
- 结果放在
rax
和rdx
上返回
请注意,Rust不遵循C ABI(实际上甚至还没有Rust ABI),因此这些规则仅适用于extern "C" fn
声明的函数。
Preserved寄存器与Scratch寄存器
调用约定将寄存器分为两种:preserved寄存器和scratch寄存器。
在函数调用过程中,preserved寄存器的值必须保持不变。因此,仅当被调用函数(“callee”)确定能够在返回前恢复寄存器原始值时,才可以写这些寄存器。因此,preserved寄存器称为“被调用者保存寄存器”(“callee-saved”)。一个常见用法是在函数开始时将这些寄存器保存在栈中,并在返回之前将其还原。
相比之下,被调用函数可以无限制地写scratch 寄存器。如果调用者想在函数调用期间保留scratch寄存器的值,则需要在函数调用之前进行备份和还原(如将其压入栈中)。因此,scratch寄存器是“调用者保存寄存器”(“caller-saved”)。
在x86_64上,C调用约定指定以下preserved和scratch寄存器:
preserved寄存器 | scratch寄存器 |
---|---|
rbp , rbx , rsp , r12 , r13 , r14 , r15 |
rax , rcx , rdx , rsi , rdi , r8 , r9 , r10 , r11 |
被调用者保存 | 调用者保存 |
编译器知道这些规则,因此会生成合适的代码。例如,大多数函数都以push rbp
开始,即将rbp
备份在栈上(因为它是被调用者保存的寄存器)。
保存所有寄存器
与函数调用不同,任何指令都可能发生异常。在大多数情况下,我们甚至在编译时都不知道生成的代码是否会导致异常。例如,编译器无法知道指令是否会导致栈溢出或页面错误。
由于我们不知道何时发生异常,我们也就无法提前备份任何寄存器。也就是说,调用约定不能以依赖于调用者保存的寄存器的行为作为异常处理程序,而是应该以保存所有寄存器的行为作为异常处理程序。x86-interrupt
调用约定就是如此,因此它可以保证在函数返回时所有寄存器值都恢复到原始值。
注意,这并不意味着函数开始时会将所有寄存器都保存到栈中,实际上编译出的代码仅备份会被函数覆盖的寄存器。这样,可以为仅使用几个寄存器的短函数生成非常高效的代码。
中断栈帧
在正常的函数调用中(使用call
指令),CPU在跳转到目标函数之前先将返回地址压栈。函数返回时(使用ret
指令),CPU将返回地址弹栈并跳转到该地址。因此,普通函数调用的栈帧如下所示:
但是,对于异常和中断处理程序,仅将返回地址压栈是不够的,因为中断处理程序通常在不同的上下文中运行(如栈指针、CPU标志等)。在发生中断时,CPU执行以下步骤:
- 对齐栈指针:任何指令都可能发生中断,因此栈指针也可以是任何值。但是,某些CPU指令(例如某些SSE指令)要求栈指针在16字节边界上对齐,因此CPU在中断后立即执行此类对齐。
- 切换栈(在某些情况下):当CPU特权级别改变时(例如,在用户模式程序中发生CPU异常时),将发生切换栈动作。还可以使用所谓的中断栈表(将在下一篇文章中介绍)为特定的中断配置切换栈。
- 压入旧栈指针:在发生中断时(对齐之前),CPU压入栈指针(
rsp
)和栈段(ss
)寄存器的值。因此,从中断处理程序返回时,这可以恢复栈指针的原始值。 - 压入和更新
RFLAGS
寄存器:RFLAGS寄存器包含各种控制位和状态位。进入中断时,CPU修改一些位并将旧值压栈。 - 压入指令指针:在跳转到中断处理程序功能之前,CPU先压入指令指针(
rip
)和代码段(cs
)。这相当于普通函数调用时的返回地址压栈。 - 压入错误码(对于某些异常):对于某些特定的异常(例如页面错误),CPU压入一个错误码,用于描述异常原因。
- 调用中断处理函数:CPU从IDT的相应字段读取中断处理函数的地址和段描述符。之后,通过将这些值加载到
rip
和cs
寄存器中来调用中断处理函数。
因此,中断栈帧如下所示:
在x86_64
crate中,中断栈帧由InterruptStackFrame
结构体表示。结构体被以&mut
的形式传递给中断处理函数,可用于获取异常原因相关信息。该结构体不包含错误码字段,因为只有少数异常会推送错误码。这些少数异常使用单独的HandlerFuncWithErrCode
函数类型,该函数类型具有附加的error_code
参数。
后台细节
x86-interrupt
调用约定是一个强大的抽象,它几乎隐藏了异常处理过程的所有杂乱细节。但是,有时了解幕后的执行细节会很有用。这是x86-interrupt
调用约定要处理的事情的简短概述:
- 取回参数:大多数调用约定都希望参数在寄存器中传递。这对于异常处理程序是不可能的,因为在将参数备份到栈上之前,我们绝不能覆盖任何寄存器。而
x86-interrupt
调用约定知道这些参数已经以特定的偏移量存放在栈上了。 - 使用
iretq
返回:中断栈帧与常规函数调用的栈帧完全不同,这使得我们无法通过常规的ret
指令从中断处理函数中返回。相应的我们必须使用iretq
指令。 - 处理错误码:为某些异常而将错误码压栈会使事情变得更加复杂。这会更改栈的对齐方式(请参阅下一点),并且在需要返回前弹栈。
x86-interrupt
中断调用约定封装了这些复杂过程。但是,它并不知道哪个处理程序函数该用于哪个异常,因此它需要从函数参数的数量中推断出该信息。这意味着程序员仍然有责任为每个异常选择正确的处理函数类型。幸运的是,由x86_64
crate定义的InterruptDescriptorTable
类型可确保使用正确的函数类型。 - 栈对齐:有些指令(尤其是SSE指令)需要16字节的栈对齐。CPU会在发生异常时确保这种对齐,但是某些异常会在将错误码压栈后再次破坏对齐。此情况发生时,
x86-interrupt
调用约定会通过重新对齐栈来解决此问题。
如果你对更多细节感兴趣:我们还有一系列文章,这些文章解释了如何使用文末的裸函数进行异常处理。
实现
现在,我们了解了中断的原理,是时候在内核中处理CPU异常了。我们将从在src/interrupts.rs
中创建一个新的中断模块开始,该模块首先新建一个init_idt
函数,用于创建一个新的InterruptDescriptorTable
实例:
1 | pub mod interrupts; |
1 | use x86_64::structures::idt::InterruptDescriptorTable; |
现在我们可以添加异常处理函数了,首先为断点异常添加处理程序。断点异常是测试异常处理函数的极佳案例。它的唯一用途是在执行断点指令int3
时临时暂停程序。
断点异常通常在调试器(debugger)中使用:当用户设置断点时,调试器用int3
指令覆盖断点行的原指令,以便在CPU执行到该行时抛出断点异常。当用户想要继续执行程序时,调试器将int3
指令再次替换为原指令,然后继续执行程序。有关更多详细信息,请参见“调试器的工作方式”系列。
在我们的例子中,并不需要覆盖任何指令,只需要在执行断点指令时打印一条消息,然后继续执行该程序。因此,让我们创建一个简单的breakpoint_handler
函数并将其添加到IDT中:
1 | use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame}; |
我们的处理程序仅输出一条消息,并使用pretty-prints样式打印中断栈帧以提升可读性。
尝试对其进行编译会发生以下错误:
1 | error[E0658]: x86-interrupt ABI is experimental and subject to change (see issue #40180) |
发生此错误的原因是x86-interrupt
调用约定这一特性仍然处于开发状态,还不稳定。如果一定要使用该特性,则必须通过在lib.rs
顶部显示的添加#![feature(abi_x86_interrupt)]
。
加载IDT
我们需要使用lidt
指令让CPU加载新建的中断描述符表。x86_64
crate的InterruptDescriptorTable
结构体将该指令封装为load
方法。让我们尝试使用它:
1 | pub fn init_idt() { |
此时编译将出现以下错误:
1 | error: `idt` does not live long enough |
查看load
方法的文档可知,调用该方法的对象应为&'static self
,即该引用变量需要在程序运行时的整个生命周期中保持有效。这是因为CPU在每次中断时都会访问该表,直到我们加载了不同的IDT。因此,使用任何比'static
短的生命周期都可能导致“析构后使用”的错误。
而这正是此编译错误的原因。我们的idt
创建在栈上,仅在init
函数内部有效。该函数结束后,栈空间将重新分配给其他函数,因此CPU可能会将栈空间中的随机内容当做IDT。幸运的是,InterruptDescriptorTable::load
方法在其函数定义中声明了生命周期要求,于是Rust编译器能够在编译时防止这种可能的错误。
要解决这个编译错误,我们需要将idt
存储在具有'static
生命周期的地方。为此,我们通常可以使用Box
为IDT在堆上分配空间,再将其转换为'static
引用,但是由于我们正在编写OS内核,因此目前并没有堆可用。
作为替代方案,我们可以尝试将IDT存储为static
:
1 | static IDT: InterruptDescriptorTable = InterruptDescriptorTable::new(); |
但是这里有一个问题:静态变量是不可变的,因此我们并不能在init
函数中修改断点条目。通常可以通过使用static mut
解决此问题:
1 | static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new(); |
虽然该方法可以通过编译,但这一用法极为罕见。因为static mut
变量非常容易发生数据竞争,导致我们只能在unsafe
块中进行对该变量的访问。
使用惰性static救场
幸运的是我们有lazy_static
宏。相较于普通静态变量在编译时求值,该宏可以让静态变量在第一次使用时才执行初始化。因此,我们几乎可以在该宏的初始化块中执行任何操作,甚至可以读取运行时的值。
在为VGA文本缓冲区创建抽象时,我们就导入lazy_static
crate了,这里可以直接使用lazy_static!
宏来创建惰性静态IDT:
1 | use lazy_static::lazy_static; |
留意此处是如何解决使用unsafe
块问题的。lazy_static!
宏实际上在后台也使用了unsafe
块,只是将其封装抽象成了安全接口对外提供。
运行起来
让异常处理在内核中起作用的最后一步是在main.rs
中调用init_idt
函数。这里我们不选择直接调用该函数,而选择在lib.rs
中引入了一个通用的总初始化函数init
:
1 | pub fn init() { |
有了这个函数,我们便可以将初始化例程中各种操作放置于此,这样就能够在main.rs
、lib.rs
和集成测试中的不同_start
函数之间共享这些例程。
现在,我们可以更新main.rs
的_start
函数以调用init
,然后触发断点异常:
1 |
|
当我们在QEMU中运行它(使用cargo run
)时,将看到以下内容:
成功了!CPU成功调用了我们的断点处理程序,该断点处理程序将打印消息,然后返回到_start
函数,继续打印出It did not crash!
。
我们看到,发生异常时,中断栈帧会告诉我们指令指针和栈指针。在调试未预期的异常时,此信息将非常有用。
添加测试
让我们创建一个测试,以确保上述操作持续有效。首先,我们更新_start
函数以也调用init
:
1 | /// Entry point for `cargo test` |
请记住,运行cargo test --lib
时才会使用此_start
函数,因为Rust的lib.rs
测试完全独立于main.rs
。在运行测试之前,我们需要在此处调用init
来设置IDT。
现在我们可以创建一个test_breakpoint_exception
测试:
1 |
|
该测试调用int3
函数来触发断点异常。通过检查之后执行是否继续,我们可以验证断点处理程序是否正常运行。
你可以通过运行cargo test
(执行所有测试)或cargo test --lib
(仅执行lib.rs
及其模块测试)来尝试此新测试。在输出中应该看到以下内容:
1 | blog_os::interrupts::test_breakpoint_exception... [ok] |
使用太多奇妙魔法了吗?
x86-interrupt
调用约定和InterruptDescriptorTable
类型使异常处理过程相对简单明了。如果这对你来说太神奇了,而你又希望了解异常处理的所有细节,可以继续阅读我们的“使用裸函数处理异常”系列文章,这些文章展示了如何在不使用x86-interrupt
调用约定的情况下处理异常,文章还创建了自己的IDT类型。从现在看来,这些旧文章介绍了x86-interrupt
调用约定和x86_64
crate出现之前的主要异常处理方法。请注意,这些帖子基于此博客的第一版,可能已过时。
下期预告
我们已经成功捕获了第一个异常并从中返回了!下一步是确保你能跟捕获所有异常,因为未捕获的异常会导致致命的三重故障,从而导致系统重置。下一篇文章将要解释如何通过正确捕获双重故障来避免这种情况的发生。
支持本项目
创建和维护这个博客和相关库是一项繁重的工作,但我真的很喜欢。通过支持我,您可以让我在新内容、新功能和持续维护上投入更多时间。
支持我的最好方式是在GitHub上赞助我,因为他们不收取任何中间费用。如果你喜欢其他平台,我也有Patreon和Donorbox账户。后者是最灵活的,因为它支持多种货币和一次性捐款。
感谢您的支持!