使用Rust编写操作系统 - 2.2 - 双重故障

本文将详细探讨双重故障异常,这种异常是在CPU无法调用异常处理程序时发生的。通过处理此异常,我们能够避免导致系统重置的致命三重故障。为了能够在任何情况下防止三重故障,我们还将建立一个中断栈表,以便在单独的内核栈上捕获双重故障。

这个博客是在GitHub上公开开发的。如果你有任何问题或疑问,请在那里开一个issue。你也可以在底部留言。这篇文章的完整源代码可以在post-06分支中找到。

何为双重故障?

总的来说,双重故障是一种特殊异常,当CPU无法调用异常处理程序时才会诱发这种异常,例如当发生页面错误却并未在中断描述符表(IDT)中注册页面错误处理程序时。这类似于编程语言中用于捕获所有异常的代码块,比如像C++中的catch(...)或是像Java及C#中的catch(Exception e)

双重故障的行为与普通异常类似。它的向量索引为8,我们可以在IDT中为其定义一个普通的处理函数。提供双重故障处理程序非常重要,因为如果未处理双重故障,则会发生致命的三重故障。三重故障无法被捕获,而大多数硬件对三重故障做出的反应就是系统复位。

触发双重故障

让我们通过触发一个未注册处理函数的异常来引发双重故障:

in src/main.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");

blog_os::init();

// 触发页面错误
unsafe {
*(0xdeadbeef as *mut u64) = 42;
};

// as before
#[cfg(test)]
test_main();

println!("It did not crash!");
loop {}
}

我们使用unsafe块向无效地址0xdeadbeef写入数据。虚拟地址未映射到页表中的物理地址,于是发生页面错误。我们尚未在IDT中注册页面错误处理程序,因此发生了双重故障。

现在启动内核时,我们看到它陷入无限重启。重启原因如下:

  1. CPU尝试向0xdeadbeef写入,这将导致页面错误。
  2. CPU查找IDT中的相应条目,发现该条目未指定任何处理函数。因此,它不能调用页面错误处理程序,并诱发双重故障。
  3. CPU查看双重故障处理程序的IDT条目,但该条目同样未指定处理函数。于是,诱发三重故障
  4. 三重故障是致命的。QEMU像大多数真实硬件一样对此做出反应——命令系统重置。

为了防止出现三重故障,我们需要为页面错误提供处理函数,或者为双重故障提供处理函数。我们希望在任何情况下都能够避免三重故障,因此,我们从所有未注册异常都将调用的双重故障入手解决此类问题。

双重故障处理程序

双重故障是一个带有错误码的普通异常,因此我们指定的函数类似于断点处理函数:

in src/interrupts.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.double_fault.set_handler_fn(double_fault_handler); // new
idt
};
}

// new
extern "x86-interrupt" fn double_fault_handler(
stack_frame: &mut InterruptStackFrame, _error_code: u64) -> !
{
panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);
}

处理程序将输出一条简短的错误消息,并转储异常栈帧。双重故障处理程序的错误码始终为零,因此没有必要打印它。与断点处理程序的不同之处在于,双重故障处理程序是发散函数,这是因为x86_64架构禁止从双重故障异常中返回。

现在启动内核时,应该看到调用了双重故障处理程序:

双重故障异常

生效了!这次的执行过程如下:

  1. CPU尝试写入0xdeadbeef,这将导致页面错误。
  2. 像以前一样,CPU查找IDT中的相应条目,发现未定义任何处理函数,于是诱发双重故障。
  3. CPU跳至我们新注册的双重故障处理程序。

由于CPU现在可以调用双重故障处理程序,因此不再诱发三次故障(无限重启)。

如此简单!那么,为什么我们需要为这一主题撰写一整篇文章呢?好了,我们现在可以捕获大多数双重故障,但是在某些情况下,我们目前的方案仍不够用。

双重故障诱因

在查看特殊情况之前,我们需要知道双重故障的确切诱因。在上一节中我们使用了一个非常模糊的定义:

双重故障是一种特殊异常,当CPU无法调用异常处理程序时才会诱发这种异常。

“无法调用”的确切含义是什么?该处理程序不存在吗?处理程序被换出了吗?如果处理程序本身又导致异常时会发生什么呢?

例如,考虑以下情况发生时:

  1. 发生断点异常,但是相应的处理函数被换出时?
  2. 发生页面错误,但是页面错误处理程序被换出时?
  3. 除零处理程序会导致断点异常,但是该断点处理程序被换出时?
  4. 我们的内核栈溢出了,同时命中保护页时?

幸运的是,AMD64手册(PDF)中描述了准确定义(位于第8.2.9节)。根据该描述,“在执行先前(第一个)异常处理程序期间发生第二个异常时,可能会诱发双重故障异常”。 这个“可能”很重要:只有非常特殊的异常组合才会导致双重故障。这些组合是:

第一个异常 第二个异常
除0错误
无效任务状态段
段不存在
栈段错误
一般性保护错误
无效任务状态段
段不存在
栈段错误
一般性保护错误
页面错误 页面错误
无效任务状态段
段不存在
栈段错误
一般性保护错误

于是,诸如除零错误后接页面错误就相安无事(继续调用页面错误处理程序),但是除零错误后接一般性保护错误就会导致双重故障。

借助此表,我们可以回答上述四个问题中的前三个:

  1. 如果发生断点异常,同时相应的处理函数被换出,则会发生页面错误,并调用页面错误处理程序。
  2. 如果发生页面错误,同时页面错误处理程序被换出,则会发生双重故障,并调用双重故障处理程序。
  3. 如果除零错误处理程序导致断点异常,则CPU会尝试调用断点处理程序。如果断点处理程序被换出,则会发生页面错误并调用页面错误处理程序。

实际上,即使没有在IDT中注册处理函数的异常的情况也遵循此方案:当发生异常时,CPU会尝试读取相应的IDT条目。由于该条目为0,即无效的IDT条目,因此会诱发一般性保护错误。我们也没有为一般保护错误定义处理函数,因此会诱发另一个一般性保护故障。根据上表,这将导致双重故障。

内核栈溢出

让我们看第四个问题:

如果我们的内核栈溢出且命中保护页,会发生什么?

保护页是栈底的特殊内存页,可用来检测栈溢出。该页面未映射到任何物理内存,因此对其进行的访问动作将会导致页面错误,而不是静默的破坏内存其他数据。bootloader为我们的内核栈设置了一个保护页面,因此栈溢出会导致页面错误

当发生页面错误时,CPU在IDT中查找页面错误处理程序,并尝试将中断栈帧压栈。但是,当前的栈指针仍指向不存在的保护页。于是,发生第二个页面错误,这将导致双重故障(根据上表)。

现在CPU将尝试调用双重故障处理程序。但是,在出现双重故障时,CPU也会尝试压入异常栈帧。此时栈指针仍指向保护页,于是发生第三个页错误,这将导致三重故障并使系统重启。可见,在这种情况下,目前的双重故障处理程序无法避免三重故障。

让我们自己尝试一下!通过调用无限递归函数就可以轻松诱发内核栈溢出:

in src/main.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");

blog_os::init();

fn stack_overflow() {
stack_overflow(); // 每次递归都会将返回地址压栈
}

// 触发栈溢出
stack_overflow();

[…] // test_main(), println(…), and loop {}
}

当我们在QEMU中运行这段代码时,将看到系统再次陷入无限重启。

那么应该怎样避免这个问题呢?我们不能忽略异常栈帧压栈,因为这是CPU的硬件行为。因此,我们需要确保在发生双重故障时栈不会溢出。幸运的是,x86_64架构可以解决此问题。

切换栈

当发生异常时,x86_64架构能够切换到预定义的已知良好的栈上。此切换发生在硬件级别,因此可以在CPU推送异常栈帧之前执行。

切换机制通过中断栈表(IST)实现。IST是由7个指向已知良好栈的指针组成的表。以Rust伪代码描述类似:

1
2
3
struct InterruptStackTable {
stack_pointers: [Option<StackPointer>; 7],
}

对于每个异常处理程序,我们可以通过相应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_64crate已经包含了TaskStateSegment结构体

我们在新的gdt(稍后会解释这个缩写的意义)模块中创建TSS:

in src/lib.rs
1
pub mod gdt;
in src/gdt.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use x86_64::VirtAddr;
use x86_64::structures::tss::TaskStateSegment;
use lazy_static::lazy_static;

pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;

lazy_static! {
static ref TSS: TaskStateSegment = {
let mut tss = TaskStateSegment::new();
tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
const STACK_SIZE: usize = 4096 * 5;
static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];

let stack_start = VirtAddr::from_ptr(unsafe { &STACK });
let stack_end = stack_start + STACK_SIZE;
stack_end
};
tss
};
}

这里使用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

in src/gdt.rs
1
2
3
4
5
6
7
8
9
10
use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor};

lazy_static! {
static ref GDT: GlobalDescriptorTable = {
let mut gdt = GlobalDescriptorTable::new();
gdt.add_entry(Descriptor::kernel_code_segment());
gdt.add_entry(Descriptor::tss_segment(&TSS));
gdt
};
}

继续通过lazy_static,用代码段和TSS段创建一个新的GDT。

加载GDT

创建一个新的gdt::init函数用于载GDT,我们再从(译注:lib.rs中的)总init函数中调用该初始化:

in src/gdt.rs
1
2
3
pub fn init() {
GDT.load();
}
in src/lib.rs
1
2
3
4
pub fn init() {
gdt::init();
interrupts::init_idt();
}

现在GDT已加载(因为_start函数调用了总init),但是我们仍然看到栈溢出时的无限重启。

最后一步

此时的问题在于新的GDT段尚未激活,因为段和TSS寄存器仍为旧GDT中的值。我们还需要修改双重故障IDT条目,使其能够使用新栈。

总之,我们需要执行以下操作:

  1. 重载代码段寄存器:我​​们更改了GDT,应该重载代码段寄存器cs。这是必需的,因为旧的段选择器现在可能指向其他GDT描述符(例如TSS描述符)。
  2. 加载TSS:我们加载了一个包含TSS选择器的GDT,但是我们仍然需要告诉CPU去使用这个新的TSS。
  3. 更新IDT条目:一旦加载了TSS,CPU就能够访问有效的中断栈表(IST)了。然后,通过修改双重故障的IDT条目,就可以告诉CPU它应该使用新的双重故障栈了。

对于前两个步骤,我们需要访问gdt::init函数中的code_selectortss_selector变量。要使得这两个变量能够被访问,我们可以通过新建Selectors结构体使它们成为静态变量的一部分:

in src/gdt.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use x86_64::structures::gdt::SegmentSelector;

lazy_static! {
static ref GDT: (GlobalDescriptorTable, Selectors) = {
let mut gdt = GlobalDescriptorTable::new();
let code_selector = gdt.add_entry(Descriptor::kernel_code_segment());
let tss_selector = gdt.add_entry(Descriptor::tss_segment(&TSS));
(gdt, Selectors { code_selector, tss_selector })
};
}

struct Selectors {
code_selector: SegmentSelector,
tss_selector: SegmentSelector,
}

现在,可以使用选择器来重载cs段寄存器并加载我们的TSS:

in src/gdt.rs
1
2
3
4
5
6
7
8
9
10
pub fn init() {
use x86_64::instructions::segmentation::set_cs;
use x86_64::instructions::tables::load_tss;

GDT.0.load();
unsafe {
set_cs(GDT.1.code_selector);
load_tss(GDT.1.tss_selector);
}
}

我们使用set_cs重载代码段寄存器,并使用load_tss加载TSS。这两个函数被标记为unsafe,因此需要在unsafe块中调用——它们可能会因为加载了无效选择器而破坏内存安全。

我们已经加载了有效的TSS和中断堆栈表,现在,可以在IDT中为双重故障处理程序设置栈索引了:

in src/interrupts.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
use crate::gdt;

lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
unsafe {
idt.double_fault.set_handler_fn(double_fault_handler)
.set_stack_index(gdt::DOUBLE_FAULT_IST_INDEX); // new
}

idt
};
}

set_stack_index方法是非安全的,调用者必须确保使用的索引有效,并且未用于其他异常。

现在,每当发生双重故障时,CPU应该都能切换到双重故障栈。因此,我们能够捕获所有双重故障,包括内核栈溢出:

QEMU栈溢出时的双重故障

从现在开始,我们再也不会看到三重故障!为确保我们不会意外地破坏以上操作,我们应该为此添加一个测试。

栈溢出测试

为了测试新写的gdt模块,并确保在栈溢出时正确调用了双重故障处理程序,我们可以添加一个集成测试。大致思路是在测试函数中引发双重故障,以验证是否调用了双重故障处理程序。

让我们从一个最小化的测试程序:

in tests/stack_overflow.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#![no_std]
#![no_main]

use core::panic::PanicInfo;

#[no_mangle]
pub extern "C" fn _start() -> ! {
unimplemented!();
}

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
blog_os::test_panic_handler(info)
}

就像我们的panic_handler测试一样,该测试将在没有测试环境的条件下运行。这是因为出现双重错误后程序无法继续执行,因此执行多于一个的测试是没有意义的。要禁用测试的测试环境,我们将以下内容添加到我们的Cargo.toml中:

in Cargo.toml
1
2
3
[[test]]
name = "stack_overflow"
harness = false

现在,cargo test --test stack_overflow应该可以编译。当然,运行测试会失败,因为unimplemented宏会引起panic。

实现_start

_start函数的实现将会像这样:

in tests/stack_overflow.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use blog_os::serial_print;

#[no_mangle]
pub extern "C" fn _start() -> ! {
serial_print!("stack_overflow::stack_overflow...\t");

blog_os::gdt::init();
init_test_idt();

// 出发栈溢出
stack_overflow();

panic!("Execution continued after stack overflow");
}

#[allow(unconditional_recursion)]
fn stack_overflow() {
stack_overflow(); // 每次递归都会将返回地址压栈
volatile::Volatile::new(0).read(); // 防止尾递归优化
}

这里不调用interrupts::init_idt函数,而是调用gdt::init函数来初始化新的GDT,原因是我们要注册一个自定义双重故障处理程序,它将执行exit_qemu(QemuExitCode::Success)退出,而不是直接panic。我们还将调用init_test_idt函数,稍后将对其进行说明。

stack_overflow函数与main.rs中的函数几乎相同。唯一的不同是,我们在函数末尾使用Volatile类型进行了额外的易失性读取,以防止称为尾调用消除的编译器优化。此优化允许编译器将最后一条语句为递归调用的递归函数,从递归调用函数转换为带有循环的普通函数(译注:尾递归优化将递归化为循环)。若有此优化,则递归化为循环后函数将不再会新建额外的栈帧(用于返回地址压栈),于是该函数对于栈的使用将变为常量(译注:循环没有返回地址压栈环节,相较于递归会大幅提升执行效率与资源利用率)。

但是,在我们的情况下,我们的确希望栈溢出的发生,于是我们在函数的末尾添加了一个假的易失性读操作,以禁止编译器删除该语句。因此,该函数不再是尾递归,就可以防止递归转换为循环。我们还添加了allow(unconditional_recursion)属性,以使编译器保持不对这个无限递归的函数发出编译警告。

测试IDT

如上所述,测试需要使用自己的IDT,并自定义双重故障处理程序。实现看起来像这样:

in tests/stack_overflow.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use lazy_static::lazy_static;
use x86_64::structures::idt::InterruptDescriptorTable;

lazy_static! {
static ref TEST_IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
unsafe {
idt.double_fault
.set_handler_fn(test_double_fault_handler)
.set_stack_index(blog_os::gdt::DOUBLE_FAULT_IST_INDEX);
}

idt
};
}

pub fn init_test_idt() {
TEST_IDT.load();
}

该实现非常类似于我们在interrupts.rs中的IDT。就像在原来的IDT中,给用于双重故障处理程序的IST设置栈索引,以便触发异常时切换到这个已知良好的栈。最后init_test_idt函数通过load方法将IDT加载到CPU上。

双重故障处理程序

唯一缺少的部分是我们的双重故障处理程序。看起来像这样:

in tests/stack_overflow.rs
1
2
3
4
5
6
7
8
9
10
11
use blog_os::{exit_qemu, QemuExitCode, serial_println};
use x86_64::structures::idt::InterruptStackFrame;

extern "x86-interrupt" fn test_double_fault_handler(
_stack_frame: &mut InterruptStackFrame,
_error_code: u64,
) -> ! {
serial_println!("[ok]");
exit_qemu(QemuExitCode::Success);
loop {}
}

调用双重故障处理程序时,我们以成功码退出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上赞助我,因为他们不收取任何中间费用。如果你喜欢其他平台,我也有PatreonDonorbox账户。后者是最灵活的,因为它支持多种货币和一次性捐款。

感谢您的支持!

评论

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×