引言
本文整理了 Linux 内核中断的相关知识,其他 Linux 相关文章均收录于贝贝猫的文章目录。
中断
大家应该很清楚,系统在执行时可以处于两种可能的状态:核心态和用户态。之前我们讨论过的系统调用,就能使进程从用户态切换到核心态去执行某些任务,当执行成功后再回到用户进程中。大家可能还记得这是通过软中断来实现的,那么中断到底是什么呢? 接下来我将介绍与中断相关的一些知识。
硬中断
通常中断可以分为如下两个类别:
- 同步中断和异常。这些由 CPU 自发地针对当前执行的程序产生的。异常可能因种种原因触发: 由于运行时发生的程序设计错误(除0),或由于出现了异常的情况或条件,致使处理区需要外部帮助才能处理。前一种情况下,内核必须通知应用程序出现了异常,比如使用信号机制,这样应用程序才有机会输出一些适当的错误信息。但是,异常也不见得一定是程序内部导致的,比如缺页异常,这时候就需要内核出面解决。
- 异步中断。多数由设备产生,可能发生在任何时间。不同与同步中断,异步中断并不与特定的进程关联。比如网卡通过发送中断通知内核有新的数据到来,因为数据可能随时到来,所以当前 CPU 上执行的可能是和该网络数据无关的进程。为了避免损害该进程的执行时间,内核必须快速的完成数据的处理工作,使得 CPU 时间能够返还给当前进程。但是处理网络数据并不简单,要花费许多的时间,所以内核采取的办法是将中断分为两半,前半段尽可能地快速完成(将网络数据缓存在内存中,缓存好了之后就将 CPU 返还给进程),后半段在不是那么繁忙的时候再处理。
无论是上述的哪种中断,都会涉及到中断处理过程中的一个关键流程,那就是:如果在发生中断时,当前 CPU 没有处于核心态,则发起从用户态到核心态的转换。接下来,在内核中执行一个专门的中断处理程序。
另外,我们知道中断是可以被禁用的,比如在一些中断处理程序中,可能通过禁用中断的方式来达到数据临界区的效果,但是,禁用中断的时间过长的话,注定会影响到系统的性能,还有可能漏掉其他重要的中断,所以中断处理程序被划分为两个部分,关键性的任务会在前半段(禁用中断时)处理,而不那么重要的工作会在后半段异步延期处理。这里讲这么多就是为了让大家明白中断可能会被分阶段处理,如果一个中断对应的工作很快就能完成,那么一般都会以同步的方式处理,而如果一个中断的处理过程可能花费很长时间的话,可能就会分成两段,前一段已同步的方式处理关键性的任务,后一段以异步的形式处理次要任务。
那么系统是怎么将中断与对应的处理程序挂钩的呢?一个简单的想法是:每个中断都有一个编号,比如分配给一个网卡的中断号是 m,分配给 SCSI 控制器的中断号是 n,那么内核即可区分两个设备,并在中断发生时对应地执行特定于设备的操作。同样的方案也可适应于异常,不同的异常指派了不同的编号。但遗憾的是,由于系统架构的原因,情况并不总是像描述的那样简单。在一些系统架构中,可用的中断编号少得可怜,所以必须由几个设备共享一个编号,这个过程被称为中断共享。在 IA-32 处理器上,硬件中断的最大数目通常是15,这个值可不怎么大,此外,还要考虑到有些中断编号已经永久性地分配给了标准的系统组件(键盘、定时器,等),这就限制了其他外设的中断编号数。
实际上,外设并不会直接产生中断,它们会有电路连接到中断控制器,在需要发送中断时,外设会向中断控制器放中断请求(IRQ),随后中断控制器将中断请求(IRQ)转化为对应的中断号,最终传输到 CPU 的中断输入中。当 CPU 得知发生中断后,它将进一步的处理委托给一个中断处理程序,该程序可能会修复故障、提供专门的处理或者将事件通知用户进程等。由于每个中断和异常都有一个唯一的编号,内核使用了一个数组来维护中断号到对应处理程序的映射关系,就如下图所示。
这里大家肯定会有疑问,中断号是怎么共享的呢?实际上每个中断号对应一个中断处理程序只是一个笼统的说法,当多个设备共用同一个中断号时,显然一个中断号会对应一组处理程序。实际上,在我们安装设备的驱动时,就会将该设备对应的中断处理程序注册到对应的中断号上,换句话说,内核会为每个中断号,维护一个处理程序链表(下图 action),链表上的每个节点都对应了一个使用该中断号的设备。
对于每个设备的 irqaction 除了会记录其对应的处理程序地址外,还会记录一个简要的设备名和设备 id,设备名主要用于显示给人看/proc/irq/{Num}/{Name}
,而设备 id 用来描述某一指定的设备,这样在卸载设备驱动时,只要指出该设备的中断号以及该设备的 id 就能将其中断处理程序从上述链表中删除。
struct irqaction {
irq_handler_t handler; // 处理程序地址
unsigned long flags;
const char *name; // 设备名
void *dev_id; // 设备 id
struct irqaction *next; // 使用该中断号的下一个设备
}
现在我们已经知道了如何动态的注册和删除中断处理程序,接下来我们还要解释一下当中断发生时,内核如何定位应该让哪个中断处理程序处理。因为 CPU 在中断发生时,只能拿到中断号这一个参数,所以内核的处理方式非常粗暴:
- 它会先检查当前是否该中断是否被屏蔽
- 逐一调用注册在该中断号上的所有处理函数
- 每个中断处理程序需要自己确定该中断是不是自己的设备发出的,一般有两个方案:
- 比较近代的设备上都会有一个设备寄存器,记录着该设备刚才有没有发出中断信号,如果寄存器为 1 则说明是自己发出的,那么就将寄存器置为 0 然后开始处理
- 如果设备没有寄存器,则会检查是否有设备数据可用,有的话处理数据,没有的话,则返回
- 每个中断处理程序如果正确的处理的 IRQ 则返回 IRQ_HANDLED,否则如果不是有自己负责的话返回 IRQ_NONE
- 内核会逐一调用每个处理函数,无论是否有处理程序已经正确的处理
那么,当内核开始处理中断时都需要做什么呢?下图就简要的描述了整个中断处理的过程。
进入路径的一个关键任务是,从用户态栈切换到核心态栈。但是,只有这点还不够。因为内核还要使用 CPU 资源执行其代码,进入路径必须保存用户应用程序当前的寄存器状态,以使在中断活动结束后恢复。这与调度期间用于上下文切换的机制是相同的。在进入核心态时,只保存部分寄存器的内容,因为内核并不会使用全部寄存器。举例来说,内核代码中不使用浮点操作(只有整数计算),因而并不保存浮点寄存器。随后内核跳转到与中断号对应的中断处理程序中执行特定的任务,在退出时,内核会检查如下事项:
- 调度器是否应该选择一个新进程代替旧的进程。
- 是否有信号必须投递到原进程。
从中断返回之后,只有确认了这两个问题,内核才能完成其常规任务,即还原寄存器集合、切换到用户态栈、切换到适当的处理器状态(如果原来是用户态,就切换回用户态)。
实现中断处理程序时,也会遇到很多问题,比如在处理中断期间,发生了其他中断,尽管可以通过禁用中断来防止这个问题,但是这又会引入别的问题,比如遗漏重要中断。所以禁用中断这个功能必须只能短时间使用。总结一下中断处理程序要满足如下几个要求:
- 实现(特别是要禁用其他中断时)要尽可能简单,以支持快速处理
- 中断处理程序也允许被其他中断打断,彼此还要互不干扰
尽管后一个问题我们可以通过精妙的设计方案来解决,但是前一个就很困难了。因为中断处理程序的每个部分并不是同等重要,所以每个中断处理程序都可以划分为 3 个部分,它们具有不同的意义:
- 关键操作必须在操作发生后立即执行。否则,无法维持系统的稳定,在执行此类操作时,必须禁止其他中断。
- 非关键操作也应该尽快执行,但是允许其他中断抢占
- 可延期处理的操作不是那么重要,不必在中断处理程序中实现。内核可以延迟处理这些工作,在时间充裕的时候进行。比如内核提供了 tasklet 机制,用于稍后执行可延期的操作。
在实现处理程序例程时,必须要注意些要点。这些会极大地影响系统的性能和稳定性。中断处理程序在所谓的中断上下文(interrupt context)中执行。内核代码有时在常规上下文运行,有时在中断上下文运行。为区分这两种不同情况并据此设计代码,内核提供了 in_interrupt 函数,用于指明当前是否在处理中断。中断上下文与普通上下文的不同之处主要有如下 3 点。
- 中断是异步执行的。换句话说,它们可以在任何时间发生。因而从用户空间来看,处理程序并不是在一个明确定义的环境中执行。所以在这种环境下,禁止访问用户空间,特别是与用户空间地址之间来回复制内存数据的行为。例如,对网络驱动程序来说,不能将接收的数据直接转发到等待的应用程序。毕竟,内核无法确定等待数据的应用程序此时是否在运行(事实上,这种可能性很低)
- 中断上下文中不能调用调度器。因为中断上下文具有最高执行优先级,这是内核无法调度别的进程来执行。它只能以主动返回的方式结束自己的处理过程。
- 处理程序例程不能进入睡眠状态。因为进程睡眠后,中断处理程序只能永远等待下去。因为中断处理程序没有调度实体,所以不可能被再次调度。当然,只确保处理程序的直接代码不进入睡眠状态是不够的。其中调用的所有其他函数都不能进入睡眠状态。对此进行的检查并不简单,必须非常谨慎。
至此,中断的主要知识就已经串完了,我们接下来还要介绍一下软中断,它是从软件层面触发中断的途径。介绍完软中断,我们才会开始介绍中断后半段的处理方式,比如 tasklet。
软中断
软中断的意义是使内核可以延期执行任务,因为它的运作方式和上述的中断类似,但完全是从软件实现的,所以称为软中断。内核借助软中断来获知异常情况的发生,而该情况将在稍后有专门的处理程序解决。
软中断是相对稀缺的资源,因为各个软中断都有一个唯一的编号,所以使用其必须谨慎,不能由各种设备驱动程序和内核组件随意使用,默认情况下,系统上只能使用 32 个软中断,但这个没什么,因为基于软中断内核还衍生出了许多其他其他延期执行机制,比如 tasklet、工作队列和内核定时器。我们稍后会介绍它们。
只有中枢的内核代码才使用软中断,软中断只用于少数场景,如下就是其中相对重要的场景。其中两个用来实现 tasklet (HI_SOFTIRQ,TASKLET_SOFTIRQ),两个用于网络的发送和接受(NET_TX_SOFTIRQ,NET_RX_SOFTIRQ,这两个是构建软中断机制的最主要原因),一个用于块层,实现异步请求完成(BLOCK_SOFTIRQ),一个用于调度器(SCHED_SOFTIRQ),以实现 SMP 系统上周期性的负载均衡。在启用高分辨率定时器时,还需要一个软中断(HRTIMER_SOFTIRQ)。
enum
{
HI_SOFTIRQ=0,
TIMER_SOFTIRQ,
NET_TX_SOFTIRQ,
NET_RX_SOFTIRQ,
BLOCK_SOFTIRQ,
TASKLET_SOFTIRQ,
SCHED_SOFTIRQ,
#ifdef CONFIG_HIGH_RES_TIMERS
HRTIMER_SOFTIRQ,
#endif
};
软中断的编号形成了个优先顺序,虽然这并不影响各个处理程序例程执行的频率或它们相当于其他系统活动的优先级,但影响了多个软中断同时处理时执行的次序。
我们可以通过 raise_softirq(int nr) 发起一个软中断(类似普通中断),软中断的编号通过参数指定。每个 CPU 都有一个位图 irg_stat,其中每一位代表了一个中断号,raise_softirq 会函数设置各 CPU 变量 irg_stat 对应的比特位。该函数会将对应的软中断标记为 1,但是该中断的处理程序并不会立即运行。通过使用特定于处理器的位图,内核才能确保几个软中断(甚至是相同的)可以同时在不同的 CPU 上执行。
那么软中断在什么时候执行呢?
- 当前面的硬件中断处理程序执行结束后,会检查当前 CPU 是否有待决的软中断,有的话则会按照次序处理所有的待决软中断,每处理一个软中断之前,就会将其对应的比特位置零,处理完所有软中断的过程,我们称之为一轮循环
- 一轮循环处理结束后,内核还会再检查是否有新的软中断到来(通过位图),如果有的话,一并处理了,这就会出现第二轮循环,第三轮循环
- 但是软中断不会无休止的重复下去,当处理的轮数超过 MAX_SOFTIRQ_RESTART(通常是 10) 时,就会唤醒软中断守护线程(每个 CPU 都有一个),然后退出
- 软中断守护线程负责在软中断过多时,以一个调度实体的形式(和其他进程一样可以被调度),帮着处理软中断请求,在这个守护线程中会重复的检测是否有待决的软中断请求
- 如果没有软中断请求了,则会进入睡眠状态,等待下次被唤醒
- 如果有请求,则会调用对应的软中断处理程序
这里大家可能有一个疑问,我们在前面介绍系统调用时也说了它是通过中断实现的,那么在前面的软中断列表中怎么没有对应的软中断呢?实际上,系统调用使用到的中断属于 "软件触发的硬中断" 而不是这里所说的软中断,因为系统调用过程是要同步处理的,不能使用异步的软中断方式实现。在我的 linux 中执行 cat /proc/interrupts
会打印所有注册的硬中断,仔细观察之后,你会发现其中包含一个名为 ‘CAL‘ 的中断,它就是系统调用所对应的中断号。这是通过执行机器指令触发的,所以我才说它是软件触发的硬中断。
cat /proc/interrupts
CPU0
0: 181 IO-APIC-edge timer
...
CAL: 0 Function call interrupts
...
中断后半段
虽然软中断是将操作推迟到未来时刻执行的最有效方法,但软中断的中断号有限,而且该延期机制处理起来非常复杂。因为多个处理器可以同时且独立地处理软中断,所以同一个软中断的处理程序例程可以在几个 CPU 上同时运行,这就要求软中断处理程序的设计必须是可重入并且线程安全的,临界区必须用自旋锁保护。此外,在软中断还不能进入睡眠,因为软中断的其中一部分是在硬中断处理结束之后进行的,这时候软中断执行函数没有调度实体,所以不能进入睡眠。
既然软中断这么多限制,那开发设备驱动程序(以及其他一般的内核代码)的同学岂不是很痛苦,实际上内核基于软中断建立了很多上层异步处理机制。
tasklet
tasklet 是一种延期执行工作的机制,其实现基于软中断,但它们更易于使用,因而更适合于设备驱动程序(以及其他一般性的内核代码)。
在内核中每个 tasklet 都有与之对应的一个对象表示,内核以链表的形式管理所有的 tasklet(next 字段),而且每个 tasklet 都有两个状态,这两个状态通过 state 字段的不同位表示,其中一个代表该 tasklet 是否注册到内核,成为一个调度实体(TASKLET_STATE_SCHED),另一个代表该 tasklet 是否正在运行(TASKLET_STATE_RUN)。通过 TASKLET_STATE_RUN 我们可以使一个 tasklet 只在一个 CPU 上执行。此外 count 字段大于 0 表示该 tasklet 被忽略。
struct tasklet_struct
{
struct tasklet_struct *next;
unsigned long state;
atomic_t count;
void (*func)(unsigned long);
unsigned long data;
};
当我们注册 tasklet 时,如果发现 TASKLET_STATE_SCHED 已经被置为 1,则说明该 tasklet 已经注册了,就不会重复注册。那么 tasklet 在什么时候执行呢? tasklet 的执行被关联到 TASKLET_SOFTIRQ 软中断。因而,在调用 raise_softirq(TASKLET_SOFTIRQ) 时,tasklet 就会在合适的时机执行。执行过程是这样的:
- 检查 tasklet 的 TASKLET_STATE_RUN 是否被置为 1,是的话则说明其他 CPU 正在执行它,那么当前 CPU 就跳过它
- 检查其是否被禁用(count 是否大于零)
- 将 TASKLET_STATE_RUN 置为 1
- 调用 tasklet 的 func
因为 tasklet 本质上也是在软中断的处理程序中进行的,所以它并不能睡眠或者阻塞,但是它能保证同一时刻某个 tasklet 只会在一个 CPU 上执行,这就有天生的线程安全保障。
除了普通的 tasklet 之外,内核还提供了另一种 tasklet,它具有更高的优先级,除此之外,它们两个完全相同。高优先级的 tasklet 通过 HI_SOFTIRQ 软中断触发而不是 TASKLET_SOFTIRQ,这两种 tasklet 在不同的链表中维护。这里的高优先级是指软中断的处理程序 HI_SOFTIRQ 比其他软中断处理程序更先执行,因为它排在软中断号的第一位。很多声卡驱动以及高速网卡都是依赖高优先级 tasklet 实现的。
等待队列
我们已经知道 tasklet 不能解决睡眠和阻塞的问题,那么当设备驱动要等待某一特定事件发生的时候,有什么办法吗?我们可以通过等待队列来完成这个需求。既然要睡眠和阻塞,势必须要一个调度实体,换句话说,等待队列中的项不再是一个简单的处理函数,而是一个类似于后台进程一样的存在。
struct wait_queue_t {
unsigned int flags; // 当 flags 为 WQ_FLAG_EXCLUSIVE 时,表示该事件可能是独占的,唤醒一个进程后就返回
void *private; // 大部分情况下指向进程对象 task_struct
wait_queue_func_t func; // 调用该函数唤醒等待进程
struct list_head task_list; // 链表实现需要
};
等待队列的使用分为如下两部分。
- 为使当前进程在一个等待队列中睡眠,需要调用 wait_event 函数。进程进入睡眠,将控制权释放给调度器。内核通常会在向块设备发出传输数据的请求后,调用该函数。因为传输不会立即发生,而在此期间又没有其他事情可做,所以进程可以睡眠,将 CPU 时间让给系统中的其他进程。
- 就上面的例子而言,块设备的数据到达后,必须调用 wake_up 函数来唤醒等待队列中的睡眠进程。在使用 wait_event 使进程睡眠之后,必须确保在内核中另一处有一个对应的 wake_up 调用。
wait_event 是一个宏,它接收两个参数,第一个是等待队列对象 wait_queue_t,第二个是判断事件是否到来的 bool 表达式。这个宏的实现也很简单,就是先将当前进程加入到等待队列的 task_struct 链表中,然后循环地通过第二个参数确认是否事件已经到来,如果来了则跳出循环,否则继续睡眠。
wake_up 函数也很简单,第一个是等待队列链表的第一个对象 wait_queue_head_t,第二个参数 mode 指定进程的状态(TASK_UNINTERRUPTIBLE | TASK_INTERRUPTIBLE),第三个参数 nr_exclusive控制唤醒该队列上的几个进程,如果是 1 则表明是独占的事件,只唤醒其中一个,如果是 0 则会唤醒该队列中的所有进程。
工作队列
工作队列是将操作延时执行的另一个手段。它和等待队列一样是通过守护进程实现,在用户上下文执行,所以可以睡眠任意长的时间。它非常像一个"线程池",在创建的时候我们需要指定线程名,同时也可以指定是单个线程,还是每个 CPU 上创建一个对应的线程。
struct workqueue_struct *__create_workqueue(const char *name,int singlethread)
创建好工作队列后,我们可以向其中注册任务,每个工作任务的结构如下。注册后的任务会维护在一个链表中,按照顺序依次执行。
struct work_struct {
atomic_long_t data; // 和本工作项相关的数据,例如工作函数可以将一些中间内容或者结果保存在 data 中
struct list_head entry; // 链表实现需要
work_func_t func; // 函数指针,其中一个函数参数指向了本 work_struct 对象,使函数内可以访问到 data 属性
}
而且,在注册工作内容时,我们还可以指定延时任务,它会在一个指定延迟后开始执行。当创建延时任务时,内核会创建一个定时器,它将在 delay jiffies 之后超时,随后相关的处理程序会将 delayed_work 内部的 work_struct 对象加入到工作队列的链表中,剩下的工作就和普通任务完全一样了。
int fastcall queue_delayed_work(struct workqueue_struct *wq,struct delayed_work *dwork,unsigned long delay)
struct delayed_work {
struct work_struct work;
struct timer_list timer;
}
参考:--->
https://zhuanlan.zhihu.com/p/94788008