第一次调通的Linux中断,我才算摸到内核的门槛
如果你问我在嵌入式Linux开发里,什么东西最让人又爱又恨,我一定首选中断机制。
说爱,是因为几乎所有的外设都靠中断来通知CPU“我这里有活干了”——网络包到了、按键按下了、串口来数据了、DMA搬运完了,全是中断。只要跑Linux,中断就无处不在。说恨,是因为它真的很绕:一个中断从硬件引脚拉起来,到你的驱动回调函数被调用,中间隔着GIC、irq domain、irq descriptor、中断线程化、下半部机制这么多层,随便卡在哪一环,现象就是“设备不干活”或者“CPU傻高”。
我第一次调一个触摸屏驱动时,中断丢了三天,最后发现是设备树里interrupts属性写错了一位数。从那以后我就明白,Linux中断机制不是看几篇博客就能拿下的,需要把整个链条上的每个环节都摸清。这篇文章把我在内核源码和实际项目里积累的东西整理出来,从硬件中断信号产生开始,一路讲到你的handler跑起来,中间每一层是干什么的、为什么这么设计、有哪些坑,尽量写透。
先说清楚:这篇文章面向的是想真正搞懂Linux中断、正在写驱动或者被中断问题折磨的人。如果你只是背面试题,可能用不上这么细,但如果你要调板子、调驱动、排查CPU占用异常,这篇能帮你省下很多瞎折腾的时间。
1. 中断机制的整体设计思路:为什么这么绕
刚开始看内核中断代码的人,基本都会有一个疑惑:一个中断来了就来了,CPU直接跳到处理函数跑不就完了?为什么要搞出 hardirq、softirq、tasklet、workqueue、threaded irq 这么一堆概念?
答案其实是一句话:中断处理上下文里不能睡眠,而真实世界的外设处理往往需要睡眠。
先把这个核心矛盾讲清楚。
1.1 中断上下文为什么不能随便睡
当一个中断信号到达CPU时,处理器会跳到异常向量表,保存现场,然后进入内核的中断处理流程。这个过程里,CPU 处在中断上下文,此时系统处于一个"非常时刻"——当前被打断的可能是用户程序,也可能是内核自己的临界区。
如果在中断处理函数里调用了一个会睡眠的函数(比如 mutex_lock、msleep、kmalloc(GFP_KERNEL)),会发生什么?
睡眠的本质是让出CPU,等条件满足再被唤醒。在正常进程上下文里,这没问题,调度器会找别的进程跑。但在中断上下文里,没有"别的进程"这个概念——你都不知道该把CPU让给谁,而且这个中断本身就是用来打断别人的,结果自己先睡了,整个系统的节奏就乱了。
所以内核有一堆检测机制专门对付这种事,最常见的就是:
BUG: sleeping function called from invalid context at kernel/mutex.c这行log一出现,基本就是有人在中断里睡觉了,内核直接判死刑。
1.2 上半部和下半部的分工逻辑
既然中断处理函数里啥都不能干,那怎么办?延迟处理。
这就是经典的上半部(top half)和下半部(bottom half)设计。上半部就是中断处理函数本身,要求快进快出,只做最紧急的事情:关中断、读状态、清标志、把数据搬到内存、然后触发下半部。下半部在更宽松的环境里处理真正的业务逻辑,串口数据解析、网络协议处理、按键消抖这些累活都在这里完成。
为什么要分这么细?因为中断处理期间,CPU往往处于关闭本地中断的状态(具体要看中断标志位和内核配置),也就是说这段代码执行的时候,其他的中断全部进不来。要是你在中断处理函数里磨蹭几百微秒,那感觉就像高峰期的高速路上突然有人停车拍照——后面全堵死了,串口丢数据、网络超时、实时任务错过deadline,各种诡异的问题都会冒出来。
所以下半部的核心价值就是:让中断处理函数尽快结束,把剩下的活放到"可以被打断"的环境里慢慢做。而且,某些下半部机制允许睡眠,这意味着你可以在里面用各种方便的内核API。
用个生活化的类比:你在厨房做菜,火警响了。上半部就是立刻关火、冲出厨房、按下报警器——这是救命的,必须立刻做。下半部是呼救、疏散、检查是什么烧了、清理现场——这些事情可以慢慢来,甚至可以让消防员(内核线程)来做。你要是非在警报响的时候把所有菜都炒完再跑,那房子早烧没了。
1.3 一个中断要对应一套策略,不是一成不变
这里要提个关键认知:不同中断源的"应急程度"是完全不同的。
网络收包中断,数据到了你不赶紧收,网卡FIFO就溢出了,包直接丢,所以要在最短时间把数据搬到内存,最好连下半部都不用,直接在中断里把预算内的包收完。按键中断,本身就不急,但因为按压会产生抖动,反而需要在延迟处理里做滤波,经常就用线程化中断配合msleep。GPIO电平触发的中断,如果外部信号不稳定,最容易产生中断风暴,处理策略又不一样。
所以内核才同时保留了好几种下半部机制:软中断、tasklet、工作队列、线程化中断。它们各有各的适用场景,选错了,轻则浪费CPU,重则系统卡死。后面我会专门用一节来对比怎么选。
2. 从硬件中断信号到Linux中断号:irq domain 的原理
很多人写驱动时,对中断的理解就停在 request_irq 这一步:在设备树里配置 interrupts 属性,驱动里用 platform_get_irq 拿中断号,然后 request_irq 注册回调,完事。
但这中间其实藏着整个中断机制最容易出问题的一环——中断号是怎么从一个硬件引脚,变成一个内核中断号的。
2.1 硬件中断号、Linux中断号与irq domain
先理清概念。一个中断控制器(比如GIC)有若干中断输入线,每条线有一个编号,这个编号叫硬件中断号(hwirq)。在老的x86系统里,硬件中断号和Linux中断号是一一对应的,简单粗暴,直接用16个IRQ就完了。到了ARM平台,GIC可能有好几百个中断源,再加上多个中断控制器级联,情况就复杂了:同一张板子上,GIC的SPI 34号中断,到底是哪个设备发出来的?设备树里写的那个32位数字,映射到内核的哪个irq号?
解决这个问题的方案就是irq domain。它的作用简单说就是一张映射表,把"硬件中断源"映射为"Linux中断号"。
在设备树时代,每个中断控制器会在系统初始化时注册一个 irq_domain,里面实现三个关键回调:map(把hwirq映射为Linux irq号)、translate(解析设备树interrupts属性里的参数)、xlate(把devicetree中的中断描述翻译成hwirq)。当驱动调用 platform_get_irq 时,内核会沿着设备树里的 interrupt-parent 找到对应的中断控制器,再通过它的 irq_domain 完成从硬件描述到Linux irq number 的翻译。
这个机制被引入的另一个重要原因,是让"中断控制器驱动的编写"和"使用中断的设备驱动的编写"解耦。设备驱动只需要说"我需要这个中断源,这是我的回调函数",具体这个中断源在硬件上怎么配置、优先级是多少、是上升沿还是下降沿触发,全交给中断控制器驱动和irq domain去处理。
2.2 设备树里中断属性的解析与常见错误
设备树里每个用到中断的设备节点,通常会这样写:
&gpio1 { key_int: key-int { compatible = "my-company,key"; interrupt-parent = <&gpio1>; interrupts = <5 IRQ_TYPE_EDGE_FALLING>; ... }; };这里的<5 IRQ_TYPE_EDGE_FALLING>两个参数,含义由 interrupt-parent 指向的中断控制器的 irq_domain 来解释。GPIO 中断控制器一般解释为:第5号GPIO、下降沿触发。
我踩过一个非常典型的坑:在 GPIO 控制器上配置中断时,没用 interrupts 属性,而是用了gpios = <&gpio1 5 GPIO_ACTIVE_LOW>,然后在驱动里用gpiod_to_irq去拿中断号。这个路子也能通,但和直接用 interrupts 有一个本质区别——前者是通用GPIO子系统帮你动态分配的中断,后者是设备树静态描述的中断。两种方式别混着用,否则容易出现驱动加载顺序导致的中断号不一致问题。
另外一个常见错误是 interrupt-parent 写错了。ARM平台经常有多个中断控制器:GIC负责SoC内部外设中断,GPIO控制器负责外部引脚中断。设备树默认的 interrupt-parent 在根节点上,但具体设备节点可以覆盖。如果你给一个挂在GPIO下的设备写了 interrupt-parent = <&gic>,中断号翻译出来的结果基本是垃圾,request_irq 可能不报错,但中断触发时你这个驱动永远收不到。
排查这类问题最快的办法是看 /proc/interrupts 里的中断计数:如果某个中断号对应的计数永远不涨,而且你的设备确实在工作,九成是中断映射出了问题。此时用 perf 的 tracepoint 看 irq 相关事件,或者 ftrace 的 irq_handler_entry,比瞎猜高效得多。
2.3 中断描述符 irq_desc 与 irq_chip
每个Linux中断号背后,都有一个struct irq_desc结构体,它是中断机制的"档案袋"。里面记录了:
- 这个中断当前的状态(是否被禁用、是否在处理中)
- 挂在它上面的所有动作(action链表,处理函数)
- 所属的中断控制器的操作函数集(irq_chip)
- 线程化中断对应的内核线程
- 各种统计信息、调试信息
irq_chip是中断控制器驱动的核心结构体,它封装了对中断控制器的底层操作:irq_mask(屏蔽)、irq_unmask(打开)、irq_set_type(设置触发方式)、irq_set_wake(配置唤醒源)、irq_ack(应答中断)。设备驱动打交道最多的其实是上面的 action 回调,而 irq_chip 全是中断控制器驱动开发者的事。你写普通外设驱动时,不用直接碰 irq_chip,但理解这层存在,对后面排查问题很有帮助——比如你发现中断触发后 handler 没被调用,但irq_mask里的屏蔽位被置上了,那就可能是中断控制器层面的问题,而不是你驱动的逻辑问题。
3. 中断处理的完整流程与API实战
现在把视角切到写驱动的人最关心的部分:我如何在驱动里注册一个中断处理函数?不同场景应该选哪个注册函数?各有什么讲究?
3.1 注册中断的API选择:你还在无脑request_irq吗
很多教材讲中断就是request_irq,这没错,但它不是唯一选择,甚至在很多场景下不是最优选择。内核里注册中断主要有这几个接口:
request_irq(irq, handler, flags, name, dev):基础版,handler在中断上下文执行,不能用任何会睡眠的函数。request_threaded_irq(irq, handler, thread_fn, flags, name, dev):进阶版,handler仍然在中断上文执行, thread_fn在内核线程中执行,可以睡眠。request_irq其实是对request_threaded_irq的封装,传入的handler对应thread_fn的位置,同时还额外传了一个不可睡眠的handler。
// 普通中断,handler在中断上下文执行 static irqreturn_t my_irq_handler(int irq, void *dev_id) { /* 这里不能睡眠 */ ... return IRQ_HANDLED; } // 线程化中断,thread_fn在进程上下文执行,可以睡眠 static irqreturn_t my_irq_thread_fn(int irq, void *dev_id) { /* 这里可以睡眠,可以上mutex,可以kmalloc GFP_KERNEL */ ... return IRQ_HANDLED; }你可能要问了:那我都用线程化中断不就行了?反正能睡眠,多省心。
这里要纠正一个误区:线程化中断不是银弹,它有一个显著的代价——延迟。
线程化中断的中断处理函数是在一个内核线程里运行的。这个线程优先级虽然比普通进程高,但它仍然要走调度器、可能要等其他CPU上的线程让出资源,所以从中断触发到 thread_fn 真正跑起来,中间可能多出几十微秒到几毫秒的延迟。对实时性要求高的场景(比如运动控制、高速数据采集),这种抖动是没法接受的。
所以选择的逻辑应该是:
- 中断处理逻辑很短,只需要读写寄存器、置个标志位、唤醒等待队列:用
request_irq,在中断上下文里快速搞定。 - 中断处理逻辑需要做耗时操作,且实时性要求不高:用线程化中断,或者用
request_irq+ 下半部机制。 - 处理逻辑里必须要用mutex、msleep这类会睡眠的API:只能线程化中断,或者工作队列。
用一张表总结就是:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 处理极短,只需读状态清中断 | request_irq | 开销最小 |
| 处理中等,需要一定延迟容忍度 | request_irq + tasklet/软中断 | 兼具低延迟和吞吐 |
| 处理较慢,可接受较大延迟 | request_thired_irq | 可睡眠,代码简单 |
| 处理很慢,且涉及IO/锁 | workqueue | 进程上下文,灵活 |
3.2 IRQF_ 系列标志位:每个都值得细看
注册中断时的flags参数,很多人只是照抄例子,实际上里面的门道很深。我挑几个最常用的展开讲。
IRQF_SHARED用来声明中断线可以共享。共享中断在硬件上很常见,多个设备共用一根中断线。这时每个设备的handler都会注册到同一个irq_desc的action链表上。关键点是:所有使用共享中断的驱动,在handler里必须先判断中断是不是自己设备的。如果不判断直接返回IRQ_HANDLED,会造成中断被"抢"走,别的设备永远收不到。正确做法是读取自己设备的寄存器状态确认,如果确实有中断挂起,处理掉;如果没有,返回IRQ_NONE,让中断核心继续调用链表里的下一个handler。
IRQF_TRIGGER_RISING、IRQF_TRIGGER_FALLING、IRQF_TRIGGER_HIGH、IRQF_TRIGGER_LOW这四个是触发方式。这里有个很值得注意的点:触发方式的配置,除了通过request函数的flags,还可以在设备树interrupts属性里指定。如果两边都指定了,以哪边为准?答案是:最终生效的是 irq_chip->irq_set_type 通过协商后的结果,但设备树方式更推荐,因为设备树描述了硬件本身,而不是某个驱动的偏好。
IRQF_DISABLED这个标志以前很有名,意思是handler执行期间保持关中断。现代内核里这个标志基本被废了,因为内核现在的行为默认就是: handler在中断上下文执行期间,当前CPU的本地中断已经被关闭,没必要再显式指定。你如果在老代码里看到它,可以放心去掉。
还有一个容易忽略的是IRQF_NO_THREAD。这个标志意思简单:即使系统开启了强制线程化(threadirqs内核参数),这个中断也不能被线程化。一般用在少数对延迟极度敏感、必须直接在中断上下文执行的中断源上。我看到有实时项目直接给所有关键中断打上这个标志,配合CPU隔离用,效果确实好,但前提是你得确信handler真的极其短小。
3.3 handler的返回值语义与常见误解
irqreturn_t有四个值:IRQ_NONE、IRQ_HANDLED、IRQ_WAKE_THREAD、IRQ_HANDLED | IRQ_WAKE_THREAD(后者等价于IRQ_WAKE_THREAD,表示中断已处理且需要唤醒thread_fn)。很多驱动里直接写return IRQ_HANDLED,这在大多数场景没什么问题,但如果涉及共享中断,这个返回值就很重要。
IRQ_NONE 表示这个中断不是我这个设备产生的,请求内核继续让下一个handler尝试;IRQ_HANDLED 表示我认识这个中断而且处理完了。如果所有共享中断handler都返回IRQ_NONE,内核会认为发生了一个"未处理的中断",在 /proc/interrupts 里那个中断的 "Unhandled" 列计数会增加,长时间未处理甚至会触发内核的spurious interrupt检测机制,自动禁用该中断。
这个行为在实际调板时很坑:你代码看着没问题,但中断就是"偶尔不好使",查半天才发现是spurious interrupt太多,内核直接把中断禁了。遇到这种情况,去dmesg里搜disabling IRQ,一搜一个准。
3.4 一个完整的按键中断驱动示例
为了把上面的API串起来,给一个工作时用过的按键中断例子。这个例子故意不用简单的request_irq,而是用线程化中断,因为按键消抖天然需要延时:
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/interrupt.h> #include <linux/of.h> #include <linux/gpio/consumer.h> #include <linux/delay.h> struct key_dev { struct gpio_desc *gpio; int irq; }; static irqreturn_t key_thread_fn(int irq, void *dev_id) { struct key_dev *key = dev_id; /* 消抖:等100ms再看电平,线程上下文可以安定使用msleep */ msleep(100); if (gpiod_get_value(key->gpio) == 0) { /* 确认是低电平,说明真的按下了 */ pr_info("key pressed, irq=%d\n", irq); } return IRQ_HANDLED; } static int key_probe(struct platform_device *pdev) { struct key_dev *key; int ret; key = devm_kzalloc(&pdev->dev, sizeof(*key), GFP_KERNEL); if (!key) return -ENOMEM; key->gpio = devm_gpiod_get(&pdev->dev, NULL, GPIOD_IN); if (IS_ERR(key->gpio)) return PTR_ERR(key->gpio); key->irq = gpiod_to_irq(key->gpio); if (key->irq < 0) return key->irq; /* 这里把gpio作为dev_id传给handler,共享中断下也可以用来区分设备 */ ret = request_threaded_irq(key->irq, NULL, key_thread_fn, IRQF_TRIGGER_FALLING | IRQF_SHARED, "my-key", key); if (ret) return ret; platform_set_drvdata(pdev, key); return 0; } static int key_remove(struct platform_device *pdev) { struct key_dev *key = platform_get_drvdata(pdev); free_irq(key->irq, key); return 0; } static const struct of_device_id key_of_match[] = { { .compatible = "my-company,key" }, { } }; static struct platform_driver key_driver = { .probe = key_probe, .remove = key_remove, .driver = { .name = "my-key", .of_match_table = key_of_match, }, }; module_platform_driver(key_driver); MODULE_LICENSE("GPL");这里有几个实操细节,我觉得比那些教科书示例重要:
第一个,request_threaded_irq的第一个handler参数传NULL,含义是"我不需要硬中断上文的特殊处理,中断一进来就直接唤醒线程"。这样写最干净,处理逻辑全在线程里,代码也好维护。
第二个,dev_id参数一般传设备结构体指针。这个指针在共享中断里是唯一的身份标识,free_irq的时候必须传同一个,否则free_irq会返回-EINVAL。我见过有人在free_irq里传NULL,结果内核直接报"Trying to free already-free IRQ",排查了很久才发现是这里没对齐。
第三个,这个例子里用IRQF_SHARED是因为我在某个项目里,几个按键确实共用了一根中断线。如果你的硬件上每个按键单独占用GPIO中断,其实不一定要共享。但加上共享标志不会损害什么,反而让代码更通用。
4. 下半部机制实测:软中断、tasklet、工作队列怎么选
前面反复说到下半部,这一节把它讲透。内核里能用来做下半部延迟处理的主要有这几个:软中断(softirq)、tasklet(基于软中断实现)、工作队列(workqueue)、线程化中断(上一节已经讲了)。它们之间的区别,我认为可以用两个维度衡量:执行上下文和并发性。
4.1 软中断:性能最好,但你自己写要小心
软中断是目前内核里最高性能的下半部机制,网络收包、块设备IO这类热点路径都用它。软中断执行在哪个上下文?严格说,它既可以在中断返回路径上执行(ksoftirqd被唤醒之前),也可以在专门的软中断守护进程 ksoftirqd/N 里执行。这意味着它运行的时候可能抢占普通进程,也可能是在中断上下文收尾阶段运行,反正不是普通进程上下文。
软中断最大的特点是可以并发:同一种软中断可以在多个CPU上同时执行。这个特性让它的吞吐量极高,但也带来一个麻烦——你自己实现的软中断处理函数必须考虑多CPU并发访问共享数据,得加锁或者用per-cpu变量,不然极易出竞态。
这正是tasklet诞生的原因:tasklet是在软中断基础上做的一个"简化版",它保证同一种tasklet在任何时刻只能在一个CPU上执行。写tasklet处理函数时,你不用考虑和其他CPU上的同一个tasklet并发的问题,省了一大堆心智负担。
但你得注意,tasklet这个"串行化"是有代价的。在有多核CPU的板子上,如果你的tasklet处理逻辑很重,其他CPU上的相同tasklet只能等着,吞吐量就上不去了。这也是现在内核社区越来越不推荐tasklet、转而推荐线程化中断和workqueue的原因之一——新代码能不用tasklet就不用。
4.2 工作队列:把下半部当"内核进程"来跑
工作队列的本质是你扔一个函数进一个队列,系统会有内核线程(worker)挨个取出来执行。它运行在进程上下文,所以想睡就睡,想拿mutex就拿,想调kmalloc(GFP_KERNEL)就调,自由度最高。
代价是:执行时机不确定。它是一个普通内核线程在干活,要参与调度,可能被其他高优先级任务抢走CPU。中断触发到你的工作函数真正开始执行,这个延迟可能高达毫秒级甚至更高。所以工作队列适合那些"晚做一会没关系,但要做的事比较复杂"的场景,比如热插拔事件处理、设备状态上报、协议解析等。
用简单代码量表示优先级和环境:
软中断/tasklet: 延迟低(us级),但上下文受限(不能睡眠) 线程化中断: 延迟中等(ms级以下),可睡眠 工作队列: 延迟相对较高,但灵活,完全进程上下文我实际的选型习惯是:处理逻辑必须极快(几十微秒内)选软中断或者tasklet;驱动里处理自己的中断事务,优先选线程化中断;事务需要和其他内核模块交互、排队、可能长时间等待,用工作队列。千万别在驱动里为了"性能"硬上软中断,你没那个必要,还容易写出并发问题。
4.3 强制线程化:一个被忽略的全局开关
内核启动参数里有一个threadirqs,加上之后,系统会强制把所有非关键中断的处理函数丢到内核线程里执行。这么做的好处很明显:中断处理不再占用中断上下文,不会因为长时间关中断导致其他中断饥饿,系统的响应延迟更平滑。坏处同前面说的线程化中断——中断处理的实时性变差。
这个参数在生产环境里我用过一次。当时一个嵌入式板子频繁出现软中断导致的网络延迟抖动,排查下来是某个网卡的接收中断处理里做了太多工作,把其他中断堵住了。加了这个参数之后,网络延迟的方差立刻好看了不少。但它治标不治本,真正的问题还是那个网卡驱动在中断里干太多活了,后来换用 NAPI 机制才彻底解决。
如果你在调实时性要求高的系统,建议把threadirqs和各中断的IRQF_NO_THREAD标志配合使用,可以做到"整体平滑、个别关键中断保证实时"。
5. 中断上下文的内存分配与锁:这些雷你别踩
很多驱动崩溃,不是中断逻辑本身错,而是在中断上下文里用了不该用的内存分配API和锁。这一部分单独拿出来讲,因为面试官爱问,调试时更常见。
5.1 中断上下文能不能用kmalloc
能,但要注意标志位。
kmalloc(..., GFP_KERNEL)在中断上下文里用必死,因为GFP_KERNEL允许睡眠,内核会尝试回收内存页、可能阻塞等待。中断里一睡就触发那个"BUG: sleeping function called from invalid context"。
中断上下文只能使用GFP_ATOMIC。这个标志告诉内存管理系统:我现在在原子上下文,不能睡,你必须在不动用"需要睡眠"的路径的情况下给我内存。代价是分配成功率低一些,内存不足时直接返回NULL,不会等。所以代码里必须判断返回值:
/* 中断上下文中正确的内存分配 */ struct my_data *data = kmalloc(sizeof(*data), GFP_ATOMIC); if (!data) { /* 处理失败,别硬来 */ return IRQ_NONE; }还有个容易被忽视的点:GFP_ATOMIC分配不能太大。它依赖的紧急内存池是有限的(大概几十KB级别),你要是每次都分配几KB还没有及时释放,内存池耗尽了,后续所有原子分配都会失败。所以我一般建议:中断里尽量不分配内存,最好在初始化时预分配好环形缓冲区或固定大小的内存池。
5.2 锁的选择:自旋锁还是互斥锁
锁的问题和睡眠问题紧密相关。
中断上下文里想保护共享数据,只能用自旋锁(spinlock),不能用互斥锁(mutex)。自旋锁的本质是忙等:拿不到锁就在原地转圈,不睡眠。虽然浪费CPU,但能保证在原子上下文里不会睡过去。
更精确的说,自旋锁在单核SMP系统中,配合关本地中断,能构成一个有效的临界区保护。在多核系统中,自旋锁保证的是其他CPU也不会同时进入临界区。
还有一个进阶话题:如果你在软中断或者tasklet里需要保护数据,通常用spin_lock_bh,它会同时关闭本地CPU的软中断,防止死锁。这是宁可在刚开始就记住的规则:在tasklet里如果用普通spin_lock,然后又有一个硬中断打断了你并在同一个锁上自旋,就死锁了。时序上稍微一巧合,内核直接hang死,连log都不出,相当难查。
另外提一个很少人注意到的点:在中断上下文里用local_irq_save/local_irq_restore手动关闭中断时,必须保证这两个函数配对使用,而且不能在save之后提前return。很多新手写临界区代码,中间一个return就把中断关在关闭状态了,后续所有中断全被屏蔽,系统表现为"像死机一样但CPU占用不高"。
6. 排查中断问题的实战工具箱
写驱动的人都知道,一个中断相关的bug,表面现象可能五花八门:设备突然不响应了、CPU某一个核占用率100%、系统偶尔卡顿一下、网络延迟忽高忽低。这一节我给出实际排查用的工具和套路,比起看源码,遇到线上问题先用这些手段定位,效率高得多。
6.1 /proc/interrupts 是第一现场
排查任何中断问题,第一步永远是看/proc/interrupts。这个文件列出了系统里所有中断号、每个CPU上该中断触发次数、中断控制器的名字、以及注册了handler的设备名。
怎么从里面读信息?先看触发次数增长情况。用两个命令间隔几秒各看一次,对比同一行中断计数是否有变化:
cat /proc/interrupts sleep 3 cat /proc/interrupts如果某个中断计数暴涨(每秒上万次),基本可以断定是中断风暴。常见原因:外部设备信号毛刺导致反复触发、中断处理函数没能清除硬件中断挂起位、GPIO中断配置成了电平触发但电平一直不恢复。中断风暴会让CPU长时间被中断处理占据,用户进程饿死,系统看起来像卡死一样。
如果某个中断计数一直不变,而设备确实在工作,那问题出在"中断根本没走到这个handler"上,也可能是设备根本没产生中断信号。顺着设备树、interrupt-parent、irq_domain映射一步步查,重点看是不是中断号对不上。
/proc/irq/[irq_num]/spurious这个文件记录了该中断被误判为spurious的次数。前面说的"内核自动禁用中断"的根源就在这。数值如果一直在涨,说明设备驱动在共享中断链路上没有正确返回IRQ_NONE。
6.2 中断延迟与耗时观测:ftrace和perf
想量化某个中断处理函数到底花了多久,最快的方式是perf看 tracepoint:
perf record -e irq:irq_handler_entry -e irq:irq_handler_exit -a sleep 5 perf report这样能看到每次中断处理从进入到退出消耗的时间分布。如果大部分时间都在几个us以内,说明处理很快;如果经常有几百us甚至ms级的,说明handler里有重活,得考虑移到下半部或线程化。
更细的观测,可以用trace-cmd配合function_graph追踪具体的处理函数调用链。有一次我排查一个触摸屏中断导致的卡顿,就是用函数图追踪发现它每次中断都在I2C总线上读寄存器,而I2C的访问延时被一个慢设备拖到了几十毫秒级。这种问题光看代码很难定位,但看trace一目了然——中断处理函数不能碰慢速外设,这条经验真的很贵。
6.3 中断线程的优先级与调度策略
如果用了线程化中断,还有个平时看不到的坑:线程化中断对应的内核线程名称里会包含"irq/[中断号]-[设备名]"。比如:
root 1234 0.0 0.0 0 0 ? S < 09:00 0:00 [irq/47-my-key]这个线程的调度策略默认是SCHED_FIFO,优先级通常是50。如果你的系统里有其他RT任务,而且碰到了中断线程抢不到CPU的情况,可以用chrt调整它的优先级:
chrt -p -f 60 1234但要注意,优先级改太高,可能反过来饿死关键应用任务。嵌入式系统里中断线程优先级和实时任务的优先级怎么配,是需要系统级全盘考虑的,不能只盯着中断看。
6.4 典型问题速查表
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 中断计数暴涨,CPU占用高 | 中断风暴、硬件毛刺、ack不清 | /proc/interrupts对比、示波器查引脚 |
| 中断计数不变,设备不工作 | 中断映射错误、设备树属性问题 | 检查interrupt-parent、irq_domain映射 |
| dmesg出现"disabling IRQ" | 共享中断handler不认领,spurious过多 | 检查handler返回值、设备状态判断逻辑 |
| 系统卡死且无法响应 | 中断里死锁、关中断后未恢复 | 查看是否使用spin_lock_bh、local_irq_save配对 |
| 中断处理延迟大 | handler里访问慢速外设、I2C/SPI操作 | ftrace函数图、perf观测 |
| 线程化中断不执行 | 内核线程被饿死、优先级太低 | 查看线程状态、chrt调整优先级 |
7. 深入中断源码的几个必看入口
如果你真想把中断吃透,光用API是不够的,还是得看源码。我建议按下面这个顺序读,不要上来就啃GIC驱动,会很懵。
7.1 源码阅读路径
第一个看kernel/irq/manage.c,这是核心逻辑,它就是中断的"调度中心",request_irq、free_irq、setup_irq都在这,读完你就知道一个handler是怎么被挂到action链表上的,函数返回值和线程化唤醒是怎么串联的。
第二个看kernel/irq/chip.c,这是中断控制器的通用封装层,mask/unmask/ack这些操作怎么通过irq_chip到达具体的中断控制器。
第三个看drivers/irqchip/irq-gic-v3.c,ARM平台最常见的GIC V3中断控制器驱动,读完你能明白一个硬件中断线是怎么在GIC里被配置、使能、发出信号的。
第四个看kernel/softirq.c,了解软中断的触发、调度和执行时机,其中__do_softirq函数和 ksoftirqd 的配合是理解中断延迟的关键。
7.2 用ftrace跟踪一次完整的中断处理流程
一个很好的学习方法是实战跟踪:用ftrace把一次完整的中断处理流程抓下来,从硬件中断到handler到下半部执行完。
echo 0 > /sys/kernel/debug/tracing/tracing_on echo function_graph > /sys/kernel/debug/tracing/current_tracer echo 'irq_handler_entry' > /sys/kernel/debug/tracing/set_event echo 1 > /sys/kernel/debug/tracing/tracing_on # 触发一次中断(比如按一下按键) echo 0 > /sys/kernel/debug/tracing/tracing_on cat /sys/kernel/debug/tracing/trace这样你能看到从gic_handle_irq进入,到handle_domain_irq,再到你的具体handler被调用的完整调用链。看几次,中断机制从抽象概念变成具象路径,以后再遇到问题就不慌了。
写在最后:中断机制是驱动开发的试金石
我个人的体会是,Linux中断机制就像一面镜子,把内核设计里关于时间、并发、调度的各种考量全映射出来了。你搞懂了它,写驱动时那些"为什么不能在这里sleep""为什么用这个锁"的问题都会迎刃而解。
最后分享一个调板子时的小技巧:新板子第一次点亮时,先别急着跑业务,写一个简单的中断测试驱动,每个可能的中断源都接上,用 /proc/interrupts 确认计数变化,把中断这条路先走通。这一步做扎实了,后面各种外设驱动的调试速度会快很多。我见过太多人在应用功能上折腾半天,最后才发现是最底层的中断根本没有正确打通。中断通了,整个嵌入式系统才算是真正"活"了。