一个操作系统的设计与实现——第16章 键盘驱动(下)

发布时间 2023-11-12 09:56:33作者: 樱雨楼

我们的操作系统虽然已经实现了键盘驱动,但其功能仅限于在屏幕上打印输入的字符,任务并不能读取到这些字符。本章将要实现读取键盘输入的系统调用。

16.1 读取键盘输入的原理

想要让任务读取到键盘输入,最简单的方法是构造一个数组,当键盘中断发生时,将键盘输入的字符保存在这个数组中。然而,这个方案有一个无法解决的问题:如果一个任务想要读取键盘输入,但此时数组是空的,该怎么办?

想要解决这个问题,就需要一个具有等待功能的数组。当任务无法在数组中读取到字符时,使任务等待;当数组中又有字符时,将任务唤醒。处于等待中的任务不能运行,所以其不能出现在任务队列中,而是应该出现在与这个数组配套使用的等待队列中;当数组中又有字符时,将此任务从等待队列中取出,并重新添加到任务队列中。这样,就实现了任务的等待与唤醒。在我们的操作系统中,这种具有等待功能的数组被称为IO队列。

16.2 读取键盘输入的实现

16.2.1 IO队列的实现

现在,请看本章代码16/IOQueue.h

第6~12行,定义了IOQueue结构体。IO队列的实现使用了双指针算法,其内部包含一个16字节的字符缓冲区,以及两个索引值。__leftIdx是慢指针,用于读取字符;__rightIdx是快指针,用于存储字符。此外,IO队列还带有一个专用的等待队列,用于存储在IO队列中等待的任务。

第15~19行,声明了IO队列的各种函数。

在实现IO队列之前需要先思考一个问题:当任务需要在IO队列上等待时,就需要主动发起0x20中断以切换到一个新任务。考察Int.s中的intTimer函数可以发现,其核心逻辑如下:

  1. 将当前任务添加到任务队列中
  2. 从任务队列中取出一个新任务并切换

在此之前,由于任务要么出现在任务队列中,要么出现在退出队列中,所以上述逻辑没有任何问题。但引入了等待队列后,对于需要等待的任务,其任务切换应该是这样的:

  1. 将当前任务添加到等待队列
  2. 从任务队列中取出一个新任务并切换

也就是说,现在的intTimer函数已经不能硬编码"将当前任务添加到任务队列中"了,TCB应负责提供这一信息。

请看本章代码16/Task.h

第12行,新增了数据成员taskQueue,其用于控制:当任务切换时,当前任务应该被添加到哪个队列中。

接下来,请看本章代码16/Task.hpp

对于内核任务以及新任务来说,taskQueue数据成员的初始值都应该是任务队列。因此,第43行,第93行,第136行,分别添加了对taskQueue数据成员的初始化。

接下来,请看本章代码16/Int.s

第128行,将原先的push taskQueue修改为push dword [eax + 16][eax + 16]即为TCB中新增的taskQueue数据成员。

接下来,请看本章代码16/IOQueue.hpp

ioqueueInit函数用于初始化IO队列。当this->__leftIdx == this->__rightIdx时,表示IO队列为空。

ioqueueEmpty函数用于判断IO队列是否为空。

ioqueueFull函数用于判断IO队列是否已满。如果快指针已经紧跟在慢指针后面,就说明IO队列已满。

ioqueuePush函数用于向IO队列添加字符。

第31行,判断IO队列是否已满。如果IO队列已满,则放弃此次添加。

第33~34行,将新添加的字符写入快指针处,然后将快指针向右移动一格。

将一个字符写入IO队列后,不管写入是否成功,IO队列都一定非空。所以,如果先前有在IO队列上等待的任务,此时就是唤醒它的时机。

第37~42行的代码需要配合ioqueuePop函数的实现阅读。

第37行,判断是否有任务在IO队列上等待。

第39行,从IO队列的等待队列中取出一个TCB。

第40行,将取出的TCB中的taskQueue数据成员重新恢复为任务队列。

第41行,将取出的TCB重新添加到任务队列。

ioqueuePop函数用于从IO队列中取出一个字符。

第48行,判断IO队列是否为空。如果IO队列为空,就说明暂时还无法取出字符。

第50~52行,当前任务的TCB中的taskQueue数据成员修改为IO队列的等待队列,然后主动发起任务切换。这样一来,当前任务就会被添加到IO队列的等待队列中,任务队列中的下一个任务将开始执行。

第48行为什么要用while而不是if呢?这是因为,当唤醒任务时(第39~41行),只是将任务重新添加到任务队列中,而不是使任务瞬间开始继续执行。这就意味着,如果这个任务运气不好的话,IO队列中新添加的字符又会被其他任务先行抢走。此时,如果不使用while,任务就会在被唤醒后直接向下执行,而此时的IO队列是空的,这就造成了错误。这就是并行编程领域非常有名的伪唤醒(Spurious wakeup)问题。

16.2.2 读取字符串的系统调用的实现

请看本章代码Keyboard.h

第6行,声明了keyboardInit函数。

第8行,声明了inputStr函数。

接下来,请看本章代码Keyboard.hpp

第8行,定义了供键盘驱动使用的IO队列。

keyboardInit函数是本章新增的函数,其用于初始化键盘IO队列。

IO队列的实现属于典型的生产者-消费者模型。生产者向IO队列添加字符,并唤醒在IO队列上等待的消费者;消费者从IO队列中读取字符,并在无法读取时在IO队列上等待。

具体来说,IO队列的生产者是键盘。当键盘中断发生时,键盘上按下的键被添加到IO队列中。

第48行,将键盘上按下的键添加到IO队列中。这个字符应该如何使用,由取出它的任务决定。

IO队列的消费者是inputStr函数,这是一个系统调用函数。

第56行,循环strLen - 1次,这是因为输入字符串的最后一个字符应强制设为0。

第58行,从IO队列中取出一个字符。这步操作可能导致任务等待。

第60行,将取出的字符写入结果字符串。

第62~67行,判断当前字符是否是\n,如果是,则意味着输入字符串已经结束了,此行为和C语言标准库的gets函数是一致的。此时,应将结果字符串的下一个字符置0,以终止字符串;然后打印换行符并终止循环。

第68~75行,判断当前字符是否是\b\b的处理需要注意溢出问题:仅当idx大于0时,才能进行一次退格。

第76~80行,处理普通字符。

第83行,将输入字符串的最后一个字符置0。

接下来,请看本章代码16/Int.s

第6行,声明了外部链接的inputStr函数。

第269行,在系统调用表中安装inputStr函数,其系统调用号为1。

接下来,请看本章代码16/Kernel.c

第19行,调用keyboardInit函数,完成键盘驱动的初始化。

16.3 测试

本章使用的测试任务是16/Test.c。其先读取字符串,再打印读取到的字符串。