LV005-异常与中断简介
一、异常与中断
1. 什么是中断?
中断是指在 CPU 正常运行期间, 由外部或内部事件引起的一种机制。 当中断发生时, CPU 会停止当前正在执行的程序, 并转而执行触发该中断的中断处理程序。 处理完中断处理程序后, CPU 会返回到中断发生的地方, 继续执行被中断的程序。 中断机制允许 CPU 在实时响应外部或内部事件的同时, 保持对其他任务的处理能力。
举个例子,有这样的一个场景,我们正在看书,这就是我们主要的任务,现在发生了下边的事:
(1)厨房灶台上的水烧开了
(2)有电话到来
这些事情发生的时候,我们就不得不去处理,我们该怎么办?
(1)书中放上书签,合上书(保存现场,以免一会回来找不到自己看到哪里了)
(2)处理刚才的两件事,水烧开了就将煤气关掉,把开水装起来,电话到来,就接听电话,先做哪个?那就看哪个更紧急啦。处理这两件事就要“中断”前面看书的过程。
(3)回来继续看书(恢复现场)。
如下图:

2. 中断有什么用?
在上面的场景中, 作为唯一具有处理能力的主体, 我们一次只能专注于一个任务, 可以等待水烧开、 看书等等。 然而, 当我们专心致志地完成一项任务时, 常常会有紧迫或不紧迫的其他事情突然出现, 需要我们关注和处理。 有些情况甚至要求我们立即停下手头的工作来应对。只有在处理完这些中断事件之后, 我们才能回到先前的任务。
中断机制赋予了我们处理意外情况的能力, 而且如果我们能充分利用这个机制, 就能够同时完成多个任务。 回到烧水的例子, 无论我们是否在厨房, 煤气灶都会将水烧开。 我们只需要在水烧开后及时关掉煤气。 为了避免在厨房等待的时间, 而水烧开时产生的声音就是中断信号,提醒我们炉子上的水已经烧开。 这样, 我们就可以在等待的时间里做其他事情, 比如看书。当水壶烧开发出声音之后, 它会打断当前的任务, 提醒水已经烧开, 这时只需要前往厨房关掉煤气即可。
中断机制使我们能够有条不紊地同时处理多个任务, 从而提高了并发处理能力。 类似地,计算机系统中也使用中断机制来应对各种外部事件。 例如, 在键盘输入时, 会发送一个中断信号给 CPU, 以便及时响应用户的操作。 这样, CPU 就不必一直轮询键盘的状态, 而可以专注于其他任务。 中断机制还可以用于处理硬盘读写完成、 网络数据包接收等事件, 提高了系统的资源利用率和并发处理能力。
3. 什么是异常?
由 CPU 内部 产生的意外事件被称为异常,也叫内中断。异常 是 CPU 执行一条指令时,由 CPU 在其 内部 检测到的、与正在执行的指令 相关 的 同步事件;中断是一种典型的由外部设备触发的、与当前正在执行的指令无关的异步事件。
这里我们不深入了解异常,因为这里主要是学习中断。
4. 中断的处理流程
arm 对异常(中断)处理过程:
- (1)中断相关的初始化:(a) 设置中断源,让它可以产生中断。(b) 设置中断控制器(可以屏蔽某个中断,优先级)。(c) 设置 CPU 总开关(使能中断)。
- (2)执行其他程序:正常程序
- (3)产生中断:比如按下按键 → 中断控制器 →CPU
- (4)CPU 每执行完一条指令都会检查有无中断/异常产生
- (5)CPU 发现有中断/异常产生,开始处理。对于不同的异常,跳去不同的地址执行程序。这些地址上,只是一条跳转指令,跳去执行某个函数(地址),这个就是异常向量。(3)(4)(5)都是硬件做的。这些函数做什么事情?软件做的事情:(a) 保存现场(各种寄存器)(b) 处理异常(中断):分辨中断源,再调用不同的处理函数(c) 恢复现场。
二、Linux 系统对中断的处理
1. 进程、线程、中断的核心:栈
中断谁?中断当前正在运行的进程、线程。进程、线程是什么?内核如何切换进程、线程、中断?要理解这些概念,必须理解栈的作用。
1.1 ARM 处理器程序运行的过程
ARM 芯片属于精简指令集计算机(RISC: Reduced Instruction Set Computing),它所用的指令比较简单,有如下特点:① 对内存只有读、写指令;② 对于数据的运算是在 CPU 内部实现;③ 使用 RISC 指令的 CPU 复杂度小一点,易于设计。
比如对于 a = a+b 这样的算式,需要经过下面 4 个步骤才可以实现:

细看这几个步骤,有些疑问:
① 读 a,那么 a 的值读出来后保存在 CPU 里面哪里?
② 读 b,那么 b 的值读出来后保存在 CPU 里面哪里?
③ a+b 的结果又保存在哪里?

我们需要深入 ARM 处理器的内部。简单概括如下,我们先忽略各种 CPU 模式(系统模式、用户模式等等)。 CPU 运行时,先去取得指令,再执行指令:
① 把内存 a 的值读入 CPU 寄存器 R0
② 把内存 b 的值读入 CPU 寄存器 R1
③ 把 R0、 R1 累加,存入 R0
④ 把 R0 的值写入内存 a
1.2 程序被中断时,怎么保存现场
从上图可知, CPU 内部的寄存器很重要,如果要暂停一个程序,中断一个程序,就需要把这些寄存器的值保存下来:这就称为保存现场。保存在哪里?内存,这块内存就称之为栈。程序要继续执行,就先从栈中恢复那些 CPU 内部寄存器的值。这个场景并不局限于中断,下图可以概括程序 A、 B 的切换过程,其他情况是类似的:

- (1)函数调用:
在函数 A 里调用函数 B,实际就是中断函数 A 的执行。那么需要把函数 A 调用 B 之前瞬间的 CPU 寄存器的值,保存到栈里;再去执行函数 B;函数 B 返回之后,就从栈中恢复函数 A 对应的 CPU 寄存器值,继续执行。
- (2)中断处理
进程 A 正在执行,这时候发生了中断。CPU 强制跳到中断异常向量地址去执行,这时就需要保存进程 A 被中断瞬间的 CPU 寄存器值,可以保存在进程 A 的内核态栈,也可以保存在进程 A 的内核结构体中。中断处理完毕,要继续运行进程 A 之前,恢复这些值。
- (3)进程切换
在所谓的多任务操作系统中,我们以为多个程序是同时运行的。如果我们能感知微秒、纳秒级的事件,可以发现操作系统时让这些程序依次执行一小段时间,进程 A 的时间用完了,就切换到进程 B。怎么切换?切换过程是发生在内核态里的,跟中断的处理类似。进程 A 的被切换瞬间的 CPU 寄存器值保存在某个地方;恢复进程 B 之前保存的 CPU 寄存器值,这样就可以运行进程 B 了。所以,在中断处理的过程中,伴存着进程的保存现场、恢复现场。 进程的调度也是使用栈来保存、恢复现场:

1.3 进程、线程的概念
假设我们写一个音乐播放器,在播放音乐的同时会根据按键选择下一首歌。把事情简化为 2 件事:发送音频数据、读取按键。那可以这样写程序:
int main(int argc, char **argv)
{
int key;
while (1)
{
key = read_key();
if (key != -1)
{
switch (key)
{
case NEXT:
select_next_music(); // 在 GUI 选中下一首歌
break;
}
}
else
{
send_music();
}
}
return 0;
}这个程序只有一条主线,读按键、播放音乐都是顺序执行。无论按键是否被按下, read_key 函数必须马上返回,否则会使得后续的 send_music 受到阻滞导致音乐播放不流畅。
读取按键、播放音乐能否分为两个程序进行?可以,但是开销太大:读按键的程序,要把按键通知播放音乐的程序,进程间通信的效率没那么高。
这时可以用多线程之编程,读取按键是一个线程,播放音乐是另一个线程,它们之间可以通过全局变量传递数据,示意代码如下:
int g_key;
void key_thread_fn()
{
while (1)
{
g_key = read_key();
if (g_key != -1)
{
switch (g_key)
{
case NEXT:
select_next_music(); // 在 GUI 选中下一首歌
break;
}
}
}
}
void music_fn()
{
while (1)
{
if (g_key == STOP)
stop_music();
else
{
send_music();
}
}
}
int main(int argc, char **argv)
{
int key;
create_thread(key_thread_fn);
create_thread(music_fn);
while (1)
{
sleep(10);
}
return 0;
}这样,按键的读取及 GUI 显示、音乐的播放,可以分开来,不必混杂在一起。按键线程可以使用阻塞方式读取按键,无按键时是休眠的,这可以节省 CPU 资源。
音乐线程专注于音乐的播放和控制,不用理会按键的具体读取工作。并且这 2 个线程通过全局变量 g_key 传递数据,高效而简单。
在 Linux 中:资源分配的单位是进程,调度的单位是线程。 也就是说,在一个进程里,可能有多个线程,这些线程共用打开的文件句柄、全局变量等等。而这些线程,之间是互相独立的,“同时运行”,也就是说:每一个线程,都有自己的栈。如下图示:

2. Linux 系统对中断处理的演进
Linux 中断系统的变化并不大。比较重要的就是引入了 threaded irq:使用内核线程来处理中断。Linux 系统中有硬件中断,也有软件中断。中断的执行需要快速响应, 但并不是所有中断都能迅速完成。
此外, Linux 中的中断不支持嵌套, 意味着在正式处理中断之前会屏蔽其他中断, 直到中断处理完成后再重新允许接收中断, 如果中断处理时间过长, 将会引发问题。所以对中断的处理有 2 个原则:不能嵌套,越快越好。
2.1 Linux 对中断的扩展:硬件中断、软件中断
2.1.1 硬件中断
Linux 系统把中断的意义扩展了,对于按键中断等硬件产生的中断,称之为“硬件中断” (hard irq)。每个硬件中断都有对应的处理函数,比如按键中断、网卡中断的处理函数肯定不一样。
为方便理解,我们可以先认为对硬件中断的处理是用数组来实现的,数组里存放的是函数指针:

注意:上图是简化的, Linux 中这个数组复杂多了。当发生 A 中断时,对应的 irq_function_A 函数被调用。硬件导致该函数被调用。
2.1.2 软件中断
相对的,还可以人为地制造中断:软件中断(soft irq),如下图所示:

注意:上图是简化的, Linux 中这个数组复杂多了。
问题来了:
(1)软件中断何时生产? 由软件决定,对于 X 号软件中断,只需要把它的 flag 设置为 1 就表示发生了该中断。
(2)软件中断何时处理? 软件中断嘛,并不是那么十万火急,有空再处理它好了。什么时候有空?不能让它一直等吧?Linux 系统中,各种硬件中断频繁发生,至少定时器中断每 10ms 发生一次,那取个巧?在处理完硬件中断后,再去处理软件中断?就这么办!
(3)有哪些软件中断?可以看这个文件 interrupt.h - include/linux/interrupt.h
enum
{
HI_SOFTIRQ=0, /* 高优先级软中断 */
TIMER_SOFTIRQ, /* 定时器软中断 */
NET_TX_SOFTIRQ, /* 网络数据发送软中断 */
NET_RX_SOFTIRQ, /* 网络数据接收软中断 */
BLOCK_SOFTIRQ,
IRQ_POLL_SOFTIRQ,
TASKLET_SOFTIRQ, /* tasklet 软中断 */
SCHED_SOFTIRQ, /* 调度软中断 */
HRTIMER_SOFTIRQ, /* 高精度定时器软中断 Unused, but kept as tools rely on the numbering. Sigh! */
RCU_SOFTIRQ, /* RCU 软中断 Preferable RCU should always be the last softirq */
NR_SOFTIRQS
};这些中断定义在一个数组中:softirq_vec
static struct softirq_action softirq_vec[NR_SOFTIRQS] __cacheline_aligned_in_smp;可以看出,一共有 10 个软中断,因此 NR_SOFTIRQS 为 10,因此数组 softirq_vec 有 10 个元素。 softirq_action 结构体中的 action 成员变量就是软中断的服务函数,数组 softirq_vec 是个全局数组,因此所有的 CPU(对于 SMP 系统而言)都可以访问到,每个 CPU 都有自己的触发和控制机制,并且只执行自己所触发的软中断。但是各个 CPU 所执行的软中断服务函数确是相同的,都是数组 softirq_vec 中定义的 action 函数。
(4)怎么触发软件中断?最核心的函数是 raise_softirq(),简单地理解就是设置 softirq_veq [nr] 的标记位:
void raise_softirq(unsigned int nr);其中 nr 表示要触发的软中断。
(5)怎么设置软件中断的处理函数?可以用 open_softirq() 来设置。
void open_softirq(int nr, void (*action)(struct softirq_action *));nr 表示要开启的软中断,action 表示软中断对应的处理函数。该函数没有返回值。
后面会学习到中断下半部 tasklet ,它就是使用软件中断实现的。
2.2 中断处理原则 1:不能嵌套
kernel 内核官网资料:genirq: Run irq handlers with interrupts disabled - kernel/git/torvalds/linux.git - Linux kernel source tree
中断处理函数需要调用 C 函数,这就需要用到栈。中断 A 正在处理的过程中,假设又发生了中断 B,那么在栈里要保存 A 的现场,然后处理 B。在处理 B 的过程中又发生了中断 C,那么在栈里要保存 B 的现场,然后处理 C。
如果中断嵌套突然暴发,那么栈将越来越大,栈终将耗尽。所以,为了防止这种情况发生,也是为了简单化中断的处理,在 Linux 系统上中断无法嵌套:即当前中断 A 没处理完之前,不会响应另一个中断 B(即使它的优先级更高)。
2.3 中断处理原则 2:越快越好
还是之前的例子,我们看书的时候,手机响起, 发出紧急电话的铃声, 打破了我们看书的过程,接电话的时间很短并不会对看书产生很大的影响, 而接电话的时候水烧开了的话可能就有问题了, 接电话时间过长的话,水可能会烧干。
同理,在 Linux 系统中,中断的处理也是越快越好。在单芯片系统中,假设中断处理很慢,那应用程序在这段时间内就无法执行:系统显得很迟顿。在 SMP 系统中,假设中断处理很慢,那么正在处理这个中断的 CPU 上的其他线程也无法执行。
在中断的处理过程中,该 CPU 是不能进行进程调度的,所以中断的处理要越快越好,尽早让其他中断能被处理 ── 进程调度靠定时器中断来实现。 在 Linux 系统中使用中断很简单,为某个中断 irq 注册中断处理函数 handler,可以使用 request_irq() 函数:
static inline int __must_check
request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,
const char *name, void *dev)
{
return request_threaded_irq(irq, handler, NULL, flags, name, dev);
}在 handler 函数中,代码尽可能高效。但是,处理某个中断要做的事情就是很多,没办法加快。比如对于按键中断,我们需要等待几十毫秒消除机械抖动。难道要在 handler 中等待吗?对于计算机来说,这可是一个段很长的时间。怎么办?
2.3.1 拆分为:上半部、下半部
当一个中断要耗费很多时间来处理时,它的坏处是:在这段时间内,其他中断无法被处理。换句话说,在这段时间内,系统是关中断的。如果某个中断就是要做那么多事,我们能不能把它拆分成两部分:紧急的、不紧急的?
在 handler 函数里只做紧急的事,然后就重新开中断,让系统得以正常运行;那些不紧急的事,以后再处理,处理时是开中断的。

所以为了让系统可以更好地处理中断事件, 提高实时性和响应能力, 可以将中断服务程序划分为上半部和下半部两部分,上半部 (就是中断服务程序)内核立即执行,而下半部(就是一些内核函数)留着稍后处理:
中断上半部是中断服务程序的第一部分, 它主要处理一些紧急且需要快速响应的任务。 中断上半部的特点是执行时间较短, 旨在尽快完成对中断的处理。 这些任务可能包括保存寄存器状态、更新计数器等, 以便在中断处理完成后能够正确地返回到中断前的执行位置。
中断下半部是中断服务程序的第二部分, 它主要处理一些相对耗时的任务。 由于中断上半部需要尽快完成, 因此中断下半部负责处理那些不能立即完成的、 需要更多时间的任务。 这些任务可能包括复杂的计算、 访问外部设备或进行长时间的数据处理等。
至于哪些代码属于上半部,哪些代码属于下半部并没有明确的规定,一切根据实际使用情况去判断。这里有一些可以借鉴的参考点:
①、如果要处理的内容不希望被其他中断打断,那么可以放到上半部。
②、如果要处理的任务对时间敏感,可以放到上半部。
③、如果要处理的任务与硬件有关,可以放到上半部
④、除了上述三点以外的其他任务,优先考虑放到下半部。
2.3.2 下半部的实现机制有哪些?
一个快速的“上半部”来处理硬件发出的请求,它必须在一个新的中断产生之前终止。通常,除了在设备和一些内存缓冲区(如果设备用到了 DMA,就不止这些)之间移动或传送数据,确定硬件是否处于健全的状态之外,这一部分做的工作很少。“下半部”运行时是允许中断请求的,而上半部运行时是关中断的,这是二者之间的主要区别。
内核到底什么时候执行下半部,以何种方式组织下半部?
以前的内核中,下半部的机制叫做 bottom-half(以下简称 BH)。但是,Linux 的这种 bottom-half 机制有两个缺点:
(1)在任意一时刻,系统只能有一个 CPU 可以执行 BH 代码,以防止两个或多个 CPU 同时来执行 BH 函数而相互干扰。因此 BH 代码的执行是严格“串行化”的。
(2)BH 函数不允许嵌套。
这两个缺点在单 CPU 系统中是无关紧要的,但在 SMP 系统中却是非常致命的。因为 BH 机制的严格串行化执行显然没有充分利用 SMP 系统的多 CPU 特点。为此,在 2.4 以后的版本中有了新的发展和改进,改进的目标使下半部可以在多处理机上并行执行,并有助于驱动程序的开发者进行驱动程序的开发。目前的内核中,下半部处理机制主要是以下三种:
| Item | 软中断请求(softirq)机制 | 小任务(tasklet)机制 | Workqueue 工作队列 |
| 运行 Context | 软中断 | 软中断(HI_SOFTIRQ 和 TASKLET_SOFTIRQ) | 进程(kernel 态) |
| 可以 Sleep? | 否 | 否 | 否 |
| 关中断? | 否 | 否 | 否 |
| 可重新调度? | 否 | 否 | 是 |
| 可带参数? | 否 | 是 | 否 |
| 谁触发谁执行 | 是 | 是 | 默认是 |
| 可同时被多个 CPU 执行? | 同一个 softirq_action 可同时被多 CPU 执行 | 同一个 tasklet 在任意时刻只能被一个 CPU 执行 | 由进程调度决定 |
| 可延时执行? | 否 | 否 | 是 |
| 数据结构 | softirq_action(中断服务) irq_cpustat_t(触发状态) | tasklet_struct tasklet_head | work_struct workqueue_struct |
| 初始化 | open_softirq | DECLARE_TASKLET DECLARE_TASKLET_DISABLED tasklet_init | DECLARE_WORK INIT_WORK DECLARE_DELAYED_WORK INIT_DELAYED_WORK |
| 改变运行状态 | tasklet_trylock tasklet_unlock tasklet_unlock_wait | ||
| 使能/静止 | tasklet_disable tasklet_enable | ||
| 触发 | raise_softirq raise_softirq_irqoff | tasklet_schedule tasklet_hi_schedule | schedule_work queue_work schedule_delayed_work queue_delayed_work |
| 执行 | do_softirq | tasklet_action tasklet_hi_action | rescuer_thread 被 cpu 调度执行 |
| 创建线程 | alloc_work_queue create_singlethread_workqueue | ||
| 结束 | tasklet_kill | destroy_worker destroy_workqueue |
具体这三种机制,后面再详细学习。
3. 总结
中断的处理有两个原则:
- 不能嵌套
- 越快越好
在处理当前中断时,即使发生了其他中断,其他中断也不会得到处理,所以中断的处理要越快越好。但是某些中断要做的事情稍微耗时,这时可以把中断拆分为上半部、下半部。
在上半部处理紧急的事情,在上半部的处理过程中,中断是被禁止的;在下半部处理耗时的事情,在下半部的处理过程中,中断是使能的。
参考资料:
嵌入式 Linux 驱动笔记(二十七)------中断子系统框架分析_irq: type mismatch-CSDN 博客