Writing an OS in Rust

Philipp Oppermann's blog

CPU异常处理

翻译内容: 这是对原文章 CPU Exceptions 的社区中文翻译。它可能不完整,过时或者包含错误。可以在 这个 Issue 上评论和提问!

翻译者: @liuyuran. With contributions from @JiangengDong@Byacrya.

CPU异常在很多情况下都有可能发生,比如访问无效的内存地址,或者在除法运算里除以0。为了处理这些错误,我们需要设置一个 中断描述符表 来提供异常处理函数。在文章的最后,我们的内核将能够捕获 断点异常 并在处理后恢复正常执行。

这个系列的blog在GitHub上开放开发,如果你有任何问题,请在这里开一个issue来讨论。当然你也可以在底部留言。你可以在post-05找到这篇文章的完整源码。

目录

🔗简述

异常信号会在当前指令触发错误时被触发,例如执行了除数为0的除法。当异常发生后,CPU会中断当前的工作,并立即根据异常类型调用对应的错误处理函数。

在x86架构中,存在20种不同的CPU异常类型,以下为最重要的几种:

  • Page Fault: 页错误是被非法内存访问触发的,例如当前指令试图访问未被映射过的页,或者试图写入只读页。
  • Invalid Opcode: 该错误是说当前指令操作符无效,比如在不支持SSE的旧式CPU上执行了 SSE 指令
  • General Protection Fault: 该错误的原因有很多,主要原因就是权限异常,即试图使用用户态代码执行核心指令,或是修改配置寄存器的保留字段。
  • Double Fault: 当错误发生时,CPU会尝试调用错误处理函数,但如果 在调用错误处理函数过程中 再次发生错误,CPU就会触发该错误。另外,如果没有注册错误处理函数也会触发该错误。
  • Triple Fault: 如果CPU调用了对应 Double Fault 异常的处理函数依然没有成功,该错误会被抛出。这是一个致命级别的 三重异常,这意味着我们已经无法捕捉它,对于大多数操作系统而言,此时就应该重置数据并重启操作系统。

OSDev wiki 可以看到完整的异常类型列表。

🔗中断描述符表

要捕捉CPU异常,我们需要设置一个 中断描述符表 (Interrupt Descriptor Table, IDT),用来捕获每一个异常。由于硬件层面会不加验证的直接使用,所以我们需要根据预定义格式直接写入数据。符表的每一行都遵循如下的16字节结构。

TypeNameDescription
u16Function Pointer [0:15]处理函数地址的低位(最后16位)
u16GDT selector全局描述符表中的代码段标记。
u16Options(如下所述)
u16Function Pointer [16:31]处理函数地址的中位(中间16位)
u32Function Pointer [32:63]处理函数地址的高位(剩下的所有位)
u32Reserved

Options字段的格式如下:

BitsNameDescription
0-2Interrupt Stack Table Index0: 不要切换栈, 1-7: 当处理函数被调用时,切换到中断栈表的第n层。
3-7Reserved
80: Interrupt Gate, 1: Trap Gate如果该比特被置为0,当处理函数被调用时,中断会被禁用。
9-11must be one
12must be zero
13‑14Descriptor Privilege Level (DPL)执行处理函数所需的最小特权等级。
15Present

每个异常都具有一个预定义的IDT序号,比如 invalid opcode 异常对应6号,而 page fault 异常对应14号,因此硬件可以直接寻找到对应的IDT条目。 OSDev wiki中的 异常对照表 可以查到所有异常的IDT序号(在Vector nr.列)。

通常而言,当异常发生时,CPU会执行如下步骤:

  1. 将一些寄存器数据入栈,包括指令指针以及 RFLAGS 寄存器。(我们会在文章稍后些的地方用到这些数据。)
  2. 读取中断描述符表(IDT)的对应条目,比如当发生 page fault 异常时,调用14号条目。
  3. 判断该条目确实存在,如果不存在,则触发 double fault 异常。
  4. 如果该条目属于中断门(interrupt gate,bit 40 被设置为0),则禁用硬件中断。
  5. GDT 选择器载入代码段寄存器(CS segment)。
  6. 跳转执行处理函数。

不过现在我们不必为4和5多加纠结,未来我们会单独讲解全局描述符表和硬件中断的。

🔗IDT类型

与其创建我们自己的IDT类型映射,不如直接使用 x86_64 crate 内置的 InterruptDescriptorTable 结构,其实现是这样的:

#[repr(C)]
pub struct InterruptDescriptorTable {
    pub divide_by_zero: Entry<HandlerFunc>,
    pub debug: Entry<HandlerFunc>,
    pub non_maskable_interrupt: Entry<HandlerFunc>,
    pub breakpoint: Entry<HandlerFunc>,
    pub overflow: Entry<HandlerFunc>,
    pub bound_range_exceeded: Entry<HandlerFunc>,
    pub invalid_opcode: Entry<HandlerFunc>,
    pub device_not_available: Entry<HandlerFunc>,
    pub double_fault: Entry<HandlerFuncWithErrCode>,
    pub invalid_tss: Entry<HandlerFuncWithErrCode>,
    pub segment_not_present: Entry<HandlerFuncWithErrCode>,
    pub stack_segment_fault: Entry<HandlerFuncWithErrCode>,
    pub general_protection_fault: Entry<HandlerFuncWithErrCode>,
    pub page_fault: Entry<PageFaultHandlerFunc>,
    pub x87_floating_point: Entry<HandlerFunc>,
    pub alignment_check: Entry<HandlerFuncWithErrCode>,
    pub machine_check: Entry<HandlerFunc>,
    pub simd_floating_point: Entry<HandlerFunc>,
    pub virtualization: Entry<HandlerFunc>,
    pub security_exception: Entry<HandlerFuncWithErrCode>,
    // some fields omitted
}

每一个字段都是 idt::Entry<F> 类型,这个类型包含了一条完整的IDT条目(定义参见上文)。 其泛型参数 F 定义了中断处理函数的类型,在有些字段中该参数为 HandlerFunc,而有些则是 HandlerFuncWithErrCode,而对于 page fault 这种特殊异常,则为 PageFaultHandlerFunc

首先让我们看一看 HandlerFunc 类型的定义:

type HandlerFunc = extern "x86-interrupt" fn(_: InterruptStackFrame);

这是一个针对 extern "x86-interrupt" fn 类型的 类型别名extern 关键字使用 外部调用约定 定义了一个函数,这种定义方式多用于和C语言代码通信(extern "C" fn),那么这里的外部调用约定又究竟调用了哪些东西?

🔗中断调用约定

异常触发十分类似于函数调用:CPU会直接跳转到处理函数的第一个指令处开始执行,执行结束后,CPU会跳转到返回地址,并继续执行之前的函数调用。

然而两者最大的不同点是:函数调用是由编译器通过 call 指令主动发起的,而错误处理函数则可能会由 任何 指令触发。要了解这两者所造成影响的不同,我们需要更深入的追踪函数调用。

调用约定 指定了函数调用的详细信息,比如可以指定函数的参数存放在哪里(寄存器,或者栈,或者别的什么地方)以及如何返回结果。在 x86_64 Linux 中,以下规则适用于C语言函数(指定于 System V ABI 标准):

  • 前六个整型参数从寄存器传入 rdi, rsi, rdx, rcx, r8, r9
  • 其他参数从栈传入
  • 函数返回值存放在 raxrdx

注意,Rust并不遵循C ABI,而是遵循自己的一套规则,即 尚未正式发布的 Rust ABI 草案,所以这些规则仅在使用 extern "C" fn 对函数进行定义时才会使用。

🔗保留寄存器和临时寄存器

调用约定将寄存器分为两部分:保留寄存器临时寄存器

保留寄存器 的值应当在函数调用时保持不变,所以被调用的函数( “callee” )只有在保证“返回之前将这些寄存器的值恢复到初始值“的前提下,才被允许覆写这些寄存器的值, 在函数开始时将这类寄存器的值存入栈中,并在返回之前将之恢复到寄存器中是一种十分常见的做法。

临时寄存器 则相反,被调用函数可以无限制的反复写入寄存器,若调用者希望此类寄存器在函数调用后保持数值不变,则需要自己来处理备份和恢复过程(例如将其数值保存在栈中),因而这类寄存器又被称为 caller-saved

在 x86_64 架构下,C调用约定指定了这些寄存器分类:

保留寄存器临时寄存器
rbp, rbx, rsp, r12, r13, r14, r15rax, rcx, rdx, rsi, rdi, r8, r9, r10, r11
callee-savedcaller-saved

编译器已经内置了这些规则,因而可以自动生成保证程序正常执行的指令。例如绝大多数函数的汇编指令都以 push rbp 开头,也就是将 rbp 的值备份到栈中(因为它是 callee-saved 型寄存器)。

🔗保存所有寄存器数据

区别于函数调用,异常在执行 任何 指令时都有可能发生。在大多数情况下,我们在编译期不可能知道程序跑起来会发生什么异常。比如编译器无法预知某条指令是否会触发 page fault 或者 stack overflow。

正因我们不知道异常会何时发生,所以我们无法预先保存寄存器。这意味着我们无法使用依赖调用方备份 (caller-saved) 的寄存器的调用传统作为异常处理程序。因此,我们需要一个保存所有寄存器的传统。x86-interrupt 恰巧就是其中之一,它可以保证在函数返回时,寄存器里的值均返回原样。

但请注意,这并不意味着所有寄存器都会在进入函数时备份入栈。编译器仅会备份被函数覆写的寄存器,继而为只使用几个寄存器的短小函数生成高效的代码。

🔗中断栈帧

当一个常规函数调用发生时(使用 call 指令),CPU会在跳转目标函数之前,将返回地址入栈。当函数返回时(使用 ret 指令),CPU会在跳回目标函数之前弹出返回地址。所以常规函数调用的栈帧看起来是这样的:

function stack frame

对于错误和中断处理函数,仅仅压入一个返回地址并不足够,因为中断处理函数通常会运行在一个不那么一样的上下文中(栈指针、CPU flags等等)。所以CPU在遇到中断发生时是这么处理的:

  1. 对齐栈指针: 任何指令都有可能触发中断,所以栈指针可能是任何值,而部分CPU指令(比如部分SSE指令)需要栈指针16字节边界对齐,因此CPU会在中断触发后立刻为其进行对齐。
  2. 切换栈 (部分情况下): 当CPU特权等级改变时,例如当一个用户态程序触发CPU异常时,会触发栈切换。该行为也可能被所谓的 中断栈表 配置,在特定中断中触发,关于该表,我们会在下一篇文章做出讲解。
  3. 压入旧的栈指针: 当中断发生后,栈指针对齐之前,CPU会将栈指针寄存器(rsp)和栈段寄存器(ss)的数据入栈,由此可在中断处理函数返回后,恢复上一层的栈指针。
  4. 压入并更新 RFLAGS 寄存器: RFLAGS 寄存器包含了各式各样的控制位和状态位,当中断发生时,CPU会改变其中的部分数值,并将旧值入栈。
  5. 压入指令指针: 在跳转中断处理函数之前,CPU会将指令指针寄存器(rip)和代码段寄存器(cs)的数据入栈,此过程与常规函数调用中返回地址入栈类似。
  6. 压入错误码 (针对部分异常): 对于部分特定的异常,比如 page faults ,CPU会推入一个错误码用于标记错误的成因。
  7. 执行中断处理函数: CPU会读取对应IDT条目中描述的中断处理函数对应的地址和段描述符,将两者载入 ripcs 以开始运行处理函数。

所以 中断栈帧 看起来是这样的:

interrupt stack frame

x86_64 crate 中,中断栈帧已经被 InterruptStackFrame 结构完整表达,该结构会以 &mut 的形式传入处理函数,并可以用于查询错误发生的更详细的原因。但该结构并不包含错误码字段,因为只有极少量的错误会传入错误码,所以对于这类需要传入 error_code 的错误,其函数类型变为了 HandlerFuncWithErrCode

🔗幕后花絮

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

// in src/lib.rs

pub mod interrupts;

// in src/interrupts.rs

use x86_64::structures::idt::InterruptDescriptorTable;

pub fn init_idt() {
    let mut idt = InterruptDescriptorTable::new();
}

现在我们就可以添加处理函数了,首先给 breakpoint exception 添加。该异常是一个绝佳的测试途径,因为它唯一的目的就是在 int3 指令执行时暂停程序运行。

breakpoint exception 通常被用在调试器中:当程序员为程序打上断点,调试器会将对应的位置覆写为 int3 指令,CPU执行该指令后,就会抛出 breakpoint exception 异常。在调试完毕,需要程序继续运行时,调试器就会将原指令覆写回 int3 的位置。如果要了解更多细节,请查阅 调试器是如何工作的 系列。

不过现在我们还不需要覆写指令,只需要打印一行日志,表明接收到了这个异常,然后让程序继续运行即可。那么我们就来创建一个简单的 breakpoint_handler 方法并加入IDT中:

// in src/interrupts.rs

use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use crate::println;

pub fn init_idt() {
    let mut idt = InterruptDescriptorTable::new();
    idt.breakpoint.set_handler_fn(breakpoint_handler);
}

extern "x86-interrupt" fn breakpoint_handler(
    stack_frame: InterruptStackFrame)
{
    println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}

现在,我们的处理函数应当会输出一行信息以及完整的栈帧。

但当我们尝试编译的时候,报出了下面的错误:

error[E0658]: x86-interrupt ABI is experimental and subject to change (see issue #40180)
  --> src/main.rs:53:1
   |
53 | / extern "x86-interrupt" fn breakpoint_handler(stack_frame: InterruptStackFrame) {
54 | |     println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
55 | | }
   | |_^
   |
   = help: add #![feature(abi_x86_interrupt)] to the crate attributes to enable

这是因为 x86-interrupt 并不是稳定特性,需要手动启用,只需要在我们的 lib.rs 中加入 #![feature(abi_x86_interrupt)] 开关即可。

🔗载入 IDT

要让CPU使用新的中断描述符表,我们需要使用 lidt 指令来装载一下,x86_64InterruptDescriptorTable 结构提供了 load 函数用来实现这个需求。让我们来试一下:

// in src/interrupts.rs

pub fn init_idt() {
    let mut idt = InterruptDescriptorTable::new();
    idt.breakpoint.set_handler_fn(breakpoint_handler);
    idt.load();
}

再次尝试编译,又出现了新的错误:

error: `idt` does not live long enough
  --> src/interrupts/mod.rs:43:5
   |
43 |     idt.load();
   |     ^^^ does not live long enough
44 | }
   | - borrowed value only lives until here
   |
   = note: borrowed value must be valid for the static lifetime...

原来 load 函数要求的生命周期为 &'static self ,也就是整个程序的生命周期,其原因就是CPU在接收到下一个IDT之前会一直使用这个描述符表。如果生命周期小于 'static ,很可能就会出现使用已释放对象的bug。

问题至此已经很清晰了,我们的 idt 是创建在栈上的,它的生命周期仅限于 init 函数执行期间,之后这部分栈内存就会被其他函数调用,CPU再来访问IDT的话,只会读取到一段随机数据。好在 InterruptDescriptorTable::load 被严格定义了函数生命周期限制,这样 Rust 编译器就可以在编译时就发现这些潜在问题。

要修复这些错误很简单,让 idt 具备 'static 类型的生命周期即可,我们可以使用 Box 在堆上申请一段内存,并转化为 'static 指针即可,但问题是我们正在写的东西是操作系统内核,(暂时)并没有堆这种东西。

作为替代,我们可以试着直接将IDT定义为 'static 变量:

static IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();

pub fn init_idt() {
    IDT.breakpoint.set_handler_fn(breakpoint_handler);
    IDT.load();
}

然而这样就会引入一个新问题:静态变量是不可修改的,这样我们就无法在 init 函数中修改里面的数据了,所以需要把变量类型修改为 static mut

static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();

pub fn init_idt() {
    unsafe {
        IDT.breakpoint.set_handler_fn(breakpoint_handler);
        IDT.load();
    }
}

这样就不会有编译错误了,但是这并不符合官方推荐的编码习惯,因为理论上说 static mut 类型的变量很容易形成数据竞争,所以需要用 unsafe 代码块 修饰调用语句。

🔗懒加载拯救世界

好在还有 lazy_static 宏可以用,区别于普通 static 变量在编译器求值,这个宏可以使代码块内的 static 变量在第一次取值时求值。所以,我们完全可以把初始化代码写在变量定义的代码块里,同时也不影响后续的取值。

创建VGA字符缓冲的单例 时我们已经引入了 lazy_static crate,所以我们可以直接使用 lazy_static! 来创建IDT:

// in src/interrupts.rs

use lazy_static::lazy_static;

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        idt
    };
}

pub fn init_idt() {
    IDT.load();
}

现在碍眼的 unsafe 代码块成功被去掉了,尽管 lazy_static! 的内部依然使用了 unsafe 代码块,但是至少它已经抽象为了一个安全接口。

🔗跑起来

最后一步就是在 main.rs 里执行 init_idt 函数以在我们的内核里装载IDT,但不要直接调用,而应在 lib.rs 里封装一个 init 函数出来:

// in src/lib.rs

pub fn init() {
    interrupts::init_idt();
}

这样我们就可以把所有初始化逻辑都集中在一个函数里,从而让 main.rslib.rs 以及单元测试中的 _start 共享初始化逻辑。

现在我们更新一下 main.rs 中的 _start 函数,调用 init 并手动触发一次 breakpoint exception:

// in src/main.rs

#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Hello World{}", "!");

    blog_os::init(); // new

    // invoke a breakpoint exception
    x86_64::instructions::interrupts::int3(); // new

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

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

当我们在QEMU中运行之后(cargo run),效果是这样的:

QEMU printing EXCEPTION: BREAKPOINT and the interrupt stack frame

成功了!CPU成功调用了中断处理函数并打印出了信息,然后返回 _start 函数打印出了 It did not crash!

我们可以看到,中断栈帧告诉了我们当错误发生时指令和栈指针的具体数值,这些信息在我们调试意外错误的时候非常有用。

🔗添加测试

那么让我们添加一个测试用例,用来确保以上工作成果可以顺利运行。首先需要在 _start 函数中调用 init

// in src/lib.rs

/// Entry point for `cargo test`
#[cfg(test)]
#[no_mangle]
pub extern "C" fn _start() -> ! {
    init();      // new
    test_main();
    loop {}
}

注意,这里的 _start 会在 cargo test --lib 这条命令的上下文中运行,而 lib.rs 的执行环境完全独立于 main.rs,所以我们需要在运行测试之前调用 init 装载IDT。

那么我们接着创建一个测试用例 test_breakpoint_exception

// in src/interrupts.rs

#[test_case]
fn test_breakpoint_exception() {
    // invoke a breakpoint exception
    x86_64::instructions::interrupts::int3();
}

该测试仅调用了 int3 函数以触发 breakpoint exception,通过查看这个函数是否能够继续运行下去,就可以确认我们对应的中断处理函数是否工作正常。

现在,你可以执行 cargo test 来运行所有测试,或者执行 cargo test --lib 来运行 lib.rs 及其子模块中包含的测试,最终输出如下:

blog_os::interrupts::test_breakpoint_exception...	[ok]

🔗黑魔法有点多?

相对来说,x86-interrupt 调用约定和 InterruptDescriptorTable 类型让错误处理变得直截了当,如果这对你来说太过于神奇,进而想要了解错误处理中的所有隐秘细节,我们推荐读一下这些:“使用裸函数处理错误” 系列文章展示了如何在不使用 x86-interrupt 的前提下创建IDT。但是需要注意的是,这些文章都是在 x86-interrupt 调用约定和 x86_64 crate 出现之前的产物,这些东西属于博客的 第一版,不排除信息已经过期了的可能。

🔗接下来是?

我们已经成功捕获了第一个异常,并从异常中成功恢复,下一步就是试着捕获所有异常,如果有未捕获的异常就会触发致命的triple fault,那就只能重启整个系统了。下一篇文章会展开说我们如何通过正确捕捉double faults来避免这种情况。



评论

你有问题需要解决,想要分享反馈,或者讨论更多的想法吗?请随时在这里留下评论!请使用尽量使用英文并遵循 Rust 的 code of conduct. 这个讨论串将与 discussion on GitHub 直接连接,所以你也可以直接在那边发表评论

Instead of authenticating the giscus application, you can also comment directly on GitHub.

请尽可能使用英语评论。