来自外部的声音

来自外部的声音

我们终于实现了分时多任务了, 进程在系统调用返回之前, 将会触发 schedule() 进行进程的上下文切换. 嗯, 这套机制运行得非常顺利. 然而, 如果被调度的是一个有 bug 的, 意外陷入了死循环的程序, 又或者是个根本就没打算使用系统调用的恶意程序, 我们的操作系统将会如何?

非常遗憾, 这是一个致命的漏洞. 产生这个致命问题的原因, 是我们将上下文切换的触发条件寄托在程序的行为之上: 触发了系统调用, 才能触发上下文切换. 我们知道程序被调度的时候, 整个计算机都会被它所控制, 无论是计算, 访存, 还是输入输出, 都是由程序来决定的. 为了修复这个漏洞, 我们必须寻找一种程序也无法控制的机制.

回想起我们考试的时候, 在试卷上如何作答都是我们来控制的, 但等到铃声一响, 无论我们是否完成答题, 都要立即上交试卷. 我们希望的恰恰就是这样一种效果: 时间一到, 无论正在运行的进程有多不情愿, 操作系统都要进行上下文切换. 而解决问题的关键, 就是时钟. 我们在 IOE 中早就已经加入了时钟了, 然而这还不能满足我们的需求, 我们希望时钟能够主动地通知处理器, 而不是被动地等着处理器来访问.

这样的通知机制, 在计算机中称为硬件中断. 作为与程序行为无关的机制, 硬件中断除了可以成为上下文切换的根基之外, 还有其它好处. 例如, 我们目前实现的 IOE 中, 都是让 CPU 轮询设备的状态, 但让 CPU 一直监视设备的工作并不是明智的选择. 以磁盘为例, 磁盘进行一次读写需要花费大约5毫秒的时间, 但对于一个 2GHz 的 CPU 来说, 它需要花费 10,000,000 个周期来等待磁盘操作的完成. 这对 CPU 来说无疑是巨大的浪费, 因此我们迫切需要一种通知机制: 在磁盘读写期间, CPU 可以继续执行与磁盘无关的代码; 磁盘读写结束后, 主动通知 CPU, 这时 CPU 才继续执行与磁盘相关的代码. 这里的通知机制也就是硬件中断. 硬件中断的实质是一个数字信号, 当设备有事件需要通知 CPU 的时候, 就会发出中断信号. 这个信号最终会传到 CPU 中, 引起 CPU 的注意.

第一个问题就是中断信号是怎么传到 CPU 中的. 支持中断机制的设备控制器都有一个中断引脚, 这个引脚会和 CPU 的 INTR 引脚相连, 当设备需要发出中断请求的时候, 它只要将中断引脚置为高电平, 中断信号就会一直传到 CPU 的 INTR 引脚中. 但计算机上通常有多个设备, 而 CPU 引脚是在制造的时候就固定了, 因而在 CPU 端为每一个设备中断分配一个引脚的做法是不现实的.

为了更好地管理各种设备的中断请求, IBM PC 兼容机中都会带有 Intel 8259 PIC(Programmable Interrupt Controller, 可编程中断控制器). 中断控制器最主要的作用就是充当设备中断信号的多路复用器, 即在多个设备中断信号中选择其中一个信号, 然后转发给 CPU.

第二个问题是 CPU 如何响应到来的中断请求. CPU 每次执行完一条指令的时候, 都会看看 INTR 引脚, 看是否有设备的中断请求到来. 一个例外的情况就是 CPU 处于关中断状态. 在 x86 中, 如果 EFLAGS 中的 IF 位为 0, 则 CPU 处于关中断状态, 此时即使 INTR 引脚为高电平, CPU 也不会响应中断. CPU 的关中断状态和中断控制器是独立的, 中断控制器只负责转发设备的中断请求, 最终 CPU 是否响应中断还需要由 CPU 的状态决定.

如果中断到来的时候, CPU 没有处在关中断状态, 它就要马上响应到来的中断请求. 我们刚才提到中断控制器会生成一个中断号, CPU 将会保存中断现场, 然后根据这个中断号在 IDT 中进行索引, 找到并跳转到入口地址, 进行一些和设备相关的处理. 这个过程和之前提到的异常处理十分相似.

对 CPU 来说, 设备的中断请求何时到来是不可预测的, 在处理一个中断请求的时候到来了另一个中断请求也是有可能的. 如果希望支持中断嵌套 -- 即在进行优先级低的中断处理的过程中, 响应另一个优先级高的中断 -- 那么堆栈将是保存中断现场信息的唯一选择. 如果选择把现场信息保存在一个固定的地方, 发生中断嵌套的时候, 第一次中断保存的现场信息将会被优先级高的中断处理过程所覆盖, 从而造成灾难性的后果.

灾难性的后果(这个问题有点难度)

假设硬件把中断信息固定保存在内存地址 0x1000 的位置, AM 也总是从这里开始构造 trap frame. 如果发生了中断嵌套, 将会发生什么样的灾难性后果? 这一灾难性的后果将会以什么样的形式表现出来? 如果你觉得毫无头绪, 你可以用纸笔模拟中断处理的过程.

在 NEMU 中, 我们只需要添加时钟中断这一种中断就可以了. 由于只有一种中断, 我们也不需要通过中断控制器进行中断的管理, 直接让时钟中断连接到 CPU 的 INTR 引脚即可, 我们也约定时钟中断的中断号是 32. 时钟中断通过 nemu/src/device/timer.c 中的 timer_intr() 触发, 每 10ms 触发一次. 触发后, 会调用 dev_raise_intr() 函数(在 nemu/src/cpu/intr.c 中定义). 你需要:

  • 在 CPU 结构体中添加一个 bool 成员 INTR.
  • dev_raise_intr() 中将 INTR 引脚设置为高电平.
  • exec_wrapper() 的末尾添加轮询 INTR 引脚的代码, 每次执行完一条指令就查看是否有硬件中断到来:
#define TIMER_IRQ 32

if (cpu.INTR & cpu.eflags.IF) {
  cpu.INTR = false;
  raise_intr(TIMER_IRQ, cpu.eip);
  update_eip();
}
  • 修改 raise_intr() 中的代码, 在保存 EFLAGS 寄存器后, 将其 IF 位置为 0, 让处理器进入关中断状态.

在软件上, 你还需要:

  • 在 ASYE 中添加时钟中断的支持, 将时钟中断打包成 _EVENT_IRQ_TIME 事件.
  • Nanos-lite 收到 _EVENT_IRQ_TIME 事件之后, 直接调用 schedule() 进行进程调度, 同时也可以去掉系统调用之后调用的 schedule() 代码了.
  • 为了可以让处理器在运行用户进程的时候响应时钟中断, 你还需要修改 _umake() 的代码, 在构造现场的时候, 设置正确的 EFLAGS.

任务:添加时钟中断

根据讲义的上述内容, 加相应的代码来实现真正的分时多任务.

为了证明时钟中断确实在工作, 你可以在 Nanos-lite 收到 _EVENT_IRQ_TIME 事件后用 Log() 输出一句话.

使命完成

需要注意的是, 添加时钟中断之后, differential testing 机制就无法正确工作了. 这是因为, 我们无法给 QEMU 注入时钟中断, 无法保证 QEMU 与 NEMU 处于相同的状态. 不过, differential testing 作为一个强大的工具用到这时候, 指令实现的正确性也基本上得到相当大的保证了.

如果没有中断的存在, 计算机的运行就是完全确定的. 根据计算机的当前状态, 你完全可以推断出下一条指令执行后, 甚至是执行 100 条指令后计算机的状态. 正是中断的不可预测性, 给计算机世界带来了不确定性的乐趣. 而在分时多任务操作系统中, 中断更是操作系统赖以生存的根基: 只要中断的东风一刮, 操作系统就会卷土重来, 一个故意执行死循环的恶意程序就算有天大的本事, 此时此刻也要被请出 CPU, 从而让其它程序得到运行的机会, 因此, 上下文切换的本质其实是中断驱动的堆栈切换; 如果没有中断, 一个陷入了死循环的程序将使操作系统万劫不复. 但另一方面, 中断的存在也不得不让操作系统在一些问题的处理上需要付出额外的代价, 最常见的问题就是保证某些操作的原子性: 如果在一个原子操作进行到一半的时候到来了中断, 数据的一致性状态将会被破坏, 成为了潜伏在系统中的炸弹; 而且由于中断到来是不可预测的, 重现错误可能需要付出比修复错误更大的代价... 即使这样, 中断对现代计算机作出的贡献是不可磨灭的, 由中断撑起半边天的操作系统也将长久不衰.

必答题

分时多任务的具体过程 请结合代码, 解释分页机制和硬件中断是如何支撑仙剑奇侠传和 hello 程序在我们的计算机系统(Nanos-lite, AM, NEMU)中分时运行的.


以上是 PA4.2 的所有内容,恭喜你成功完成本学期的选做任务!

results matching ""

    No results matching ""