来自外部的声音

我们终于实现了分时多任务了, 进程在系统调用返回之前, 将会触发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到此结束. 请你编写好实验报告(不要忘记在实验报告中回答必答题), 然后把命名为学号.pdf的实验报告文件放置在工程目录下, 执行make submit对工程进行打包, 最后将压缩包提交到指定网站.

results matching ""

    No results matching ""