clistbox 试图执行系统_用Rust写操作系统(三)——操作系统的基石:中断与异常...

本次实验的问题总结,有需要的小伙伴可以戳这里↓

GMN23362:用Rust写操作系统(三)——问题汇总​zhuanlan.zhihu.com

一、概要说明

中断、异常和陷阱指令(合称类中断)是操作系统的基石,现代操作系统就是由(类)中断驱动的。本实验的目的在于深刻理解(类)中断的原理和机制,掌握CPU访问设备控制器的方法,掌握x86体系结构的(类)中断机制和规范,实现时钟中断服务和部分异常处理等。

二、实验原理

1. 陷入操作系统

操作系统是一个多入口的程序,执行陷阱(Trap)指令,出现异常、发生中断时都会陷入到操作系统。

2. 异常及其主要类型

当CPU执行指令出现问题时就会产生异常。如被0除,执行非法指令等都会产生异常。发生异常时,CPU会中断当前指令序列的执行,并根据异常类型立即调用特定的异常处理函数。在x86上,大约有20种不同的CPU异常类型。最重要的包括:

• 页面错误:在非法内存访问中发生页面错误。例如,如果当前指令试图从未映射的页面读取或试图写入只读页面。

• 无效的操作码:当当前指令无效时,例如当我们尝试在不支持它们的旧CPU上使用较新的SSE指令时,将发生此异常。

• 一般保护故障:这是最广泛原因的例外。它发生在各种访问冲突中,例如试图在用户级代码中执行特权指令或在配置寄存器中写入保留字段。

• 双重故障:发生异常时,CPU尝试调用相应的处理程序函数。如果在调用异常处理程序时发生另一个异常,则CPU会引发双重故障异常。当没有为异常注册的处理函数时,也会发生此异常。

• 三重故障:如果在CPU尝试调用二重故障处理程序函数时发生异常,它将发出致命的三重故障。我们无法捕捉或处理三重故障。大多数处理器通过重置自身并重新引导操作系统来作出反应。

有关异常的完整列表,可以查看http://wiki.osdev.org/Exceptions。

3. 中断

中断是一种硬件机制。借助于中断,CPU可以不必再采用轮询这种低效的方式访问外部设备。将所有的外部设备与CPU直接相连是不现实的,外部设备的中断请求一般经由中断控制器再转发给CPU。

• 8259可编程中断控制器

8259中断控制器由Intel在1976年引入,今天已经被advanced programmable interrupt controller (APIC,先进可编程中断控制器) 所代替,但其可以后向兼容8259芯片。

上图为两片8259中断控制器芯片级联,共支持15个优先级别的中断信号。这些中断信号采用固定映射方式,如主芯片的0号用于时钟,从芯片的4号用于鼠标。每个中断控制器芯片都包含一个命令口(command port)和一个数据口(data port),可以通过CPU的IO指令进行访问,用于配置和访问中断控制器。其中主芯片的端口IO地址为0x20(命令)和0x21(数据),从芯片的端口IO地址为0xa0(命令)和0xa1(数据)。CPU对其他设备控制器的访问与对中断控制器的配置和访问方式类似,请仔细体会CPU是如何访问设备控制器的。由于中断向量表中0-15号中断已经被异常所占用,所以需要配置(即依据硬件手册向中断控制器的命令口和数据口写入合适的数据)中断控制器使其产生其他编号的中断,通常选择32-47号。

4. 中断描述符及中断描述符表

中断是一种硬件机制,但中断的处理却是通过执行软件代码完成的。为了处理中断和异常,我们需要将特定的中断和特定的中断处理函数关联起来,在x86中这是通过中断描述符表(Interrupt Descriptor Table,IDT)来完成的。中断描述符表中包括一个一个的中断描述符,每个中断描述符都是一个大小为16字节的结构。其定义如表1所示。其中第3部分 Options的详细定义由表2给出。

因此我们需要按照表1和表2的定义初始化中断描述符表中的所有中断描述符,然后告诉CPU中断描述符表所在(内存中)的位置。CPU中有专门的寄存器IDTR来保存中断描述符表在内存中的位置,也提供了专门的SIDT和LIDT指令分别用于读取和设置IDTR寄存器的内容。其中LIDT指令用于把中断描述符表的基址和界限加载到IDTR寄存器中。这样当发生中断时,CPU就可以自动跳转到相应的中断服务处理函数了(表1的第1,4,5行一起给出中断服务函数的地址)。

请深刻体会硬件提供给系统的接口的表现形式及系统与硬件如何协作。

发生异常时,CPU大致(自动)执行以下操作:

• 将某些寄存器压入堆栈,包括指令指针和RFLAGS寄存器等。

• 从中断描述符表(IDT)中读取相应的条目。例如,发生页面错误(14号异常)时,CPU 读取第14个中断描述符。

• 检查是否存在该描述符。如果没有,则引发双重故障。

• 如果该描述符的Interrupt Gate位为0,则禁用硬件中断。

• 将指定的全局描述符表(Global Descriptor Table, GDT)描述符加载到CS段中。

• 跳转到指定的处理函数。

5. 栈帧(Stack Frame)

当执行普通的函数调用时(执行call指令),栈帧的情况如下图所示。CPU在跳转到被调用函数入口之前先将返回地址(Return Address)压栈。在被调用函数返回时(执行ret指令),CPU从栈中弹出返回地址,并将其值存入PC寄存器中,然后从该地址开始继续执行调用函数。

处理中断(异常)时的栈帧要比函数调用时的栈帧更复杂。发生中断时,CPU将执行以下步

骤:

• 对齐堆栈指针:执行任何指令时都可能发生中断,因此堆栈指针也可以是任何值。但某些CPU指令(如某些SSE指令)要求堆栈指针在16字节边界上对齐,因此CPU在中断后需要做堆栈对齐。

• 切换堆栈(在某些情况下):当CPU特权级别变化(模式切换)时需要切换堆栈。如在用户模式程序中发生CPU异常时,就会发生堆栈切换,即使内核代码和用户代码执行时使用不同的堆栈。还可以通过使用所谓的中断堆栈表(Interrupt Stack Table)为特定中断配置堆栈。

• 压入旧的堆栈指针:在发生中断时,CPU压入原始(对齐之前)的堆栈指针(rsp)和堆栈段(ss)寄存器的值。从中断处理程序返回时,这可以恢复原始堆栈指针。

• 保存RFLAGS寄存器的值:RFLAGS寄存器包含各种控制和状态位。进入中断时,CPU 将压入RFLAGS的旧值进入堆栈。

• 压入指令指针:在跳转到中断处理程序功能之前,CPU压入指令指针(rip)和代码段(cs)。 这相当于普通函数调用的返回地址。

• 压入错误代码(对于某些异常):对于某些特定的异常(例如页面错误),CPU 压入一个错误代码进入堆栈,该代码描述了异常的原因。

• 调用中断处理程序:CPU从中断描述符表(IDT)中的相应字段读取中断处理程序功能的地址和段描述符。然后,它通过将值加载到rip和cs寄存器中来调用该中断处理程序。

因此,发生中断后的栈帧如右图所示。

三、CPU异常

CPU异常发生在各种错误情况下,例如访问无效内存地址或除以0时。为了对它们作出反应,我们必须设置一个提供处理程序函数的中断描述符表。在本次实验的最后,我们的内核将能够捕获断点异常,并在之后恢复正常的执行。

• 我们将从在src/interrupts中创建一个新的interrupts模块开始。它首先创建一个init_idt函数,该函数创建一个新的InterruptDescriptorTable。

现在我们可以添加处理程序函数了。我们首先为断点异常添加一个处理程序。断点异常可以完美地测试异常处理。它的唯一目的是在执行断点指令int3时暂时暂停程序。

断点异常通常在调试器中使用:当用户设置断点时,调试器用int3指令覆盖相应的指令,以便CPU在到达该行时抛出断点异常。当用户想要继续程序时,调试器会再次用原始指令替换int3指令并继续程序。

• 对于我们的用例,我们不需要覆盖任何指令。相反,我们只想在执行断点指令时打印一条消息,然后继续程序。因此,让我们创建一个简单的breakpoint_handler函数,并将其添加到IDT中:

• 我们的处理程序只输出一条消息并打印中断堆栈框架。尝试编译它,会发生以下错误:

• 发生此错误的原因是x86-interrupt调用约定仍然不稳定。为了使用它,我们必须在lib.rs之上显式地添加#![feature(abi_x86_interrupt)]来启用它。

1. 加载IDT

• 为了让CPU使用新的中断描述符表,我们需要使用lidt指令加载它。x86_64的InterruptDescriptorTable结构为此提供了一个加载方法函数。

• 当我们现在尝试编译它的时候,会发生以下错误:

load 方法希望有一个 &'static self,以确保 idt 引用在整个程序生命周期中可用。因为CPU会在每个异常发生的时候访问这张表,直到我们加载了其它的InterruptDescriptorTable对象。所以,使用比 'static 短的生命周期会导致 use-after-free bug。

事实上,这就是这里发生的事情。idt是在堆栈上创建的,因此它只在init函数内有效。然后堆栈内存被其他函数重用,因此CPU将随机堆栈内存解释为IDT。幸运的是,InterruptDescriptorTable::load方法在其函数定义中编码了这个生存期要求,因此Rust编译器能够在编译时防止这个可能的错误。

为了解决这个问题,我们需要将idt存储在一个具有’static生存期”的位置。为了实现这一点,我们可以使用Box在堆上分配我们的IDT,然后将它转换为一个‘static引用,但我们正在编写一个OS内核,因此(还)没有堆。

• 作为另一种选择,我们可以尝试将IDT存储为一个静态变量。

• 但是,存在一个问题:静态变量是不可变的,因此我们不能修改init函数中的断点条目。我们可以通过使用static mut来解决这个问题。

这个变量编译时没有错误,但它远非习惯用法。静态mut很容易出现数据竞争,因此每次访问都需要一个不安全的块。

* 通过lazy static拯救

幸运的是lazy_static宏存在。与在编译时计算静态值不同,该宏在第一次引用静态时执行初始化。因此,我们可以在初始化块中做几乎所有的事情,甚至能够读取运行时值。

• 在为VGA文本缓冲区创建抽象时,我们已经导入了lazy_static包。所以我们可以直接使用lazy_static!创建静态IDT的宏。

请注意,此解决方案不需要unsafe块。lazy_static !宏在幕后使用了unsafe,但是它被抽象到一个安全的接口中。

2. 运行它

使异常在内核中工作的最后一步是从main.rs中调用init_idt函数。我们没有直接调用它,而是在lib.rs中引入了一个通用的init函数。

有了这个函数,我们现在就有了一个用于初始化例程的中心位置,可以在main.rs、lib.rs和集成测试中的不同_start函数之间共享这些例程。

• 现在我们可以更新main.rs的_start函数来调用init,然后触发断点异常。

成功了!CPU成功地调用了我们的断点处理程序,该处理程序将打印消息,然后返回到_start函数,“It did not crash!”消息被打印出来。

我们看到,中断堆栈帧告诉我们异常发生时的指令和堆栈指针。此信息在调试意外异常时非常有用。

3. 添加一个测试

• 让我们创建一个测试来确保上面的内容能够继续工作。首先,更新_start函数来调用init。

记住,这个_start函数是在运行cargo test--lib时使用的,因为Rust测试lib.rs完全独立于main.rs。我们需要在运行测试之前调用init来设置IDT。

• 现在我们可以创建一个test_breakpoint_exception测试。

测试调用int3函数来触发断点异常。通过检查执行之后是否继续,我们可以验证断点处理程序是否正常工作。

• 可以通过运行cargo test(所有测试)或cargo test--lib(仅对lib.rs及其模块进行测试)来尝试这个新的测试。应该会在输出中看到以下内容。

四、双重故障

双重故障异常发生在CPU无法调用异常处理程序的时候。通过处理这个异常,我们避免了导致系统重置的致命三重错误。为了防止在所有情况下出现三重错误,我们还设置了一个中断堆栈表来捕获单独内核堆栈上的双重故障。

1. 什么是双重故障

简单地说,双重故障是当CPU不能调用异常处理程序时发生的特殊异常。例如,当触发了一个页面错误,但在中断描述符表(IDT)中没有注册页面错误处理程序时,就会发生这种情况。所以它有点类似于编程语言中带有异常的全部捕获块,例如c++中的catch(…)或Java或c#中的catch(Exception e)。

双重故障的行为与正常异常类似。它的向量为8,我们可以在IDT中为它定义一个普通的处理程序函数。提供双重故障处理程序非常重要,因为如果双重故障没有得到处理,就会发生致命的三重错误。三重故障不能被抓住,大多数硬件反应与系统复位。

* 触发双重故障

• 通过触发一个我们没有定义处理函数的异常来引发双重故障。

我们使用unsafe写入无效地址0xdeadbeef。虚拟地址没有映射到页表中的物理地址,因此会发生页错误。我们没有在IDT中注册页面错误处理程序,因此出现了双重故障。

• 现在,当我们启动内核时,我们看到它进入了一个无穷无尽的引导循环。

引导循环的原因如下:

a) CPU尝试写入0xdeadbeef,这会导致页面错误。

b) CPU查看IDT中的相应条目,发现没有指定任何处理程序函数。因此,它不能调用页面错误处理程序,从而出现双重故障。

c) CPU查看双故障处理程序的IDT条目,但是该条目也没有指定处理程序函数。因此,出现了三重故障。

d) 三重错误是致命的。QEMU会像大多数实际硬件一样对它做出反应,并发出系统重启命令。

因此,为了防止这种三重错误,我们需要为页面错误提供一个处理程序函数或一个双重故障处理函数。我们希望在所有情况下都避免三重错误,因此让我们从为所有未处理异常类型调用的双重故障处理函数开始。

2. 一个双重故障处理函数

• 双重异常由普通异常和错误码组成,所以我们可以像断点异常处理函数那样定义一个双重异常处理函数。

我们的处理函数打印一个简短的错误消息并转储异常堆栈框架。双重故障处理函数的错误代码总是零,所以没有理由打印它。与断点处理函数的一个区别是,双重故障处理函数是发散的。原因是x86_64体系结构不允许从双重故障异常返回。

• 现在,当我们启动内核时,应该会看到调用了双重故障处理函数。

成功了!下面是这次发生的事情:

a) CPU尝试写入0xdeadbeef,这会导致页面错误。

b) 与前面一样,CPU查看IDT中相应的条目,发现没有定义处理程序函数。因此,出现了双重故障。

c) CPU跳转到-现在-双重故障处理函数。

三重错误(和引导循环)不再发生,因为CPU现在可以调用双重故障处理函数。

我们现在能够捕捉大多数双重故障,但有些情况下,我们目前的方法还不够。

3. 双重故障原因

在研究特殊情况之前,我们需要知道双重故障的确切原因。上面,我们使用了一个相当模糊的定义:

双重故障是当CPU无法调用异常处理程序时发生的特殊异常。

“不能调用”到底是什么意思?处理程序不在场?处理程序被换出?如果处理程序本身导致异常,会发生什么?

例如,如果:

a) 出现断点异常,但相应的处理程序函数被换出?

b) 出现页面错误,但页面错误处理程序被换出?

c) 0分处理程序会导致断点异常,但是断点处理程序会被换出?

d) 我们的内核溢出它的堆栈和保护页被击中?

幸运的是,AMD64手册(PDF)有一个确切的定义(在8.2.9节中)。根据它,“当在处理优先(第一个)异常处理程序期间发生第二个异常时,可能会发生双重故障异常”。“can”很重要:只有非常特定的异常组合才会导致双重故障。这些组合是:

因此,例如,一个除0错误后面跟着一个页面错误是可以的(调用页面错误处理程序),但是一个除0错误后面跟着一个一般保护错误会导致一个双重故障。借助这张表,我们可以回答上面的前三个问题:如果发生断点异常并交换出相应的处理程序函数,则会发生页面错误并调用页面错误处理程序。如果出现页面错误,并且换出页面错误处理程序,则会出现双重故障并调用双重故障处理程序。如果0分处理程序导致断点异常,CPU尝试调用断点处理程序。如果将断点处理程序换出,则会发生页面错误并调用页面错误处理程序。实际上,即使是IDT中没有处理程序函数的异常情况也遵循这种模式:当异常发生时,CPU尝试读取相应的IDT条目。由于条目为0,这不是一个有效的IDT条目,因此会发生一般的保护故障。我们也没有为通用保护故障定义处理程序函数,因此会发生另一个通用保护故障。根据上表,这导致了双重故障。

* 内核堆栈溢出

让我们看看第四个问题:如果内核溢出了它的堆栈,并且触及了保护页,会发生什么?

保护页是位于堆栈底部的一个特殊内存页,它使检测堆栈溢出成为可能。该页面没有映射到任何物理框架,因此访问它会导致页面错误,而不是默默地破坏其他内存。引导装载程序为内核堆栈设置了一个保护页,因此堆栈溢出会导致页面错误。

当出现页面错误时,CPU在IDT中查找页面错误处理程序,并尝试将中断堆栈帧压到堆栈上。但是,当前堆栈指针仍然指向不存在的保护页。因此,会出现第二个页面错误,从而导致双重故障(根据上表)。

所以CPU现在尝试调用双重故障处理程序。但是,在出现双重故障时,CPU也尝试压入异常堆栈帧。堆栈指针仍然指向保护页,因此出现第三个页错误,这将导致三重故障和系统重新启动。因此,我们当前的双重故障处理程序在这种情况下无法避免三重故障。

• 让我们复现这个情形吧!通过调用无穷的递归函数可以轻易引发内核栈溢出。

在QEMU中尝试这段代码时,我们看到系统再次进入引导循环。

那么我们怎样才能避免这个问题呢?我们不能忽略异常堆栈帧的压入,因为这是由CPU自己完成的。因此,我们需要以某种方式确保当发生双重故障异常时,堆栈始终有效。幸运的是,x86_64体系结构可以解决这个问题。

4. 切换栈

当发生异常时,x86_64体系结构能够切换到预定义的堆栈。此切换发生在硬件级别,因此可以在CPU压入异常堆栈帧之前执行。

切换机制被实现为一个中断堆栈表(IST)。IST是一个有7个指针的表,指针指向已知的好堆栈。Rust中的伪代码:

对于每个异常处理程序,我们可以通过对应IDT条目中的stack_pointer字段从IST中选择一个堆栈。例如,我们可以使用IST中的第一个堆栈作为双重故障处理程序。然后,每当发生双重故障时,CPU会自动切换到这个堆栈。这个开关会在任何东西被压栈之前发生,所以它可以防止三重故障。

1) IST和TSS

中断堆栈表(IST)是任务状态段(TSS)的旧遗留结构的一部分。TSS用于保存关于32位模式下任务的各种信息(例如处理器寄存器状态),例如用于硬件上下文切换。但是,64位模式不再支持硬件上下文切换,TSS的格式也完全改变了。

在x86_64上,TSS不再保存任何特定于任务的信息。相反,它拥有两个堆栈(IST是其中之一)。32位和64位TSS之间唯一的公共字段是指向I/O端口权限位图的指针。

64位TSS的格式如下:

当特权级别发生变化时,CPU将使用特权堆栈表。例如,如果CPU处于用户模式(特权级别3)时发生异常,CPU通常会在调用异常处理程序之前切换到内核模式(特权级别0)。在这种情况下,CPU将切换到特权堆栈表中的第0堆栈(因为0是目标特权级别)。我们还没有任何用户模式程序,所以我们暂时忽略这个表。

2) 创建一个TSS

让我们创建一个新的TSS,在它的中断堆栈表中包含一个单独的双重故障堆栈。为此,需要一个TSS结构。幸运的是,x86_64包已经包含了可以使用的TaskStateSegment结构。

• 在一个新的gdt模块中创建TSS(这个名称稍后会有意义)。

我们使用lazy_static是因为Rust的常量求值器还不够强大,无法在编译时执行这个初始化。我们定义第0个IST条目是双重故障堆栈(任何其他IST索引也可以工作)。然后我们将双重故障堆栈的顶地址写入第0项。我们写顶部地址是因为x86上的堆栈是向下增长的,即从高地址到低地址。

我们还没有实现内存管理,所以我们没有一个合适的方法来分配一个新的堆栈。现在,我们使用static mut数组作为堆栈存储。unsafe是必需的,因为编译器不能保证访问可变静态变量时的竞争自由。重要的是,它是一个static mut而不是一个不可变的static,否则引导加载程序将把它映射到只读页面。在以后的文章中,我们将用一个正确的堆栈分配来替换它,然后将不再需要unsafe。

请注意,这个双重故障堆栈没有防止堆栈溢出的保护页。这意味着我们不应该在双重故障处理程序中执行任何堆栈密集型操作,因为堆栈溢出可能会损坏堆栈下面的内存。

* 加载TSS

现在我们创建了一个新的TSS,我们需要一种方法来告诉CPU应该使用它。不幸的是,这有点麻烦,因为TSS使用分割系统(由于历史原因)。我们需要向全局描述符表(GDT)添加一个新的段描述符,而不是直接加载表。然后,我们可以用各自的GDT索引调用ltr指令来加载TSS。(这就是我们将模块命名为gdt的原因。)

3) 全局描述符表

全局描述符表(GDT)是在分页成为事实上的标准之前用于内存分段的残余物。在64位模式下,对于内核/用户模式配置或TSS加载等各种事情,仍然需要它。

GDT是一个包含程序片段的结构。在分页成为标准之前,它在较老的体系结构上用于将程序彼此隔离。虽然64位模式不再支持分段,但GDT仍然存在。它主要用于两件事:在内核空间和用户空间之间切换,以及加载TSS结构。

* 创建GDT

• 让我们创建一个静态GDT,其中包括一个我们的TSS static片段。

我们再次使用lazy_static,因为Rust的常量求值器还不够强大。我们创建一个带有代码段和TSS段的新GDT。

* 加载GDT

• 为了加载我们的GDT,我们创建一个新的GDT::init函数,并从init函数中调用。

现在我们的GDT已经加载(因为_start函数调用init),但是我们仍然看到堆栈溢出的引导循环。

4) 最后一个步骤

问题是GDT段还没有生效,因为段和TSS寄存器仍然包含来自旧GDT的值。我们还需要修改双重故障IDT条目,以便它使用新堆栈。

总之,我们需要做以下工作:

a) 重新加载代码段寄存器:我们修改了GDT,所以应该重新加载代码段寄存器cs。这是必需的,因为旧的段选择器现在可以指向不同的GDT描述符(例如TSS描述符)。

b) 加载TSS:我们加载了一个包含TSS选择器的GDT,但是我们仍然需要告诉CPU它应该使用那个TSS。

c) 更新IDT条目:一旦TSS被加载,CPU就可以访问一个有效的中断堆栈表(IST)。然后,我们可以通过修改双故障IDT条目来告诉CPU应该使用新的双故障堆栈。

• 对于前两个步骤,我们需要访问gdt::init函数中的code_selector和tss_selector变量。我们可以通过一个新的结构体Selector使它们成为静态变量的一部分来实现这一点。

• 现在我们可以使用选择器来重新加载cs段寄存器和加载我们的TSS。

我们使用set_cs重新加载代码段寄存器,使用load_tss加载TSS。这些函数被标记为unsafe,因此我们需要一个unsafe块来调用它们。原因是它可能会破坏内存安全加载无效的选择器。

• 现在我们加载了一个有效的TSS和中断堆栈表,我们可以在IDT中为我们的双重故障处理程序设置堆栈索引。

set_stack_index方法是不安全的,因为调用者必须确保使用的索引是有效的,而且还未被另一个异常使用。

• 就是这样!现在,每当出现双故障时,CPU应该切换到双重故障堆栈。因此,我们能够捕获所有的双重故障,包括内核堆栈溢出。

从现在起,我们再也不会看到三重故障了!为了确保我们不会意外地破坏上面的内容,我们应该为此添加一个测试。

5. 堆栈溢出测试

为了测试新的gdt模块并确保在堆栈溢出时正确调用双重故障处理程序,我们可以添加一个集成测试。其思想是在测试函数中引发双重错误,并验证调用了双重故障处理函数。

• 让我们从最小的骨架开始。

• 类似于 panic_handler 测试,新的测试不会运行在测试环境下(without a test harness)。原因在于我们不能在双重异常之后继续执行,所以连续进行多个测试是行不通的。为了禁用测试环境,需要在 Cargo.toml 中增加以下配置:。

现在cargo tes--test stack_overflow应该已经成功编译。当然,测试失败了,因为unimplemented宏出现了恐慌。

1) 实现_start

• _start函数的实现是这样的。

我们调用gdt::init函数来初始化一个新的gdt。我们不是调用我们的interrupts::init_idt函数,而是调用init_test_idt函数,稍后将对此进行解释。原因是我们想要注册一个定制的双重故障处理程序,它执行exit_qemu(QemuExitCode::Success)而不是恐慌。

stack_overflow函数几乎与main.rs中的函数相同。唯一的区别是,我们在函数的末尾使用volatile类型执行额外的volatile读取,以防止编译器优化,即尾部调用消除。除此之外,这种优化允许编译器将最后一个语句是递归函数调用的函数转换为普通循环。因此,不会为函数调用创建额外的堆栈帧,因此堆栈使用情况保持不变。

然而,在我们的示例中,我们希望发生堆栈溢出,因此我们在函数的末尾添加了一个空的volatile read语句,编译器不允许删除该语句。因此,该函数不再是尾递归的,并且防止转换为循环。我们还添加了allow(unconditional_recursion)属性,以消除编译器发出的函数不断递归的警告。

2) 测试的IDT

• 如上所述,测试需要自己的IDT和自定义的双重故障处理程序。实现是这样的。

该实现与我们在interrupts.rs中的常规IDT非常相似。与在普通IDT中一样,我们在IST中为双重故障处理程序设置堆栈索引,以便切换到单独的堆栈。init_test_idt函数通过load方法在CPU上加载IDT。

3) 双重故障处理函数

• 唯一缺失的部分是我们的双重故障处理函数。它是这样的。

当调用双重故障处理函数时,我们使用一个成功退出代码退出QEMU,该代码将测试标记为通过。因为集成测试是完全独立的可执行程序,所以我们需要在测试文件的顶部再次设置#![feature(abi_x86_interrupt)]属性。

现在我们可以通过cargo test--test stack_overflow(或者通过cargo test运行所有测试)运行我们的测试。正如预期的那样,我们看到stack_overflow…[ok]输出到控制台。尝试注释掉set_stack_index行:它会导致测试失败。

五、硬件中断

我们将设置可编程中断控制器来正确地将硬件中断转发给CPU。为了处理这些中断,我们向中断描述符表添加新条目,就像我们为异常处理程序所做的那样。我们将学习如何获得周期性计时器中断和如何从键盘获得输入。

1. 8259PIC

1) 阅读pic8259_simple库的源码,说明如何初始化8259中断控制器。

• 初始化:定义初始化命令为CMD_INIT,unsigned类型,值为0x11。

• 定义单独的PIC芯片类,其中包括偏移量、发送命令和发送或接收数据的处理器I/O口。

同时对两个PIC进行初始化:我们需要增加写入到我们的PIC之间的延迟,保存我们原来的中断掩码,告诉每个PIC将在其数据端口上发送一个3字节的初始化序列,将字节1设置成我们的基址偏移量,将字节2配置成PIC1和PIC2之间的链接,将字节3设置成我们想要的模式,最后恢复保存的mask。

pic8259_simple库的源码:

支持8259可编程中断控制器,它处理基本的I/O中断。在多核模式下,我们显然需要用APIC接口替换它。

这里的基本思想是,我们有两个PIC芯片,PIC1和PIC2,而PIC2被迫在PIC1上中断2。你可以在http://wiki.osdev.org/PIC(和往常一样)找到整个故事。基本上,我们非常先进的现代芯片组正在从事80年代早期的角色扮演,我们的目标是做最基本的要求,以得到合理的中断。这里我们需要做的最重要的事情是为我们的两个PIC设置基“偏移量”,因为在默认情况下,PIC1的偏移量是0x8,这意味着来自PIC1的I/O中断将会重叠处理中断,比如“一般保护故障”。因为从0x00到0x1F的中断是由处理器保留的,所以我们将PIC1中断移动到0x20-0x27,而PIC2中断移动到0x28-0x2F。如果我们想编写一个DOS模拟器,我们可能需要选择不同的基本中断,因为DOS对系统调用使用0x21中断。

#![feature(const_fn)]
#![no_std]
 
extern crate cpuio;
 
//开始PIC初始化的命令。
const CMD_INIT: u8 = 0x11;
 
//用于确认中断的命令。
const CMD_END_OF_INTERRUPT: u8 = 0x20;
 
//我们想要运行PIC的模式。
const MODE_8086: u8 = 0x01;
 
//一个单独的PIC芯片。这个没有导出,因为我们总是通过下面的“Pics”访问它。
struct Pic {
 //中断映射到的基址偏移量。
 offset: u8,
 
 //我们在其上发送命令的处理器I/O口。
 command: cpuio::UnsafePort<u8>,
 
 //我们发送和接收数据的处理器I/O口。
 data: cpuio::UnsafePort<u8>,
}
 
impl Pic {
 //我们是否改变了对指定中断的处理?(每个PIC处理8个中断。)
 fn handles_interrupt(&self, interupt_id: u8) -> bool {
 self.offset <= interupt_id && interupt_id < self.offset + 8
 }
 
 //通知我们一个中断已经被处理,我们准备好接受更多的中断。
 unsafe fn end_of_interrupt(&mut self) {
 self.command.write(CMD_END_OF_INTERRUPT);
 }
}
 
//一对链式PIC控制器。这是x86上的标准设置。
pub struct ChainedPics {
 pics: [Pic; 2],
}
 
impl ChainedPics {
 //为标准PIC1和PIC2控制器创建一个新接口,指定所需的中断偏移量。
 pub const unsafe fn new(offset1: u8, offset2: u8) -> ChainedPics {
 ChainedPics {
 pics: [
 Pic {
 offset: offset1,
 command: cpuio::UnsafePort::new(0x20),
 data: cpuio::UnsafePort::new(0x21),
 },
 Pic {
 offset: offset2,
 command: cpuio::UnsafePort::new(0xA0),
 data: cpuio::UnsafePort::new(0xA1),
 },
 ]
 }
}
 
//初始化我们的PIC。我们同时对它们进行初始化,因为这样做是传统的做法,而且在较老的处理器上,I/O操作可能不是瞬时的。
 pub unsafe fn initialize(&mut self) {
 //我们需要增加写入到我们的PIC之间的延迟,特别是在旧的主板上。但我们还没有必要使用任何类型的计时器,因为大多数计时器都需要中断。各种旧版本的Linux和其他PC操作系统通过将垃圾数据写入端口0x80来解决这个问题,据说这需要足够长的时间才能在大多数硬件上正常工作。在这里,wait是一个结束。
 let mut wait_port: cpuio::Port<u8> = cpuio::Port::new(0x80);
 let mut wait = || { wait_port.write(0) };
 
 //保存我们原来的中断掩码,因为我太懒了,无法计算出合理的值。完成后我们会恢复这些。
 let saved_mask1 = self.pics[0].data.read();
 let saved_mask2 = self.pics[1].data.read();
 
 //告诉每个PIC,我们将在其数据端口上发送一个3字节的初始化序列。
 self.pics[0].command.write(CMD_INIT);
 wait();
 self.pics[1].command.write(CMD_INIT);
 wait();
 
 //字节1:设置我们的基址偏移量。
 self.pics[0].data.write(self.pics[0].offset);
 wait();
 self.pics[1].data.write(self.pics[1].offset);
 wait();
 
 //字节2:配置PIC1和PIC2之间的链接。
 self.pics[0].data.write(4);
 wait();
 self.pics[1].data.write(2);
 wait();
 
 //字节3:设置我们的模式。
 self.pics[0].data.write(MODE_8086);
 wait();
 self.pics[1].data.write(MODE_8086);
 wait();
 
 //恢复保存的面具。
 self.pics[0].data.write(saved_mask1);
 self.pics[1].data.write(saved_mask2);
 }
 
//我们处理这个中断吗?
 pub fn handles_interrupt(&self, interrupt_id: u8) -> bool {
 self.pics.iter().any(|p| p.handles_interrupt(interrupt_id))
 }
 
 //找出哪些(如果有)图片在我们的链需要知道这个中断。这很棘手,因为所有来自“pics[1]”的中断都通过“pics[0]”链接。
 pub unsafe fn notify_end_of_interrupt(&mut self, interrupt_id: u8) {
 if self.handles_interrupt(interrupt_id) {
 if self.pics[1].handles_interrupt(interrupt_id) {
 self.pics[1].end_of_interrupt();
 }
 self.pics[0].end_of_interrupt();
 }
 }
}

2) 实现

默认的PIC配置是不可用的,因为它发送在0-15范围的中断矢量给CPU。这些数字已经被CPU异常占用,例如数字8对应于一个双重故障。为了解决这个重叠问题,我们需要重新映射PIC中断到不同的数字。只要不与异常重叠,实际范围并不重要,但通常选择范围32-47,因为这是32个异常槽之后的第一个空闲数字。

配置是通过向PIC的命令和数据端口写入特殊值来实现的。幸运的是,已经有一个名为pic8259_simple的包,因此我们不需要自己编写初始化序列。

• 为了添加作为依赖项的包,我们将以下内容添加到我们的项目中。

• 包提供的主要抽象是ChainedPics结构,它代表我们上面看到的主要/次要PIC布局。它被设计用于以下方式。

我们正在为图片设置距离32-47的偏移量,正如我们上面提到的。通过将ChainedPics结构包装在互斥锁中,我们能够获得安全的可变访问(通过lock方法),这是我们下一步需要的。新功能是不安全的,因为错误的偏移量会导致未定义的行为。

• 现在我们可以在init函数中初始化8259 PIC。

我们使用initialize函数来执行PIC初始化。像ChainedPics::new函数,这个函数也是不安全的,因为它可以导致未定义的行为,如果图片是错误的配置。

• 如果一切顺利,我们应该在执行cargo run时继续看到“It did not crash!”消息。

2. 启用中断

• 到目前为止没有发生任何事情,因为在CPU配置中仍然禁用中断。这意味着CPU根本不侦听中断控制器,因此没有中断可以到达CPU。让我们改变这种情况。

• x86_64包的interrupts::enable函数执行特殊的sti指令(“设置中断”)来启用外部中断。当现在我们尝试cargo run,我们看到一个双重故障发生。

这个双重故障的原因是硬件计时器(确切地说是Intel 8253)在默认情况下是启用的,因此一旦启用了中断,我们就开始接收计时器中断。由于我们还没有为它定义处理程序函数,因此调用了双错误处理程序。

3. 处理定时器中断

• 正如我们从上图中看到的,定时器使用主PIC的第0行。这意味着它到达CPU时是中断32(0 +偏移量32)。而不是硬编码索引32,我们存储在一个中断索引enum。

这个枚举变量是类似于c的枚举,因此我们可以直接为每个变体指定索引。repr(u8)属性指定每个变量都表示为u8。我们将在以后为其他中断添加更多变体。

• 现在我们可以为定时器中断添加一个处理器函数。

我们的timer_interrupt_handler具有与异常处理程序相同的签名,因为CPU对异常和外部中断的反应完全相同(唯一的区别是有些异常会推入错误代码)。InterruptDescriptorTable结构实现了IndexMut特性,因此我们可以通过数组索引语法访问单个条目。

• 在我们的定时器中断处理程序中,我们打印一个点到屏幕上。由于定时中断定期发生,我们期望看到一个点出现在每个定时滴答。然而,当我们运行它时,我们看到只有一个点被打印。

1) 结束中断

原因是PIC期望从我们的中断处理程序得到一个显式的“中断结束”(EOI)信号。这个信号告诉控制器中断已被处理,系统已经准备好接受下一个中断。因此,PIC认为我们仍在忙于处理第一个定时器中断,并耐心地等待EOI信号,然后再发送下一个。

• 为了发送意向书,我们再次使用我们的静态PICS结构。

notify_end_of_interrupt计算出是主PIC还是辅助PIC发送了中断,然后使用命令和数据端口向各自的控制器发送一个EOI信号。如果辅助PIC发送了中断,两个PICs都需要被通知,因为辅助PIC连接到主PIC的一个输入行。

我们需要小心地使用正确的中断向量,否则我们可能会意外地删除一个重要的未发送的中断或导致系统挂起。这就是该函数不安全的原因。

• 当我们现在执行cargo run,我们看到点周期性地出现在屏幕上。

2) 配置定时器

我们使用的硬件计时器称为可执行间隔计时器(PIT)。顾名思义,可以配置两个中断之间的间隔。我们在这里不会深入讨论细节,因为我们将很快切换到APIC计时器。

4. 总结

4567c6dfa876e0613dbea71f7521f4a1.png

b9497f8f70f40bc3ac74bf37b848d8fc.png

0c67e809dd05894f592f83c23346c1b9.png

441c7ff4443e1c96899baf31233c8f74.png

333085592817935395d1ae4f1e12b2e4.png
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值