任何一个通用 CPU 都可以在执行完一条指令后检测从 CPU 外部发送过来或从内部产生的一种特殊信息,并且可以立即转而对该信息进行处理。这种“特殊信息”衍生出了异常、中断和信号这三个概念,其中牵涉到的知识点非常广泛且复杂,环环相扣。本文旨在对其进行梳理,形成一个脉络,具体底层细节还需要读者自行探究。更多内容欢迎来我的博客转转:
精神的壳qiuyueqy.com定义
中断(又称异步中断、硬中断): 本质是一种电信号。当设备有某种事件发生时,它就会产生中断,通过总线把电信号发送给中断控制器(如 8259A)。如果中断的线是激活的,中断控制器就把电信号发送给处理器的某个特定引脚。处理器于是立即停止自己正在做的事,跳到中断处理程序的入口点,进行中断处理。
异常(又称同步中断,软中断):由指令产生,如 div 指令中除数为 0 就会引发一个除零异常。异常是可以预期的,通过观察程序的指令就可以知道什么时候会产生什么异常。
中断和异常都是用称为 中断类型码
来表示中断信息的来源。中断类型码是一个字节型数据,可以表示 256 种来源。
信号:信号是软件层面上对中断的模拟,用来通知进程发生了异步事件。进程之间可以通过系统调用发送软中断信号。内核也可以因为内部事件给进程发送信号,通知进程发生了某个事件。注意,信号只是用来通知某进程发生了什么事件,并不给该进程传递任何数据
异常与中断
我们先来看看异常。刚才说到,异常由指令触发,也就是代表了来自程序内部的某些行为,这些行为总体可以分为四类:
- 除法错误,如除零。类型码:0
- 单步执行。类型码:1
- 执行 into 指令。类型码:4
- 执行 int n 指令。类型码:n
异常处理流程:
- 异常发生,控制单元产生一个对应的类型码
- CPU 根据这个类型码从中断向量表(idt, interrupt description table) 找到异常处理处理程序入口
- 保存当前程序现场,切换到对应异常处理程序
- 异常处理程序最后向进程发送一个信号 SIGXXX,记录在进程的 PCB 里
- 如果进程自定义了该信号的处理程序,跳去执行它;否则执行内核预定义的行为
异常都是不可屏蔽的,CPU 必须对此做出响应。
再来看中断。当 CPU 外部有需要处理的事情发生(如外设输入输出等),中断控制器会向 CPU 发出对应的中断类型码,引发中断过程。中断又可以分为两类:
- 可屏蔽中断:CPU 可以不响应这个中断。而具体响不响应要看标志寄存器的 IF 位。IF = 1 时 CPU 执行完当前指令后响应中断,IF = 0 则不响应。多数由 IO 引发的中断都是可屏蔽的。
- 不可屏蔽中断:CPU 必须响应的中断。CPU 执行完当前指令后立刻引发中断过程。它的类型码固定为 2。存储器校验出错,I/O 通道校验出错等都属于不可屏蔽中断。
中断处理流程:
- 设备产生中断,PIC(可编程中断控制器)产生一个对应的类型码
- CPU 根据这个类型码从中断向量表(idt, interrupt description table) 找到异常处理处理程序入口
- 保存当前程序现场,切换到对应异常处理程序
- 中断处理程序进行保存现场,做相关处理,恢复现场
- 内核调度,返回用户进程
现代操作系统把中断处理程序从概念上被分为上底(top half)
和下底(bottom half)
。在中断发生时上半部分的处理过程立即执行,但是下半部分(如果有的话)却推迟执行。内核把上半部分和下半部分作为独立的函数来处理,上半部分决定其相关的下半部分是否需要执行。必须立即执行的部分必须位于上半部分,而可以推迟的部分可能属于下半部分。两者通过软中断衔接。比如,网卡接收数据的过程中,首先网卡发送中断信号告诉 CPU 来取数据并用 DMA 技术把数据包写入内存,然后 CPU 调用网卡驱动先禁用网卡中断,再把原始数据包解析成协议栈对应的格式,最后送入各层依次解析。这些如果都让中断处理程序来处理显然过程太长,造成新来的中断阻塞。因此 Linux 将这种任务分为两个部分,一个叫上底,即中断处理程序,短平快地处理与硬件相关的操作(如禁用网卡中断);而把对时间要求相对宽松的任务(如解析数据的工作)放在另一个部分执行。
为什么要这样划分为两部分呢?
- 把中断的总延迟时间最小化。Linux 内核定义了两种类型的中断,快速的和慢速的,这两者之间的一个区别是慢速中断自身还可以被中断,而快速中断则不能。因此,当处理快速中断时,如果有其它中断到达;不管是快速中断还是慢速中断,它们都必须等待。为了尽可能快地处理这些其它的中断,内核就需要尽可能地将处理延迟到下半部分执行。
- 当内核执行上半部分时,正在服务的这个特殊IRQ将会被可编程中断控制器禁止,于是,连接在同一个 IRQ 上的其它设备就只有等到该该中断处理被处理完毕后果才能发出 IRQ 请求。而采用 Bottom_half 机制后,不需要立即处理的部分就可以放在下半部分处理,从而,加快了处理机对外部设备的中断请求的响应速度。
- 处理程序的下半部分还可以包含一些并非每次中断都必须处理的操作;对这些操作,内核可以在一系列设备中断之后集中处理一次就可以了。即在这种情况下,每次都执行并非必要的操作完全是一种浪费,而采用 Bottom_half 机制后,可以稍稍延迟并在后来只执行一次就行了。
二者的关系
异常和中断最主要的区别在于前者由指令触发,根据指令给出类型码,后者由 PIC 与 CPU 引脚之间的连线触发,从数据线上取得类型码。其次,异常都是不可屏蔽的,而中断分为可屏蔽与不可屏蔽两种。
现在通过数据包从 网卡 -> 内存 -> 网络模块 -> 协议栈 这个过程示范一下中断处理程序是怎么利用硬中断与异常完成工作的:
信号
我们在运行一个 shell 程序的时候,往往按下 Ctrl+C 就可以强制退出。按下组合键的过程就是操作系统给进程发送了一个信号:
- 松开按键后键盘会产生一个中断,如果 CPU 正在执行这个进程的代码则该进程的用户代码先暂停执行,用户从用户态切换到内核态去处理中断
- 键盘驱动程序检测到按下的是 ctrl+c,将这一对组合键翻译成一个 SIGINT 信号记在该进程的 PCB 中(也就是发送了一个 SIGINT 信号给该进程)
- 在从内核态回到用户态继续执行进程的用户代码之前,首先要处理 PCB 中的信号,这是发现有一个 SIGINT 要处理,而这个信号的在内核默认处理的方式是终止进程,所以直接终止进程,不再返回用户空间执行代码
在 bash 执行 kill -l
可以看到所有信号及其编号:
产生信号的方式:
- 按下某些组合键。如 Ctrl+C 产生 SIGINT,Ctrl+/ 产生 SIGQUIT ,Ctrl+Z 产生 SIGTSTP。
- 异常产生信号。由硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行指令 div 0,CPU 的运算单元会产生异常,内核将这个异常解释为 SIGFPE 信号发送给进程;再比如当前进程访问了非法内存地址,MMU 会产生异常,内核将这个异常解释为 SIGSEGV 信号发送给进程。
- 进程通过系统调用 kill 函数给另一个进程发送信号。
- 通过 kill 命令给某个进程发送信号(内部也是通过系统调用 kill 函数实现的)
- 某种特定的软件行为产生信号。如向读端已关闭的管道写数据时产生 SIGPIPE 信号,时钟函数 alarm 超时产生 SIGALRM 信号
举个例子,用 alarm 函数设定一个闹钟,告诉内核在 2 秒后给当前进程发送一个 SIGALRM 信号,该信号的默认处理动作是终止当前进程:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
int main() {
int count = 1;
alarm(2);
for (; ; count++) {
printf("count = %dn", count);
}
return 0;
}
可以看到,时钟超时前一直在对 count 进行计数,2s 后进程收到 SIGALRM 信号终止。
下图总结了信号的捕获过程。与中断不同,应用层面的开发者是可以控制程序收到信号后的行为的:
参考文章
Linux进程信号详解
信号和中断的比较+中断和异常的比较
《深入理解计算机系统》第 8 章:异常控制流