本文整理了 Linux 内核中断的相关知识。更多相关文章和其他文章均收录于贝贝猫的文章目录。
大家应该很清楚,系统在执行时可以处于两种可能的状态:核心态和用户态。之前我们讨论过的系统调用,就能使进程从用户态切换到核心态去执行某些任务,当执行成功后再回到用户进程中。大家可能还记得这是通过软中断来实现的,那么中断到底是什么呢? 接下来我将介绍与中断相关的一些知识。
通常中断可以分为如下两个类别:
无论是上述的哪种中断,都会涉及到中断处理过程中的一个关键流程,那就是:如果在发生中断时,当前 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 在中断发生时,只能拿到中断号这一个参数,所以内核的处理方式非常粗暴:
那么,当内核开始处理中断时都需要做什么呢?下图就简要的描述了整个中断处理的过程。
进入路径的一个关键任务是,从用户态栈切换到核心态栈。但是,只有这点还不够。因为内核还要使用 CPU 资源执行其代码,进入路径必须保存用户应用程序当前的寄存器状态,以使在中断活动结束后恢复。这与调度期间用于上下文切换的机制是相同的。在进入核心态时,只保存部分寄存器的内容,因为内核并不会使用全部寄存器。举例来说,内核代码中不使用浮点操作(只有整数计算),因而并不保存浮点寄存器。随后内核跳转到与中断号对应的中断处理程序中执行特定的任务,在退出时,内核会检查如下事项:
从中断返回之后,只有确认了这两个问题,内核才能完成其常规任务,即还原寄存器集合、切换到用户态栈、切换到适当的处理器状态(如果原来是用户态,就切换回用户态)。
实现中断处理程序时,也会遇到很多问题,比如在处理中断期间,发生了其他中断,尽管可以通过禁用中断来防止这个问题,但是这又会引入别的问题,比如遗漏重要中断。所以禁用中断这个功能必须只能短时间使用。总结一下中断处理程序要满足如下几个要求:
尽管后一个问题我们可以通过精妙的设计方案来解决,但是前一个就很困难了。因为中断处理程序的每个部分并不是同等重要,所以每个中断处理程序都可以划分为 3 个部分,它们具有不同的意义:
在实现处理程序例程时,必须要注意些要点。这些会极大地影响系统的性能和稳定性。中断处理程序在所谓的中断上下文(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 上执行。
那么软中断在什么时候执行呢?
这里大家可能有一个疑问,我们在前面介绍系统调用时也说了它是通过中断实现的,那么在前面的软中断列表中怎么没有对应的软中断呢?实际上,系统调用使用到的中断属于 “软件触发的硬中断” 而不是这里所说的软中断,因为系统调用过程是要同步处理的,不能使用异步的软中断方式实现。在我的 linux 中执行 cat /proc/interrupts
会打印所有注册的硬中断,仔细观察之后,你会发现其中包含一个名为 ‘CAL’ 的中断,它就是系统调用所对应的中断号。这是通过执行机器指令触发的,所以我才说它是软件触发的硬中断。
cat /proc/interrupts
CPU0
0: 181 IO-APIC-edge timer
...
CAL: 0 Function call interrupts
...
虽然软中断是将操作推迟到未来时刻执行的最有效方法,但软中断的中断号有限,而且该延期机制处理起来非常复杂。因为多个处理器可以同时且独立地处理软中断,所以同一个软中断的处理程序例程可以在几个 CPU 上同时运行,这就要求软中断处理程序的设计必须是可重入并且线程安全的,临界区必须用自旋锁保护。此外,在软中断还不能进入睡眠,因为软中断的其中一部分是在硬中断处理结束之后进行的,这时候软中断执行函数没有调度实体,所以不能进入睡眠。
既然软中断这么多限制,那开发设备驱动程序(以及其他一般的内核代码)的同学岂不是很痛苦,实际上内核基于软中断建立了很多上层异步处理机制。
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 只会在一个 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 是一个宏,它接收两个参数,第一个是等待队列对象 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;
}
[1]《Linux内核设计与实现》
[2]《Linux系统编程》
[3]《深入理解Linux内核》
[4]《深入Linux内核架构》
[5] Linux 内核进程管理之进程ID
[6] 服务器三大体系SMP、NUMA、MPP介绍
[7] Linux中的物理内存管理 [一]
[8] Linux内核中的page migration和compaction机制简介
[9] 物理地址、虚拟地址(线性地址)、逻辑地址以及MMU的知识
[10] 逻辑地址
[11] linux内核学习笔记-struct vm_area_struct
[12] Linux中匿名页的反向映射
[13] 系统调用过程详解
[14] 再谈Linux内核中的RCU机制
[15] Unix domain socket 和 TCP/IP socket 的区别
[16] Linux通用块设备层
[17] ext2文件系统结构分析
[18] linux ACL权限规划:getfacl,setfacl使用
[18] 查找——图文翔解RadixTree(基数树)
[19] 页缓存page cache和地址空间address_space
[20] rocketmq使用的系统参数(dirty_background_ration dirty_ratio)
[21] Linux内存调节之zone watermark
[22] Linux的内存回收和交换
[23] Linux中的内存回收[一]
[24] linux内存源码分析 - 内存回收(整体流程)
[25] Linux 软中断机制分析
[26] 对 jiffies 溢出、回绕及 time_after 宏的理解
[27] learn-linux-network-namespace
[28] 显式拥塞通知
[29] 聊聊 TCP 长连接和心跳那些事
[30] 关于 TCP/IP,必知必会的十个问题
[31] TCP协议三次握手连接四次握手断开和DOS攻击
[32] TCP 的那些事儿(上)
[33] TCP 的那些事儿(下)