中断的历史原因
在聊中断机制之前,我想先和大家聊一聊中断机制出现的前因后果。最一开始计算机操作系统的设计是能够一次性的执行所有的计算任务的,这被称为顺序执行,也是批处理操作系统(Batch system)。
顺序执行的意思是一个任务接着一个任务的依次执行,就像我们编写代码的时候,我们肯定是写完一行代码才会写下一行代码,此时的计算机也是这样的,执行完一个任务后才会执行下一个。就相当于 main 函数里面只有一个 while(1) ,永不停止。
这样的操作系统是当时最高效的系统,但是这种系统会存在两个问题:
(相关资料图)
下一个任务只能在当前任务执行完成后才得以执行,拿上图来说就是任务 A 执行完成后才会执行任务 B,任务 C 在任务 A 和任务 B 执行完成后才会得到执行,任务 D 同理。当任务执行遇到问题或者出错时,就直接修改当前任务的 PC 指针,将其指向下一个任务就完事了。
任务执行的次序是单项的,就是只能以 A -> B -> C -> D 这样的次序执行,不能以 D -> C -> B -> A 这样反向的顺序执行。
这样的操作系统无疑是很简陋的(或者此时不应该称之为操作系统,实际上就是一个监控系统)。
随着时代的发展,后来出现了很多计算机,不过此时计算机还没有改变依次执行顺序,当计算机在做 IO 任务的时候,计算任务必须等待;在做计算任务的时候,IO 任务必须等待。这显然是一个急需解决的问题。
一直等到 IBM 开发的 OS/360 计算机才解决了这个问题,OS/360 可以说算是一个划时代的标志,因为它有一个很重要的特点是能够允许多道程序运行,并且能够实现多道任务之间的切换,这些任务可以是 IO 任务,也可以是计算任务。但是这些任务执行于何处停止,何时进行切换却没有一个明确的标准。
后来出现了 MIT 开发的 MULTICS 操作系统,这种操作系统是一种分时系统,它允许每个任务都各自运行一段时间后再进行切换,这样能够兼顾所有的任务,使他们都能够得到运行。虽然解决了分时复用的问题,但是不同任务所需要的时间并不一定是恒定的,所以 MULTICS 注定了只能是个过度。
后来出现了大名鼎鼎的 UNIX,由Dennis Ritchi 丹尼斯里奇
和Ken Thompson 肯汤姆森
共同开发,UNIX 是一个简化版的 MULTICS ,核心概念差不多,但是 UNIX 却更加灵活和成功。奠定了小型化机器流行的基础。
在 UNIX 开发出来不久,Andrew Tanenbaum 也开发出来了一套操作系统 MINIX,不过这个操作系统是用于教学目的,没有开源,而 Tanenbaum 就是写现代操作系统的那个大佬。
又过了几年,Linus Torvalds 基于 UNIX 操作系统开发了 Linux,一直流传至今。
我没有查到中断到底是何时引入的,但是从 Linux 问世以来就已经有了,而且 Linux 是基于 UNIX 开发的,可以认为 UNIX 就已经引入中断机制了,而且换个角度来说,UNIX 作为如此著名的操作系统,应该会引入中断机制的。
当然我知道大多数人对计算机历史没有太多兴趣,所以我们现在还是切回主线了。
中断的概念和相关原理
中断是指计算机在运行过程中,由于某些原因(这个原因可以是系统外部、也可以是系统内部或者程序出现紧急事件)不得不停下来当前正在执行的任务,转而处理其他任务的过程,在处理完其他事情后,计算机会返回继续执行当前任务,这个完整的过程就被称为中断(Interrupt)
。
还有一种处理方式是轮询,现代计算机一般都包含输入输出设备,在轮询机制中,CPU 会不断的顺序询问每个设备是否需要提供服务,如果需要提供服务,CPU 就会转而为设备驱动进行服务;可以看到,这种轮询的方式性能较差,而且比较耗费 CPU 资源。
轮询的方式可以看做是一种被动要求 CPU 为其服务的方式,而中断可以看做是一种主动要求 CPU 为其服务的方式。从我们日常生活和学习过程中就能够知道,主动要求的方式效率要比被动询问的方式要高,因为你肯定也经历过上课老师问同学们会不会的时候,有人主动站起来问问题要比老师问每个学生没有回复效率要高的多。
在中断的过程中,设备会向 CPU 发出的请求,而这个请求被称为中断请求(IRQ - Interrupt Request),CPU 针对中断请求做出响应转而执行相关程序被称为中断服务程序(ISR - Interrupt Service Routine)或者叫中断服务过程。
这里需要认识一个新的概念:中断控制器(PIC - Programmable Interrupt Controller),中断控制器负责管理设备发出的这些中断请求,简单来说它就是这些中断请求的管理者。这个玩意会和设备的引脚相连接以便接收设备发出来的中断信号,当设备激活 IRQ 时,中断控制器会立刻检测到并对其做出响应。不过真实的情况是,计算机无时无刻都在发出 IRQ,所以中断控制器经常会收到很多 IRQ,甚至有可能 CPU 正在执行中断过程的同时 PIC 还收到了 IRQ,这时中断控制器需要对这些 IRQ 排出一个响应优先级,来告知 CPU 应该首先执行哪个中断处理程序。
PIC 更多是适用于单核 CPU ,对于多核 CPU 来说并不适用,适用于多核 CPU 的是 APIC,APIC 我们后面简单提到一些,不过目前还是以 PIC 为主,因为 Linux 0.11 用的是 PIC。
中断的具体过程是这样的:PIC 会向 CPU 的引脚发出一个中断信号,CPU 知道产生了中断信号后会立刻停下当前进程,并询问 PIC 需要执行哪个中断请求,PIC 通过数据总线告知 CPU 中断号,CPU 根据中断号去 IDT(中断向量表)中取得中断向量并执行中断处理程序,处理完成后,CPU 会返回当前的任务继续执行。
上面聊到的这些中断都是通过设备产生的中断,这些中断的本质是外部设备产生的信号来告知操作系统其状态的变化,这种中断被称为硬中断
;还有一种中断是软中断
,软中断通常是由软件中引起中断的指令产生的,比如 int 指令就会产生软中断,设备产生的硬中断不会等待太长时间,响应速度比较快,而指令产生的软中断是一种推后的机制,响应速度不如硬中断快。
80x86 的中断系统
这部分主要介绍一下 x86 所使用的中断控制芯片相关内容,会涉及到一些嵌入式相关的知识。
80x86 组成的微机机系统中采用了 8259A 可编程中断控制芯片。每个 8259A 芯片可以管理 8 个中断源。通过多片级联的方式,8259A 能构成最多管理 64 个中断向量的系统。在 PC/AT 系列兼容机中,使用了两片 8259A 芯片,可以管理 15 级中断向量,如下图所示:
从图中可以看到,图上方是主芯片,图下方是从芯片,从芯片的 INT 引脚连接到主芯片的 IR2 引脚上,这也就是说,从芯片的中断信号可以作为主芯片的输入信号。
8259A 是一块可编程芯片,可以通过 IN 和 OUT 指令对 8259A 进行编程,一旦完成了初始化编程,芯片就进入了操作状态,此时芯片可以随时响应外部设备提出的中断请求(IRQ0 - IRQ15)。通过中断判优选择,芯片将当前最高优先级的中断请求作为中断服务对象,并通过 INT 请求通知 CPU 外中断请求到来,然后根据中断号执行中断处理程序。
中断向量表
上面提到过中断向量表是 CPU 根据中断号执行中断处理程序前需要查询的"一张表",获取中断向量值后就可以对应中断服务程序的入口值。
80x86 机器支持 256 个中断,理论上每个中断都需要安排一个中断处理程序。在 80x86 实模式下,每个中断向量由 4 个字节组成,这 4 个字节组成了一个中断处理程序的段值和段内偏移值,所以整个中断向量表的大小是 1024 字节。在程序加电启动时,程序进入实模式,此时 ROM BIOS 会在物理地址 0x0000:0x0000 处完成中断向量表的初始化。在中断向量表中,中断向量号顺序排列,每个中断向量号占用 4 字节,因此每个中断向量的内存位置就是 [0x0000:N 乘 4,0x0000:N+1 乘 4 - 1) 。
中断向量表在 32 位保护模式下也叫做中断描述符表,也是我们常说的 IDT 表。
IDT 表和中断向量表都是描述中断服务程序地址的表项,基本上中断向量表和 IDT 表换汤不换药,只不过 IDT 表除了有中断服务程序的地址外,还包含有特权级和描述符类别等信息。
对于 Linux 内核来说,中断信号分为两类:硬件中断和软件中断,每个中断是由 0 - 255 之间的一个数字来标识。对于中断 int0 - int31 来说,每个中断的功能都由 intel 制定或保留用,这些属于软件中断,但是 intel 公司称之为异常。叫做异常也是可以理解的,因为这些中断都是在探测到异常情况下发出的。中断 int32 - int255 可以由用户自己设定。常见的硬件和软件中断描述见下表。
在 Linux 系统中,将 int32 - int47 对应于 8259A 中断控制芯片发出的硬件中断请求信号 IRQ0 - IRQ15,并把程序编程发出的系统调用中断设置为 int128 ,也就是 0x80。
下面是 8259A 芯片中断请求发出的中断号列表:
中断请求号 | 中断号 | 用途 |
---|---|---|
IRQ0 | 0x20(32) | 8253 发出的 100HZ 时钟中断 |
IRQ1 | 0x21(33) | 键盘中断 |
IRQ2 | 0x22(34) | 接连从芯片 |
IRQ3 | 0x23(35) | 串行口 2 |
IRQ4 | 0x24(36) | 串行口 1 |
IRQ5 | 0x25(37) | 并行口 2 |
IRQ6 | 0x26(38) | 软盘驱动器 |
IRQ7 | 0x27(39) | 并行口 1 |
IRQ8 | 0x28(40) | 实时钟中断 |
IRQ9 | 0x29(41) | 保留 |
IRQ10 | 0x2a(42) | 保留 |
IRQ11 | 0x2b(43) | 保留(网络接口) |
IRQ12 | 0x2c(44) | PS/2 鼠标口中断 |
IRQ13 | 0x2d(45) | 数学协处理器中断 |
IRQ14 | 0x2e(46) | 硬盘中断 |
IRQ15 | 0x2f(47) | 保留 |
在系统刚刚初始化后,内核在 head.s 程序中会对所有 256 个中断向量进行默认设置。默认设置就是给这些中断向量随便设置一个初值,设置这个值的目的是为了防止出现一般保护性错误。
一般保护性错误:是指在英特尔 x86 架构和 AMDx86-64 架构和其它架构中的一种中断情况,指正在运行的程序(内核或用户态程序)违反处理器架构中保护措施的情况。
最常见的情况就是
Linux 中的这些中断不会所有的都用到,有些中断是保留中,另外对于系统中所使用的一些中断,内核会在其初始化过程中重新设置这些中断描述符,让他们指向实际的处理过程。
另外,在设置中断描述符表 IDT 表时 Linux 内核使用了中断门和陷阱门两种门描述符。它们之间的区别在于对标志寄存器 EFLAGS 中的中断允许标志 IF 的影响。由中断门描述符执行的中断会复位 IF 标志,因此可以避免其他中断干扰当前中断的处理。随后中断结束后指令 iret 会恢复 IF 标志的原值;而通过陷阱门执行的中断不会响应 IF 标志。
这里需要说一下两个指令 cli 和 sti,为了避免竞争条件对临界代码的干扰,在 Linux 0.11 内核中很多地方都使用了 cli 和 sti 指令。cli 指令用于复位 CPU 标志寄存器 EFLAGS 中的中断标志,使得系统在执行 cli 指令后不会响应外部中断。sti 指令用于设置标志寄存器中的中断标志,能够让 CPU 识别并响应外部设备发出的中断。这俩相当于是个可逆的关系。
当一段代码进入可能引起竞争条件的临界代码区时,内核中就会使用 cli 指令来关闭对外部中断的响应,而在执行完竞争代码区时内核就会执行 sti 指令以重新允许 CPU 响应外部中断。如果不设置 cli 和 sti 的话,就可能引起对临界代码的多重写操作,导致数据不一致,产生崩溃现象。