1. 从一个“键盘卡顿”的现场说起
如果你在嵌入式Linux或者服务器运维的圈子里待过一阵子,大概率遇到过这种场景:一块开发板跑着跑着,串口打印突然丢字符;一台服务器网卡吞吐上不去,top里si(软中断)那一栏飙得老高;又或者你写了个GPIO按键驱动,按下按键后系统要愣个几十毫秒才响应。这些问题往深了挖,十有八九会落到同一个地方——硬中断及其中断处理机制。
硬中断(Hardware Interrupt)是CPU和外部设备之间最原始、最直接的一种“对话方式”。设备有事情要告诉CPU,就拉一根中断线,CPU暂停手头的活,跳到一个预先注册好的地址去执行代码,处理完再回来。听起来简单,但Linux为了把这套机制做得既快又稳,在硬中断之上叠了一整套分层设计:上半部(Top Half)、下半部(Bottom Half)、软中断(Softirq)、tasklet、工作队列(Workqueue),还有后来的线程化中断(Threaded IRQ)。这些名词堆在一起,初学者很容易懵。
这篇内容就是想把“Linux硬中断及其中断处理”这条线从头到尾捋清楚。我会从硬件层面中断是怎么送到CPU的讲起,一路讲到内核里request_irq怎么注册、上半部为什么必须短、下半部怎么选型,再配上可以实际编译运行的内核模块代码,最后把我自己踩过的坑和排查手法整理成速查表。适合正在写驱动的新手,也适合想搞明白/proc/interrupts里那些数字到底代表什么的老手。看完你至少能做到三件事:能读懂中断相关的内核日志、能自己写一个规范的中断处理模块、能在中断风暴或者中断丢失时有一套靠谱的排查思路。
2. 硬中断到底是怎么一回事
2.1 从设备拉线到CPU响应:一条中断的生命周期
要理解Linux的中断处理,得先把硬件这条链路走一遍。以最常见的GPIO按键为例,按键按下,电平变化,这个变化被送到中断控制器(比如ARM架构里的GIC,x86里的APIC,或者简单的SoC内部中断控制器)。中断控制器的作用像个“前台”,它负责收集各个设备的中断请求,做优先级仲裁,然后决定要不要向CPU核心发信号。
CPU收到中断信号后,如果当前没有屏蔽中断(比如没在关中断的临界区里),就会在当前指令执行完之后,保存现场——把程序计数器、状态寄存器等压栈,然后根据中断向量表跳到对应的入口。这个入口在Linux里不是直接跳到你的驱动函数,而是先跳到一段汇编写的公共入口代码,这段代码负责进一步保存寄存器、切换栈、调用C语言写的通用中断处理框架。
这里有个关键点很多人会忽略:中断上下文不是进程上下文。在硬中断处理函数里,你不能睡眠,不能调用可能引起调度的函数,不能访问用户空间内存。为什么?因为中断处理没有对应的进程,内核调度器没法把你“挂起”再“唤醒”,你一睡,这个CPU核心可能就再也回不来了。这个约束直接决定了后面上半部/下半部分层的设计。
2.2 中断号和中断向量:别把这两个搞混
新手最容易混淆的概念之一就是中断号和中断向量。简单说,中断向量是CPU架构层面的概念,是中断发生时CPU跳转的入口地址索引;中断号(IRQ number)是Linux内核层面的逻辑编号,是你调用request_irq时传进去的那个整数。
在x86上,早期中断向量和IRQ号有固定的映射关系,后来引入了可编程中断控制器,映射变得灵活。在ARM上,中断号通常由设备树(Device Tree)里的interrupts属性描述,内核解析设备树时动态分配。你在驱动里拿到的irq变量,是内核给你的逻辑号,不是硬件手册上那个物理中断号。这个区别在调试时很重要——/proc/interrupts里显示的是逻辑号,而芯片手册里写的是物理号,两者对不上是正常的,需要通过设备树或者irq_of_parse_and_map这类接口去对应。
2.3 为什么硬中断处理必须“快进快出”
硬中断处理函数运行时,当前CPU核心上正在执行的进程被强行暂停。如果这个处理函数执行时间过长,会带来几个连锁反应:第一,同核心上其他中断被延迟甚至丢失,因为中断默认是关的(或者同优先级中断被屏蔽);第二,被暂停的进程响应变差,用户能感知到卡顿;第三,如果中断处理里访问了共享数据,其他核心可能一直在自旋等锁,白白浪费CPU。
所以Linux的设计哲学是:硬中断处理只做最紧急、最必要的事,比如读取硬件状态寄存器、清除中断标志、把数据拷贝到一个临时缓冲区,然后立刻退出,把耗时的后续处理丢给下半部。这个“上半部/下半部”的划分,是整个Linux中断处理框架的骨架。
3. 上半部与下半部:Linux中断处理的分层设计
3.1 上半部:注册、执行与返回的完整流程
上半部就是你用request_irq注册的那个处理函数,它在硬中断上下文中执行。它的典型工作包括:确认中断来自自己的设备(因为多个设备可能共享一根中断线)、读取设备状态、清除中断标志、保存必要数据、触发下半部。
注册上半部的接口是request_irq,原型大致如下:
int request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev);irq是逻辑中断号,handler是你的处理函数,flags控制中断触发方式和共享行为,name会显示在/proc/interrupts里方便调试,dev是传给处理函数的私有数据,也用于区分共享中断线上的不同设备。
flags里几个常用的值需要记牢:IRQF_TRIGGER_RISING、IRQF_TRIGGER_FALLING、IRQF_TRIGGER_HIGH、IRQF_TRIGGER_LOW指定触发方式;IRQF_SHARED表示允许多个设备共享这根中断线,此时dev必须唯一且不能为NULL,因为内核靠它来区分该调用哪个handler。
处理函数的返回值也有讲究:返回IRQ_HANDLED表示这个中断确实是你的设备产生的并且处理了;返回IRQ_NONE表示不是你的设备产生的。在共享中断场景下,内核会遍历该中断线上所有注册的handler,直到有人返回IRQ_HANDLED。如果你明明处理了却返回IRQ_NONE,内核会认为这根线出了问题,可能触发中断风暴保护。
3.2 下半部机制选型:softirq、tasklet、workqueue怎么选
下半部的核心目标是:把不紧急但必须做的后续工作,推迟到中断上下文之外执行。Linux提供了好几种机制,选型是驱动开发里的一个高频决策点。
Softirq是编译期静态定义的,数量有限(内核里就那几种,比如网络收发、定时器、块设备等),执行在中断上下文,不能睡眠。它的优点是性能极高,缺点是普通驱动开发者基本用不上,也不应该自己新增softirq类型。
Tasklet建立在softirq之上,是动态注册的,同样运行在中断上下文,不能睡眠。它适合那种“数据量不大、不需要睡眠、但又不适合放在上半部”的场景。同一个tasklet不会在多个CPU上并发执行,这简化了同步问题。不过tasklet正在被内核社区逐步边缘化,新代码更推荐用线程化中断或者workqueue。
Workqueue把工作推到内核线程里执行,运行在进程上下文,可以睡眠、可以拿互斥锁、可以访问用户空间。代价是延迟比tasklet高,因为要经过调度器。适合需要睡眠的操作,比如I2C/SPI传输、文件操作、大量内存分配。
选型的经验法则是:如果下半部里要做的事情可能睡眠,只能用workqueue;如果确定不会睡眠且对延迟敏感,用tasklet;如果是网络、块设备这类内核核心子系统,用softirq。对于新写的GPIO、传感器驱动,我个人的建议是优先考虑线程化中断(request_threaded_irq),它把上半部做得极短,下半部直接跑在内核线程里,既能睡眠又保持了中断的实时性,代码结构还清晰。
3.3 线程化中断:新代码的优先选择
request_threaded_irq允许你注册两个函数:一个硬中断处理函数(primary handler),一个内核线程处理函数(threaded handler)。如果primary handler返回IRQ_WAKE_THREAD,内核就会唤醒对应的中断线程去执行threaded handler。
int request_threaded_irq(unsigned int irq, irq_handler_t handler, irq_handler_t thread_fn, unsigned long flags, const char *name, void *dev);这种模式的好处很直接:threaded handler运行在进程上下文,可以睡眠、可以拿锁、可以做任何耗时操作,同时中断线程有正常的调度优先级(默认是SCHED_FIFO,优先级50),实时性有保障。对于大多数外设驱动,这比手动在tasklet和workqueue之间纠结要省心得多。
4. 动手写一个完整的中断处理模块
4.1 环境准备与模块骨架
下面这个例子基于一个虚构的嵌入式平台,假设有一个GPIO按键接在逻辑中断号IRQ_NUM上(实际开发中这个号从设备树获取)。我把它写成一个可加载的内核模块,包含上半部、tasklet下半部、以及一个workqueue做耗时处理,目的是把三种机制都演示一遍。
先看模块的基本骨架和头文件:
#include <linux/module.h> #include <linux/kernel.h> #include <linux/interrupt.h> #include <linux/gpio.h> #include <linux/workqueue.h> #include <linux/slab.h> #define IRQ_NUM 100 // 实际应从设备树解析 static int irq_num = IRQ_NUM; static struct tasklet_struct my_tasklet; static struct work_struct my_work; static struct workqueue_struct *my_wq; static int irq_count = 0;这里irq_count用来统计中断触发次数,方便调试。my_tasklet和my_work分别是下半部的两种实现载体。
4.2 上半部处理函数:只做最必要的事
上半部的代码要克制,我见过太多新手把一堆printk和复杂逻辑塞进上半部,结果系统一跑就卡。下面这个上半部只做三件事:确认中断、更新计数、调度下半部。
static irqreturn_t my_irq_handler(int irq, void *dev_id) { // 确认中断来源,实际应读硬件寄存器 // 这里简化为直接认为是我们设备 irq_count++; // 调度tasklet(轻量、快速) tasklet_schedule(&my_tasklet); // 调度workqueue(可睡眠、耗时) queue_work(my_wq, &my_work); return IRQ_HANDLED; }注意tasklet_schedule和queue_work都是非阻塞的,它们只是把任务挂到队列上,实际执行时机由内核决定。上半部执行完立刻返回,CPU可以继续响应其他中断。
4.3 下半部实现:tasklet与workqueue的配合
tasklet的处理函数运行在软中断上下文,不能睡眠。这里我让它打印一条日志并做简单的计数:
static void my_tasklet_func(unsigned long data) { // 软中断上下文,不能睡眠 pr_info("tasklet: irq_count=%d\n", irq_count); }workqueue的处理函数运行在内核线程,可以睡眠。这里我模拟一个耗时操作,比如读取I2C传感器:
static void my_work_func(struct work_struct *work) { // 进程上下文,可以睡眠 pr_info("workqueue: start heavy work, irq_count=%d\n", irq_count); msleep(10); // 模拟耗时操作 pr_info("workqueue: heavy work done\n"); }4.4 模块初始化与注销:资源申请与释放的对称性
初始化的顺序很重要:先创建工作队列,再初始化work和tasklet,最后注册中断。注销时顺序反过来,先释放中断,再销毁工作队列。这个对称性如果搞错,卸载模块时可能崩溃。
static int __init my_irq_init(void) { int ret; my_wq = create_singlethread_workqueue("my_irq_wq"); if (!my_wq) { pr_err("failed to create workqueue\n"); return -ENOMEM; } INIT_WORK(&my_work, my_work_func); tasklet_init(&my_tasklet, my_tasklet_func, 0); ret = request_irq(irq_num, my_irq_handler, IRQF_TRIGGER_FALLING | IRQF_SHARED, "my_irq_device", &irq_count); if (ret) { pr_err("failed to request irq %d, ret=%d\n", irq_num, ret); destroy_workqueue(my_wq); return ret; } pr_info("my_irq module loaded, irq=%d\n", irq_num); return 0; } static void __exit my_irq_exit(void) { free_irq(irq_num, &irq_count); tasklet_kill(&my_tasklet); flush_workqueue(my_wq); destroy_workqueue(my_wq); pr_info("my_irq module unloaded\n"); } module_init(my_irq_init); module_exit(my_irq_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("example"); MODULE_DESCRIPTION("Demo for Linux hardirq handling");tasklet_kill会等待正在执行的tasklet结束,flush_workqueue会等待队列里所有work执行完,这两个调用保证了卸载时不会有残留任务访问已经释放的资源。这是很多人卸载模块时遇到oops的根源。
4.5 编译与验证:Makefile和/proc/interrupts
编译这个模块需要一个简单的Makefile:
obj-m += my_irq.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: make -C $(KDIR) M=$(PWD) modules clean: make -C $(KDIR) M=$(PWD) clean加载模块后,用cat /proc/interrupts可以看到你注册的中断线,name列会显示my_irq_device,计数会随着中断触发增长。如果计数不增长,说明中断没触发或者被别的handler拦截了。用dmesg可以看到tasklet和workqueue的打印,注意观察它们的执行顺序和时机。
5. 中断调试与问题排查实录
5.1 读懂/proc/interrupts:每一列都是线索
/proc/interrupts是排查中断问题的第一现场。它的格式大致是:第一列是逻辑中断号,后面每个CPU核心一列显示该核心上处理的中断次数,再往后是中断控制器信息和设备名。
看这个文件有几个技巧:如果某个中断号后面所有CPU的计数都是0,说明这个中断从来没触发过,可能是设备没使能、触发方式配错、或者中断线接错。如果某个CPU的计数远高于其他核心,说明中断亲和性(affinity)设置有问题,所有中断都打到同一个核心上了,可以通过/proc/irq/<num>/smp_affinity调整。如果计数增长极快(每秒几万次),那就是中断风暴,需要检查设备是否在不停地拉中断线。
5.2 中断丢失与中断风暴的典型成因
中断丢失的常见原因有几个:上半部执行时间太长导致同核心其他中断被延迟;中断被错误地关闭且没有恢复;共享中断线上某个handler一直返回IRQ_HANDLED导致真正的设备handler没被调用;中断亲和性配置不当导致某个核心过载。
中断风暴的成因更隐蔽:设备的中断标志没有被正确清除,导致中断线一直保持有效;边沿触发配置成了电平触发,电平一直有效;硬件故障导致中断线被持续拉低。内核有中断风暴保护机制,当某个中断在短时间内触发次数超过阈值,会临时屏蔽该中断并打印警告,但根治还是要从驱动和硬件入手。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| /proc/interrupts计数不增长 | 设备未使能、触发方式错误、中断线未连接 | 检查设备寄存器、设备树配置、硬件连线 |
| 系统卡顿,si列飙高 | 软中断处理过重、tasklet执行时间过长 | 用ftrace跟踪softirq、检查tasklet逻辑 |
| 卸载模块时oops | 中断未释放、tasklet/work未清理 | 确保free_irq和tasklet_kill/flush_workqueue顺序正确 |
| 共享中断上设备不响应 | 其他handler返回IRQ_HANDLED拦截 | 检查共享线上所有handler的返回值 |
| 中断延迟大 | 上半部太长、关中断时间过长 | 用ftrace irqsoff跟踪、精简上半部 |
| 中断只在一个CPU上 | 亲和性默认配置 | 调整smp_affinity或使用irqbalance |
5.4 我踩过的几个坑
第一个坑是request_irq的dev参数在共享中断里传了NULL,结果内核直接报错。共享中断要求dev必须唯一,因为内核靠它区分handler。后来我统一用设备结构体指针作为dev,既唯一又能传递上下文。
第二个坑是在上半部里调用了msleep,系统直接挂了。中断上下文不能睡眠这个约束是硬性的,没有任何商量余地。后来我把所有可能睡眠的操作都挪到了workqueue或者线程化中断的threaded handler里。
第三个坑是卸载模块时忘了tasklet_kill,结果tasklet还在排队,模块代码已经被卸载,访问了非法地址。这个问题的隐蔽性在于它不一定每次都崩,取决于tasklet是否正在执行。加上tasklet_kill之后就稳定了。
第四个坑是中断亲和性。有次做网络性能测试,发现所有网卡中断都打在一个CPU上,其他核心闲着。调整/proc/irq/<num>/smp_affinity把中断分散到多个核心后,吞吐量直接翻倍。这个经验告诉我,中断调试不能只看驱动代码,还要看系统层面的配置。
6. 从硬中断到软中断:一条数据的完整旅程
6.1 网卡收包的例子:把整条链路串起来
拿网卡收包举例,能把前面讲的所有概念串成一条线。网卡收到一个数据帧,DMA把数据写到内存环形缓冲区,然后网卡拉中断线。CPU响应中断,跳到网卡驱动的上半部。上半部做两件事:屏蔽网卡中断(避免处理期间又来中断)、触发NET_RX_SOFTIRQ软中断。上半部返回,CPU继续干别的。
稍后,软中断在合适的时机执行,NET_RX_SOFTIRQ的处理函数从环形缓冲区取出数据包,走协议栈,最终送到某个socket的接收队列。如果协议栈处理过程中需要睡眠(比如等待内存),软中断上下文不允许,所以协议栈会做各种无锁设计和内存预分配。
这个例子里,上半部极短,只做了“屏蔽中断+触发软中断”,真正耗时的协议栈处理在软中断里完成。这就是分层设计的威力:既保证了中断响应的实时性,又完成了复杂的数据处理。
6.2 中断上下文与进程上下文的边界
理解中断上下文和进程上下文的边界,是写出正确驱动代码的前提。中断上下文里,current宏指向的是被中断的那个进程,但这个进程和你的中断处理没有关系,你不能对它做任何假设。你不能调用任何可能睡眠的函数,包括kmalloc(GFP_KERNEL)、mutex_lock、copy_to_user等。
判断一个函数能不能在中断上下文调用,有个简单方法:看它的实现里有没有might_sleep()或者schedule()。内核文档里也会标注函数的上下文要求。拿不准的时候,宁可把操作推到workqueue里,也不要冒险在上半部调用。
6.3 中断处理的性能考量
中断处理的性能不只是“快”一个字。它涉及几个维度:延迟(从中断触发到上半部开始执行的时间)、吞吐(单位时间能处理的中断数)、CPU占用(中断处理消耗的CPU时间)。这三个维度往往互相制约。
降低延迟的办法包括:提高中断线程优先级、减少关中断临界区、使用线程化中断。提高吞吐的办法包括:中断合并(网卡的多包合并)、NAPI(轮询替代中断)、多队列网卡配合中断亲和性。降低CPU占用的办法包括:把耗时操作推到workqueue、使用DMA减少CPU拷贝、批量处理。
这些优化手段的选择取决于具体场景。实时性要求高的场景优先降延迟,吞吐要求高的场景优先提吞吐,功耗敏感的场景优先降CPU占用。没有万能方案,只有适合当前场景的方案。
7. 一些实操心得与建议
写中断处理代码这些年,我最大的体会是:克制。上半部能少写一行就少写一行,下半部能简单就简单,机制选型能用线程化中断就别自己搭tasklet+workqueue的组合。代码越简单,出问题的概率越低。
另一个体会是善用工具。/proc/interrupts、ftrace的irqsoff和softirq跟踪器、perf的中断统计,这些工具能帮你快速定位问题,比盯着代码猜要高效得多。我习惯在驱动开发阶段就打开CONFIG_DEBUG_ATOMIC_SLEEP,它能在你于中断上下文调用可能睡眠的函数时立刻报错,把问题扼杀在开发阶段。
最后,中断处理是硬件和软件的交界处,很多问题不是纯软件能解决的。遇到诡异的中断行为,先怀疑硬件:中断线有没有接对、电平有没有毛刺、电源稳不稳。我遇到过好几次“驱动没问题但中断就是不对”的情况,最后发现是硬件上的上拉电阻没焊。软硬结合着排查,才是正道。
这个内容后续还可以往两个方向扩展:一是深入讲中断控制器的级联和中断域(irq_domain)的映射机制,二是结合具体架构讲中断向量表的初始化和异常处理入口。这两个方向都偏底层,适合想进一步深入内核启动流程的读者。