本文前置内容:特权级全面剖析
文章参考:中断的作用 ,《真相还原》,Bochs源码分析 ,《X86汇编:从实模式到保护模式》

什么是中断?

定义:中断是指计算机运行过程中,出现某些情况需主机干预时,机器能自动停止正在运行的程序并转入处理新情况的程序,处理完毕后又返回原被暂停的程序继续运行。
中断是 CPU 对系统发生的某个事件作出的一种反应。引起中断的事件称为中断源 ;中断源向 CPU 提出处理的请求称为中断请求 ;发生中断时被打断程序的暂停点成为断点 ;CPU 暂停现行程序而转为响应中断请求的过程称为中断响应 ;处理中断源的程序称为中断处理程序 ;CPU执行有关的中断处理程序称为中断处理 ;而返回断点的过程称为中断返回

中断的意义

  • 操作系统由事件驱动,而事件是以中断的形式来通知操作系统的,所以操作系统是由中断来驱动的。

  • 中断机制是现代计算机系统中的基础设施之一,它在系统中起着通信网络作用(相当于信号),以协调系统对各种外部事件的响应和处理。

  • 中断使得计算机系统具备应对对处理突发事件的能力,提高了CPU的工作效率 。如果没有中断系统,CPU 就只能按照原来的程序编写的先后顺序,对各个外设进行查询和处理,即 轮询 工作方式,轮询方法貌似公平,但实际工作效率很低,不能及时响应紧急事件。

  • 中断能够显著提升并发,从而提高效率。

    因为中断是由信号引发,只要收到信号,马上转移执行流,开始中断程序。只要信号频率足够,就能实现并发。

中断的分类

  • 硬中断 :即来自 CPU 外部的中断,中断源为外部硬件,故而又叫硬件中断。外中断又分为可屏蔽中断和不可屏蔽中断:

    • 可屏蔽中断绝大多数外中断都是可屏蔽中断,例如网卡收到网络包并通知 CPU;打印机向 CPU 发出提示等。当 eflags 中的 IF 位为 0 时,CPU 忽视可屏蔽中断;IF 为 1 时,接收可屏蔽中断。IF 仅对可屏蔽中断有效

      还记得吗?我们可以通过 sti/cli 指令开关外中断,即置 IF 位为 1/0 。

    • 不可屏蔽中断通知CPU发生了灾难性事件 ,如电源掉电、总线奇偶位出错等。

  • 软中断 :来自 CPU 内部或软件的中断,分为以下三类:

    • 陷阱(trap) :陷阱是软件主动发起的中断,并不是某种内部错误。陷阱是实现系统 API 函数调用的手段陷阱通过 int 指令调用,如 int 0x80

      在 Linux 中,使用了一个,也是唯一的一个 trap,就是 int 0x80 系统调用。

    • 终止(Abort) :终止严重错误,如系统表 IDT、GDT 中的数据不一致或无效。发生该类错误时,恢复正常已经非常困难,所以操作系统通常只能把该任务从系统中抹去。

    • 异常(fault) :异常是 CPU 内部出错所发起的中断,有些异常可以主动调用,如 bound、int3;另一些异常则无需(不是不能)主动调用,如除零异常 。笔者了解的可主动调用的异常大概有以下几种:

      1. bound :检查数组越界指令,触发 5 号中断,用于检测数组的索引是否在上下边界之内。其格式为:

        1
        2
        bound r16,m16
        bound r32,m32

        r16/r32 中存放的是数组索引,m32/m16 地址处存放了一对地址,第一个地址是数组的下限(起始),第二个地址是数组的上限。如果索引不在边界内,则会发出超出边界范围的异常,即 0x5 号异常。

      2. ud2 :未定义指令,表示该指令无效,CPU 无法识别,触发 6 号中断。该指令常用于软件测试,无实际用途。

      顺便提一下常见的两个陷阱

      1. into:中断溢出指令,触发 4 号中断。是否能触发还要看 eflags 寄存器中的 OF 位是否为 1,若不为 1,则直接无视。
      2. int3:调试断点指令,触发 3 号中断。注意是 int3 而非 int 3,这两者不同。

      需要注意的是,into 与 int3 指令经常被划为异常,实际上它们是陷阱,原因下面阐述。

这里重点强调陷阱和异常的区别:陷阱时,会向栈中压入 EIP,该 EIP 指向触发异常的那条指令的下一条指令 ;而异常发生时,压入的 EIP 是指向触发异常的那条指令因此,当从异常返回时,异常会重新执行那条指令;而陷阱就不会重新执行 。这一点实际上也是相当重要的,比如我们熟悉的缺页异常(page fault),由于是 fault,所以当缺页异常处理完成之后,还会去尝试重新执行那条触发异常的指令(此时所缺页一般已经被加载进内存)。而上面我们谈到的 into/int3 中断执行完后并不会再执行原指令,所以它应该是 trap 而非 fault 。下面调用除零溢出来证实上面观点,见下图:

大家快看!咋们只 div 了一次,却一直循环发生除零错误,这就是因为当异常处理完毕后,还会跳转到之前那条触发异常的指令。图中还夹杂了时钟中断,后续会详解。需要说明的是,如果你手动调用异常,就不会循环跳转了

这部分代码在 interrupt 分支,有兴趣的朋友可以提前玩玩。

下面给出中断的类型分布图:

另外,外中断是通过 INTR(interrupt) 和 NMI(Non Maskable Interrupt) 这两根信号线来通知 CPU 的。从 INTR 引脚收到的外中断是可屏蔽中断,由 eflags 的 IF 位决定是否接受;从 NMI 引脚收到的是不可屏蔽中断 ,不可忽略。图示如下:

需要注意的是,由于不可屏蔽中断一旦发生,就意味着局面已经无法挽回,操作系统也无能为力,所以就没必要再细分原因。因此,所有不可屏蔽中断都被划入一个中断号,即 0x2

异常和不可屏蔽中断的中断向量号由 CPU 自动提供,不能修改;可屏蔽中断的中断向量号由中断代理(8259A)提供;陷阱的中断向量号由操作系统提供CPU 为了处理并发的中断请求,规定了中断的优先权,中断优先权由高到低的顺序是: (1)除法错、溢出中断、陷阱 (2)不可屏蔽中断 (3)可屏蔽中断 (4)单步中断。

中断描述符表IDT

中断描述符表(Interrupt Descriptor Table,IDT)保护模式 下用于储存中断程序入口地址的表。当 CPU 接收到中断时,需要用该中断的中断号去检索 IDT 中对应的描述符,描述符中储存着该中断例程的地址,接着跳到该地址处执行程序。

需要注意的是,实模式下的中断表叫做 中断向量表(Interrupt Vector Table,IVT) ,它的作用和 IDT 完全相同,其他不同之处有以下两点:

  • IVT 的描述符为 4 字节,而 IDT 的描述符为 8 字节
  • IVT 的位置固定在 0x0000~0x03FF ,而 IDT 可放于任意位置(由 IDTR 跟踪)。
  • IVT 是由 BIOS 在开机时建立的,中断例程也已经建立好了;而 IDT 以及其对应的中断例程都需要我们自己建立。

另外,BIOS 中断在保护模式下无法使用 ,因为其中断例程都是用于 16 位指令架构,不再适用于 32 位保护模式。关于 IVT,详细内容可参考汇编入门

中断描述符中装着各种门的描述符,包括任务门中断门陷阱门描述符(注意,不包含调用门) ,这三种描述符的结构和作用请参见特权级全面剖析 ,就不在此赘述了。

IDT 与 GDT 的不同之处大概有以下几点:

  1. GDT 的第 0 个描述符不可用;IDT 的第 0 个描述符是可以用的,且第 0 个中断为著名的除零异常(上面已经演示)。
  2. GDT 中包含普通段描述符、TSS描述符、LDT描述符、调用门/任务门描述符。而 IDT 则只包含中断门/陷阱门/任务门描述符。
  3. GDT 最多能容纳 8192 个描述符,而 IDT 最多只能有 256 个描述符 (即使 IDTR 的索引部分有 13 位)。
  4. GDT 描述符由操作系统编写者自己定,而 IDT 中第 0~19 号描述符的作用已经写死进 CPU,不能自己决定。

另外,IDT 的位置由 IDTR 寄存器进行跟踪,其格式和 GDTR 相同(回想一下 IDTR 的结构):


使用 lidt 进行加载:

1
2
lidt 48位内存数据
;lidt [idt_ptr]

中断错误码

有些异常产生时,CPU 会自动在中断任务的栈中压入一个错误代码 ,此错误码一般用来报告异常是在哪个段上发生的,因此错误码中包含了选择子等信息。错误码格式如下:

  • EXT(External Event) :此位置 1 时,表示异常由 NMI、硬件中断等引发,
  • IDT :用于指示该选择子索引是指向哪的。为 1 时,指向中断描述符表(IDT);为 0 时,指向 GDT 或 LDT 。
  • TI仅在 IDT 为 0 时有效。此位为 1 时,指向 GDT;为 0 时,指向 LDT 。

需要重点强调的是,当通过 iret/iretd 指令从中断程序返回时,CPU 并不会自动弹出错误码 !因此,对于那些有错误码的中断例程(见上文的中断图),必须在 iret/iretd 前手动弹出错误代码 ,否则堆栈将失衡,最终引发程序崩溃。演示如下(先别管代码):

另外,对于外部异常(由 CPU 引脚触发),以及用软中断指令 int n 引发的异常,处理器不会压入错误代码,即使它原本是一个有错误代码的异常 !演示如下:

能压入错误码的中断属于 0~32 号的异常,外部中断和陷阱不会压入错误码。

中断处理及其压栈过程

特权级全面剖析 中剖析了调用门的处理过程,建议读者将中断门处理和调用门处理对比阅读。

(1) 发生中断,CPU 收到中断向量号,由此在 IDT 中定位到响应中断描述符。
(2) 进行特权级检查。由于中断向量号只是一个整数,所以特权级检查并不涉及 RPL 。分以下两种情况:
a)由陷阱 int nint3into 引起的中断,这些中断由用户主动发起,因此进行如下检查:

1
2
目标代码段的DPL≤CPL≤门描述符的DPL
其中目标代码段指的是该中断门描述符中的选择子指向的代码段描述符

​ b)由外部设备(可屏蔽中断)和异常引起的,只作如下检查:

1
目标代码段的DPL≤CPL

为什么由外部设备和异常引起的中断不检查门描述符的 DPL ?
这点笔者在特权级全面剖析留下了线索,其中提到“门槛”的作用是防止某些低特权级应用通过门来调用只服务于内核的程序,如页故障处理。而应用能这么做的前提是它可以主动发起门,但,由外部设备和异常引起的中断并不能由用户主动调用,因此也无需用门槛进行检查啦。

(3) 若特权级检查通过,则将中断门描述符中的选择子加载进 cs 。然后根据检查结果判断是否要转移到新栈,若发生特权级转移,则会转移到新栈。下面以转移到新栈为例。处理器先临时在其他地方保存旧栈的 SS 和 ESP,记为 SS_old 和 ESP_old,然后在对应 TSS 中找到相同等级的栈并转移到新栈,为了返回时能够切换回旧栈,在新栈中压入临时保存的 ESP_old 和 SS_old:

注意,不管是否发生特权级转移,都会保存之前的 SS 和 ESP!

(4) 压入 EFLAGS 寄存器。需要注意,中断发生后 EFLAGS 的 NT 位和 TF 位会被自动置零( 先将 EFLAGS 压栈再置零 );如果中断对应的是中断门,则 IF 也被自动置零;如果中断对应的是任务门/陷阱门,IF 则不会置零 。详细原因见下文。
(5) 为了中断结束后能够顺利返回,将 CS_old 和 EIP_old 压栈:

(6) 某些异常可能有错误码,有错误码则压栈,无错误码则不做操作:

(7) 进行中断处理过程。处理完毕后使用 iret/iretd 返回,栈中内容自动弹出,恢复到转移前的状态。
(8) 如果返回时需要改变特权级,则还会检查 DS/FS/GS/ES 中的内容,如果某个寄存器中选择子指向的数据段描述符的 DPL 比返回后的 CPL 高,则处理器自动将选择子置零。原因在特权级全面剖析中分析过,不再赘述。

下面对几个细节进行说明:

关于 IF 置零

  • 对于中断门,将 IF 置零,忽略可屏蔽中断。这是为了避免中断嵌套,防止在中断处理时又来一个相同的外中断,这将导致 GP 异常(0xd中断)。
  • 对于陷阱门,无需将 IF 置零。陷阱门用于调试,允许响应其他中断。
  • 对于任务门,无需将 IF 置零。任务都应该在开中断的情况下进行,否则就会独占 CPU,多任务系统便退化为单任务系统。

关于 TF 置零
TF(Trap Flag),陷阱标志位,用于调试环境,能够使 CPU 单步执行。处理器执行一条指令前,如果检测到单步标志位 TF 为 1,则在该条指令执行后立即停止,引起 0x1 号中断,0x1 号中断处理程序中可以安排自己想实现的功能,如显示各个寄存器的值以及下一条指令(Debug就是如此,参见汇编入门)。问题是,当 TF=1 时,CPU在执行完一条指令后将引发单步中断,转去执行中断处理程序,注意,中断处理程序也是由一条条指令组成的,如果在执行中断处理程序时,TF=1,则 CPU 在执行完中断处理程序的第一条指令后,又会引发单步中断,重新进入中断处理程序,进而一直在此循环。因此,进入中断前必须将 TF 置 0 。

关于 NT 置零
NT(Next Task Flag),任务嵌套标志位。任务嵌套指旧任务调用了新任务,旧任务挂起,执行流转入新任务。新任务如何返回到旧任务呢?通过两点:1)新任务的 TSS 中记录了旧任务 TSS 的指针,详见特权级剖析。2)新任务的 EFLAGS 中 NT 位被置 1 。新任务返回到旧任务也是通过 iret 指令进行的 ,那么问题来了:如果在新任务中发生了中断,当执行到 iret 指令时,处理器怎么知道该从中断返回还是从新任务返回到旧任务呢?这就是 NT 位起的作用,当 NT=0,则 iret 从中断返回;当 NT=1,则 iret 从任务返回

对错误码的压栈处理
对于那些有错误码的中断例程,弹栈时 CPU 不会主动越过错误码,所以我们必须在 iret/iretd 前手动弹出错误代码 ,否则堆栈将失衡,最终引发程序崩溃。通常我们接收但无需处理错误码。

本文结束。