Skip to content

LV005-异常与中断简介

一、异常与中断

1. 什么是中断?

中断是指在 CPU 正常运行期间, 由外部或内部事件引起的一种机制。 当中断发生时, CPU 会停止当前正在执行的程序, 并转而执行触发该中断的中断处理程序。 处理完中断处理程序后, CPU 会返回到中断发生的地方, 继续执行被中断的程序。 中断机制允许 CPU 在实时响应外部或内部事件的同时, 保持对其他任务的处理能力。

举个例子,有这样的一个场景,我们正在看书,这就是我们主要的任务,现在发生了下边的事:

(1)厨房灶台上的水烧开了

(2)有电话到来

这些事情发生的时候,我们就不得不去处理,我们该怎么办?

(1)书中放上书签,合上书(保存现场,以免一会回来找不到自己看到哪里了)

(2)处理刚才的两件事,水烧开了就将煤气关掉,把开水装起来,电话到来,就接听电话,先做哪个?那就看哪个更紧急啦。处理这两件事就要“中断”前面看书的过程。

(3)回来继续看书(恢复现场)。

如下图:

image-20250214143155615

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 个步骤才可以实现:

image-20250214145510571

细看这几个步骤,有些疑问:

① 读 a,那么 a 的值读出来后保存在 CPU 里面哪里?

② 读 b,那么 b 的值读出来后保存在 CPU 里面哪里?

③ a+b 的结果又保存在哪里?

image-20250214145539057

我们需要深入 ARM 处理器的内部。简单概括如下,我们先忽略各种 CPU 模式(系统模式、用户模式等等)。 CPU 运行时,先去取得指令,再执行指令:

① 把内存 a 的值读入 CPU 寄存器 R0

② 把内存 b 的值读入 CPU 寄存器 R1

③ 把 R0、 R1 累加,存入 R0

④ 把 R0 的值写入内存 a

1.2 程序被中断时,怎么保存现场

从上图可知, CPU 内部的寄存器很重要,如果要暂停一个程序,中断一个程序,就需要把这些寄存器的值保存下来:这就称为保存现场。保存在哪里?内存,这块内存就称之为栈。程序要继续执行,就先从栈中恢复那些 CPU 内部寄存器的值。这个场景并不局限于中断,下图可以概括程序 A、 B 的切换过程,其他情况是类似的:

image-20250214145810987
  • (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 了。所以,在中断处理的过程中,伴存着进程的保存现场、恢复现场。 进程的调度也是使用栈来保存、恢复现场:

image-20250214150026383

1.3 进程、线程的概念

假设我们写一个音乐播放器,在播放音乐的同时会根据按键选择下一首歌。把事情简化为 2 件事:发送音频数据、读取按键。那可以这样写程序:

c
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 受到阻滞导致音乐播放不流畅。

读取按键、播放音乐能否分为两个程序进行?可以,但是开销太大:读按键的程序,要把按键通知播放音乐的程序,进程间通信的效率没那么高。

这时可以用多线程之编程,读取按键是一个线程,播放音乐是另一个线程,它们之间可以通过全局变量传递数据,示意代码如下:

c
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 中:资源分配的单位是进程,调度的单位是线程。 也就是说,在一个进程里,可能有多个线程,这些线程共用打开的文件句柄、全局变量等等。而这些线程,之间是互相独立的,“同时运行”,也就是说:每一个线程,都有自己的栈。如下图示:

image-20250214150819673

2. Linux 系统对中断处理的演进

Linux 中断系统的变化并不大。比较重要的就是引入了 threaded irq:使用内核线程来处理中断。Linux 系统中有硬件中断,也有软件中断。中断的执行需要快速响应, 但并不是所有中断都能迅速完成。

此外, Linux 中的中断不支持嵌套, 意味着在正式处理中断之前会屏蔽其他中断, 直到中断处理完成后再重新允许接收中断, 如果中断处理时间过长, 将会引发问题。所以对中断的处理有 2 个原则:不能嵌套,越快越好。

2.1 Linux 对中断的扩展:硬件中断、软件中断

2.1.1 硬件中断

Linux 系统把中断的意义扩展了,对于按键中断等硬件产生的中断,称之为“硬件中断” (hard irq)。每个硬件中断都有对应的处理函数,比如按键中断、网卡中断的处理函数肯定不一样。

为方便理解,我们可以先认为对硬件中断的处理是用数组来实现的,数组里存放的是函数指针:

image-20250214151722926

注意:上图是简化的, Linux 中这个数组复杂多了。当发生 A 中断时,对应的 irq_function_A 函数被调用。硬件导致该函数被调用。

2.1.2 软件中断

相对的,还可以人为地制造中断:软件中断(soft irq),如下图所示:

image-20250214151807711

注意:上图是简化的, Linux 中这个数组复杂多了。

问题来了:

(1)软件中断何时生产? 由软件决定,对于 X 号软件中断,只需要把它的 flag 设置为 1 就表示发生了该中断。

(2)软件中断何时处理? 软件中断嘛,并不是那么十万火急,有空再处理它好了。什么时候有空?不能让它一直等吧?Linux 系统中,各种硬件中断频繁发生,至少定时器中断每 10ms 发生一次,那取个巧?在处理完硬件中断后,再去处理软件中断?就这么办!

(3)有哪些软件中断?可以看这个文件 interrupt.h - include/linux/interrupt.h

c
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

c
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] 的标记位:

c
void raise_softirq(unsigned int nr);

其中 nr 表示要触发的软中断。

(5)怎么设置软件中断的处理函数?可以用 open_softirq() 来设置。

c
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() 函数:

c
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 函数里只做紧急的事,然后就重新开中断,让系统得以正常运行;那些不紧急的事,以后再处理,处理时是开中断的。

image-20250214152941268

所以为了让系统可以更好地处理中断事件, 提高实时性和响应能力, 可以将中断服务程序划分为上半部和下半部两部分,上半部 (就是中断服务程序)内核立即执行,而下半部(就是一些内核函数)留着稍后处理:

中断上半部是中断服务程序的第一部分, 它主要处理一些紧急且需要快速响应的任务。 中断上半部的特点是执行时间较短, 旨在尽快完成对中断的处理。 这些任务可能包括保存寄存器状态、更新计数器等, 以便在中断处理完成后能够正确地返回到中断前的执行位置。

中断下半部是中断服务程序的第二部分, 它主要处理一些相对耗时的任务。 由于中断上半部需要尽快完成, 因此中断下半部负责处理那些不能立即完成的、 需要更多时间的任务。 这些任务可能包括复杂的计算、 访问外部设备或进行长时间的数据处理等。

至于哪些代码属于上半部,哪些代码属于下半部并没有明确的规定,一切根据实际使用情况去判断。这里有一些可以借鉴的参考点:

①、如果要处理的内容不希望被其他中断打断,那么可以放到上半部。

②、如果要处理的任务对时间敏感,可以放到上半部。

③、如果要处理的任务与硬件有关,可以放到上半部

④、除了上述三点以外的其他任务,优先考虑放到下半部。

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 博客

中断处理下半部机制-CSDN 博客