使用Rust编写操作系统 - 2.3 - 硬件中断
在这篇文章中,我们将设置可编程中断控制器,以便将硬件中断正确的转发到CPU。为了处理这些中断,我们将新条目添加到中断描述符表中,就像我们对异常处理程序所做的一样。我们将学习如何获取定期的定时器中断以及如何从键盘获取输入。
这个博客是在GitHub上公开开发的。如果你有任何问题或疑问,请在那里开一个issue。你也可以在底部留言。这篇文章的完整源代码可以在post-07分支中找到。
概述
中断为连接在CPU上的硬件设备提供了一种通知CPU的方法。因此,键盘不必告诉内核要定期检查是否有新的字符输入(一种称为轮询的过程),而是将每次按键事件通知内核。如此,内核仅在发生某些事件时才做出反应,因此效率更高。这样做还可以缩短响应时间,因为内核不需要等待下一次轮询,它将会立即做出反应。
我们无法将所有硬件设备都直连到CPU,而是采用一个独立的中断控制器汇总所有外设的中断,再通知CPU:
1 | ____________ _____ |
大多数中断控制器是可编程的,这意味着它们支持不同的中断优先级。例如,这可以让计时器中断的优先级比键盘中断更高,以便确保计时的准确性。
与异常不同,硬件中断的发送是异步的。这意味着中断与正在执行的代码无关,中断是随时都会发生的。因此,内核中突然出现了一种并发,同时也包含了各种潜在的与并发相关的bug。Rust严格的所有权模型在这里帮上了大忙,因为它禁止了可变全局状态。但是,仍然可能出现死锁,正如我们将在本文后面看到的那样。
8259 PIC
英特尔 8259是1976年推出的可编程中断控制器(PIC)。虽然它早已被较新的APIC取代,但由于向后兼容的原因,如今的系统仍支持其接口。8259 PIC的配置比APIC要容易得多,因此在后续文章中切换到APIC之前,我们仍将使用8529 PIC来引入中断。
8259有8条中断线和几条用于与CPU通讯的线。当年的典型系统会配备两个8259 PIC实例,一个主控制器和一个通过中短线连接在主控上的从控制器:
1 | ____________ ____________ |
上图显示了一个典型的中断线分配布局。可以看到15条线中的大多数都有固定的映射,例如 次PIC的4号线分配给了鼠标。
每个控制器可以通过两个[I/O端口](https://os.phil-opp.com/testing/#i-o-ports)
——一个“命令”端口和一个“数据”端口——进行配置。对于主控制器,这些端口是0x20
(命令)和0x21
(数据)。对于从控制器,它们是0xa0
(命令)和0xa1
(数据)。有关如何配置PIC的更多信息,请参见osdev.org
上的文章。
实现
PIC的默认配置不可用,因为它会将范围为0到15的中断向量编号发送到CPU。而这些编号已被CPU异常占用,例如,编号8对应双重故障。为了解决这个占用问题,我们需要将PIC中断重新映射到不同的编号。实际范围并不重要,只要它不与例外重叠即可,但是我们通常会选择编号32到47,因为这些是32个异常占用后的第一个段空闲数字。
我们可以通过向PIC的命令和数据端口写入特殊值来使配置生效。幸运的是,已经有一个名为pic8259_simple
的crate,因此我们不需要自己编写初始化过程。如果你对它的工作方式感兴趣,请查看它的源代码,该crate很小并且文档齐全。
要将crate添加为依赖,我们需要将以下内容添加到项目中:
1 | [dependencies] |
该crate提供的主要抽象为结构体ChainedPics
。该结构体代表了我们在上面介绍的主/次PIC布局。它的用法如下:
1 |
|
像上面这样将PIC的偏移量设置为32-47。通过将ChainedPics
结构体放置于Mutex
中,我们就能够(通过lock
方法)获得安全的写访问权限,这是下一步所必需的。ChainedPics::new
函数被标记为非安全的,因为提供错误的偏移量将可能导致未定义的行为。
现在,我们可以在init
函数中初始化8259 PIC了:
1 | pub fn init() { |
我们使用[initialize](https://docs.rs/pic8259_simple/0.2.0/pic8259_simple/struct.ChainedPics.html#method.initialize)
函数来执行PIC初始化。与ChainedPics::new
函数一样,该函数也是非安全的,因为如果PIC配置错误,使用它也将可能导致未定义的行为。
如果一切顺利,执行cargo run
时,我们应该继续看到“It not not crash”消息。
启用中断
到目前为止,什么都没发生,因为在CPU配置中依然禁用着中断。这意味着CPU根本不侦听中断控制器,也就没有中断可以到达CPU。让我们更改一下配置:
1 | pub fn init() { |
x86_64
crate的interrupts::enable
将函数执行特殊的sti
指令(即“设置中断”)以启用外部中断。当我们现在尝试cargo run
时,将看到发生双重故障:
发生此双重故障是因为硬件计时器在默认情况下为启用状态(确切地说是Intel 8253),因此一旦启用中断,我们便开始接收计时器中断。由于尚未为计时器定义处理函数,因此双重故障处理程序将会被调用。
处理定时器中断
从上图可以看出,定时器使用主PIC的0号线。这意味着它将作为中断32
(0+偏移量32)到达CPU。我们不对索引32进行硬编码,而是将其存放在InterruptIndex
枚举中:
1 |
|
该枚举是一个C风格枚举,因此我们可以直接为每个变体指定索引。repr(u8)
属性指定每个变体都表 示为u8
。将来我们还会添加更多中断变量。
现在我们可以为计时器中断添加一个处理函数:
1 | use crate::print; |
timer_interrupt_handler
的函数签名与之前的异常处理函数相同,因为CPU对异常和外部中断的反应相同(唯一的区别是某些异常会推送错误码)。InterruptDescriptorTable
结构体实现了IndexMut
trait,因此我们可以使用数组索引语法访问各个条目。
在计时器中断处理程序中,我们在屏幕上打印了一个点。由于定时器中断是周期性发生的,因此我们希望每个定时器定期出现一个点。但是,当我们运行它时,我们看到只打印了一个点:
当中断结束时
原因是PIC希望中断处理程序显示发出“中断结束”(EOI)信号。该信号告诉控制器该中断已被处理,同时系统已经准备好接收下一个中断。因此,PIC认为我们仍在忙于处理第一个计时器中断,并在耐心等待EOI信号,然后才发送下一个中断。
要发送EOI,我们需要再次使用静态PICS
结构体:
1 | extern "x86-interrupt" fn timer_interrupt_handler( |
notify_end_of_interrupt
会推断出是主PIC还是从PIC发送了中断,然后使用command
和data
端口将EOI信号发送到各控制器。如果是从PIC发送了中断,则需要通知两个PIC,因为从PIC通过输入线连接在主PIC上。
我们需要小心的使用正确的中断向量编号,否则可能会意外删除重要的未发送中断或导致系统挂起。这也是为什么该函数别标记为了非安全。
现在,当我们执行cargo run
时,我们会看到点定期出现在屏幕上:
配置计时器
我们使用的硬件计时器叫做可编程间隔计时器,也简称为PIT。顾名思义,我们可以配置两个中断之间的间隔。这里不做详细介绍,因为后文将很快切换到APIC计时器,但是OSDev Wiki上有大量有关配置PIT的文章。
死锁
现在,我们的内核中具有了一种并发形式:定时器中断会异步的发生,因此它们可以随时中断我们的_start
函数。幸运的是,Rust的所有权系统可以在编译时就能够防止很多与并发相关的bug。不过,死锁是一个值得注意的例外。如果线程试图获取永远不会释放的锁,则会发生死锁。此时,线程会无限期地挂起。
我们现在就可以在内核中诱发死锁。记住,我们的println
宏调用vga_buffer::__print
函数,而该函数会用自旋锁锁定全局变量WRITER
:
1 | […] |
该函数先锁定WRITER
再调用其write_fmt
,并会在函数末尾隐式将WRITER
解锁。现在想象一下,在WRITER锁定时发生了中断,并且中断处理程序也尝试打印一些内容:
时序 | _start |
interrupt_handler |
---|---|---|
0 | 调用println! |
|
1 | print 锁定WRITER |
|
2 | 中断发生,调用中断处理程序 | |
3 | 调用 println! |
|
4 | print 尝试锁定WRITER (已被锁定) |
|
5 | print 尝试锁定WRITER (已被锁定) |
|
… | … | |
永不 | 解锁 WRITER |
WRITER
被锁定,因此中断处理程序会等待锁释放。但这永远不会发生,因为_start
函数仅在中断处理程序返回后才继续运行。因此,整个系统挂起。
诱发死锁
通过在_start
函数末尾的loop
循环中打印一些内容,我们就可以轻松地在内核中引发这种死锁:
1 |
|
我们看到只有有限的连字符被打印,当第一次定时器中断发生时便停止打印。之后系统挂起,因为计时器中断处理程序在尝试打印点时会死锁。这就是我们在上面的输出中看不到任何点的原因。
每次运行打印的连字符数量会有所不同,因为计时器中断是异步发生的。正是这种不确定性使得与并发相关的bug难以调试。
修复死锁
为了避免发生这种死锁,只要Mutex
处于锁定状态,我们就禁用中断:
1 | /// Prints the given formatted string to the VGA text buffer |
without_interrupts
函数将获取一个闭包并在无中断的环境中执行该闭包。我们使用它来确保只要互斥锁被锁定,就不会发生中断。现在,当我们运行内核时,我们看到它一直在运行而不会挂起。(我们仍然没有注意到任何点,这是因为打印滚动的速度太快。请尝试减慢打印速度,例如,将for _ in 0..10000 {}
放置在loop
中。)
我们可以对串行打印功能应用相同的更改,以确保不会发生死锁:
1 |
|
请注意,禁用中断并不应该作为通用的解决方案。因为这样做会增加最坏情况下的中断响应时间,即直到系统被允许对中断做出反应之前的时间。因此,只应该在很短的时间内禁用中断。
修复竞争条件
现在如果执行cargo test
,可能会看到test_println_output
测试失败:
1 | > cargo test --lib |
原因是测试与我们的计时器处理程序之间存在竞争条件。回忆一下测试看起来像这样:
1 |
|
该测试将一个字符串打印到VGA缓冲区,然后通过手动迭代buffer_chars
数组来检查输出。由于计时器中断处理程序可能在println
之后,读取屏幕字符之前运行(中断处理函数会输出一个.
),因此发生竞争状态。请注意,这不是危险的数据竞争,Rust在编译时完全避免了这种竞争。有关详细信息,请参见Rustonomicon。
要解决此问题,我们需要在测试的整个过程中保持WRITER
处于锁定状态,以使计时器处理程序无法将.
打印到“打印行为”和“读取行为”之间的屏幕上。修复的测试如下所示:
1 |
|
我们进行了以下改进:
- 显式调用
lock()
方法,使WRITER
在整个测试过程中保持锁定状态。代替println
,我们使用writeln
宏,该宏允许打印到已经锁定的写入器。 - 为了避免再次出现死锁,我们在测试期间禁用中断。否则,在
WRITER
仍处于锁定状态时,测试可能会中断。 - 由于计时器中断处理程序仍旧可能在测试之前运行,因此在打印字符串
s
之前,我们还要打印一个换行符\n
。这样,即使计时器中断处理程序已经打印出.
,我们仍然可以避免测试失败。
通过上述更改,现在可以确定地再次运行cargo test
。
这是一个无害的竞争条件,仅可能会导致测试失败。你可以想象,其他竞争条件会由于其不确定性而更加难以调试。幸运的是,Rust帮我们阻止了最严重的竞争条件——数据竞争,该竞争条件会导致各种不确定的行为,包括系统崩溃和静默的内存数据损坏。
hlt
指令
到目前为止,我们在_start
和panic
函数的末尾使用了一个简单的空循环语句。这将使得CPU一直在工作,虽然这是代码预期的效果,但这也是非常低效的,因为即使没有任何工作,CPU仍将继续满负荷运行。运行内核时,您可以在任务管理器中观察到此现象:QEMU进程始终需要近100%的CPU使用率。
我们真正想做的是停止CPU,直到下一个中断发生。这期间应允许CPU进入睡眠状态,在该状态下CPU消耗的能量要少得多。hlt
指令正是这样做的。让我们使用该指令创建一个节能的无限循环:
1 | pub fn hlt_loop() -> ! { |
instructions::hlt
函数只是简单封装了汇编指令。不过这是安全的,因为这个操作并不会损害内存安全。
现在,我们可以使用此hlt_loop
代替_start
和panic
函数中的无限循环:
1 |
|
把lib.rs
也一更新一下:
1 | /// Entry point for `cargo test` |
现在在QEMU中运行内核时,我们发现CPU使用率要低得多。
键盘输入
现在我们已经能够处理来自外部设备的中断了,也终于可以添加对键盘输入的支持了。这是将是我们与内核的首次交互。
请注意,此处我们仅描述如何处理
PS/2
键盘,而不是USB键盘。但是,主板会将USB键盘模拟为PS/2
设备以支持较旧的软件,因此我们可以安心地忽略USB键盘,直到内核中能够提供对USB的支持。
与硬件计时器一样,键盘控制器在默认情况下就是启用状态。因此,当您按下一个键时,键盘控制器会向PIC发送一个中断,然后将其转发给CPU。CPU在IDT中查找处理程序功能,但相应的条目为空。于是会发生双重故障。
因此,让我们为键盘中断添加一个处理函数。这与我们为计时器中断定义处理程序的方式非常相似,只是使用了一个不同的中断编号而已:
1 |
|
从前面的图可以看出,键盘使用了主PIC的第1行。这意味着它作为中断33(1+偏移量32)到达CPU。将此索引作为InterruptIndex
枚举的新变量Keyboard
添加。我们并不需要显式指定该值,因为它默认为前一个值加一,也就是33。在中断处理程序中,我们打印一个k
并将中断结束信号发送到中断控制器。
现在按下键盘时屏幕上会打印一个k
。但是,这仅对我们按的第一个键起作用,此后即使我们继续按键盘也不会在屏幕上打印更多k
了。这是因为键盘控制器在我们读取该键所对应的扫描码之前不会再发送下一个中断。
读取扫描码
为了找出按下了哪个键,我们需要查询键盘控制器。通过读取PS/2
控制器的数据端口(即I/O
端口0x60
)来执行此操作:
1 | extern "x86-interrupt" fn keyboard_interrupt_handler( |
我们使用x86_64
crate提供的Port
类型从键盘的数据端口读取一个字节。该字节称为扫描码,是一个代表按下/释放的键所对应的数字。我们还没有用扫描码做任何事情,只是将其打印在屏幕上:
上图显示了我缓慢键入“123”时的情况。我们看到相邻的键具有相邻的扫描码,并且“按下键”和“释放键”所触发的扫描码并不相同。那么,我们如何将扫描码准确地转换为实际的按键动作呢?
翻译扫描码
扫描码和按键之间有三种不同的映射标准,即所谓的扫描码集。这三个码集都可以追溯到早期IBM计算机的键盘:IBM XT、IBM 3270 PC和IBM AT。值得庆幸的是后来的计算机没有延续这种定义新扫描码集的趋势,而是模拟了现有的扫描码集并进行扩展。如今,大多数键盘都可以配置为模拟这三组中的任意一组。
默认情况下,PS/2
键盘模拟扫描代码集1(“XT”)。在此集中,扫描码字节的低7位定义键,而最高位定义是按下(“0”)还是释放(“1”)。那些IBM XT键盘上不存在的键,如Enter键,会连续生成两个扫描代码:一个0xe0
转义字节,后接一个代表触发键的字节。有关集合1中所有扫描码及其对应键的表,参见OSDev Wiki。
要将扫描码转换为键,我们可以使用match语句:
1 |
|
上面的代码将翻译数字键0-9,并忽略其他键。它使用match
语句为每个扫描码分配一个字符或一个None
。然后使用if let
语句解构变量key
中的字符。通过在模式中使用相同变量名key
,我们可以遮蔽先前的声明,这是Rust中解构Option
类型的常见写法。
现在我们可以打印数字了:
翻译其他键的方法相同。 幸运的是,有一个名为pc-keyboard
的crate可用于翻译集1和集2的扫描码,因此我们不必自己实现此功能。要使用crate,请将其添加到Cargo.toml
中,然后将其导入lib.rs
中:
1 | [dependencies] |
现在,我们可以使用此crate重写我们的keyboard_interrupt_handler
:
1 | extern "x86-interrupt" fn keyboard_interrupt_handler( |
通过lazy_static
宏创建一个由Mutex
保护的静态Keyboard
对象。使用美式键盘布局和扫描码集1初始化Keyboard
。HandleControl
参数允许将ctrl+[a-z]
映射到U+0001
至U+001A
的Unicode字符上。我们并不想这样做,因此使用Ignore
选项来像处理普通键一样处理ctrl。
对于每个中断,我们锁定Mutex,从键盘控制器读取扫描码,并将其传递给add_byte
方法,该方法将扫描代码转换为Option<KeyEvent>
。KeyEvent
包含该键触发的事件,以及究竟是按下事件还是释放事件。
为了翻译此按键事件,我们将其传递给process_keyevent
方法,如果可能的话,该方法会将按键事件转换为字符。例如,根据是否按下了Shift键,将A
键的按下事件转换为小写字符a
或大写A
字符。
使用修改后的中断处理程序,我们已经能够输入文本了:
配置键盘
我们可以配置PS/2
键盘的某些功能,例如应使用哪个扫描码集。我们不会在这里介绍它,因为这篇文章已经足够长了,但是OSDev Wiki概述了可能的配置命令。
小结
本文解释了如何启用和处理外部设备中断。我们了解了8259 PIC及其主/从布局、中断号的重新映射以及发送“中断结束”信号。我们为硬件计时器和键盘中断实现了处理程序,并了解了hlt
指令,该指令能够将CPU暂停,直到下一个中断。
现在,我们可以与内核进行交互,并且初步编写出了一些基础模块,可用于创建小型shell或简单游戏。
下期预告
计时器中断对于操作系统来说至关重要,因为它们提供了一种定期中断运行中的进程并使得内核重新获得控制权的方法。之后,内核就可以切换到另一个进程,并让人们产生多个进程并行执行的错觉。
但是在创建进程或线程之前,我们需要一种为它们分配内存的方法。下一篇文章将探讨内存管理以提供此类基础模块。
支持本项目
创建和维护这个博客和相关库是一项繁重的工作,但我真的很喜欢。通过支持我,您可以让我在新内容、新功能和持续维护上投入更多时间。
支持我的最好方式是在GitHub上赞助我,因为他们不收取任何中间费用。如果你喜欢其他平台,我也有Patreon和Donorbox账户。后者是最灵活的,因为它支持多种货币和一次性捐款。
感谢您的支持!