Files
blog_os/blog/content/edition-2/posts/05-cpu-exceptions/index.zh-CN.md

28 KiB
Raw Blame History

+++ title = "CPU异常处理" weight = 5 path = "zh-CN/cpu-exceptions" date = 2018-06-17

[extra]

Please update this when updating the translation

translation_based_on_commit = "096c044b4f3697e91d8e30a2e817e567d0ef21a2"

GitHub usernames of the people that translated this post

translators = ["liuyuran"]

GitHub usernames of the people that contributed to this translation

translation_contributors = ["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字节结构。

Type Name Description
u16 Function Pointer [0:15] 处理函数地址的低位最后16位
u16 GDT selector 全局描述符表中的代码段标记。
u16 Options (如下所述)
u16 Function Pointer [16:31] 处理函数地址的中位中间16位
u32 Function Pointer [32:63] 处理函数地址的高位(剩下的所有位)
u32 Reserved

Options字段的格式如下

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

每个异常都具有一个预定义的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 gatebit 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, r15 rax, rcx, rdx, rsi, rdi, r8, r9, r10, r11
callee-saved caller-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来避免这种情况。