一个操作系统的设计与实现——第11章 任务(二):0特权级任务

发布时间 2023-11-12 09:51:25作者: 樱雨楼

上一章中,我们的操作系统已经支持内核共享,这为任务的加载和运行做好了准备。

本章将要实现的是0特权级任务的加载与任务切换。

11.1 任务切换的原理

11.1.1 协同式与抢占式任务切换

如果CPU上只运行着Kernel.cmain函数,那么情况非常简单,只需要不断执行下一条指令即可。然而,如果现在有不止一个任务需要运行,CPU就必须在这几个任务之间不断切换,使每个任务都能得到运行的机会。那么,CPU在何时进行任务切换?又怎么进行任务切换呢?

最简单的任务切换方案被称为协同式任务切换。这种方案的运作方式为:操作系统提供一种任务切换的方法,各个任务均应在合适的时机主动使用这个方法,完成任务切换。协同式任务切换的优点是效率高且灵活,通过精心设计任务切换的时机,可以最大限度的利用CPU。但其缺点也很明显:又是"合适的时机",又是"主动",都是非强制的手段,一个任务完全可以永远不进行任务切换,让CPU一直为自己服务。因此,任务切换应当是一个具有周期性和强制性的过程。

时钟中断很适合被用于任务切换。这是因为,一方面,时钟中断的发起具有周期性;另一方面,外中断的发起具有强制性,不受任务的控制。因此,可以在时钟中断发生期间进行任务切换。

这种由硬件强制进行的任务切换,被称为抢占式任务切换。

11.1.2 任务队列与任务控制块

想要实现任务切换,就需要有一个能存取任务的数据结构。当进行任务切换时,先将当前任务添加到此数据结构,再从中取出一个新任务,并切换到这个新任务。队列是实现任务切换的合适数据结构,其可使用链表实现。

在这个队列中,每个任务都是一个节点,这个节点由链表指针和其他信息构成,其被称为任务控制块(Task Control Block,TCB)。TCB的设计目标是:只要拿到TCB,就能得到这个任务的全部信息。

11.1.3 任务的执行环境

任务对任务切换的发生必须是无感知的。所以,在任务切换时,当前任务的执行环境需要被保存起来,以供将来恢复。

一个任务的执行环境包含以下内容:

  • 8个通用寄存器
  • 6个段寄存器
  • EFLAGS
  • EIP
  • CR3
  • 虚拟地址位图

也就是说,只要能在任务切换时将任务的这些内容保存好,其就能恢复到任务切换前的状态,且对任务对此毫无感知。

中断发生时,CPU会自动将EFLAGS、CS、EIP压栈,然后进入中断处理函数。这其实意味着:任务的一部分信息已经保存在栈中了。而TCB的设计目标是:只要拿到TCB,就能得到这个任务的全部信息。所以,一个非常巧妙的设计是:将任务的栈和TCB放置在同一页的两头,这样一来,只要任务进行了至少一次压栈(在中断发生时一定如此),就能通过ESP & 0xfffff000得到TCB的地址。所以,此时可以继续将8个通用寄存器压栈。由于6个段寄存器对于每个任务来说都是一样的,所以无需压栈。

现在还剩下CR3和虚拟地址位图,这两个信息可以保存在TCB中。并且,其在任务的运行期间是不变的,所以,不需要在每次任务切换时重复保存。

至此,ESP就成了任务恢复的关键,只要拿到ESP,就能得到TCB和任务的栈,进而将任务恢复。所以,ESP的当前值也需要保存在TCB中。

综上,TCB中保存的信息如下:

  1. TCB + 0x1000处是任务的栈顶。栈中保存有EFLAGS、CS、EIP以及8个通用寄存器
  2. CR3
  3. 虚拟地址位图
  4. ESP

11.1.4 任务切换的完整过程

综上,任务切换的完整过程如下:

  1. 由时钟中断发起任务切换
  2. 执行pusha指令,将任务的8个通用寄存器压栈
  3. 发送中断响应信号
  4. 通过ESP & 0xfffff000取得任务的TCB
  5. 将ESP保存在TCB中
  6. 将TCB添加到任务队列中
  7. 从任务队列中取出新的TCB
  8. 将ESP和CR3用新的TCB中的值覆盖
  9. 执行popairet指令,切换到新任务

11.1.5 新任务的创建

上文一直在讨论任务切换。然而,任务切换有一个隐含的前提:任务在切换时应当是正在运行的,这样才谈得上切换。

内核在任务切换时确实是正在运行的,但对于一个从来没有运行过的新任务,该怎么办呢?

一个非常巧妙的办法是:伪造这个新任务的TCB,使其好像是先前被切换过一样。这样,一个新任务就可以"混入"任务队列中了。具体来说,新任务的创建分为以下几个步骤:

  1. 分配3页,分别作为新任务的TCB、CR3以及虚拟地址位图
  2. 将内核页目录表的第768~1022项复制到任务的CR3中,并将任务的CR3的最后一个PDE指向其自己
  3. 伪造新任务的栈。在任务切换时,栈顶从上往下依次是EFLAGS、CS、EIP以及8个通用寄存器,一共11 * 4字节。所以,TCB中存放的ESP应设为TCB地址 + 0x1000 - 11 * 4。然后,在TCB的顶部填好这些寄存器的值,当任务启动时,这些值就是各个寄存器的初始值
  4. 初始化虚拟地址位图
  5. 此时,新任务的TCB已经和其他任务的TCB没有区别了。所以,将其添加到任务队列中

11.1.6 内核任务

内核本身也是一个任务,也需要参与任务切换。并且,由于内核确实是一个正在运行的任务,所以不需要伪造栈,只需要设置好CR3和虚拟地址位图即可。

事实上,内核的TCB已经在上一章中准备好了,它位于0xc009f000。上一章Mbr.s中的mov esp, 0xc00a0000正是出于这个目的。

11.2 任务切换的实现

11.2.1 任务队列

想要实现任务切换,就需要先实现一个队列,队列的底层可使用链表实现。

队列的实现位于本章代码11/Queue.h11/Queue.hpp中。这套实现与普通链表唯一的区别在于:在queueEmpty函数,queuePush函数以及queuePop函数的头尾增加了开关中断的指令。这是一种最简单的锁,可以保证这三个函数在运行期间不会发生任务切换,从而避免了由于任务切换而引发的错误。

11.2.2 任务切换

请看本章代码11/Task.h

第7~13行,定义了TCB结构体。

第16行,声明了外部链接的任务队列。

第18~20行,声明了任务模块中的各种函数。

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

第4~6行,声明了外部链接的queuePush函数,queuePop函数以及任务队列taskQueue

intTimer函数是任务切换的核心。

第106行,将8个通用寄存器压栈。

第108~110行,向8259A发送中断响应信号。

第112~113行,取得TCB的地址。

第115行,将ESP存入TCB中。

第117~120行,调用queuePush函数,将TCB添加到任务队列中。

第122~124行,调用queuePop函数,从任务队列中取出一个新的TCB。

第126~127行,将CR3切换到新任务上。

第129行,将ESP切换到新任务上。

第131行,将8个通用寄存器切换到新任务上。

第133行,将EFLAGS,CS,EIP切换到新任务上。

至此,任务切换完成。

11.2.3 安装内核任务

请看本章代码11/Task.hpp

第9行,定义了任务队列taskQueue。任务切换时,当前任务会被添加到这个队列,新任务会从这个队列中取出。

__installKernelTask函数用于安装内核TCB。

第13行,取得位于0xc009f000处的内核TCB。

第15行,将内核页目录表的物理地址0x100000填入TCB。

第17行,初始化内核的虚拟地址位图。这行代码曾经位于Memory.hppmemoryInit函数中。

taskInit函数是queueInit(&taskQueue)__installKernelTask函数的封装。

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

第24行,调用taskInit函数,完成任务模块的初始化。

11.2.4 新任务的创建

请看本章代码11/Task.hpp

getTCB函数使用ESP & 0xfffff000取得TCB。

__getEFLAGS函数用于取得EFLAGS的值。

loadTaskPL0函数用于创建新任务。

第55行,分配3页。第1页用于新任务的TCB;第2页用于新任务的CR3;第3页用于新任务的虚拟地址位图。

第59行,使用第8章中的公式得到CR3的物理地址。

第64行,将新任务的页目录表清空。

第65行,将内核页目录表的第768~1022项复制到新任务的页目录表中。这里同样使用了第8章中的技术。memcpy函数的实现位于本章代码11/Memory.hpp中。

第67行,将新任务的页目录表的最后一项指向自己。

第69~83行,伪造新任务的栈。需要注意的是:新任务的EFLAGS中的IF位(第9位)必须为1;否则,在第一次切换到新任务后,就不会再发生任务切换了。

第85行,初始化新任务的虚拟地址位图。

第87行,将新任务的TCB添加到任务队列中。

11.2.5 内存管理系统的微调

引入TCB后,任务的虚拟地址位图被迁移到TCB中。所以,内存管理系统需要一些微调。

请看本章代码11/Memory.h

第6行,声明了memcpy函数。

接下来,请看本章代码11/Memory.hpp

__vMemoryBitmap全局变量,以及memoryInit函数中对此变量的初始化代码已删除;allocateKernelPage函数和deallocateKernelPage函数中的&__vMemoryBitmap现在修改为&((TCB *)0xc009f000)->vMemoryBitmap

memcpy函数是本章新增的函数,其用于内存复制。

11.3 测试

本章代码11/Kernel.c__testTask函数作为测试任务,并创建了两个这样的任务。所以,输出结果中Task字符串的数量应为Kernel字符串的两倍。在打印字符串时使用了开关中断的指令,以避免由于任务切换而引发的错误。

我们的操作系统目前还不支持任务回收,所以,用于测试的任务不能退出。