☰
blog_os 内核系列:Double Fault 异常处理与中断栈表(IST)实战
2026/10/2 2:14:12 网站建设 项目流程
  • 文档
  • 教程
  • 技术博客
  • 操作系统

【免费下载链接】blog_os

Writing an OS in Rust

项目地址:https://gitcode.com/GitHub_Trending/bl/blog_os
点击查看免费下载

本篇技术指南围绕 blog_os(Writing an OS in Rust)第二版内核中的 Double Fault 异常展开,从触发机制、成因矩阵讲起,逐步落地一个可捕获全部 Double Fault(含内核栈溢出场景)的处理方案,最终通过x86_64crate 的 IDT/TSS/GDT 封装完成硬件级栈切换,并配以集成测试验证。读完你将掌握在裸机 Rust 内核中注册异常处理器、配置中断栈表(IST)、加载任务状态段(TSS)与全局描述符表(GDT)的完整工程实践。

何谓 Double Fault

简而言之,double fault 就是 CPU 在"调用异常处理函数失败"时抛出的特殊异常。例如:当程序触发了一个 page fault,但在中断描述符表(IDT)中并没有注册对应的 page fault 处理函数,此时 CPU 就会抛出 double fault。它的角色类似于编程语言中异常机制的 catch-all 语法——C++ 的catch(...)、Java/C# 的catch(Exception e)。

double fault 的行为与普通异常一致:

  • 它的向量号(vector number)是8;
  • 我们可以在 IDT 中为它注册一个普通的处理函数;
  • 必须提供 double fault 处理器:一旦 double fault 无人处理,CPU 将抛出致命的triple fault。triple fault 无法被任何软件捕获,绝大多数硬件对它的反应是直接系统复位。

触发一个 Double Fault

先来故意制造一个 double fault:触发一个我们没有注册处理函数的异常即可。向无效内存地址0xdeadbeef写入数据(该虚拟地址未在页表中映射到物理地址,必然触发 page fault):

// in src/main.rs #[no_mangle] pub extern "C" fn _start() -> ! { println!("Hello World{}", "!"); blog_os::init(); // trigger a page fault unsafe { *(0xdeadbeef as *mut u8) = 42; }; // as before #[cfg(test)] test_main(); println!("It did not crash!"); loop {} }

这里的unsafe块直接操作了无效地址。由于我们没有在 IDT 中注册 page fault 处理器,double fault 会紧接着被抛出。启动内核后,QEMU 会陷入无休止的启动循环,原因如下:

  1. CPU 尝试向0xdeadbeef写入数据,造成 page fault 异常;
  2. CPU 查找 IDT 中对应的条目,发现没有指定处理函数,无法调用 page fault 处理器,于是抛出 double fault;
  3. CPU 继续查找 double fault 处理器的 IDT 条目,该条目同样没有处理函数,于是抛出triple fault;
  4. triple fault 是致命的,QEMU 与大多数真实硬件一样,对此的反应是系统复位。

因此,要阻止 triple fault,要么为 page fault 提供处理函数,要么提供 double fault 处理器。我们希望在所有情况下都避免 triple fault,所以从注册一个能兜底所有未处理异常类型的 double fault 处理器开始。

Double Fault 处理器

double fault 是带错误码的常规异常,可以仿照上一篇的 breakpoint 处理器来定义:

// in src/interrupts.rs 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: InterruptStackFrame, _error_code: u64) -> ! { panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame); }

处理器打印一条简短的错误信息并转储异常栈帧(interrupt stack frame)。double fault 的错误码恒为 0,所以没有打印的必要。与 breakpoint 处理器的一个重要区别是:double fault 处理器是发散的(diverging)——返回类型为-> !,因为x86_64架构不允许从 double fault 异常中返回。

再次启动内核,可以看到 double fault 处理器被成功调用:

这次发生了什么:

  1. CPU 尝试向0xdeadbeef写入数据,造成 page fault 异常;
  2. 与之前一样,CPU 在 IDT 中没有找到对应处理函数,于是抛出 double fault;
  3. CPU 跳转到——现在已存在的——double fault 处理器。

triple fault 与启动循环不再出现,因为 CPU 现在能够调用 double fault 处理器了。

看起来相当直接,那为什么要为这一主题单独写一篇文章?因为目前只能捕获大部分double fault,在某些特殊场景下现有做法依然不够。

Double Fault 的成因

先精确界定"调用失败"到底指什么:是没有提供处理函数?处理函数被[换出内存]了?还是处理函数自身又触发了异常?考虑如下场景:

  1. breakpoint 异常被触发,但其处理函数被换出内存?
  2. page fault 被触发,但其处理函数被换出内存?
  3. divide-by-zero 处理函数触发了 breakpoint 异常,而 breakpoint 处理器恰好被换出内存?
  4. 内核发生栈溢出,意外访问到guard page?

AMD64 手册(第 8.2.9 节)给出了精确定义:double fault 异常可能("can")在"处理先前(第一层)异常处理函数期间发生第二层异常"时触发。注意"可能"二字很关键:只有特定组合的异常才会导致 double fault,组合如下:

第一层异常第二层异常
Divide-by-zero、Invalid TSS、Segment Not Present、Stack-Segment Fault、General Protection FaultInvalid TSS、Segment Not Present、Stack-Segment Fault、General Protection Fault
Page FaultPage Fault、Invalid TSS、Segment Not Present、Stack-Segment Fault、General Protection Fault

据此可以回答前面三个问题:

  1. breakpoint 异常发生且处理器被换出 → 触发page fault,调用 page fault 处理器;
  2. page fault 发生且处理器被换出 → 触发double fault,调用 double fault 处理器;
  3. divide-by-zero 处理器触发 breakpoint 且 breakpoint 处理器被换出 → 触发page fault,调用 page fault 处理器。

顺带解释一个细节:IDT 中没有处理函数时为什么会触发 double fault?当异常发生时,CPU 尝试读取对应 IDT 条目;条目值为 0(不是合法 IDT 条目)时触发general protection fault;而我们同样没有注册 general protection fault 处理器,于是又一个 general protection fault 发生——根据上表,这最终导致 double fault。

内核栈溢出

再看第四个问题:内核栈溢出并命中 guard page 会发生什么?

guard page 是位于栈底部的一种特殊内存页,用于检测栈溢出:它没有映射到任何物理帧,访问它会触发 page fault,而不是悄悄破坏其他内存。bootloader 已为内核栈设置好 guard page,因此栈溢出会引发page fault。

当 page fault 发生时,CPU 在 IDT 中查找 page fault 处理器,并尝试把中断栈帧压入栈中。然而此时栈指针仍指向不存在的 guard page,于是第二个 page fault 发生,根据上表触发 double fault。

此时 CPU 会尝试调用 double fault 处理器——但问题来了:double fault 时 CPU 同样要把异常栈帧压栈,栈指针依然指向 guard page,于是第三个 page fault 发生,最终触发triple fault与系统重启。所以只注册 double fault 处理器,并不能在这种场景下避免 triple fault。

亲手验证一下:用一个无限递归函数很容易制造内核栈溢出:

// in src/main.rs #[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(); // for each recursion, the return address is pushed } // trigger a stack overflow stack_overflow(); […] // test_main(), println(…), and loop {} }

在 QEMU 中运行,系统再次进入启动循环。

我们无法省略"压入异常栈帧"这一步——这是 CPU 硬件行为。所以必须保证:double fault 异常发生时栈始终有效。幸运的是,x86_64 架构为此提供了内置解决方案。

切换栈:中断栈表(IST)

x86_64 允许在异常发生时切换到预定义的"已知良好"栈,这个切换发生在硬件层面,完全可以在 CPU 压入异常栈帧之前完成。

切换机制由中断栈表(Interrupt Stack Table,IST)实现:它是一张包含 7 个已知良好栈指针的表。用 Rust 风格伪代码表示:

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

对每个异常处理器,都可以通过对应 IDT 条目中的stack_pointers字段选择 IST 中的某个栈。例如让 double fault 处理器使用 IST 中的第 0 个栈:每当 double fault 发生时,CPU 自动切换到这个栈,且切换发生在任何压栈之前,从而阻止 triple fault。

IST 与 TSS

IST 属于一个古老的遗留结构——[任务状态段(Task State Segment,TSS)]。TSS 在 32 位模式下存放任务的各类信息(如处理器寄存器状态),曾被用于硬件上下文切换。但在 64 位模式下,硬件上下文切换已不再被支持,TSS 的格式也彻底改变。

在 x86_64 上,TSS 不再存放任何任务特定信息,取而代之的是两张栈表(IST 是其中之一)。32 位与 64 位 TSS 唯一的共同字段是指向 I/O 端口权限位图的指针。64 位 TSS 的格式如下:

字段类型
(保留)u32
特权栈表(Privilege Stack Table)[u64; 3]
(保留)u64
中断栈表(Interrupt Stack Table)[u64; 7]
(保留)u64
(保留)u16
I/O 映射基地址(I/O Map Base Address)u16

特权栈表在 CPU 特权级别变更时被使用:例如 CPU 处于用户态(特权级 3)时发生异常,CPU 通常会在调用异常处理器前切换到内核态(特权级 0),此时会切换到特权栈表的第 0 个栈(0 是目标特权级)。blog_os 目前还没有用户态程序,因此该表暂可忽略。

创建 TSS

新建一个gdt模块(命名原因稍后揭晓)来创建包含 double fault 专属栈的 TSS。x86_64crate 已经提供了TaskStateSegment结构体:

// in src/lib.rs pub mod gdt; // in src/gdt.rs 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(&raw const STACK); let stack_end = stack_start + STACK_SIZE; stack_end }; tss }; }

要点说明:

  • 使用lazy_static是因为 Rust 的 const 求值器还不足以在编译期完成该初始化;
  • 定义 IST 第 0 项为 double fault 栈(其他索引同样可用,只要保持全局唯一);
  • 写入的是栈的顶端地址,因为 x86 的栈向下增长(从高地址到低地址);
  • 内核尚未实现内存管理,没有正规途径分配新栈,故暂时用static mut数组充当栈存储。必须是static mut而非不可变的static,否则 bootloader 会把它映射到只读页;后续文章会换成真正的栈分配;
  • 该 double fault 栈没有防止溢出的 guard page,因此在 double fault 处理器中不要做栈密集型操作,否则溢出可能破坏栈下方的内存。
加载 TSS

TSS 的加载比较繁琐(历史原因,它依赖分段系统)。不能直接加载 TSS 本身,而是要先在[全局描述符表(GDT)]中添加一个 TSS 段描述符,然后通过ltr指令配合对应的 GDT 索引来加载。(这正是模块命名为gdt的原因。)

全局描述符表(GDT)

GDT 是分页成为事实标准之前用于[内存分段]的遗留结构,但在 64 位模式下仍承担重要职责:内核态/用户态切换、加载 TSS 结构。

创建 GDT

创建一个包含代码段与 TSS 段的静态 GDT:

// in src/gdt.rs 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 }; }
加载 GDT

创建gdt::init函数,并从lib.rs的init函数中调用:

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

GDT 已加载(_start会调用init),但栈溢出时依然出现启动循环——因为 GDT 段尚未激活,段寄存器与 TSS 寄存器仍保存着旧 GDT 的值。

最终步骤

还需要三件事:

  1. 重载代码段寄存器cs:GDT 已改变,旧的选择子可能指向不同的 GDT 描述符(例如 TSS 描述符),必须重载;
  2. 加载 TSS:GDT 中已包含 TSS 选择子,但还需告知 CPU 使用该 TSS;
  3. 更新 IDT 条目:TSS 加载后 CPU 才能访问到有效的 IST,然后通过修改 double fault 的 IDT 条目来启用新栈。

为完成前两步,需要把code_selector与tss_selector暴露出来——用一个新的Selectors结构把它们与 GDT 一起放进 static:

// in src/gdt.rs 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 pub fn init() { use x86_64::instructions::tables::load_tss; use x86_64::instructions::segmentation::{CS, Segment}; GDT.0.load(); unsafe { CS::set_reg(GDT.1.code_selector); load_tss(GDT.1.tss_selector); } }

CS::set_reg与load_tss都标记为unsafe——加载无效选择子可能破坏内存安全,所以必须在unsafe块中调用。

现在 TSS 与 IST 都已就绪,最后在 IDT 中为 double fault 处理器设置栈索引:

// in src/interrupts.rs 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之所以是unsafe,是因为调用者必须保证所用索引有效且未被其他异常占用。

完成!现在每当 double fault 发生,CPU 会自动切换到 double fault 专属栈,包括内核栈溢出在内的所有 double fault 都能被捕获:

从此不应再看到 triple fault。为防回归,需要为这套机制添加测试。

栈溢出集成测试

测试思路:在测试函数中主动触发 double fault,验证 double fault 处理器被正确调用。先搭建最小骨架:

// in tests/stack_overflow.rs #![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) }

与 04-testing 中的 panic 测试一样,本测试应禁用测试框架(无 harness)——因为 double fault 之后无法继续执行,跑多个测试没有意义。在Cargo.toml中添加:

# in Cargo.toml [[test]] name = "stack_overflow" harness = false

此时cargo test --test stack_overflow应能编译通过(当然会失败,因为unimplemented!宏必然 panic)。

实现_start

// in tests/stack_overflow.rs 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(); // trigger a stack overflow stack_overflow(); panic!("Execution continued after stack overflow"); } #[allow(unconditional_recursion)] fn stack_overflow() { stack_overflow(); // for each recursion, the return address is pushed volatile::Volatile::new(0).read(); // prevent tail recursion optimizations }

这里调用gdt::init初始化新 GDT,但不调用interrupts::init_idt,而是调用稍后实现的init_test_idt——因为要注册一个自定义的 double fault 处理器,它执行exit_qemu(QemuExitCode::Success)而非 panic。

stack_overflow与main.rs中那个几乎一样,唯一区别是末尾用Volatile类型做了一次 volatile 读取,用于阻止编译器做尾调用消除(tail call elimination):该优化会把"最后一条语句是递归调用"的函数改写为普通循环,从而不再创建新栈帧、栈使用量恒定。而这里恰恰需要栈溢出发生,所以用一条编译器不得删除的 dummy volatile 读取破坏尾递归;allow(unconditional_recursion)则用于消除"函数无限递归"的编译警告。

测试专用 IDT

// in tests/stack_overflow.rs 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 非常相似:同样为 double fault 设置了 IST 栈索引,init_test_idt通过load方法把该 IDT 装载到 CPU。

Double Fault 处理器

// in tests/stack_overflow.rs use blog_os::{exit_qemu, QemuExitCode, serial_println}; use x86_64::structures::idt::InterruptStackFrame; extern "x86-interrupt" fn test_double_fault_handler( _stack_frame: 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一行,观察测试失败的情形——这正好反向验证了 IST 栈切换的必要性。

总结

本篇完成了三件事:

  1. 明确了 double fault 的定义与精确成因(AMD64 手册的异常组合矩阵),并验证了未注册处理器时由 triple fault 引发的系统重启循环;
  2. 注册了打印错误信息的 double fault 处理器,解决了"普通" double fault 场景;
  3. 针对栈溢出这种"处理器自身无法压栈"的极端场景,通过 TSS + IST + GDT 的组合实现了硬件级栈切换,并用harness = false的集成测试固化行为。

实现过程中还顺带理解了三个 x86 硬件机制:任务状态段(TSS)、其内部的中断栈表(IST)、以及旧架构用于内存分段、如今仍负责内核/用户态切换与 TSS 加载的全局描述符表(GDT)。

下一篇将转向外部设备(定时器、键盘、网络控制器)的中断处理:硬件中断与异常非常相似(同样经 IDT 分发),但并非由 CPU 直接抛出,而是由中断控制器按优先级聚合后转发给 CPU。届时将基于 Intel 8259(PIC)中断控制器实现键盘支持。

  • 文档
  • 教程
  • 技术博客
  • 操作系统

【免费下载链接】blog_os

Writing an OS in Rust

项目地址:https://gitcode.com/GitHub_Trending/bl/blog_os
点击查看免费下载

相关推荐

上一篇:终极幻兽帕鲁存档修复指南:轻松实现跨服务器无损迁移
下一篇:CFR Java反编译器:快速入门和高效使用的完整指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询