使用Rust编写操作系统 - 2.2 - 双重故障
本文将详细探讨双重故障异常,这种异常是在CPU无法调用异常处理程序时发生的。通过处理此异常,我们能够避免导致系统重置的致命三重故障。为了能够在任何情况下防止三重故障,我们还将建立一个中断栈表,以便在单独的内核栈上捕获双重故障。
这个博客是在GitHub上公开开发的。如果你有任何问题或疑问,请在那里开一个issue。你也可以在底部留言。这篇文章的完整源代码可以在post-06分支中找到。
何为双重故障?
总的来说,双重故障是一种特殊异常,当CPU无法调用异常处理程序时才会诱发这种异常,例如当发生页面错误却并未在中断描述符表(IDT)中注册页面错误处理程序时。这类似于编程语言中用于捕获所有异常的代码块,比如像C++中的catch(...)
或是像Java及C#中的catch(Exception e)
。
双重故障的行为与普通异常类似。它的向量索引为8
,我们可以在IDT中为其定义一个普通的处理函数。提供双重故障处理程序非常重要,因为如果未处理双重故障,则会发生致命的三重故障。三重故障无法被捕获,而大多数硬件对三重故障做出的反应就是系统复位。
触发双重故障
让我们通过触发一个未注册处理函数的异常来引发双重故障:
1 |
|
我们使用unsafe
块向无效地址0xdeadbeef
写入数据。虚拟地址未映射到页表中的物理地址,于是发生页面错误。我们尚未在IDT中注册页面错误处理程序,因此发生了双重故障。
现在启动内核时,我们看到它陷入无限重启。重启原因如下:
- CPU尝试向
0xdeadbeef
写入,这将导致页面错误。 - CPU查找IDT中的相应条目,发现该条目未指定任何处理函数。因此,它不能调用页面错误处理程序,并诱发双重故障。
- CPU查看双重故障处理程序的IDT条目,但该条目同样未指定处理函数。于是,诱发三重故障。
- 三重故障是致命的。QEMU像大多数真实硬件一样对此做出反应——命令系统重置。
为了防止出现三重故障,我们需要为页面错误提供处理函数,或者为双重故障提供处理函数。我们希望在任何情况下都能够避免三重故障,因此,我们从所有未注册异常都将调用的双重故障入手解决此类问题。
双重故障处理程序
双重故障是一个带有错误码的普通异常,因此我们指定的函数类似于断点处理函数:
1 | lazy_static! { |
处理程序将输出一条简短的错误消息,并转储异常栈帧。双重故障处理程序的错误码始终为零,因此没有必要打印它。与断点处理程序的不同之处在于,双重故障处理程序是发散函数,这是因为x86_64
架构禁止从双重故障异常中返回。
现在启动内核时,应该看到调用了双重故障处理程序:
生效了!这次的执行过程如下:
- CPU尝试写入
0xdeadbeef
,这将导致页面错误。 - 像以前一样,CPU查找IDT中的相应条目,发现未定义任何处理函数,于是诱发双重故障。
- CPU跳至我们新注册的双重故障处理程序。
由于CPU现在可以调用双重故障处理程序,因此不再诱发三次故障(无限重启)。
如此简单!那么,为什么我们需要为这一主题撰写一整篇文章呢?好了,我们现在可以捕获大多数双重故障,但是在某些情况下,我们目前的方案仍不够用。
双重故障诱因
在查看特殊情况之前,我们需要知道双重故障的确切诱因。在上一节中我们使用了一个非常模糊的定义:
双重故障是一种特殊异常,当CPU无法调用异常处理程序时才会诱发这种异常。
“无法调用”的确切含义是什么?该处理程序不存在吗?处理程序被换出了吗?如果处理程序本身又导致异常时会发生什么呢?
例如,考虑以下情况发生时:
- 发生断点异常,但是相应的处理函数被换出时?
- 发生页面错误,但是页面错误处理程序被换出时?
- 除零处理程序会导致断点异常,但是该断点处理程序被换出时?
- 我们的内核栈溢出了,同时命中保护页时?
幸运的是,AMD64手册(PDF)中描述了准确定义(位于第8.2.9节)。根据该描述,“在执行先前(第一个)异常处理程序期间发生第二个异常时,可能会诱发双重故障异常”。 这个“可能”很重要:只有非常特殊的异常组合才会导致双重故障。这些组合是:
第一个异常 | 第二个异常 |
---|---|
除0错误, 无效任务状态段, 段不存在, 栈段错误, 一般性保护错误 |
无效任务状态段, 段不存在, 栈段错误, 一般性保护错误 |
页面错误 | 页面错误, 无效任务状态段, 段不存在, 栈段错误, 一般性保护错误 |
于是,诸如除零错误后接页面错误就相安无事(继续调用页面错误处理程序),但是除零错误后接一般性保护错误就会导致双重故障。
借助此表,我们可以回答上述四个问题中的前三个:
- 如果发生断点异常,同时相应的处理函数被换出,则会发生页面错误,并调用页面错误处理程序。
- 如果发生页面错误,同时页面错误处理程序被换出,则会发生双重故障,并调用双重故障处理程序。
- 如果除零错误处理程序导致断点异常,则CPU会尝试调用断点处理程序。如果断点处理程序被换出,则会发生页面错误并调用页面错误处理程序。
实际上,即使没有在IDT中注册处理函数的异常的情况也遵循此方案:当发生异常时,CPU会尝试读取相应的IDT条目。由于该条目为0,即无效的IDT条目,因此会诱发一般性保护错误。我们也没有为一般保护错误定义处理函数,因此会诱发另一个一般性保护故障。根据上表,这将导致双重故障。
内核栈溢出
让我们看第四个问题:
如果我们的内核栈溢出且命中保护页,会发生什么?
保护页是栈底的特殊内存页,可用来检测栈溢出。该页面未映射到任何物理内存,因此对其进行的访问动作将会导致页面错误,而不是静默的破坏内存其他数据。bootloader为我们的内核栈设置了一个保护页面,因此栈溢出会导致页面错误。
当发生页面错误时,CPU在IDT中查找页面错误处理程序,并尝试将中断栈帧压栈。但是,当前的栈指针仍指向不存在的保护页。于是,发生第二个页面错误,这将导致双重故障(根据上表)。
现在CPU将尝试调用双重故障处理程序。但是,在出现双重故障时,CPU也会尝试压入异常栈帧。此时栈指针仍指向保护页,于是发生第三个页错误,这将导致三重故障并使系统重启。可见,在这种情况下,目前的双重故障处理程序无法避免三重故障。
让我们自己尝试一下!通过调用无限递归函数就可以轻松诱发内核栈溢出:
1 | // don't mangle the name of this function |
当我们在QEMU中运行这段代码时,将看到系统再次陷入无限重启。
那么应该怎样避免这个问题呢?我们不能忽略异常栈帧压栈,因为这是CPU的硬件行为。因此,我们需要确保在发生双重故障时栈不会溢出。幸运的是,x86_64架构可以解决此问题。
切换栈
当发生异常时,x86_64架构能够切换到预定义的已知良好的栈上。此切换发生在硬件级别,因此可以在CPU推送异常栈帧之前执行。
切换机制通过中断栈表(IST)实现。IST是由7个指向已知良好栈的指针组成的表。以Rust伪代码描述类似:
1 | struct InterruptStackTable { |
对于每个异常处理程序,我们可以通过相应IDT条目中的stack_pointers
参数在IST中指定一个栈。例如,我们可以将IST中的第一个栈用于双重故障处理程序。此后,每当发生双重故障时,CPU都会自动切换到该栈。该切换将发生在一切压栈动作之前,因此能够防止三重故障。
IST和TSS
中断栈表(IST)是旧时代遗留的结构体任务状态段(TSS)中的一部分。在32位模式下TSS用于保存有关任务的各种信息(如处理器寄存器状态),例如用于硬件上下文切换。但是,在64位模式下不再支持硬件上下文切换,并且TSS的格式已完全更改。
在x86_64上,TSS不再用于保存任务相关信息。现在,它包含两个栈表(IST便是其中之一)。32位和64位TSS之间的唯一公共字段指向I/O port permissions bitmap。
64位TSS具有以下格式:
Field | Type |
---|---|
(保留位) | u32 |
特权栈表 | [u64; 3] |
(保留位) | u64 |
中断栈表 | [u64; 7] |
(保留位) | u64 |
(保留位) | u16 |
I/O映射基地址 | u16 |
当特权级别变更时,CPU使用特权栈表。例如,如果在CPU处于用户模式(特权级别3)时发生异常,则在调用异常处理程序之前,CPU通常会切换到内核模式(特权级别0)。在这种情况下,CPU将切换到“特权栈表”中的第0个栈(因为0是目标特权级别)。我们目前还没有任何用户模式程序,因此我们暂时忽略此表。
新建TSS
让我们创建一个新的TSS,并在其中断栈表中包含一个单独的双重故障栈。为此,我们需要一个TSS结构体。幸运的是,x86_64
crate已经包含了TaskStateSegment结构体
。
我们在新的gdt
(稍后会解释这个缩写的意义)模块中创建TSS:
1 | pub mod gdt; |
1 | use x86_64::VirtAddr; |
这里使用lazy_static
是因为Rust的常量求值器还不够强大,无法在编译时进行上面的初始化操作。我们定义第0个IST条目为双重故障栈(换做其他任何IST条目均可)。再将双重故障栈的高位地址写入第0个条目。写入高位地址是因为x86上的栈向下增长,即从高位地址到低位地址(译注:即高位为栈底,低位为栈顶)。
我们尚未实现内存管理,因此目前并没有一个合适的方法能够用于新栈的分配。作为代替,我们使用static mut
数组作为栈存储空间。这里需要使用unsafe
块,因为在访问可变静态变量时,编译器无法保证数据竞争条件。重要的是它是一个static mut
而不是一个普通static
,否则bootloader会将其映射到只读页面。我们将在以后的文章中将其替换为适当的栈分配方法,使得这里不再需要unsafe
块。
请注意,此双重故障栈并没用以防止栈溢出的保护页面。这意味着我们不应该在双重故障处理程序中执行密集的栈操作,从而导致栈溢出并破坏栈下方的内存。
加载TSS
我们创建了一个新的TSS,现在需要告诉CPU它应该使用这个新TSS。不幸的是,这有点麻烦,因为TSS使用分段系统(出于历史原因)。这里我们不应也不能直接加载表,而应向全局描述符表(GDT)添加新的段描述符。之后,就可以使用相应的GDT索引调用ltr指令来加载我们的TSS。(这就是为什么我们将模块命名为gdt
。)
全局描述符表
全局描述符表(GDT)是旧时代遗留下来的,出现在内存分页成为事实上的标准之前,当时用来进行内存分段。不过它仍然在64位模式下的多种操作中起作用,例如内核模式/用户模式的配置或TSS的加载。
GDT是包含程序段的结构体,在内存分页成为标准之前的旧架构中,用于将程序彼此隔离。有关分段的更多信息,请查阅一本名为“Three Easy Pieces”的免费书籍中的同名章节。虽然在64位模式下不再支持分段,但是GDT仍然存在。现在它主要用于两件事:在内核空间和用户空间之间切换,以及加载TSS结构。
创建GDT
让我们创建一个静态GDT
,其中包含我们的静态变量TSS
:
1 | use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor}; |
继续通过lazy_static
,用代码段和TSS段创建一个新的GDT。
加载GDT
创建一个新的gdt::init
函数用于载GDT,我们再从(译注:lib.rs
中的)总init
函数中调用该初始化:
1 | pub fn init() { |
1 | pub fn init() { |
现在GDT已加载(因为_start
函数调用了总init
),但是我们仍然看到栈溢出时的无限重启。
最后一步
此时的问题在于新的GDT段尚未激活,因为段和TSS寄存器仍为旧GDT中的值。我们还需要修改双重故障IDT条目,使其能够使用新栈。
总之,我们需要执行以下操作:
- 重载代码段寄存器:我们更改了GDT,应该重载代码段寄存器
cs
。这是必需的,因为旧的段选择器现在可能指向其他GDT描述符(例如TSS描述符)。 - 加载TSS:我们加载了一个包含TSS选择器的GDT,但是我们仍然需要告诉CPU去使用这个新的TSS。
- 更新IDT条目:一旦加载了TSS,CPU就能够访问有效的中断栈表(IST)了。然后,通过修改双重故障的IDT条目,就可以告诉CPU它应该使用新的双重故障栈了。
对于前两个步骤,我们需要访问gdt::init
函数中的code_selector
和tss_selector
变量。要使得这两个变量能够被访问,我们可以通过新建Selectors
结构体使它们成为静态变量的一部分:
1 | use x86_64::structures::gdt::SegmentSelector; |
现在,可以使用选择器来重载cs
段寄存器并加载我们的TSS:
1 | pub fn init() { |
我们使用set_cs
重载代码段寄存器,并使用load_tss
加载TSS。这两个函数被标记为unsafe
,因此需要在unsafe
块中调用——它们可能会因为加载了无效选择器而破坏内存安全。
我们已经加载了有效的TSS和中断堆栈表,现在,可以在IDT中为双重故障处理程序设置栈索引了:
1 | use crate::gdt; |
set_stack_index
方法是非安全的,调用者必须确保使用的索引有效,并且未用于其他异常。
现在,每当发生双重故障时,CPU应该都能切换到双重故障栈。因此,我们能够捕获所有双重故障,包括内核栈溢出:
从现在开始,我们再也不会看到三重故障!为确保我们不会意外地破坏以上操作,我们应该为此添加一个测试。
栈溢出测试
为了测试新写的gdt
模块,并确保在栈溢出时正确调用了双重故障处理程序,我们可以添加一个集成测试。大致思路是在测试函数中引发双重故障,以验证是否调用了双重故障处理程序。
让我们从一个最小化的测试程序:
1 |
|
就像我们的panic_handler
测试一样,该测试将在没有测试环境的条件下运行。这是因为出现双重错误后程序无法继续执行,因此执行多于一个的测试是没有意义的。要禁用测试的测试环境,我们将以下内容添加到我们的Cargo.toml中:
1 | [[test]] |
现在,cargo test --test stack_overflow
应该可以编译。当然,运行测试会失败,因为unimplemented
宏会引起panic。
实现_start
_start
函数的实现将会像这样:
1 | use blog_os::serial_print; |
这里不调用interrupts::init_idt
函数,而是调用gdt::init
函数来初始化新的GDT,原因是我们要注册一个自定义双重故障处理程序,它将执行exit_qemu(QemuExitCode::Success)
退出,而不是直接panic。我们还将调用init_test_idt
函数,稍后将对其进行说明。
stack_overflow
函数与main.rs
中的函数几乎相同。唯一的不同是,我们在函数末尾使用Volatile
类型进行了额外的易失性读取,以防止称为尾调用消除的编译器优化。此优化允许编译器将最后一条语句为递归调用的递归函数,从递归调用函数转换为带有循环的普通函数(译注:尾递归优化将递归化为循环)。若有此优化,则递归化为循环后函数将不再会新建额外的栈帧(用于返回地址压栈),于是该函数对于栈的使用将变为常量(译注:循环没有返回地址压栈环节,相较于递归会大幅提升执行效率与资源利用率)。
但是,在我们的情况下,我们的确希望栈溢出的发生,于是我们在函数的末尾添加了一个假的易失性读操作,以禁止编译器删除该语句。因此,该函数不再是尾递归,就可以防止递归转换为循环。我们还添加了allow(unconditional_recursion)
属性,以使编译器保持不对这个无限递归的函数发出编译警告。
测试IDT
如上所述,测试需要使用自己的IDT,并自定义双重故障处理程序。实现看起来像这样:
1 | use lazy_static::lazy_static; |
该实现非常类似于我们在interrupts.rs
中的IDT。就像在原来的IDT中,给用于双重故障处理程序的IST设置栈索引,以便触发异常时切换到这个已知良好的栈。最后init_test_idt
函数通过load
方法将IDT加载到CPU上。
双重故障处理程序
唯一缺少的部分是我们的双重故障处理程序。看起来像这样:
1 | use blog_os::{exit_qemu, QemuExitCode, serial_println}; |
调用双重故障处理程序时,我们以成功码退出QEMU,该代码将测试标记为已通过。由于集成测试是完全独立的可执行文件,因此我们仍需要在测试文件的顶部设置#![feature(abi_x86_interrupt)]
属性。
现在,我们可以通过cargo test --test stack_overflow
来运行该测试(或通过cargo test
运行所有测试)。不出所料,我们在控制台中看到输出stack_overflow... [ok]
。尝试注释掉set_stack_index
一行:它应该导致测试失败。
小结
在这篇文章中,我们了解了什么是双重故障以及它将会在在什么情况下会发生。我们添加了一个基本的双重故障处理程序,该处理程序可以打印一条错误消息,并为此添加了集成测试。
我们还启用了硬件支持的双重故障异常上的切换栈功能,这保证了在栈溢出时程序依然能够正常运行。在实现它的过程中,我们了解了任务状态段(TSS)和其中包含的中断堆栈表(IST),以及用于在旧架构上进行内存分段的全局描述符表(GDT)。
下期预告
下一篇文章将介绍如何处理来自外部设备(如计时器,键盘或网络控制器)的中断。这些硬件中断与异常非常相似,比如它们同样也通过IDT调度。但是,与异常不同,它们不会直接出现在CPU上,而是会汇总在中断控制器上,然后根据优先级将它们转发给CPU。在接下来的内容中,我们将探索Intel 8259(“PIC”)中断控制器,并学习如何实现对键盘的支持。
支持本项目
创建和维护这个博客和相关库是一项繁重的工作,但我真的很喜欢。通过支持我,您可以让我在新内容、新功能和持续维护上投入更多时间。
支持我的最好方式是在GitHub上赞助我,因为他们不收取任何中间费用。如果你喜欢其他平台,我也有Patreon和Donorbox账户。后者是最灵活的,因为它支持多种货币和一次性捐款。
感谢您的支持!