文档教程技术博客操作系统【免费下载链接】blog_osWriting an OS in Rust项目地址https://gitcode.com/GitHub_Trending/bl/blog_os点击查看免费下载导读本篇技术指南基于《Writing an OS in Rust》项目即本仓库blog_os第一版的《Catching Exceptions》一文展开核心任务是为自研内核建立一个中断描述符表Interrupt Descriptor TableIDT并为每个 CPU 异常注册处理函数最终让内核能够捕获并打印divide by zero除零错误。读完本文你将掌握IDT 的 16 字节条目结构与选项位域的完整格式、用 Rust 的安全类型系统表达硬件表结构的方法、通过lidt指令装载 IDT 的原理、static生命周期约束背后的内存安全推导以及利用内联汇编触发真实 CPU 异常的实战技巧。该文属于仓库中的历史系列 Handling Exceptions with Naked Functions在新版中已由x86-interrupt调用约定取代参见 Handling Exceptions但本文从头实现 IDT 的过程依然是理解异常处理底层机制的绝佳教材。什么是 CPU 异常异常exception是对当前正在执行的指令出错的信号。例如当 CPU 执行除零指令时就会发出异常。异常发生时CPU 会中断当前工作并立即调用与异常类型对应的特定处理函数handler。在本项目前序文章中我们已经遇到过几种异常类型这里逐一回顾异常类型触发场景可否处理Invalid Opcode无效操作码当前指令无效。例如在启用 SSE 之前使用movups、movaps等 SSE 指令CPU 不认识它们就会抛出该异常可注册处理函数Page Fault页错误非法内存访问如访问未映射的页、向只读页写入可注册处理函数Double Fault双重错误在 CPU调用异常处理函数的过程中又发生了另一个异常或某异常根本没有注册处理函数可注册处理函数后文系列会讨论Triple Fault三重错误在 CPU 尝试调用双重错误处理函数时又发生异常触发致命的三重错误无法捕获、无法处理。多数处理器会直接复位并重启操作系统——这正是我们在前序帖子中遇到的启动循环bootloop的根源不可捕获在 x86 架构下每个异常都有一个预定义的 IDT 索引向量号。例如无效操作码异常对应的表索引为 6页错误异常对应的表索引为 14。因此硬件能根据异常类型自动装载对应的 IDT 条目。中断描述符表IDT的硬件格式要捕获并处理异常内核必须建立一张中断描述符表。硬件直接使用这张表因此我们必须严格遵循预定义格式。每个 IDT 条目都是如下 16 字节结构类型名称说明u16函数指针 [0:15]处理函数指针的低 16 位u16GDT 选择子GDT 中的代码段选择子u16选项Options见下方位域表u16函数指针 [16:31]处理函数指针的中间 16 位u32函数指针 [32:63]处理函数指针的剩余 32 位u32保留Reserved必须为 0其中Options选项字段的位域格式如下位名称说明0-2中断栈表索引IST0不切换栈1-7调用该处理函数时切换到中断栈表中第 n 个栈3-7保留80中断门1陷阱门该位为 0 时调用处理函数会禁用中断9-11必须为 1硬件要求12必须为 0硬件要求13-14描述符特权级DPL调用该处理函数所需的最低特权级15Present该条目是否有效存在当异常发生时CPU 大致执行以下流程从 IDT 中读取对应条目例如页错误时读取第 14 个条目检查条目是否存在Present 位若不存在则触发双重错误向栈中压入若干寄存器包括指令指针RIP与 RFLAGS 寄存器后续帖子会用到这些值如果条目是中断门第 40 位即选项位 8 未置位则禁用中断将指定的 GDT 选择子加载到 CS 段寄存器跳转到指定的处理函数。为异常处理函数类型设定一个前提处理函数必须永不返回处理函数类型HandlerFunc在本帖中被定义为pub type HandlerFunc extern C fn() - !;它必须是具有确定调用约定的函数因为硬件会直接调用它。C 调用约定是操作系统开发的事实标准这里同样采用。函数不带任何参数因为硬件跳转到处理函数时不会提供任何参数。关键约束是该函数必须是发散函数diverging返回类型为!即永不返回。原因在于硬件并不是用call指令“调用”处理函数而是在向栈中压入一些值之后直接“跳转”jump过去此时栈的布局与普通函数调用完全不同——异常处理函数所面对的栈帧与普通函数的返回地址栈帧并不一致如果处理函数正常返回它会尝试从栈中弹出返回地址但弹出的可能是完全不同的值——例如某些异常会压入错误码error code。若把错误码当作返回地址跳转过去后果不堪设想。因此中断处理函数必须发散。另一个原因是执行处理函数会覆盖当前寄存器值被中断的函数丢失状态后也无法继续执行。用 Rust 定义 IDT 类型我们首先创建一个新的interrupts模块内含idt子模块// in src/lib.rs mod interrupts;// src/interrupts/mod.rs mod idt;然后为 IDT 及其条目定义类型// src/interrupts/idt.rs use x86_64::instructions::segmentation; use x86_64::structures::gdt::SegmentSelector; use x86_64::PrivilegeLevel; pub struct Idt([Entry; 16]); #[derive(Debug, Clone, Copy)] #[repr(C, packed)] pub struct Entry { pointer_low: u16, gdt_selector: SegmentSelector, options: EntryOptions, pointer_middle: u16, pointer_high: u32, reserved: u32, }这里有两个值得注意的设计决策IDT 是变长结构最多可有 256 个条目。本帖只用到前 16 个因此表格定义为[Entry; 16]其余 240 个处理函数会被 CPU 视为“不存在”non-present。Entry类型是对上文 16 字节硬件表结构的直接翻译。#[repr(C, packed)]属性确保编译器保持字段顺序且不插入任何填充字节——这是硬件结构在 Rust 中安全落地的前提。gdt_selector没有用裸u16而是采用x86crate即x86_64crate提供的SegmentSelector类型。同时把第 32 到 47 位合并进一个options字段因为 Rust 没有u3或u1这样的位宽类型。EntryOptions位操作的抽象艺术EntryOptions类型的骨架如下#[derive(Debug, Clone, Copy)] pub struct EntryOptions(u16); impl EntryOptions { fn new() - Self {...} pub fn set_present(mut self, present: bool) {...} pub fn disable_interrupts(mut self, disable: bool) {...} pub fn set_privilege_level(mut self, dpl: u16) {...} pub fn set_stack_index(mut self, index: u16) {...} }这些方法的实现需要只修改u16中正确的位而不触碰其他位。例如设置栈索引需要这样的位运算self.0 (self.0 0xfff8) | stack_index;或者等价地self.0 (self.0 (!0b111)) | stack_index;又或者self.0 ((self.0 3) 3) | stack_index;这些写法都不够可读而且极易出错。因此项目作者创建了一个BitFieldtrait提供基于Range的 APIself.0.set_bits(0..3, stack_index);这大大提升了可读性因为它把底层的位掩码细节全部抽象掉了。BitFieldtrait 位于 [bit_field] crate 中该 crate 较新可能仍有缺陷。把它加入依赖需要运行cargo add bit_field并在src/lib.rs中加上extern crate bit_field;。有了BitFieldtraitEntryOptions的方法实现变得清晰// in src/interrupts/idt.rs use bit_field::BitField; #[derive(Debug, Clone, Copy)] pub struct EntryOptions(u16); impl EntryOptions { fn minimal() - Self { let mut options 0; options.set_bits(9..12, 0b111); // must-be-one bits EntryOptions(options) } fn new() - Self { let mut options Self::minimal(); options.set_present(true).disable_interrupts(true); options } pub fn set_present(mut self, present: bool) - mut Self { self.0.set_bit(15, present); self } pub fn disable_interrupts(mut self, disable: bool) - mut Self { self.0.set_bit(8, !disable); self } pub fn set_privilege_level(mut self, dpl: u16) - mut Self { self.0.set_bits(13..15, dpl); self } pub fn set_stack_index(mut self, index: u16) - mut Self { self.0.set_bits(0..3, index); self } }要点解读注意这里的Range区间不包含上界exclusive upper boundminimal()函数创建一个只设置了“必须为 1”位第 9-11 位的EntryOptionsnew()则采用合理的默认值设置 Present 位为什么要创建不存在的条目呢并禁用中断通常我们不希望异常处理函数被打断set_*方法返回self指针mut Self便于链式调用例如options.set_present(true).disable_interrupts(true)。创建 IDT 条目与set_handler注册新建条目有了EntryOptions就可以实现Entry::newimpl Entry { fn new(gdt_selector: SegmentSelector, handler: HandlerFunc) - Self { let pointer handler as u64; Entry { gdt_selector: gdt_selector, pointer_low: pointer as u16, pointer_middle: (pointer 16) as u16, pointer_high: (pointer 32) as u32, options: EntryOptions::new(), reserved: 0, } } }它接收 GDT 选择子与处理函数指针把 64 位函数地址拆分为三段填入条目低 16 位进pointer_low中间 16 位进pointer_middle剩余 32 位进pointer_high。选项字段使用默认值present 且禁用中断。空表与注册方法Idt::new创建一张全部由“缺失”条目组成的表impl Idt { pub fn new() - Idt { Idt([Entry::missing(); 16]) } } impl Entry { fn missing() - Self { Entry { gdt_selector: SegmentSelector::new(0, PrivilegeLevel::Ring0), pointer_low: 0, pointer_middle: 0, pointer_high: 0, options: EntryOptions::minimal(), reserved: 0, } } }missing()创建的是非 Present条目。只要 Present 位未设置指针与 GDT 选择子字段可以取任意值。全表都是非 Present 条目显然没有用处因此添加set_handler方法注册处理函数impl Idt { pub fn set_handler(mut self, entry: u8, handler: HandlerFunc) - mut EntryOptions { self.0[entry as usize] Entry::new(segmentation::cs(), handler); mut self.0[entry as usize].options } }该方法把指定条目覆写为给定的处理函数使用x86_64crate 的segmentation::cs获取当前代码段描述符。长模式下不需要不同的内核代码段因此当前的cs值始终是正确的选择通过返回条目 options 的可变引用允许调用方覆盖默认设置。例如idt.set_handler(11, handler_fn).set_present(false)可创建一个非 Present 条目。加载 IDTlidt指令与DescriptorTablePointer能创建并注册 IDT 还不够还需要一种方式让CPU 使用这张表。x86 架构用一个特殊寄存器保存当前生效的 IDT 及其长度要装载新 IDT 需通过lidt指令更新该寄存器。lidt指令期望一个指向特殊数据结构的指针该结构指定 IDT 的起始地址与长度类型名称说明u16Limit表中最大可寻址字节等于表大小字节减 1u64Offset表的虚拟起始地址该结构已包含在x86_64crate 中DescriptorTablePointerlidt函数同样如此我们只需把它们拼起来impl Idt { pub fn load(self) { use x86_64::instructions::tables::{DescriptorTablePointer, lidt}; use core::mem::size_of; let ptr DescriptorTablePointer { base: self as *const _ as u64, limit: (size_of::Self() - 1) as u16, }; unsafe { lidt(ptr) }; } }细节说明方法无需修改 IDT因此以不可变引用self接收DescriptorTablePointer的base字段要求u64类型所以需要把self指针强转计算limit时使用mem::size_of并额外减 1因为 limit 字段表示最大可寻址字节含边界需要unsafe块包裹lidt因为该函数假设指定的处理函数地址都是有效的。内存安全剖析load方法为什么需要staticunsafe是有代价的必须论证其安全性。我们逐一分析idt模块的公开接口Idt::new创建一张全部由非 Present 条目构成的表模块外部没有任何方式把这些条目设为 present所以安全set_handler允许覆写指定条目并指向某个处理函数。Rust 的类型系统保证函数指针始终有效只要不涉及unsafe所以也安全。模块中没有其他公开函数除了load看起来应该安全了……但这是错误的。想象如下场景pub fn init() { load_idt(); cause_page_fault(); } fn load_idt() { let mut idt idt::Idt::new(); idt.set_handler(14, page_fault_handler); idt.load(); } fn cause_page_fault() { let x [1,2,3,4,5,6,7,8,9]; unsafe{ *(0xdeadbeaf as *mut u64) x[4] }; }这段代码无法正常工作幸运的话触发三重错误和启动循环不幸的话内核会做出各种奇怪行为并在完全无关的地方失败。问题出在哪里我们在栈上构造了 IDT 并加载它。在load_idt函数结束前它完全有效但函数一返回其栈帧就可能被其他函数复用IDT 随即被cause_page_fault的栈帧覆盖。当页错误发生时CPU 从 IDT 读到的是垃圾值于是触发双重错误进而升级为三重错误和 CPU 复位。更危险的是如果cause_page_fault恰好声明了一个指针数组且 Present 位碰巧被置位CPU 就会跳转到某个随机指针并把随机内存当作代码执行——这是对内存安全的明确破坏。修复方法把要求写进类型签名如何修复可以把load函数本身标记为unsafe并把不安全责任推给调用方但这里存在更好的方案。把load方法的需求明确表述为被引用的 IDT 必须一直有效直到新 IDT 被加载。我们无法预知下一次加载何时发生甚至可能永远不加载所以最坏情况下被引用的 IDT 必须在内核运行的整个期间都有效。这正是**静态生命周期static**的定义。因此只需在load函数签名中加上static约束pub fn load(static self) {...} // ^^^^^^^ 确保 IDT 引用具有 static 生命周期现在 Rust 编译器会在编译期阻止上述错误error: idt does not live long enough -- src/interrupts/mod.rs:78:5 78 | idt.load(); | ^^^ note: reference must be valid for the static lifetime... note: ...but borrowed value is only valid for the block suffix following statement 0 at 75:34 -- src/interrupts/mod.rs:75:35 75 | let mut idt idt::Idt::new(); | ^这正是 Rust 语言“把不安全条件编码进类型系统、让编译器替我们检查”的典型范例与新版 Handling Exceptions 中Idt::load要求static self的理由完全一致。构造静态 IDT从普通 static 到 lazy_static满足static生命周期的方式有两种直接创建一个staticIDT或通过Box::into_raw故意泄漏一个堆上的Box。可预见的未来我们只需要一张 IDT所以先尝试 static 方案// in src/interrupts/mod.rs static IDT: idt::Idt { let mut idt idt::Idt::new(); idt.set_handler(0, divide_by_zero_handler); idt }; extern C fn divide_by_zero_handler() - ! { println!(EXCEPTION: DIVIDE BY ZERO); loop {} }这里注册了一个除零错误索引 0的处理函数。除零异常正如其名是在数字除以 0 时发生的因此是测试新处理函数的便捷手段。但这样写无法编译error: calls in statics are limited to constant functions, struct and enum constructors [E0015] ... error: blocks in statics are limited to items and tail expressions [E0016] ... error: references in statics may only refer to immutable values [E0017] ...原因是 Rust 编译器无法在编译期求值这个static。也许未来const函数更强大后可行但在此之前需要另寻出路。lazy_static 登场幸运的是lazy_static宏存在。它不在编译期求值static而是在该static首次被引用时执行初始化因此在初始化块里几乎可以做任何事甚至读取运行时值。先把lazy_staticcrate 加入项目// in src/lib.rs #[macro_use] extern crate lazy_static;# in Cargo.toml [dependencies.lazy_static] version 0.2.1 features [spin_no_std]需要spin_no_stdfeature因为我们没有链接标准库。随后用lazy_static定义 IDT// in src/interrupts/mod.rs lazy_static! { static ref IDT: idt::Idt { let mut idt idt::Idt::new(); idt.set_handler(0, divide_by_zero_handler); idt }; }最后添加interrupts::init函数装载 IDT// in src/interrupts/mod.rs pub fn init() { IDT.load(); }这里不需要assert_has_not_been_called宏init被调用两次也不会出问题只是重新加载同一张 IDT 而已。新版博客 Handling Exceptions 中还解释了lazy_static!宏的幕后原理它生成一个spin::OnceIdt类型底层用AtomicUsize做同步、UnsafeCell存值把unsafe抽象进安全接口。实测用内联汇编触发除零异常为什么42 / 0不行在rust_main中调用interrupts::init()之后试试直接写42 / 0;// in src/lib.rs pub extern C fn rust_main(...) { ... memory::init(boot_info); // initialize our IDT interrupts::init(); // provoke a divide-by-zero fault 42 / 0; println!(It did not crash!); loop {} }运行结果却是运行时 panicPANIC in src/lib.rs at line 57: attempted to divide by zero这不是我们的异常处理函数。原因是 Rust 自己会在编译期/运行期检查可能的除零并 panic。因此要在 CPU 层面真正触发除零错误必须绕过 Rust 编译器。内联汇编要触发除零异常需要执行操作数为 0 的div或idiv汇编指令。可以写一个小汇编函数再调用但更简单的方式是使用 Rust 的内联汇编宏asm!。它允许在 Rust 函数内直接写原始 x86 汇编。该特性尚不稳定需在src/lib.rs中加入#![feature(asm)]然后编写fn divide_by_zero() { unsafe { asm!(mov dx, 0; div dx ::: ax, dx : volatile, intel) } }逐段解码这条汇编asm!宏发出原始汇编指令因此使用它属于unsafe两条指令mov dx, 0把 0 载入dx寄存器rdx的子集div dx用dx除ax寄存器div指令总是隐式操作ax寄存器冒号是分隔符第一个:后可指定输出操作数第二个:后可指定输入操作数这里都不需要所以留空第三个冒号后指定clobbers被破坏的寄存器。它告诉编译器我们的汇编修改了某些寄存器的值否则编译器会假定这些寄存器保持原值。这里破坏clobber了dx被载入 0和axdiv指令把结果放入其中第四个冒号后的块指定选项volatile告诉编译器“这段代码有副作用不要删除、不要挪走”这里的“副作用”正是除零异常intel选项允许使用 Intel 汇编语法而非默认的 ATT 语法。运行结果把rust_main中的42 / 0替换为divide_by_zero()// in src/lib.rs pub extern C fn rust_main(...) { ... interrupts::init(); // provoke a divide-by-zero fault divide_by_zero(); println!(It did not crash!); loop {} }运行make run即可在 QEMU 中看到屏幕底部打印出EXCEPTION: DIVIDE BY ZERO至此我们成功捕获了第一个 CPU 异常关联源码脉络在仓库中继续深入本仓库包含完整的历史系列与新版实现可按以下路径继续研读系列总览Handling Exceptions with Naked Functions其中列出了02-better-exception-messages异常栈帧打印、naked 函数、页错误处理与03-returning-from-exceptionsiretq返回、调用约定、多媒体寄存器、red zone两篇后续文章新版实现基于x86-interrupt调用约定Handling Exceptions其中展示了Idt结构体、HandlerFunc extern x86-interrupt fn(mut ExceptionStackFrame)、lazy_static静态 IDT 及int3()触发断点异常的完整流程并解释了 Fault/Trap/Abort 三类异常的区别与本文直接相关的配图与运行截图存放于 01-catching-exceptions 目录含normal-vs-interrupt-function-return.svg与qemu-divide-error-println.png调试辅助Set Up GDB 一文可作为后续帖子中用 GDB 排查异常的预备知识。小结与下一步本文完成了异常处理的第一步定义了 IDT 硬件结构对应的 Rust 类型、通过bit_fieldcrate 优雅地操作选项位域、用segmentation::cs自动获取代码段选择子、经lidt装载 IDT并以static生命周期约束堵住了“栈上 IDT 被覆写”的内存安全漏洞最后借助lazy_static构造静态 IDT用内联汇编触发除零异常完成了端到端验证。不过EXCEPTION: DIVIDE BY ZERO这条消息还不包含异常原因的详细信息。系列后续文章将改进这一点打印异常发生时的栈指针与引发异常的指令地址并进一步探讨页错误等携带错误码的异常以及如何用iretq从异常中正确返回。赞分享文档教程技术博客操作系统【免费下载链接】blog_osWriting an OS in Rust项目地址https://gitcode.com/GitHub_Trending/bl/blog_os点击查看免费下载相关推荐Linux 内核揭秘中断描述符表IDT——门描述符、错误代码与 x86_64 中断处理机制全解Linux 内核揭秘中断描述符表IDT——门描述符、错误代码与 x86_64 中断处理机制全解 中断描述符表Interrupt Descriptor TLinux 内核初始化linux-insides 第 2 部分早期异常处理与中断描述符表IDT的构建Linux 内核初始化linux insides 第 2 部分早期异常处理与中断描述符表IDT的构建 本文是 linux insides https:文档教程操作系统Linux 内核揭秘中断描述符表IDT与内核内部系统数据结构深度解析Linux 内核揭秘中断描述符表IDT与内核内部系统数据结构深度解析 导读本文是《Linux 内核揭秘》linux insides zh中内核数据上一篇Buzz 离线语音转文字完整指南3个场景搞定音频转文字下一篇实战指南5个秘诀快速掌握Kutt现代URL短链接服务创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考