一、概要说明

当多个任务访问同一个资源(数据)是就会引发竞争条件问题,这不仅在进程间会出现,在操作系统和进程间也会出现。由竞争条件引发的问题很难复现和调试,这也是其最困难的地方。本实验的目的在于了解竞争条件和死锁现象,并掌握处理这些问题的初步方法等。

二、实验原理

1. 死锁的出现与简单处理表1展示了我们系统(当前状态)如果在_start函数和中断处理函数中都调用打印宏(println!)可能出现死锁的情况。_start函数与中断处理函数(interrupt_handler)之间因为竞争资源而可能出现死锁。这是因为_start函数占有WRITE 锁且请求在CPU上运行,而中断处理函数占有CPU请求WRITE锁,构成死锁。

为了防止出现死锁,一个简单的办法是在使用锁时禁止中断。但需要注意的是禁用中断会增加中断响应延迟,而中断响应延迟一个非常重要的性能指标。所以只能在短时间内禁用中断。

2. 竞争条件所谓竞争条件是指多进程并发访问(操作)同一个数据时执行的结果依赖于进程之间执行的顺序。参见教材第6章(第7版)

三、硬件中断

1. 死锁

现在内核中存在一种并发的情形:定时器中断是异步发生的,因此它们可以随时中断我们的_start函数。幸运的是,Rust的所有权系统可以在编译时防止许多种类型的与并发相关的错误。但死锁是一个值得注意的例外。如果一个线程试图获取一个永远不会释放的锁,就会发生死锁。这样,线程将会无限期地处于挂起状态。

• 当前我们的内核中已经可以引发死锁。请注意,我们的println宏调用vga_buffer::_print 函数,它使用自旋锁来锁定一个全局的WRITER类。

它锁定WRITER,调用write_fmt,并在函数的末尾隐式地解锁。现在,我们设想一下,如果在WRITER被锁定时触发一个中断,同时相应的中断处理程序也试图打印一些东西。

由于WRITER已经被锁定,所以中断处理程序将会一直等待,直到它被释放。但这种情况永远不会发生,因为_start函数只有在中断处理程序返回后才继续运行。因此,整个系统就会挂起。

1) 引发死锁

• 通过在 _start 函数末尾的循环中打印一些内容,我们很容易在内核中引发这样的死锁。

• 当我们在 QEMU 中运行它时,得到的输出如下。

只有有限数量的连字符'-' 被打印,直到第一次定时器中断发生。接着系统挂起,因为定时器中断处理程序试图打印点时引发了死锁。这就是为什么我们在上面的输出中看不到任何点的原因。

由于定时器中断是异步发生的,因此连字符的实际数量在两次运行之间会有所不同。这种不确定性使得与并发相关的错误很难调试。

2) 修复死锁

• 为了避免这种死锁,我们可以采取这样的方案:只要互斥锁Mutex是锁定的,就可以禁用中断。

without_interrupts 函数接受一个闭包(closure),并在无中断的环境中执行。我们使用它来确保只要Mutex处于锁定状态,就不会发生中断。现在运行内核,就可以看到它一直运行而不会挂起。(我们仍然无法看到任何点,但这是因为他们滚动过快。尝试减慢打印速度,例如在循环中加上for_in 0..10000{})。

• 我们可以对串行打印函数进行相同的更改,以确保它不会发生死锁。

值得注意的是,禁用中断不应该成为一种通用的解决方案。这一方案的弊端是,它会延长最坏情况下的中断等待时间,也就是系统对中断做出反应之前的时间。因此,应该只在非常短的时间内禁用中断。

2. 修复竞争条件

• 如果你运行cargo xtest,可能会看到test_println_output测试失败。

• 这是由测试和定时器处理程序之间的竞争条件导致的。测试程序是这样的.

测试将一个字符串打印到VGA缓冲区,然后通过在缓冲区字符数组buffer_chars上手动迭代来检查输出。出现竞争条件是因为定时器中断处理程序可能在println和读取屏幕字符之间运行。注意,这不是危险的数据竞争,Rust在编译时完全避免了这种竞争。

• 要解决这个问题,我们需要在测试的整个持续时间内保持对WRITER的锁定状态,这样定时器处理程序就不能在操作之间将.写入屏幕。修复后的测试看起来像这样。

我们做了下述改动:

• 显式地使用lock()方法来保证writer在整个测试期间都处于锁定状态。使用writeln宏替代println,这将会允许打印字符到已锁定的writer中。

• 为避免再次出现死锁,我们在测试期间禁用中断。否则,在writer仍然处于锁定状态时,测试可能会中断。

• 由于计时器中断处理程序仍然可以在测试之前运行,因此我们在打印字符串s之前再打印一个换行符'\n'。这样可以避免因计时器处理程序已经将一些'.'字符打印到当前行而引起的测试失败。

经过修改后,cargo xtest现在确实又成功了。

这是一个相对无害的竞争条件,它只会导致测试失败。可以想象,由于其他竞争条件的不确定性,它们的调试可能更加困难。幸运的是,Rust防止了数据竞争的出现,这是最严重的竞争条件,因为它们可以导致各种各样的未定义行为,包括系统崩溃和静默内存损坏。

3. hlt指令

到目前为止,我们在_start和panic函数的末尾使用了一个简单的空循环语句。这将导致CPU无休止地自旋,从而按预期工作。但是这种方法也是非常低效的,因为即使在没有任何工作要做的情况下,CPU仍然会继续全速运行。在运行内核时,您可以在任务管理器中看到这个问题:QEMU进程在整个过程中都需要接近100%的CPU。

• 我们真正想做的是让CPU停下来,直到下一个中断到达。这允许CPU进入休眠状态,在这种状态下它消耗的能量要少得多。hlt指令正是为此而生。让我们使用它来创建一个节能的无限循环。

instructions::hlt函数只是汇编指令的瘦包装。这是安全的,因为它不可能危及内存安全。

• 现在,我们可以使用hlt_loop循环来代替_start和panic函数中的无限循环。

• 让我们也更新一下lib.rs。

现在,用QEMU运行内核,我们会发现CPU使用率大大降低。

4. 键盘输入

现在已经能够处理来自外部设备的中断,我们终于可以添加对键盘输入的支持。这将是我们与内核进行的第一次交互。

与硬件定时器一样,键盘控制器也被设置为默认启用。因此,当你按下一个键时,键盘控制器会向PIC发送一个中断,然后由PIC将中断转发给CPU。CPU在IDT中查找处理程序函数,但是相应的表项是空的。所以会引发双重错误。

• 那么,让我们为键盘中断添加一个处理程序函数。它和我们定义的定时器中断处理程序非常相似,只是使用了一个不同的中断类型码。

如上文中的图例所示,键盘使用了主PIC的第1条中断控制线。这意味着中断会以中断类型码33(1+偏移量32)的形式到达CPU。我们将这个索引作为新的Keyboard变体添加到InterruptIndex枚举中。我们不需要显式指定这个值,因为它默认为前一个值加1,也就是33。在中断处理程序中,我们输出一个k并将中断结束信号发送给中断控制器。

现在看到,当我们按下一个键时,屏幕上会出现一个k。然而,这只适用于按下的第一个键,即使我们继续按键,也不会有更多的k出现在屏幕上。这是因为键盘控制器在我们读取所谓的「键盘扫描码(scancode)」之前不会发送另一个中断。

1) 读取键盘扫描码

要找出按了哪个键,需要查询键盘控制器。我们可以通过读取PS/2控制器的数据端口来实现这一点,该端口属于I/O端口,编号为0x60。

• 我们使用 x86_64包提供的端口类型Port从键盘的数据端口读取一个字节。这个字节就是「键盘扫描码」,一个表示物理键按下/松开的数字。目前,我们还没有对键盘扫描码进行处理,只是把它打印到屏幕上。

上图显示了我正在慢慢地键入字符串"123"。可以看到,相邻物理键的键盘扫描码也相邻,而按下/松开物理键触发的键盘扫描码是不同的。但是我们如何将键盘扫描码转换为实际的按键操作呢?

2) 解释键盘扫描码

键盘扫描码和物理键之间的映射有三种不同的标准,即所谓的「键盘扫描码集」。这三者都可以追溯到早期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 库实现键盘的主要键位的解析。

• 我们也可以用同样的方式转换其他按键操作。幸运的是,有一个名为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包含引发事件的物理键以及它的事件类型——按下或是松开。

为了解释按键事件,我们将其传递给process_keyevent,该方法将按键事件转换为字符。例如,根据是否按下shift键,将物理键a的按下事件转换为对应的小写字符或大写字符。

• 有了这个修改过的中断处理程序,我们就可以写一些文本内容。

3) 配置键盘

我们也可以对PS/2键盘的某些方面进行配置,例如应该使用哪个键盘扫描码集。我们不会在这里讨论它,因为这篇文章已经足够长了,但是OSDev Wiki上有一篇关于可能的配置命令的概述。

Ø 不使用 pc-keyboard 库实现键盘的主要键位的解析。

1) 将Scancode和键盘对应。

Ø 将extern "x86-interrupt" fn keyboard_interrupt_handler中使用了pc-keyboard库的部分删掉。

2) 实现按下“Shift”键进行大小写切换。

• 定义全局可变静态变量flag。

• 按下Shift,flag=1,大写模式;松开Shift,flag=0,小写模式。

• 添加大写模式和键盘的对应。

3) 实现按下“Caps”进行大小写切换。