天外有天的世界
我们之前让NEMU伸出上帝之手, 用户进程才能输出一句话, 怎么说都有点作弊的嫌疑. 在真实的计算机中, 输入输出都是通过I/O设备来完成的.
设备的工作原理其实没什么神秘的. 你应该已经(或将要)在数字电路实验中看到键盘控制器和VGA控制器的verilog代码, 只要向设备控制器发送一些有意义的信号, 设备就会按照这些信号的含义来工作. 让一些信号来指导设备如何工作, 这不就像"程序的指令指导CPU如何工作"一样吗? 恰恰就是这样! 设备也有自己的状态寄存器(相当于CPU的寄存器), 也有自己的功能部件(相当于CPU的运算器). 当然不同的设备有不同的功能部件, 例如键盘控制器有一个把按键的模拟信号转换成扫描码的部件, 而VGA控制器则有一个把像素颜色信息转换成显示器模拟信号的部件. 如果把控制设备工作的信号称为"设备指令", 设备控制器的工作就是负责接收设备指令, 并进行译码和执行... 你已经知道CPU的工作方式, 这一切对你来说都太熟悉了. 唯一让你觉得神秘的, 就要数设备功能部件中的模/数转换, 数/模转换等各种有趣的实现. 遗憾的是, 我们的课程并没有为我们提供实践的机会, 因此它们成为了一种神秘的存在.
巴别之塔
我们希望计算机能够控制设备, 让设备做我们想要做的事情, 这一重任毫无悬念地落到了CPU身上. CPU除了进行运算之外, 还需要与设备协作来完成不同的任务. 要控制设备工作, 就需要向设备发送设备指令. 接下来的问题是, CPU怎么区分不同的设备? 具体要怎么向一个设备发送设备指令?
对第一个问题的回答涉及到I/O的寻址方式. 一种I/O寻址方式是端口映射I/O(port-mapped I/O), CPU使用专门的I/O指令对设备进行访问, 并为设备中允许CPU访问的寄存器逐一编号, 这些编号叫端口号. 有了端口号以后, 在I/O指令中给出端口号, 就知道要访问哪一个设备的哪一个寄存器了. 市场上的计算机绝大多数都是IBM PC兼容机, IBM PC兼容机对常见设备端口号的分配有专门的规定. 设备中可能会有一些私有寄存器, 它们是由设备控制器自己维护的, 它们没有端口号, CPU不能直接访问它们.
IA-32提供了 in
和 out
指令用于访问设备, 其中 in
指令用于将设备寄存器中的数据传输到CPU寄存器中, out
指令用于将CPU寄存器中的数据传送到设备寄存器中. 一个例子是 kernel/src/irq/i8259.c
的代码, 代码使用 out
指令与Intel 8259中断控制器进行通信, 给控制器发送命令字. 例如
movl $0x11, %eax
movb $0x20, %dl
outb %al, (%dx)
上述代码把数据0x11传送到0x20号端口所对应的设备寄存器中. 你要注意区分I/O指令和设备指令, I/O指令是CPU执行的, 作用是对设备寄存器进行读写; 而设备指令是设备来执行的, 作用和设备相关, 由设备来解释和执行. CPU执行上述代码后, 会将0x11这个数据传送到中断控制器的一个控制寄存器中, 中断控制器接收到0x11后, 把它解释成一条设备指令, 发现是一条初始化指令, 于是就会进入初始化状态; 但对CPU来说, 它并不关心0x11的含义, 只会老老实实地把0x11传送到0x20号端口, 至于设备接收到0x11之后会做什么, 那就是设备自己的事情了.
另一种I/O寻址方式是内存映射I/O(memory-mapped I/O). 这种寻址方式将一部分物理内存映射到I/O设备空间中, 使得CPU可以通过普通的访存指令来访问设备. 这种物理内存的映射对CPU是透明的, CPU觉得自己是在访问内存, 但实际上可能是访问了相应的I/O空间. 这样以后, 访问设备的灵活性就大大提高了. 一个例子是物理地址区间 [0xa0000, 0xc0000)
, 这段物理地址区间被映射到VGA内部的显存, 读写这段物理地址区间就相当于对读写VGA显存的数据. 例如
会将显存中一个屏幕大小的数据清零, 即往整个屏幕写入黑色像素, 作用相当于清屏.
我们知道使用cache可以提高访问内存的速度, 但是对于用作内存映射I/O的物理地址空间, 使用cache却可能造成致命的错误. 因此一般会将这部分物理地址空间设置为不可缓存, 这样, 每一次对它们的访问都不会经过cache, 而是老老实实地访问相应的"内存区域". 你知道为什么要这样做吗?
也许你从来都没听说过C语言中有 volatile
这个关键字, 但它从C语言诞生开始就一直存在. volatile
关键字的作用十分特别, 它的作用是避免编译器对相应代码进行优化. 你应该动手体会一下 volatile
的作用, 在GNU/Linux下编写以下代码:
void fun() {
volatile unsigned char *p = (void *)0x8049000;
*p = 0;
while(*p != 0xff);
*p = 0x33;
*p = 0x34;
*p = 0x86;
}
然后使用 -O2
编译代码. 尝试去掉代码中的 volatile
关键字, 重新使用 -O2
编译, 并对比去掉 volatile
前后反汇编结果的不同.
你或许会感到疑惑, 代码优化不是一件好事情吗? 为什么会有 volatile
这种奇葩的存在? 思考一下, 如果代码中的地址 0x8049000
最终被映射到一个设备寄存器, 去掉 volatile
可能会带来什么问题?
你已经见识过IA-32引入的保护机制对构造计算机和谐社会所做出的贡献了. IA-32所倡导的和谐是全方面的, 自然也不希望恶意程序随意访问设备. 针对端口映射I/O, IA-32规定了可以使用I/O指令的最低特权级, 它用EFLAGS寄存器的IOPL位来表示. 如果当前进程的CPL在数值上大于IOPL, 使用 in
/ out
指令将会导致CPU抛出异常, 于是你在GNU/Linux中又看到了熟悉的段错误了.
思考一下, IA-32怎么样对内存映射I/O进行保护? 尝试查阅i386手册对比你的想法.
来自外部的声音
让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.
+----------------+
| |
---->+ IRQ0 | +---------+
---->+ IRQ1 | | |
---->+ IRQ2 INT +--------------------->+ INTR |
---->+ IRQ3 I8259 | | CPU |
---->+ IRQ4 PIC | | |
---->+ IRQ5 | +----+----+
---->+ IRQ6 Data +<----+ ^
---->+ IRQ7 | | |
| | | |
+----------------+ | |
v v
------------------------------------------------------
Data Bus
------------------------------------------------------
如上图所示, i8259有8个不同的中断请求引脚IRQ0-IRQ7, 可以识别8种不同的硬件中断来源. 在i8259内部还有一些中断屏蔽逻辑和判优逻辑, 当多个中断请求引脚都有中断请求到来时, i8259会根据当前的设置选择一个未被屏蔽的, 优先级最高的中断请求, 根据该中断请求的所在的引脚生成一个中断号, 将中断号发送到数据总线上, 并将INT引脚置为高电平, 通知CPU有中断请求到来.
上面描述的是中断控制器最简单的工作过程, 在真实的机器中还有很多细节问题, 例如i8259的级联, 多个设备共享同一个IRQ引脚等, 这里就不详细展开了, 更多的信息可以查看i8259手册, 或者在互联网上搜索相关信息.
第二个问题是CPU如何响应到来的中断请求. CPU每次执行完一条指令的时候, 都会看看INTR引脚, 看是否有设备的中断请求到来. 一个例外的情况就是CPU处于关中断状态. 在x86中, 如果EFLAGS中的IF位为0, 则CPU处于关中断状态, 此时即使INTR引脚为高电平, CPU也不会响应中断. CPU的关中断状态和中断控制器是独立的, 中断控制器只负责转发设备的中断请求, 最终CPU是否响应中断还需要由CPU的状态决定.
如果中断到来的时候, CPU没有处在关中断状态, 它就要马上响应到来的中断请求. 我们刚才提到中断控制器会生成一个中断号, IA-32将会保存中断现场, 然后根据这个中断号在IDT中进行索引, 找到并跳转到入口地址, 进行一些和设备相关的处理, 这个过程和之前提到的异常处理十分相似. 一般来说, 中断处理会在IDT中找到中断门描述符, 异常处理会在IDT中找到陷阱门描述符. 它们的唯一区别就是, 穿过中断门的时候, EFLAGS中的IF位将会被清零, 达到屏蔽其它外部中断的目的; 而穿过陷阱门的时候, IF位将保持不变.
对CPU来说, 设备的中断请求何时到来是不可预测的, 在处理一个中断请求的时候到来了另一个中断请求也是有可能的. 如果希望支持中断嵌套 -- 即在进行优先级低的中断处理的过程中, 响应另一个优先级高的中断 -- 那么堆栈将是保存中断现场信息的唯一选择. 如果选择把现场信息保存在一个固定的地方, 发生中断嵌套的时候, 第一次中断保存的现场信息将会被优先级高的中断处理过程所覆盖, 从而造成灾难性的后果.
假设硬件把中断信息固定保存在内存地址 0x1000
的位置, 内核也总是从这里开始构造trap frame, 如果发生了中断嵌套, 将会发生什么样的灾难性后果? 这一灾难性的后果将会以什么样的形式表现出来? 如果你觉得毫无头绪, 你可以用纸笔模拟中断处理的过程.
如果没有中断的存在, 计算机的运行就是完全确定的, 根据当前的指令和计算机的状态, 你完全可以推断出下一条指令执行后, 甚至是执行100条指令后计算机的状态. 正是中断的不可预测性, 给计算机世界带来了不确定性的乐趣. 而在分时多任务操作系统中, 中断更是操作系统赖以生存的根基: 只要中断的东风一刮, 操作系统就会卷土重来, 一个故意执行死循环的恶意程序就算有天大的本事, 此时此刻也要被请出CPU, 从而让其它程序得到运行的机会; 如果没有中断, 一个陷入了死循环的程序将使操作系统万劫不复. 但另一方面, 中断的存在也不得不让操作系统在一些问题的处理上需要付出额外的代价, 最常见的问题就是保证某些操作的原子性: 如果在一个原子操作进行到一半的时候到来了中断, 数据的一致性状态将会被破坏, 成为了潜伏在系统中的炸弹; 而且由于中断到来是不可预测的, 重现错误可能需要付出比修复错误更大的代价... 即使这样, 中断对现代计算机作出的贡献是不可磨灭的, 由中断撑起半边天的操作系统也将长久不衰.