blog_os 内核系列:Double Fault 异常处理与中断栈表(IST)实战
文档教程技术博客操作系统【免费下载链接】blog_osWriting an OS in Rust项目地址https://gitcode.com/GitHub_Trending/bl/blog_os点击查看免费下载本篇技术指南围绕 blog_osWriting 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 会陷入无休止的启动循环原因如下CPU 尝试向0xdeadbeef写入数据造成 page fault 异常CPU 查找 IDT 中对应的条目发现没有指定处理函数无法调用 page fault 处理器于是抛出 double faultCPU 继续查找 double fault 处理器的 IDT 条目该条目同样没有处理函数于是抛出triple faulttriple 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 处理器被成功调用这次发生了什么CPU 尝试向0xdeadbeef写入数据造成 page fault 异常与之前一样CPU 在 IDT 中没有找到对应处理函数于是抛出 double faultCPU 跳转到——现在已存在的——double fault 处理器。triple fault 与启动循环不再出现因为 CPU 现在能够调用 double fault 处理器了。看起来相当直接那为什么要为这一主题单独写一篇文章因为目前只能捕获大部分double fault在某些特殊场景下现有做法依然不够。Double Fault 的成因先精确界定调用失败到底指什么是没有提供处理函数处理函数被[换出内存]了还是处理函数自身又触发了异常考虑如下场景breakpoint 异常被触发但其处理函数被换出内存page fault 被触发但其处理函数被换出内存divide-by-zero 处理函数触发了 breakpoint 异常而 breakpoint 处理器恰好被换出内存内核发生栈溢出意外访问到guard pageAMD64 手册第 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 FaultPage FaultPage Fault、Invalid TSS、Segment Not Present、Stack-Segment Fault、General Protection Fault据此可以回答前面三个问题breakpoint 异常发生且处理器被换出 → 触发page fault调用 page fault 处理器page fault 发生且处理器被换出 → 触发double fault调用 double fault 处理器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] // dont 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 架构为此提供了内置解决方案。切换栈中断栈表ISTx86_64 允许在异常发生时切换到预定义的已知良好栈这个切换发生在硬件层面完全可以在 CPU 压入异常栈帧之前完成。切换机制由中断栈表Interrupt Stack TableIST实现它是一张包含 7 个已知良好栈指针的表。用 Rust 风格伪代码表示struct InterruptStackTable { stack_pointers: [OptionStackPointer; 7], }对每个异常处理器都可以通过对应 IDT 条目中的stack_pointers字段选择 IST 中的某个栈。例如让 double fault 处理器使用 IST 中的第 0 个栈每当 double fault 发生时CPU 自动切换到这个栈且切换发生在任何压栈之前从而阻止 triple fault。IST 与 TSSIST 属于一个古老的遗留结构——[任务状态段Task State SegmentTSS]。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保留u16I/O 映射基地址I/O Map Base Addressu16特权栈表在 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 处理器中不要做栈密集型操作否则溢出可能破坏栈下方的内存。加载 TSSTSS 的加载比较繁琐历史原因它依赖分段系统。不能直接加载 TSS 本身而是要先在[全局描述符表GDT]中添加一个 TSS 段描述符然后通过ltr指令配合对应的 GDT 索引来加载。这正是模块命名为gdt的原因。全局描述符表GDTGDT 是分页成为事实标准之前用于[内存分段]的遗留结构但在 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 的值。最终步骤还需要三件事重载代码段寄存器csGDT 已改变旧的选择子可能指向不同的 GDT 描述符例如 TSS 描述符必须重载加载 TSSGDT 中已包含 TSS 选择子但还需告知 CPU 使用该 TSS更新 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 栈切换的必要性。总结本篇完成了三件事明确了 double fault 的定义与精确成因AMD64 手册的异常组合矩阵并验证了未注册处理器时由 triple fault 引发的系统重启循环注册了打印错误信息的 double fault 处理器解决了普通 double fault 场景针对栈溢出这种处理器自身无法压栈的极端场景通过 TSS IST GDT 的组合实现了硬件级栈切换并用harness false的集成测试固化行为。实现过程中还顺带理解了三个 x86 硬件机制任务状态段TSS、其内部的中断栈表IST、以及旧架构用于内存分段、如今仍负责内核/用户态切换与 TSS 加载的全局描述符表GDT。下一篇将转向外部设备定时器、键盘、网络控制器的中断处理硬件中断与异常非常相似同样经 IDT 分发但并非由 CPU 直接抛出而是由中断控制器按优先级聚合后转发给 CPU。届时将基于 Intel 8259PIC中断控制器实现键盘支持。赞分享文档教程技术博客操作系统【免费下载链接】blog_osWriting an OS in Rust项目地址https://gitcode.com/GitHub_Trending/bl/blog_os点击查看免费下载相关推荐终极指南一文搞懂Blog_OS中的中断栈表(IST)从崩溃到可靠异常处理终极指南一文搞懂Blog_OS中的中断栈表 IST 从崩溃到可靠异常处理 在Blog_OS项目中中断栈表IST是实现可靠异常处理的关键技术它能够有效文档教程技术博客操作系统Linux 内核中断与异常处理五异常处理程序的实现 —— 从 DO_ERROR 宏到 Double Fault 与 General Protection 故障Linux 内核中断与异常处理五异常处理程序的实现 —— 从 DO_ERROR 宏到 Double Fault 与 General Protection文档教程操作系统User Scanner模块退役机制为什么废弃模块从不删除而是移入abandonedUser Scanner模块退役机制为什么废弃模块从不删除而是移入abandoned User Scanner 是一款集邮件与用户名于一体的 OSINT开源文档教程技术博客操作系统上一篇终极幻兽帕鲁存档修复指南轻松实现跨服务器无损迁移下一篇CFR Java反编译器快速入门和高效使用的完整指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
上一篇/下一篇内容由系统自动关联
返回资讯列表 →