代码、内容参考来自于包括《操作系统真象还原》、《一个64位操作系统的设计与实现》以及《ORANGE’S:一个操作系统的实现》。
1.中断概述
在计算机系统中,中断(Interrupt)是一种硬件或软件生成的信号,用于通知处理器某种事件已发生,需要处理器暂时中断当前执行的程序或任务,转而执行相应的中断服务程序(Interrupt Service Routine,ISR)。
当处理器接收到中断信号时,会立即停止当前正在执行的程序,保存当前的执行状态(比如程序计数器、寄存器状态等),然后跳转到对应的中断服务程序开始执行。中断服务程序会处理中断引起的事件,并在处理完成后恢复原来的执行状态,使被中断的程序继续执行。
中断按事件来源分类,来自CPU外部的中断就称为外部中断,来自CPU内部的中断称为内部中断。
外部中断
CPU 为大家提供了两条信号线。外部硬件的中断是通过两根信号线通知CPU 的,这两根信号线就是 INTR (INTeRrupt) 和 NMI(Non Maskable Interrupt)。
如图所示:

CPU有两根线接收外部中断信号
一个是INTR可屏蔽中断,接收正常的外设中断
一个是NMI不可屏蔽中断,接收一些会导致机器宕机的灾难性错误。
只要是从NMI接收的中断,基本全是硬伤,从INTR接收的中断,CPU甚至可以装作不知道。
操作系统是中断驱动的,当发生中断时会执行相应的中断处理程序,我们希望操作系统响应中断的时间越短越好,这样的话可以腾出时间响应更多的中断,但是中断是要完整执行的,所以Linux中将中断处理程序分为上下两个部分。把中断处理程序中需要立即执行的部分(分分钟不能耽误的部分)划分到上半部,这部分是要限时执行的,而中断处理程序中那些不紧急的部分则被推迟到下半部中去完成。
CPU收到中断后,得知道发生了什么事情才能执行相应的处理办法。这是通过中断向量表或中断描述符表(中断向量表是实模式下的中断处理程序数组,在保护模式下已经被中断描述符表代替)来实现的,首先为每一种中断分配一个中断向量号,中断向量号就是一个整数,它就是中断向量表或中断描述符表中的索引下标,用来索引中断项。中断发起时,相应的中断向量号通过NMI或INTR引脚被传入CPU,中断向量号是中断向量表或中断描述符表里中断项的下标,CPU根据此中断向量号在中断向量表或中断描述符表中检索对应的中断处理程序并去执行。
可屏蔽中断每一种中断源都可以获得一个中断向量号。而不可屏蔽中断引起的致命错误原因虽然有很多,但是每一种都是对于软件工程师来说都意义不大,就没必要再细分原因,统统为导致宕机的各种原因分配一个中断向量号就足够了,所以不可屏蔽中断的中断向量号为 2。
内部中断
内部中断可分为软中断和异常。
软中断
软中断,就是由软件主动发起的中断,因为它来自于软件,所以称之为软中断。
由于该中断是软件运行中主动发起的,所以它是主观上的,并不是客观上的某种内部错误。 以下是引起是可以发起中断的指令:
- “int 8 位立即数”。 我们要通过它进行系统调用,8 位立即数可表示256种中断,这与处理器所支持的中断数是相吻合的。
- “int3”。 它们之间无间隙。 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 无法识别。主动使用它发起中断。
值得注意的是以上几种软中断指令,除第一种的“int 8位立即数”之外,其他的几种又可以称为异常,因为它们既具备软中断的“主动”行为,又具备异常的“错误”结果。
异常
异常是另一种内部中断,是指令执行期间CPU内部产生的错误引起的。由于是运行时错误,所以它不受标志寄存器 eflags 中的 IF 位影响,无法向用户隐瞒。
并不是所有的异常都很致命,按照轻重程度,可以分为以下三种:
- Fault,也称为故障。 这种错误是可以被修复的一种类型,属于最轻的一种异常,它给软件一次“改过自新”的机会。 当发生此类异常时CPU将机器状态恢复到异常之前的状态,之后调用中断处理程序时,CPU将返回地址依然指向导致fault异常的那条指令。 通常中断处理程序中会将此问题修复,待中断处理程序返回后便能重试。 最典型的例子就是操作系统课程中所说的缺页异常page fault,话说Linux的虚拟内存就是基于 page fault 的,这充分说明这种异常是极易被修复的,甚至是有益的。
- Trap,也称为陷阱,此异常通常用在调试中,比如int3指令便引发此类异常,为了让中断处理程序返回后能够继续向下执行, CPU将中断处理程序的返回地址指向导致异常指令的下一个指令地址。
- Abort 终止,一旦出现,由于错误无法修复,程序将无法继续运行,操作系统为了自保,只能将此程序从进程表中去掉。

表中Error code字段中,如果值为Y,表示相应中断会由CPU压入错误码。
之前说到的中断向量就是表中第一列的 Vector No即中断向量号。它就是个整数,范围是 0~255。
中断机制的本质是来了一个中断信号后,调用相应的中断处理程序。所以,CPU不管有多少种类型的中断,为了统一中断管理,把来自外部设备、内部指令的各种中断类型统统归结为一种管理方式,即为每个中断信号分配一个整数,用此整数作为中断的ID,而这个整数就是所谓的中断向量,然后用此ID作为中断描述符表中的索引,这样就能找到对应的表项,进而从中找到对应的中断处理程序。
异常和不可屏蔽中断的中断向量号是由CPU自动提供的,而来自外部设备的可屏蔽中断号是由中断代理提供的(咱们这里的中断代理是8259A),软中断是由软件提供的。
2.中断描述表
中断描述符表(Interrupt Descriptor Table,IDT)是保护模式下用于存储中断处理程序入口的表,当CPU接收一个中断时,需要用中断向量在此表中检索对应的描述符,在该描述符中找到中断处理程序的起始地址,然后执行中断处理程序。
实模式也有中断,实模式下用于存储中断处理程序入口的表叫中断向量表(Interrupt Vector Table,IVT)。不过我们都是讨论保护模式下的中断,我们更关注保护模式的中断描述符表。
中断描述符表中的描述符有自己的名称——门。中断门结构如下:

S 位表示这是数据段还是系统段,之前我们都是设置为数据段,现在这里我们设置为代码段了
p 中断处理程序是否在内存中
DPL 表示访问这个门需要的最小特权级
中断门包含了中断处理程序所在段的段选择子和段内偏移地址。当通过此方式进入中断后,标志寄存器eflags中的IF位自动置0,也就是在进入中断后,自动把中断关闭,避免中断嵌套。Linux就是利用中断门实现的系统调用,就是那个著名的int 0x80。中断门只允许存在于IDT中。
以前说过的低端1MB内存布局,位于地址0~0x3ff的是中断向量表IVT,它是实模式下用于存储中断处理程序入口的表。由于实模式下功能有限,运行机制比较“死板”,所以它的位置是固定的,必须位于最低端。大家看到了,已知0~0x3ff共1024个字节,又知IVT可容纳256个中断向量,所以每个中断向量用4字节描述。
对比中断向量表,中断描述符表有两个区别:
- 中断描述符表地址不限制,在哪里都可以。
- 中断描述符表中的每个描述符用8字节描述。
在CPU内部有个中断描述符表寄存器(Interrupt Descriptor Table Register, IDTR),该寄存器分为两部分:第0~15位是表界限,即IDT大小减1,第16~47位是IDT的基地址,和之前介绍的GDTR是一样的原理。中断描述符表地址要加载到这个寄存器中,只有寄存器IDTR指向了IDT,当CPU接收到中断向量号时才能找到中断向量处理程序,这样中断系统才能正常运作。

16位的表界限,表示最大范围是0xffff,即64KB。可容纳的描述符个数是64KB/8=8K=8192个。GDT的第一个描述符即第0个描述符是不可用的,但是IDT没有这个限制。中断向量号为 0 的中断是除法错。处理器只支持256个中断,即0~254,中断描述符中其余的描述符不可用。
在门描述符中有个P位,所以,咱们将来在构建IDT时,记得把P位置0,这样就表示门描述符中的中断处理程序不在内存中。
32位的表基地址,就是IDT的线性基地址。
加载IDTR也有个专门的指令—lidt,其用法和gdtr一模一样。
用法:lidt 48位内存数据
在这48位内存数据中,前16位是IDT表界限,后32位是IDT线性基地址。
3.中断处理过程
完整的中断过程分为CPU外和CPU内两部分:
- CPU外:外部设备的中断由中断代理芯片接收,处理后将该中断的中断向量号发送到CPU。
- CPU内:CPU 执行该中断向量号对应的中断处理程序。
中断处理过程具体分为三步:
(1)处理器根据中断向量号定位中断门描述符
中断向量号是中断描述符的索引,当处理器收到一个外部中断向量号后,它用此向量号在中断描述符表中查询对应的中断描述符,然后再去执行该中断描述符中的中断处理程序。由于中断描述符是8个字节,所以处理器用中断向量号乘以8后,再与IDTR中的中断描述符表地址相加,所求的地址之和便是该中断向量号对应的中断描述符。
(2)处理器进行特权级检查
由于中断是通过中断向量号通知到处理器的,中断向量号只是个整数,其中并没有RPL,所以在对由中断引起的特权级转移做特权级检查中,并不涉及到RPL。中断门的特权检查同调用门类似,对于软件主动发起的软中断,当前特权级CPL必须在门描述符DPL和门中目标代码段DPL之间。这是为了防止位于3特权级下的用户程序主动调用某些只为内核服务的例程。
如果是由软中断int n、 int3和into引发的中断,这些是用户进程中主动发起的中断,由用户代码控制,处理器要检查当前特权级CPL和门描述符DPL,这是检查进门的特权下限,如果CPL权限大于等于DPL,即数值上CPL小于等于门描述符DPL,特权级“门槛”检查通过,进入下一步的“门框”检查。否则,处理器抛出异常。
然后检查特权级的上限(门框):处理器要检查当前特权级CPL和门描述符中所记录的选择子对应的目标代码段DPL,如果CPL权限小于目标代码段DPL,即数值上CPL大于目标代码段DPL,检查通过。否则CPL若大于等于目标代码段DPL,处理器将引发异常,也就是说,除了用返回指令从高特权级返回,特权转移只能发生在由低向高。
若中断是由外部设备和异常引起的,只直接检查CPL和目标代码段的DPL,和上面的是一样的,要求CPL权限小于目标代码段 DPL,即数值上 CPL大于目标代码段DPL,否则处理器引发异常。
(3)执行中断处理程序
特权级检查通过后,将门描述符目标代码段选择子加载到代码段寄存器CS中,把门描述符中中断处理程序的偏移地址加载到EIP,开始执行中断处理程序。

由于IDT中全都是门描述符,所以图的 IDT 中的“某门描述符”表示中断门、陷阱门或任务门。
中断发生后,eflags中的NT位和TF位会被置0,如果中断对应的门描述符是中断门,标志寄存器eflags中的IF位被自动置0,避免中断嵌套,即中断处理过程中又来了个新的中断,这是为防止在处理某个中断的过程中又来了个相同的中断,即同一种中断未处理完时又来了一个,这会导致一般保护性(GP)异常。 这表示默认情况下,处理器会在无人打扰的方式下执行中断门描述符中的中断处理例程。
若中断发生时对应的描述符是任务门或陷阱门的话, CPU是不会将IF位清0的。 因为陷阱门主要用于调试,它允许CPU响应更高级别的中断,所以允许中断嵌套。 而对任务门来说,这是执行一个新任务,任务都应该在开中断的情况下进行,否则就独占CPU资源,操作系统也会由多任务退化成单任务了。 从中断返回的指令是iret,它从栈中弹出数据到寄存器cs、 eip、eflags等,根据特权级是否改变,判断是否要恢复旧栈,也就是说是否将栈中位于SS_old和ESP_old位置的值弹出到寄存ss和esp,当中断处理程序执行完成返回后,通过iret指令从栈中恢复eflags的内容。
处理器提供了专门用于控制IF位的指令,指令cli 使 IF 位为 0,这称为关中断,指令 sti 使 IF 位为 1,这称为开中断。
IF位只能限制外部设备的中断,对那些影响系统正常运行的中断都无效,如异常exception,软中断,如 int n等,不可屏蔽中断 NMI 都不受IF 限制。
TF:TrapFlag,陷阱标志位。它用在调试环境中,当TF为0时表示禁止单步运行。在单步执行模式下,处理器在执行每条指令后会引发一个中断,以便调试器或监控程序能够监视程序的执行,并对每条指令的执行进行跟踪和分析。这对于调试程序非常有用,可以逐条指令地检查程序的状态和执行路径。
NT位表示Nest Task Flag,即任务嵌套标志位,也就是用来标记任务嵌套调用的情况。任务嵌套调用是指CPU将当前正执行的旧任务挂起,转去执行另外的新任务,待新任务执行完后,CPU再回到旧任务继续执行。
CPU执行完旧任务后还能回到新任务呢,原因是在执行新任务之前,CPU做了两件准备工作。
- 将旧任务TSS选择子写到了新任务TSS中的“上一个任务TSS的指针”字段中。
- 将新任务标志寄存器eflags中的NT位置1,表示新任务之所以能够执行,是因为有别的任务调用了它。这个别的任务就是“上一个任务TSS的指针”指向的任务。
CPU把新任务执行完后通过iret指令返回去执行旧任务。
iret指令有两个功能:
- 中断返回
- 返回到调用自己执行的那个旧任务中。
当CPU执行iret时,会查看NT位。当NT为1时,说明任务是被嵌套执行的,因此会从自己的TSS中“上一个任务TSS的指针”中获取旧任务,然后去执行该任务;若NT 为 0,则说明是在中断处理环境下,就执行正常的中断退出流程。
从中断返回的指令是iret,它从栈中弹出数据到寄存器cs、eip、eflags中,根据特权级是否改变,判断是否需要恢复旧栈。
4.中断处理过程中的压栈
中断在发生时,处理器收到一个中断向量号,根据此中断向量号在中断描述符表中找到相应的中断门描述符,门描述符中保存的是中断处理程序所在代码段的选择子及在段内偏移量,处理器从该描述符中加载目标代码段选择子到代码段寄存器CS及偏移量到指令指针寄存器EIP。
注意,由于CS加载了新的目标代码段选择子,处理器不管新的选择子和任何段寄存器(包括 CS)中当前的选择子是否相同,也不管这两个选择子是否指向当前相同的段,只要段寄存器被加载,段描述符缓冲寄存器就会被刷新,处理器都认为是换了一个段,属于段间转移,也就是远转移。所以,当前进程被中断打断后,为了从中断返回后能继续运行该进程,处理器自动把CS和EIP的当前值保存到中断处理程序使用的栈中。不同特权级别下处理器使用不同的栈,至于中断处理程序使用的是哪个栈,要视它当时所在的特权级别,因为中断是可以在任何特权级别下发生的。除了要保存CS、EIP外,还需要保存标志寄存器EFLAGS,如果涉及到特权级变化,还要压入SS和ESP 寄存器。
以下为入栈顺序:
(1)处理器根据中断描述符拿到相应的中断描述符后,就找到了中断处理程序对应的选择子,也就找到了即将跳转的代码的DPL,将当前的CPL与DPL做对比,如果当前CPL的特权级比较低,那么就涉及到低特权级向高特权级的转移,必须保存好旧栈的 SS 和 ESP 的值,这两个值记为 SS_old 与 ESP_old,然后在TSS中找到同目标代码段DPL级别相同的栈加载到寄存器SS和ESP中,记作SS_new和ESP_new,再将之前临时保存的SS_old和ESP_old压入新栈备份,以备返回时重新加载到栈段寄存器SS和栈指针ESP。SS 为16为数据,为了对齐会被补充到32位入栈。
(2)在新栈中压入EFLAGS寄存器。
(3)由于要切换到目标代码段,对于这种段间转移,要将CS和EIP保存到当前栈中备份,记作CS_old和EIP_old,以便中断程序执行结束后能恢复到被中断的进程。同样,CS寄存器会被对齐到32位。
(4)某些异常会有错误码,此错误码用于报告异常是在哪个段上发生的,也就是异常发生的位置,所以错误码中包含选择子等信息,错误码会紧跟在EIP之后入栈,记作ERROR_CODE。

如果在第1步中判断未涉及到特权级转移,便不会到TSS中寻找新栈,而是继续使用当前旧栈,因此也谈不上恢复旧栈,此时中断发生时栈中数据不包括SS_old和ESP_old。比如中断发生时当前正在运行的是内核程序,这是0特权级到0特权级,无特权级变化。
处理器进入中断执行完中断处理程序后,还要返回到被中断的进程,这是进入中断的逆过程。中断返回是用iret 指令实现的。Iret,即 interrupt ret,此指令专用于从中断处理程序返回,假设在32位模式下,它从当前栈顶处依次弹出32位数据分别到寄存器EIP、 CS、 EFLAGS,iret指令并不清楚栈中数据的正确性,它只负责把栈顶处往上的数据,每次4字节,对号入座弹出到相关寄存器,所以在使用iret之前,一定要保证栈顶往上的数据是正确的,且从栈顶往上的顺序是EIP、CS、EFLAGS,根据特权级是否有变化,还有ESP,SS。由于段寄存器CS是16位,故从栈中返回的32位数据,其高16位被丢弃,只将低16位载入到CS,若处理器发现返回后特权级会变化,还会继续将两个双字数据返回到ESP,SS,其中SS也是16位寄存器,所以同样也是弹出32位数据后,只将其中的低16位加载到SS, iret指令意味着从中断返回,它是中断处理程序中最后一个指令。
同类的指令还有iretw和iretd,16位模式下用iretw,32位模式下用iretd,iret是iretw和iretd的简写,无论是在16位模式,还是在32位模式下编码,都可以只用iret指令,它是被编译成iretw,还是iretd,取决于伪指令BITS所指明的字长。
iretw隐含操作数是16位,所以只用在16位模式下。它依次从栈中分别弹出2字节到寄存器IP、CS和eflags中,在不涉及到特权级改变的情况下,栈指针sp自减6。
iretd 隐含操作数是 32位,所以只用在 32位模式下。它先从栈中弹出32位数据到寄存器EIP,再弹出32位数据,先丢弃高16位,只将低16位加载到CS,再弹出32位数据到eflags中。在不涉及到特权级改变的情况下,栈指针esp自减12。
如果中断有错误码,处理器并不会主动跳过它的位置,必须手动将其跳过,也就是说,在准备用iret指令返回时,当前栈指针esp必须指向栈中备份的EIP_old所在的位置,这样处理器才能将栈中的数据对号入座,弹出到各自对应的寄存器中。
处理器在返回到被中断过程中也要再进行一次特权级检查。
从中断处理程序返回的过程:
假设此时已经手动将ERROR_CODE从栈中弹出,栈顶已位于正确的位置,即指向EIP_old。
(1)当处理器执行到iret指令时,它知道要执行远返回,首先需要从栈中返回被中断进程的代码段选择子CS_old及指令指针EIP_old。这时候它要进行特权级检查。先检查栈中CS选择子CS_old,根据其RPL位,即未来的CPL,判断在返回过程中是否要改变特权级。
(2)栈中CS选择子是CS_old,根据CS_old对应的代码段的DPL及CS_old中的RPL做特权级检查,如果检查通过,随即需要更新寄存器 CS 和 EIP。由于CS_old 在入栈时已经将高 16 位扩充为0,现在是32位数据,段寄存器CS是16位,故处理器丢弃CS_old高16位,将低16位加载到CS,将EIP_old加载到EIP寄存器,之后栈指针指向EFLAGS。如果进入中断时未涉及特权级转移,此时栈指针是ESP_old (说明在之前进入中断后,是继续使用旧栈),否则栈指针是ESP_new (说明在之前进入中断后用的是TSS中记录的新栈)。
(3)将栈中保存的 EFLAGS 弹出到标志寄存器 EFLAGS。如果在第 1 步中判断返回后要改变特权级,此时栈指针是ESP_new,它指向栈中的ESP_old。否则进入中断时属于平级转移,用的是旧栈,此时栈指针是ESP_old,栈中已无因此次中断发生而入栈的数据,栈指针指向中断发生前的栈顶。
(4)如果在第1步中判断出返回时需要改变特权级,也就是说需要恢复旧栈,此时便需要将ESP_old和SS_old分别加载到寄存器ESP及SS,丢弃寄存器SS和ESP中原有的SS_new和ESP_new,同时进行特权级检查,由于SS_old在入栈时已经由处理器将高16位填充为0,现在是32位数据,所以在重新加载到栈段寄存器 SS 之前,需要将 SS_old 高 16 位剥离丢弃,只用其低 16 位加载 SS。
(5)如果在返回时需要改变特权级,将会检查数据段寄存器DS、ES、FS和GS的内容,如果在它们之中,某个寄存器中选择子所指向的数据段描述符的DPL权限比返回后的CPL (CS.RPL)高,即数值上返回后的CPL>数据段描述符的DPL,处理器将把数值0填充到相应的段寄存器,该段描述符不可用,从而故意使处理器抛异常。
5.中断错误码
中断发生时会压入一个中断错误码,错误码格式如下:

错误码本质上就是个描述符选择子,通过低3位属性来修饰此选择子指向是哪个表中的哪个描述符。
EXT表示EXTernal event,即外部事件,用来指明中断源是否来自处理器外部,如果中断源是不可屏蔽中断NMI或外部设备,EXT为1,否则为0。
IDT表示选择子是否指向中断描述符表IDT,IDT位为1,则表示此选择子指向中断描述符表,否则指向全局描述符表GDT或局部描述符表LDT。
TI和选择子中TI是一个意思,为0时用来指明选择子是从GDT中检索描述符,为1时是从LDT中检索描述符。当然,只有在IDT位为0时TI位才有意义。
选择子高13位索引就是选择子中用来在表中索引描述符用的下标。
有时候不仅错误码的高16位全为0,低16位也全为0,当全0的错误码出现时,表示中断的发生与特定的段无关,或者引用了一个空描述符,引用描述符就是往段寄存器中加载选择子的时候,处理器发现选择子指向的描述符是空的。
中断返回时,iret指令并不会把错误码从栈中弹出,所以在中断处理程序中需要手动用栈指针跨过错误码或将其弹出。否则栈顶处若不是EIP(EIP_old)的话,iret返回时将会载入错误的值到后续寄存器。
通常能够压入错误码的中断属于中断向量号在0~32之内的异常,而外部中断(中断向量号在32~255之间)和int 软中断并不会产生错误码。通常我们并不用处理错误码。
6.参考
郑钢著操作系统真象还原
田宇著一个64位操作系统的设计与实现
丁渊著ORANGE’S:一个操作系统的实现


