一 什么是中断?为什么要有中断?
CPU获知了计算机中发生的某些事,CPU暂停正在执行的程序,转而去处理该事件的程序,当这段程序执行完毕后,CPU继续执行刚才的程序。称为中断。
中断的目的:提升并发,提高计算机的效率。
二 中断分类
外部中断
外部中断是指来自 CPU 外部的中断,而外部的中断源必须是某个硬件,所以外部中断又称为硬件中断。比如说网卡收到了来自网络的数据包,这时候网卡就会主动通知 CPU, CPU 得到通知后便将数据拷贝到内核缓冲区。
CPU 为大家提供了两条信号线。外部硬件的中断是通过两根信号线通知 CPU 的,这两根信号线就是INTR (INTeRrupt)和 NMI (Non Maskable Interrupt)
只要从 INTR 引脚收到的中断都是不影响系统运行的,可以随时处理,甚至 CPU 可以不处理,假装设 看见,因为它不影响 CPU 运行。而只要从 NMI 引脚收到的中断,那基本上全是硬伤, CPU 都没有运行下去的必要了。
可屏蔽中断
可屏蔽中断是通过时TR 引脚进入 CPU 的,外部设备如硬盘、网卡等发出的中断都是可屏蔽中断。 可屏蔽的意思是此外部设备发出的中断, CPU 可以不理会,因为它不会让系统宕机,所以可以通过 eflags 寄存器的 IF 位将所有这些外部设备的中断屏蔽。
对于这类可屏蔽中断,CPU 可以选择不用理会,甚至,即使在理会后,也可以像 Linux 那样,把中断分为上半部和下半部分开处理。
操作系统是中断驱动的,中断发生后会执行相应的中断处理程序,我们希望 CPU 中断响应的时间越 短越好,这样便能响应更多设备的中断。但是中断处理程序还是需要完整执行的,不能光为了提高中断响 应效率而只执行部分中断处理程序。 于是,把中断处理程序分为上半部和下半部两部分,把中断处理程序 中需要立即执行的部分(分分钟不能耽误的部分)划分到上半部,这部分是要限时执行的,所以通常情况下只完成中断应答或硬件复位等重要紧迫的工作。而中断处理程序中那些不紧急的部分则被推迟到下半部 中去完成。由于中断处理程序的上半部是刻不容缓要执行的,所以上半部是在关中断不被打扰的情况下执 行的。当上半部执行完成后就把中断打开了,下半部也属于中断处理程序,所以中断处理程序下半部则是在开中断的情况下执行的,如果有新的中断发生,原来这个旧中断的下半部就会被换下 CPU,先执行新 的中断处理程序的上半部,等待线程调度机制为旧中断处理程序择一 日期(就是指调度算法认为的某个恰 当时机)后,再调度其上 CPU 完成其下半部的执行。
不可屏蔽中断
不可屏蔽中断是通过 NMI 引脚进入 CPU 的,它表示系统中发生了致命的错误,它等同于宣布:计算机的运行到此结束。
中断处理
CPU 收到中断后,得知道发生了什么事情才能执行相应的处理办法。这是通过中断向量表或中断描述 符表(中断向量表是实模式下的中断处理程序数组,在保护模式下已经被中断描述符表代替,在后面章节 中会细说〉来实现的,首先为每一种中断分配一个中断向量号,中断向量号就是一个整数,它就是中断向 量表或中断描述符表中的索引下标,用来索引中断项。中断发起时,相应的中断向量号通过 NMI 或 INTR 引脚被传入 CPU,中断向量号是中断向量表或中断描述符表里中断项的下标, CPU 根据此中断向量号在 中断向量表或中断描述符表中检索对应的中断处理程序井去执行。
内部中断
内部中断分为软中断和异常。
软中断,就是由软件主动发起的中断,因为它来自于软件,所以称之为软中断。由于该中断是软件运 行中主动发起的,所以它是主观上的,井不是客观上的某种内部错误。软中断又有分类:
-
"int 8位立即数"这是我们以后常用的指令,我们要通过它进行系统调用, 8位立即数可表示256
种中断,这与处理器所支持的中断数是相吻合的。 -
“ int3”。这可不是 int 空格 3,它们之间无间隙。 int3 是调试断点指令,其所触发的中断向量号是 3,以后在中断和异常表中大家会看到。我们用 gdb 或 bochs 调试程序时,实际上就是调试器 fork 了一个子进程,子进程用于运行被调试的程序。调试器中经常要设置断点,其原理就是父进程修改了子进程的指令,将其用 int3指令替换,从而子进程调用了 int3 指令触发中断。用此指令实现调试的原理是 int3 指令的机器码是 0xcc,断点本质上是指令的地址,调试器(父进程〉将被调试进程(子进程〉断点起始地址的第 1 个字节备份好之后,在原地将该指令的第 1 字节修改为 0xcc。这样指令执行到断点处时,会去执行机器码为 0xcc 的 int3 指令,该指令会触发 3 号中断,从而会去执行 3 号中断对应的中断处理程序,由于中断处理程序在运行时也要用到寄存 器,为了保存所调试进程的现场,该中断处理程序必须先将当前的寄存器和相关内存单元压校保存(提醒,当前寄存器和相关内存都属于那个被调试的进程),用户在查看寄存器和变量时就是从楼中获取的。当恢复执行所调试的进程时,中断处理程序需要将之前备份的 1字节还原至断点处,然后恢复各寄存器和内存单元的值, 修改返回地址为断点地址,用iret指令退出中断,返回到用户进程继续执行。
- into。这是中断溢出指令,它所触发的中断向量号是 4。 不过,能否引发 4 号中断是要看 eflags标 志寄存器中的 OF 位是否为 1,如果是 1 才会引发中断,否则该指令悄悄地什么都不做
-
bound。这是检查数组索引越界指令,它可以触发 5 号中断,用于检查数组的索引下标是否在上下 边界之内。该指令格式是"bound 16/32 位寄存器, 16/32 位内存"。目的操作数是用寄存器来存储的 , 其内容是待检测的数组下标值。源操作数是内存,其内容是数组下标的下边界和上边界。 当执行bound指令时,若下标处于数组索引的范围之外,则会触发 5 号中断。
- ud2。未定义指令,这会触发第 6 号中断。该指令表示指令无效, CPU 无法识别。主动使用它发起中断,常用于软件测试中,无实际用途 。
异常是另一种内部中断,是指令执行期间 CPU 内部产生的错误引起的。
上述软中断中,除了第一种,其他几种也可以成为异常。如cpu不认识某个指令了,会触发ud2异常。
异常根据严重类型分为以下几种:
- Faule,也称为故障。这种错误是可以被修复的一种类型,属于最轻的一种异常,它给软件一次“改过自新”的机会。当发生此类异常时 CPU 将机器状态恢复到异常之前的状态,之后调用中断处理程序时, CPU将返回地址依然指向导致 fault 异常的那条指令。通常中断处理程序中会将此问题修复,待中断处理程序返回后便能重试。最典型的例子就是操作系统课程中所说的缺页异常 page fault,话说 Linux 的虚拟内存就是基于 page fault 的,这充分说明这种异常是极易被修复的,甚至是有益的。
- Trap,也称为陷阱,这一名称很形象地说明软件掉进了 CPU 设下的陷阱,导致停了下来。此异常通常用在调试中,比如 int3 指令便引发此类异常,为了让中断处理程序返回后能够继续向下执行,CPU 将中断处理程序的返回地址指向导致异常指令的下一个指令地址。
- bort,也称为终止,从名字上看,这是最严重的异常类型, 一旦出现,由于错误无法修复,程 序将无法继续运行,操作系统为了自保,只能将此程序从进程表中去掉。导致此异常的错误通常是硬件错误,或者某些系统数据结构出错。
三 中断描述符表
中断描述符表(Interrupt Descriptor Table, IDT)是保护模式下用于存储中断处理程序入口的表,当 CPU 接收一个中断时,需要用中断向量在此表中检索对应的描述符,在该描述符中找到中断处理程序的起始地址,然后执行中断处理程序。
中断描述符表中除了中断描述符以外,还有任务门描述符和陷阱门描述符。表中所有描述符都是记录一段程序的起始地址,相当于通向某段程序的大门,所以IDT中描述符又称为 门。
中断门结构如下:
中断门包含了中断处理程序所在段的段选择子和段内偏移地址。当通过此方式进入中断后,标志寄存 器 eflags 中的 E 位自动置 0,也就是在进入中断后,自动把中断关闭,避免中断嵌套。 Linux 就是利用中 断门实现的系统调用,就是那个著名的 int 0x80。中断 门只允许存在于 IDT 中。描述符中中断门的 type 值 为二进制 1110。
在 CPU 内部有个中断描述符表寄存器( Interrupt Descriptor Table Register, IDTR),该寄存器分为两部分:第 0 ~ 15 位是表界眼,即 IDT 大小减 1,第 16~47 位是 IDT 的基地址,这和咱们之前介绍的 GDTR 是一样的原理。中断描述符表地址肯定要加载到这个寄存器中,只有寄存器 IDTR 指向了 IDT,当 CPU接收到中断向量号时才能找到中断向量处理程序,这样中断系统才能正常运作。
虽然IDT界限最大64KB,可以存储64KB/8B=8192=8K个描述符,但是处理器仅支持256个中断,即0~254。
中断处理过程及保护
- 处理器根据中断向量号定位中断门描述符。中断向量号是中断描述符的索引,当处理器收到一个外部中断向 量 号后,它用此向量号在中断描述符 表中查询对应的中断描述符,然后再去执行该中断描述符中的中断处理程序。由于中断描述符是 8个字节, 所以处理器用中断向量号乘以 8 后,再与 IDTR 中的中断描述符表地址相加,所求的地址之和便是该中断 向量号对应的中断描述符 。
- 处理器进行特权级检查。(a)如果是由软中断intn, int3和into引发的中断,这些是用户进程中主动发起的中断,由用户代码控制,处理器要检查当前特权级 CPL 和门描述符 DPL,这是检查进门的特权下限,如果 CPL 权限大于等于 DPL,即数值上 CPL<=门描述符 DPL,特权级“门槛”检查通过,进入下一步的“门框”检查。否则, 处理器抛出异常 。(b)这一步检查特权级的上限(门框〉:处理器要检查当前特权级 CPL和门描述符中所记录的选择子 对应的目标代码段 DPL,如果 CPL 权限小于目标代码段 DPL,即数值上 CPL>目标代码段 DPL,检查通过 。 否则 CPL 若大于等于目标代码段 DPL,处理器将引发异常,也就是说,除了用返回指令从高特权级返回,特权转移只能发生在由低向高。
- 执行中断处理程序。特权级检查通过后,将门描述符目标代码段选择子加载到代码段寄存器 cs 中,把门描述符中中断处 理程序的偏移地址加载到 EIP,开始执行中断处理程序。
中断发生后,eflags中的 NT位和 TF位会被置 0。如果中断对应的门描述符是中断门,标志寄存器 eflags 中的 IF 位被自动置 0,避免中断嵌套,即中断处理过程中又来了个新的中断,这是为防止在处理某个中断 的过程中又来了个相同的中断,即同一种中断未处理完时又来了一个,这会导致一般保护性(GP)异常。 这表示默认情况下,处理器会在无人打扰的方式下执行中断门描述符中的中断处理例程。
若中断发生时对应的描述符是任务门或陷阱门的话, CPU 是不会将 IF 位清 0 的 。 因为陷阱门主要用于调试,它允许 CPU 响应更高级别的中断,所以允许中断嵌套。而对任务门来说,这是执行一个新任务, 任务都应该在开中断的情况下进行,否则就独占 CPU 资源,操作系统也会由多任务退化成单任务了。
进入中断时要把 NT 位和 TF 位置为 0。 TF 表示 Trap Flag,也就是陷阱标志位,这用在调试环境中,当 TF 为 0 时表示禁止单步执行,也就是说,进入中断后将 TF 直为 0,表示不允许中断处理程序单步执行, 这是好理解的。至于为什么要把 NT 位置为 0 呢?这和中断返回指令 iret有关。
NT位表示NestTaskFlag,即任务嵌套标志位,也就是用来标记任务嵌套调用的情况。任务嵌套调用是指 CPU将当前正执行的旧任务挂起,转去执行另外的新任务,待新任务执行完后, CPU再回到旧任务继续执行。 为什么 CPU 执行完旧任务后还能回到新任务呢?原因是在执行新任务之前, CPU 做了两件准备工作:
- 将旧任务 TSS 选择子写到了新任务 TSS 中的“上一个任务 TSS 的指针”字段中
- 将新任务标志寄存器 eflags 中的 NT 位置 1,表示新任务之所以能够执行,是因为有别的任务调用了它。
当 CPU 执行 iret 时,它会去检查 NT 位的值,如果 NT 位为 1,这说明当前任务 是被嵌套执行的,因此会从自己 TSS 中“上一个任务 TSS 的指针”字段中获取旧任务,然后去执行该任 务。如果 NT 位的值为 0,这表示当前是在中断处理环境下,于是就执行正常的中断退出流程。
中断发生时的压栈
中断在发生时,处理器收到一个中断向量号,根据此中断向量号在中断描述符表中找到相应的中断门描述符,门描述符中保存的是中断处理程序所在代码段的选择子及在段内偏移量 ,处理器从该描述符中加载目标代码段选择子到代码段寄存器 cs 及偏移量到指令指针寄存器 EIP。 注意,由于 cs 加载了新的目标代码段选择子,处理器不管新的选择子和任何段寄存器(包括 CS)中当前的选择子是否相同,也不管这两个选择子是否指向当前相同的段,只要段寄存器被加载,段描述符缓冲寄存器就会被刷新,处理器都认为是换了一个段,属于段间转移,也就是远转移。所以,当前进程被中断打断后,为了从中断返回后能继续运行该进程,处理器自动把 cs 和 EIP 的当前值保存到中断处理程序使用的栈中。不同特权级别下处理器使用不同的栈,至于中断处理程序使用的是哪个栈,要视它当时所在的特权级别,因为中断是可以在任何特权级别下发生的。除了要保存 cs、 EIP 外,还需要保存标志寄存器 EFLAGS,如果涉及到特权级变化,还要压入 SS 和 ESP 寄存器。
下面看看以上寄存器入校情况及顺序:
- 处理器根据中断向量号找到对应的中断描述符后,拿 CPL 和中断门描述符中选择子对应的目标代码段的 DPL 比对,若 CPL 权限比 DPL 低,即数值上 CPL>DPL,这表示要向高特权级转移,需要切换到高特权级的栈。这也意味着当执行完中断处理程序后,若要正确返回到当前被中断的进程,同样需要将栈恢复为此时的旧栈。处理器先临时保存当前旧栈SS和 ESP 的值,记作 SS_old和 ESP_old,然后在 TSS 中找到同目标代码段 DPL 级别相同的栈加载到寄存器 SS 和 ESP 中,记作 SS_new 和 ESP_new,再将之前临时保存的 SS old 和 ESP old 压入新栈备份,以备返回时重新加载到栈段寄存器 SS 和栈指针 ESP。 由于 SS_old 是 16 位数据, 32 位模式下的战操作数是 32 位,所以将 SS_old 用 0 扩展其 高 16 位 ,成为 32 位数据后入栈。
- 在新栈中压入 EFLAGS 寄存器。
- 由于要切换到目标代码段,对于这种段间转移,要将 cs 和 EIP 保存到当前栈中备份,记作 CS old 和 EIP_old,以便中断程序执行结束后能恢复到被中断的进程。同样 CS_old是 16位数据, 需要用 0填充 其高 16 位,扩展为 32 位数据后入栈。
- 某些异常会有错误码,此错误码用于报告异常是在哪个段上发生的,也就是异常发生的位置,所以错误码中包含选择子等信息。错误码会紧跟在 EIP 之后入栈,记作 ERROR CODE。
如果在第 1 步中判断未涉及到特权级转移,便不会到 TSS 中寻找新栈,而是继续使用当前旧栈, 因此也谈不上恢复旧栈,此时中断发生时栈中数据不包括 SS_old和 ESP_old。比如中断发生时当前正在运行的是内核程序,这是 0 特权级到 0 特权级,无特权级变化。