使用Rust编写操作系统(七):硬件中断
在这一章中,我们将会学习如何设置可编程中断控制器(Programmable Interrupt Controller,PIC),以将硬件中断正确转发到 CPU 。为了处理这些中断,需要向中断描述符表(Interrupt Descriptor Table,IDT)中添加新的表项,就像我们实现异常处理程序那样。通过对这一章的学习,你会了解到如何获取周期性定时器中断以及键盘输入。
简介
中断为外部硬件设备提供了向 CPU 发送通知的方法。这样一来,内核就不必定期检查键盘上是否有新字符产生(这一过程称作「轮询」),而是由键盘在出现按键事件时通知内核。采用这种方法有两个好处:一是中断处理更高效,因为内核只需要在硬件触发中断后进行响应;二是响应时间更短,因为内核可以即时作出响应,而不是在下一次轮询中进行处理。
要将所有硬件设备都与 CPU 直接连接是不现实的。替代办法是使用一个单独的「中断控制器」(Interrupt Controller)来聚合所有中断,然后再通知 CPU :
|
|
大多数中断控制器都是可编程的,这意味着它们支持为中断分配不同的优先级。举个例子:如果需要保证计时准确,我们可以为定时器中断设置比键盘中断更高的优先级。
与异常不同的是,硬件中断是异步(Asynchronously)发生的。这意味着它们完全独立于执行的代码,并且可能在任何时候发生。因此,内核中就突然出现了一种并发形式,而且我们也不得不面对所有与并发相关的潜在错误。Rust 严格的所有权模型会为我们提供一定帮助,因为它禁止使用可变的全局状态。然而,死锁仍然可能发生,我们在后面也会遇到这种情况。
8259 可编程中断控制器
Intel 8259 是一款在 1976 年推出的可编程中断控制器(PIC)。早已被新的「高级可编程中断控制器(APIC)」所取代,但由于 APIC 保持了较好的向后兼容,所以它的接口仍然在当前系统上得到较好的支持。8259 PIC 比 APIC 更容易设置,所以在我们切换到 APIC 之前,将先使用它来介绍中断。
8259 有 8 条中断控制线和几条与 CPU 通信的线。当时的典型系统配备了一主一从两个 8259 PIC 实例,其中从控制器连接到主控制器的一条中断控制线上。
|
|
上图显示了中断控制线的经典分配方案。我们看到剩下 15 条线中的大多数都对应有一个固定的映射,例如从中断控制器的第 4 条中断控制线被分配给了鼠标。
每个控制器可以通过两个 I/O 端口 进行配置,其中一个是「命令」端口,另一个是「数据」端口。 在主控制器中,这两个端口分别位于 0x20(命令)和 0x21(数据)。 而在从控制器中,分别是 0xa0(命令)和 0xa1(数据)。 如果你想要了解关于「如何配置可编程中断控制器」的更多信息,可以参考 osdev.org 上的文章。
实现
不能使用默认的 PIC 配置,因为它将会向 CPU 发送 0-15 范围内的中断类型码。而这些数字已经被 CPU 异常占用了,例如数字 8 对应二重错误。为了解决此重叠问题,我们需要将中断重新映射到不同的中断类型码。实际范围并不重要,只要不与异常重叠就可以,但通常会选择范围 32-47 的数字,因为这是 32 个异常槽之后的第一组空闲数字。
配置是通过向 PIC 的命令和数据端口写入特殊值来完成的。幸运的是,已经有一个名为 pic8259_simple
的包,所以我们不需要自己编写初始化序列。如果您对它的工作原理感兴趣,请查看 它的源代码 ,它相当小并且有齐全的文档说明。
要将包添加为依赖项,我们需要将以下内容添加到项目中:
|
|
这个包提供的主要抽象是 ChainedPics
结构,它表示我们上面看到的「主/从二级可编程中断控制器」布局。基于它的设计,我们可以按以下方式来使用它:
|
|
就像在前面提过的那样,我们将会为 PIC 设置偏移量,使得中断类型码范围为 32-47 。 通过用 Mutex
包装 ChainedPics
结构,能够获得安全的可变访问(通过 lock
方法),这是下一步所需要的。ChainedPics::new
函数不安全,因为错误的偏移量可能会导致未定义行为。
现在我们可以在 init
函数中初始化 8259 PIC:
|
|
我们使用 initialize
函数来执行 PIC 的初始化。 像 ChainedPics::new
函数一样,这个函数也不安全,因为如果 PIC 配置错误,它可能导致未定义行为。
如果一切顺利,我们应该在执行 cargo xrun
时继续看到「It did not crash」这条消息。
启用中断
到目前为止,什么都没有发生,因为 CPU 配置中仍然禁用了中断。 这意味着 CPU 根本没有侦听中断控制器,因此没有中断可以到达 CPU。让我们试着改变这一点:
|
|
x86_64
包的 interrupts::enable
函数执行特殊的 sti
指令(设置中断「set interrupts」)以启用外部中断。现在尝试运行 cargo xrun
命令,我们会看到发生双重错误:
出现这种双重错误的原因是硬件定时器(确切地说是 Intel 8253)被设置为默认启用,一旦启用中断,我们就会开始接收定时器中断。 由于我们还没有为它定义中断处理程序,因此就会调用双重错误处理程序。
处理定时器中断
如 上文 中的图例所示,定时器使用了主 PIC 的第 0 条中断控制线。 这意味着中断会以中断类型码 32( 0 + 偏移量 32 )的形式到达 CPU。 我们不会对索引 32 进行硬编码,而是将它存储在枚举结构(enum) InterruptIndex
中:
|
|
Rust 中的枚举是 c-like 风格的枚举,因此我们可以直接为其内的每个变体指定索引。 repr(u8)
属性指定每个变体都以 u8
类型表示。 接下来,我们将会为其他中断添加更多的变体。
现在我们可以为定时器中断添加一个处理函数:
|
|
定时器中断处理程序 timer_interrupt_handler
具有与异常处理程序相同的函数签名,因为 CPU 对异常和外部中断的反应是相同的(唯一的区别是有些异常会返回错误代码)。 InterruptDescriptorTable
结构实现了 IndexMut
特质(trait),因此我们可以通过数组索引语法访问单个表项。
定时器定时器中断处理程序将会在屏幕上输出一个点 '.'
。由于定时器中断周期性地发生,我们期望看到每当定时器「滴答」一下就输出一个点。但是,当我们运行程序时,屏幕上只输出了一个点:
中断结束
之所以出现上面的故障,是因为 PIC 期望从错误处理程序得到一个明确的「中断结束」(End of Interrupt,EOI)信号。 这个信号告诉控制器:中断已经被处理,并且系统已经准备好接收下一个中断。 所以 PIC 认为系统仍然忙于处理第一个定时器中断,并在发送下一个中断之前耐心地等待 EOI 信号。
为了发送 EOI ,我们再次使用静态结构 PICS
:
|
|
通知中断结束函数 notify_end_of_interrupt
将会指出主控制器或从控制器是否发送中断,然后使用 命令
和 数据
端口向各控制器发送相应的 EOI 信号。 如果从 PIC 发送了中断,那么需要通知两个 PIC ,因为从 PIC 与主 PIC 的一条输入线相连。
我们需要谨慎地使用正确的中断类型码,否则可能会意外地删除重要的未发送中断或导致我们的系统挂起。这也是该函数不安全的原因。
现在执行 cargo xrun
,我们会看到一些点周期性地出现在屏幕上:
配置定时器
我们使用的硬件定时器是可编程间隔定时器(Progammable Interval Timer, PIT)。 顾名思义,可以配置两个中断之间的间隔。我们不会详细介绍这些,因为我们很快就会切换到 APIC 定时器,但是 OSDev wiki 上有一篇详细的关于「如何配置 PIT 」的文章。
死锁
现在内核中存在一种并发的情形:定时器中断是异步发生的,因此它们可以随时中断我们的 _start
函数。 幸运的是,Rust 的所有权系统可以在编译时防止许多种类型的与并发相关的错误。 但死锁是一个值得注意的例外。 如果一个线程试图获取一个永远不会释放的锁,就会发生死锁。 这样,线程将会无限期地处于挂起状态。
当前我们的内核中已经可以引发死锁。请注意,我们的 println
宏调用 vga_buffer::_print
函数,它使用自旋锁来锁定一个全局的 WRITER 类:
|
|
它锁定 WRITER
,调用 write_fmt
,并在函数的末尾隐式地解锁。现在,我们设想一下,如果在 WRITER
被锁定时触发一个中断,同时相应的中断处理程序也试图打印一些东西:
时间 | _start | interrupt_handler |
---|---|---|
0 | 调用 println! | |
1 | print 锁定 WRITER | |
2 | 中断发生,处理程序开始运行 | |
3 | 调用 println! | |
4 | print 尝试锁定 WRITER (已经被锁定) | |
5 | print 尝试锁定 WRITER (已经被锁定) | |
… | … | |
无法发生 | 解锁 WRITER |
由于 WRITER
已经被锁定,所以中断处理程序将会一直等待,直到它被释放。但这种情况永远不会发生,因为 _start
函数只有在中断处理程序返回后才继续运行。因此,整个系统就会挂起。
引发死锁
通过在 _start
函数末尾的循环中打印一些内容,我们很容易在内核中引发这样的死锁:
|
|
当我们在 QEMU 中运行它时,得到的输出如下:
只有有限数量的连字符 '-'
被打印,直到第一次定时器中断发生。接着系统挂起,因为定时器中断处理程序试图打印点时引发了死锁。这就是为什么我们在上面的输出中看不到任何点的原因。
由于定时器中断是异步发生的,因此连字符的实际数量在两次运行之间会有所不同。这种不确定性使得与并发相关的错误很难调试。
修复死锁
为了避免这种死锁,我们可以采取这样的方案:只要互斥锁 Mutex
是锁定的,就可以禁用中断。
|
|
without_interrupts
函数接受一个 闭包(closure),并在无中断的环境中执行。我们使用它来确保只要 Mutex
处于锁定状态,就不会发生中断。现在运行内核,就可以看到它一直运行而不会挂起。(我们仍然无法看到任何点,但这是因为他们滚动过快。尝试减慢打印速度,例如在循环中加上 for _ in 0..10000 {}
)。
我们可以对串行打印函数进行相同的更改,以确保它不会发生死锁:
|
|
值得注意的是,禁用中断不应该成为一种通用的解决方案。这一方案的弊端是,它会延长最坏情况下的中断等待时间,也就是系统对中断做出反应之前的时间。 因此,应该只在非常短的时间内禁用中断。
修复竞争条件
如果你运行 cargo xtest
,可能会看到 test_println_output
测试失败:
|
|
这是由测试和定时器处理程序之间的竞争条件导致的。测试程序是这样的:
|
|
测试将一个字符串打印到 VGA 缓冲区,然后通过在缓冲区字符数组 buffer_chars
上手动迭代来检查输出。 出现竞争条件是因为定时器中断处理程序可能在 println
和读取屏幕字符之间运行。注意,这不是危险的 数据竞争,Rust 在编译时完全避免了这种竞争。 更多详细信息,可以参考 Rustonomicon 。
要解决这个问题,我们需要在测试的整个持续时间内保持对 WRITER
的锁定状态,这样定时器处理程序就不能在操作之间将 .
写入屏幕。修复后的测试看起来像这样:
|
|
我们做了下述改动:
- 显式地使用
lock()
方法来保证writer
在整个测试期间都处于锁定状态。使用writeln
宏替代println
,这将会允许打印字符到已锁定的writer
中。 - 为避免再次出现死锁,我们在测试期间禁用中断。 否则,在
writer
仍然处于锁定状态时,测试可能会中断。 - 由于计时器中断处理程序仍然可以在测试之前运行,因此我们在打印字符串
s
之前再打印一个换行符'\n'
。这样可以避免因计时器处理程序已经将一些'.'
字符打印到当前行而引起的测试失败。
经过修改后,cargo xtest
现在确实又成功了。
这是一个相对无害的竞争条件,它只会导致测试失败。可以想象,由于其他竞争条件的不确定性,它们的调试可能更加困难。幸运的是,Rust 防止了数据竞争的出现,这是最严重的竞争条件,因为它们可以导致各种各样的未定义行为,包括系统崩溃和静默内存损坏。
hlt
指令
到目前为止,我们在 _start
和 panic
函数的末尾使用了一个简单的空循环语句。这将导致 CPU 无休止地自旋,从而按预期工作。但是这种方法也是非常低效的,因为即使在没有任何工作要做的情况下,CPU 仍然会继续全速运行。在运行内核时,您可以在任务管理器中看到这个问题: QEMU 进程在整个过程中都需要接近 100% 的 CPU。
我们真正想做的是让 CPU 停下来,直到下一个中断到达。这允许 CPU 进入休眠状态,在这种状态下它消耗的能量要少得多。hlt
指令 正是为此而生。让我们使用它来创建一个节能的无限循环:
|
|
instructions::hlt
函数只是汇编指令的 瘦包装。这是安全的,因为它不可能危及内存安全。
现在,我们可以使用 hlt_loop
循环来代替 _start
和 panic
函数中的无限循环:
|
|
让我们也更新一下 lib.rs
:
|
|
现在,用 QEMU 运行内核,我们会发现 CPU 使用率大大降低。
键盘输入
现在已经能够处理来自外部设备的中断,我们终于可以添加对键盘输入的支持。 这将是我们与内核进行的第一次交互。
注意,这里只描述如何处理 PS/2 键盘,而不包括 USB 键盘。然而,主板会将 USB 键盘模拟为 PS/2 设备,以支持旧的软件,所以可以放心地忽略 USB 键盘,直到内核中有 USB 支持为止。
与硬件定时器一样,键盘控制器也被设置为默认启用。因此,当你按下一个键时,键盘控制器会向 PIC 发送一个中断,然后由 PIC 将中断转发给 CPU 。CPU 在 IDT 中查找处理程序函数,但是相应的表项是空的。所以会引发双重错误。
那么,让我们为键盘中断添加一个处理程序函数。它和我们定义的定时器中断处理程序非常相似,只是使用了一个不同的中断类型码:
|
|
如 上文 中的图例所示,键盘使用了主 PIC 的第 1 条中断控制线。这意味着中断会以中断类型码 33( 1 + 偏移量 32 )的形式到达 CPU 。我们将这个索引作为新的 Keyboard
变体添加到 InterruptIndex
枚举中。 我们不需要显式指定这个值,因为它默认为前一个值加 1 ,也就是 33 。 在中断处理程序中,我们输出一个 k
并将中断结束信号发送给中断控制器。
现在看到,当我们按下一个键时,屏幕上会出现一个 k
。 然而,这只适用于按下的第一个键,即使我们继续按键,也不会有更多的 k
出现在屏幕上。 这是因为键盘控制器在我们读取所谓的「键盘扫描码(scancode)」之前不会发送另一个中断。
读取键盘扫描码
要找出按了 哪个 键,需要查询键盘控制器。我们可以通过读取 PS/2 控制器的数据端口来实现这一点,该端口属于 I/O 端口 ,编号为 0x60
:
|
|
我们使用 x86_64
包提供的端口类型 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
语句:
|
|
上面的代码转换数字键 0-9 的按键操作,并忽略所有其他键。 它使用 match 语句为每个键盘扫描码分配相应的字符或 None
。 然后它使用 if let
来解构可选的 key
。 通过在模式中使用相同的变量名 key
,我们可以 隐藏 前面的声明,这是 Rust 中解构 Option
类型的常见模式。
现在我们可以往屏幕上写数字了:
我们也可以用同样的方式转换其他按键操作。幸运的是,有一个名为 pc-keyboard
的包,专门用于翻译键盘扫描码集 1 和 2 中的键盘扫描码,因此我们无须自己实现。要使用这个包,需要将它添加到 Cargo.toml
内,并导入到 lib.rs
中:
|
|
现在我们可以使用这个包来重写键盘中断处理程序 keyboard_interrupt_handler
:
|
|
我们使用 lazy_static
宏来创建一个由互斥锁保护的静态对象 Keyboard
。 我们使用美国键盘布局初始化键盘,并采用键盘扫描码集 1 。 HandleControl
参数允许将 ctrl+[a-z]
映射到 Unicode 字符 U+0001
- U+001A
。 我们不想这样做,所以使用 Ignore
选项来像处理普通键一样处理 ctrl
键。
每当中断发生,我们锁定互斥对象,从键盘控制器读取键盘扫描码并将其传递给 add_byte
方法,后者将键盘扫描码转换为 Option<KeyEvent>
。 KeyEvent
包含引发事件的物理键以及它的事件类型——按下或是松开。
为了解释按键事件,我们将其传递给 process_keyevent
,该方法将按键事件转换为字符。例如,根据是否按下 shift
键,将物理键 a
的按下事件转换为对应的小写字符或大写字符。
有了这个修改过的中断处理程序,我们就可以写一些文本内容:
配置键盘
我们也可以对 PS/2 键盘的某些方面进行配置,例如应该使用哪个键盘扫描码集。我们不会在这里讨论它,因为这篇文章已经足够长了,但是 OSDev Wiki 上有一篇关于可能的 配置命令 的概述。
小结
这篇文章解释了如何启用和处理外部中断。 我们学习了 8259 PIC 和经典的主/从二级布局,中断类型码的重映射,以及「中断结束」信号。 我们实现了硬件定时器和键盘的中断处理程序,并且学习了 hlt
指令,它会暂停 CPU 直到触发下一个中断。
现在我们可以与内核进行交互,并且有一些基本的构建块,可以用来创建一个小 shell 或简单的游戏。
下篇预告
定时器中断对于操作系统来说是必不可少的,因为它们提供了一种周期性地中断运行进程并使内核重新获得控制权的方法。这样一来,内核就可以切换到不同的进程,并营造出一种多个进程并行运行的错觉。
但是在创建进程或线程之前,我们需要一种为它们分配内存的方法。下一篇文章将探讨内存管理,以提供这一基本构建块。