干过Linux驱动移植的朋友应该都有这种经历:板子起来了,外设也能枚举到,但一操作就是死等、中断不来,或者中断来了却进错回调、触发几百次。排查到最后,十有八九是中断子系统这条链路里某个环节没理顺。作为一个常年跟I2C、SPI、UART、GPIO打交道的人,我越来越觉得,驱动移植能不能顺利跑通,往往不是看驱动代码写得有多花哨,而是看你有没有把中断子系统整体框架吃透。
这篇文章就从“linux驱动移植”的实际场景切入,把中断子系统整体框架拆开揉碎讲一遍。我会先梳理硬件中断是怎么一步步走到你的驱动回调函数里的,再讲清楚设备树中断属性、请求中断的几种方式、中断上半部和下半部的选择,以及移植过程中最常踩的坑和排查手段。无论你是刚接触驱动开发,还是已经写过几个驱动但总被中断折腾得头疼,这篇文章都值得收藏下来慢慢看。
1. 从驱动移植视角看中断子系统在整机里的位置
1.1 一个驱动跑不通,八成先查中断
我接到过很多“帮我看下这个驱动为什么不行”的请求,里面大概一半的问题最后都落在中断上。比如说,一个触摸屏驱动,设备树节点、I2C通信、寄存器读写全都没问题,但手指一点屏幕,系统一点反应都没有。这时候你去看/proc/interrupts,发现对应中断号的计数压根没变,说明中断根本没有产生,或者产生了但没路由到CPU。又比如一个网卡驱动,一插网线中断风暴就来了,CPU占用直接飙到百分之百,这种多半是中断标志没配对,或者触发方式写成了高电平触发而实际硬件是边沿触发。
驱动移植的本质,是把一个原本在某套硬件上能跑的驱动,适配到另一套硬件平台上。这个过程中,寄存器地址要改、时钟要调、GPIO要重新映射,但真正让驱动“活”起来的那根弦,就是中断。因为外设和CPU的绝大部分异步通信都是靠中断完成的:数据准备好了,打断CPU一下,CPU去读;数据发送完毕,再打断一下,CPU去发下一包。所以搞懂中断子系统,是驱动移植里绕不开的必修课。
1.2 硬件中断是怎么一步步变成驱动里的回调函数的
从外设引脚到你的irq_handler,中间隔着好几层。我习惯把这个链路分成四段:硬件触发源、中断控制器、Linux通用中断层、驱动回调。
第一段是硬件触发源。外设GPIO拉高、拉低,或者产生一个边沿跳变,经过物理引脚送进中断控制器。第二段是中断控制器,在ARM平台最常见的是GIC(Generic Interrupt Controller),它负责检测中断、记录pending状态、分配优先级,然后把中断分发给某个CPU核心。第三段是Linux通用中断层,它把不同中断控制器的差异抽象掉,向上提供统一的request_irq、enable_irq、disable_irq接口。最后一段才是你写的驱动回调函数。
驱动移植时,前两段通常是芯片厂商BSP已经做好的,你要做的更多是让设备树里的中断描述和这个框架正确对上,以及搞清楚你申请到的那个Linux中断号到底对应哪个硬件中断源。很多人一上来就抱着驱动代码啃,被irq_of_parse_and_map、platform_get_irq这些函数搞晕,其实根子在于没有理解这条链路。
2. 中断子系统核心组件拆解:你得认识的几个“角色”
2.1 三个关键结构体:irq_desc、irqaction、irq_chip
Linux中断子系统的核心数据结构可以简单概括成三件套:irq_desc、irqaction、irq_chip。
irq_desc是整个中断子系统的中枢,系统里每一个中断源都对应一个irq_desc实例。它里面保存了这个中断当前的状态(是enable还是disable)、当前挂载的irqaction链表、这个中断对应的irq_chip指针、以及各种统计信息。你可以通过/proc/interrupts看到的每行内容,本质上就是从irq_desc里捞出来的数据。
irqaction则是驱动注册回调时创建的结构体,里面最关键的两个字段是handler和dev_id。handler就是你的中断处理函数,而dev_id是用来区分共享中断的标识。这也是为什么共享中断时free_irq必须传和request_irq一样的dev_id,否则内核没法确认你要释放的是哪个处理函数。
irq_chip是中断控制器在软件里的化身,里面是一堆回调函数指针,比如irq_mask、irq_unmask、irq_ack、irq_set_type、irq_set_affinity等。你在设备树里配了interrupts = <&gpio 5 IRQ_TYPE_EDGE_FALLING>,内核最终就会调用对应irq_chip的irq_set_type,告诉硬件中断控制器这个中断需要怎么触发。
移植驱动时,如果换了一个中断控制器平台,你通常不需要改驱动代码,但要确认内核里有没有对应的irq_chip实现,以及设备树里的interrupt-parent是否指向了正确的中断控制器节点。这个对应关系错一个字母,中断都起不来。
2.2 中断域(irq_domain):把硬件中断号翻译成Linux中断号的桥梁
很多驱动工程师会被gpio_to_irq、irq_of_parse_and_map这些函数搞糊涂,说白了它们就是做一件事:把设备树里的硬件中断描述(比如GIC的SPI 45号中断)转换成Linux内部的虚拟中断号(IRQ number)。
这个转换过程就是irq_domain在管。早期内核里没有irq_domain概念,中断号直接一一对应,后来系统越来越复杂,外设中断数量远超硬件中断控制器能直接管理的数量,特别是GPIO这种可以任意扩展的中断源,必须要有一张映射表来管理。irq_domain就是这张映射表的抽象层。
拿GPIO中断举例,设备树里写interrupts-extended = <&gpio3 20 IRQ_TYPE_EDGE_RISING>,内核通过GPIO子系统和irq_domain,把这个“GPIO3控制器第20脚上升沿”转换成Linux中断号。这个中断号可能是个很小的数,也可能是个很大的动态分配数,你不用关心它具体是多少,只要让内核拿到这个号去申请中断就行。
移植时最直接的感受是:在旧平台用gpio_to_irq拿中断号,在新平台换了个GPIO控制器,这个函数可能就不好用了,改用gpiod_to_irq更通用。因为新版内核更推荐用GPIO descriptor框架,而不是整数GPIO号。
3. 驱动移植中中断部分的标准动作:从设备树到request_irq
3.1 设备树里中断属性怎么配:interrupts、interrupt-parent、interrupt-names
设备树是驱动移植的第一道关卡。一个设备节点要使用中断,至少要有interrupt-parent和interrupts两个属性。
interrupt-parent指定这个设备挂在哪个中断控制器下。如果节点没有显式写,就继承父节点的interrupt-parent。在SoC内部,很多外设的中断都统一挂在GIC下面,所以通常在根节点写一个interrupt-parent = <&gic>,子节点不写也能继承。但如果你外设的中断是经过GPIO控制器转发的,那这个属性就必须明确指向对应的GPIO控制器节点。
interrupts属性的格式取决于中断控制器的#interrupt-cells定义。对ARM GIC来说,常见的是三单元格写法:第一个是中断类型(0表示SPI共享外设中断,1表示PPI私有外设中断),第二个是中断号,第三个是触发类型(1表示上升沿,2表示下降沿,4表示高电平,8表示低电平)。比如interrupts = <0 45 4>表示SPI 45号中断,高电平触发。
另外还有个interrupt-names属性,它给interrupts里的每个中断起个名字。这个名字配合platform_get_irq_byname用起来非常方便,尤其是驱动里要申请多个不同作用的中断时,不用记硬编码序号了。我在移植驱动时,习惯先把设备树里这几个属性反复核对,因为这直接影响后面platform_get_irq返回的数值。
这里列一个我在RK平台上常用的配置对照:
| 项目 | 写法 | 说明 |
|---|---|---|
| GIC标准SPI | interrupts = <0 45 4> | 0=SPI,45为中断号,4=高电平触发 |
| GIC标准PPI | interrupts = <1 13 1> | 1=PPI,13为物理定时器中断,1=上升沿 |
| GPIO扩展中断 | interrupts-extended = <&gpio3 20 IRQ_TYPE_EDGE_RISING> | 必须用extended,否则没法区分中断控制器 |
| 多个中断 | interrupts = <0 30 4>, <0 31 4>; interrupt-names = "rx", "tx"; | 可直接用by name获取 |
3.2 申请中断的几条路径:request_irq、request_threaded_irq、devm_版本
驱动代码里申请中断最常见的两个函数是request_irq和request_threaded_irq,以及它们的devm_版本。它们的核心区别在于中断处理运行在什么上下文。
request_irq注册的是标准中断处理函数,运行在硬中断上下文。这个函数里不允许调用任何可能睡眠的操作,比如kmalloc(GFP_KERNEL)、mutex_lock、msleep等。很多老驱动直接在中断里干重活,导致系统卡顿甚至死锁。
request_threaded_irq则是把中断处理拆成两部分:第一个参数handler是快速处理函数,运行在硬中断上下文,通常只做ack和置标志;第二个参数thread_fn是线程化处理函数,运行在独立的内核线程中,可以睡眠,可以调用各种锁。如果你只传了thread_fn而handler传NULL,内核会使用默认的irq_default_primary_handler,相当于整个处理过程全部线程化。
devm_request_irq和devm_request_threaded_irq是带有资源管理的版本。它们把free_irq的调用绑定到设备驱动的remove流程里,设备卸载时自动释放。这能省掉不少麻烦,尤其是设备可能被反复解绑绑定时,忘了释放中断会导致第二次申请直接返回-EBUSY。
选择建议很直接:中断处理里只做寄存器读写、置标志等极短操作,用request_irq;处理里有数据拷贝、协议解析、I2C读写等耗时或可能睡眠的操作,用request_threaded_irq。在驱动移植阶段,我的默认选项是devm_request_threaded_irq,省心且安全。
3.3 中断标志和电源管理相关的坑
中断标志直接决定硬件在什么条件下触发中断。设备树里写IRQ_TYPE_EDGE_FALLING、IRQ_TYPE_LEVEL_HIGH这些值,最后会通过irq_set_irq_type传给中断控制器。
这里有个特别容易踩的坑:边沿触发和电平触发的ack方式不一样。边沿触发的中断,硬件在检测到边沿后,即使外部信号没有恢复,也不会再次触发,除非产生新的边沿。而电平触发不一样,只要电平条件一直满足,就会持续产生中断请求,所以驱动处理完之后必须确保外部电平被清除,否则会形成中断风暴。我之前移植一个马达驱动,设备树里配了高电平触发,但硬件上中断脚在正常工作时一直保持高电平,结果中断触发一次之后没完没了,CPU占用居高不下。后来把触发方式改成上升沿,问题立刻消失。
电源管理也容易出问题。系统进入suspend后,如果设备的中断被配置成唤醒源,那么中断信号到来时应该能唤醒系统。这需要调用enable_irq_wake。我在移植一个RTC唤醒功能时,发现中断能唤醒,但唤醒之后系统会卡在某个驱动里,查了半天,是另一个设备共享了同一个中断号,它的irqaction里没有处理唤醒路径,导致中断通知链断了。所以多设备共享中断时,要考虑清楚哪些中断需要irq_wake能力。
4. 中断处理流程与上下文:上半部下半部线程化,别在中断里睡觉
4.1 中断进来之后内核做了什么:GIC→通用中断层→驱动回调
当硬件中断信号到达GIC时,GIC会判断优先级,选择当前可以中断的CPU,并把中断号通过硬件接口送给CPU。CPU响应中断后,会跳转到内核预置的异常向量表,在ARM64平台上就是el1_irq入口。
从这里开始,内核会先读中断控制器,确认具体中断号,然后进入通用的handle_irq流程。这个流程会根据中断类型调用不同的处理函数,比如handle_level_irq或handle_edge_irq。通用层做的事情包括:屏蔽中断、ack、把中断标记为pending,然后遍历这个中断号对应的irqaction链表,依次调用驱动注册的回调函数。
如果是线程化中断,通用层不会直接运行thread_fn,而是唤醒对应的内核线程。整个流程跑完后,通用层会调用irq_chip的unmask恢复中断,使得下一次中断可以继续进来。
在驱动移植时,你不需要自己实现这块逻辑,但你要能分辨出问题发生在哪一层。打个比方:如果中断计数一直是0,问题在硬件路由或设备树映射;如果计数在涨但你的回调没执行,问题在irqaction链表或线程化调度;如果回调执行了但功能不对,那才是驱动本身逻辑的问题。
4.2 上半部、下半部、线程化中断怎么选
Linux中断处理下半部机制有软中断、tasklet、workqueue、线程化中断等。它们是按“能够在什么上下文运行”和“何时运行”来区分的。
上半部就是你的handler,它在硬中断上下文,必须是原子的、快速的。下半部是为了把耗时的处理推迟到更安全的环境中去。传统驱动喜欢用tasklet,但现在我越来越推荐线程化中断,原因很简单:tasklet运行在软中断上下文中,仍然不能睡眠,而线程化中断可以睡眠,可以用各种锁,调试起来也直观,每个线程化中断对应一个名叫irq/xxx-driver的内核线程,用ps一眼就能看到。
不过线程化中断也不是万能的。它需要额外的一次上下文切换,对于极高频率的简单中断(比如网络包接收),直接硬中断处理加软中断通常吞吐更高。驱动移植时如果性能不是极致敏感,优先用线程化,等后面性能摸底再决定要不要优化。
我列一个选择矩阵,方便你根据场景快速决定:
| 中断处理内容 | 推荐方式 | 原因 |
|---|---|---|
| 只清中断标志、读一个寄存器 | request_irq | 开销最小 |
| 稍复杂的操作,但不睡眠、不占用锁太久 | request_irq + tasklet | 避免线程切换 |
| 有I2C/SPI读写、数据解析、锁竞争 | request_threaded_irq | 可睡眠,安全 |
| 处理内容不明确,移植阶段先保实际再用 | devm_request_threaded_irq | 默认安全选项 |
4.3 实测经验:中断上下文里的限制清单
在硬中断上下文里,哪怕只是request_irq的handler部分,也有几条铁律:
- 不能调用
msleep、ssleep等任何睡眠函数。 - 不能调用
mutex_lock、down_interruptible等会睡眠的锁。 - 不能调用
kmalloc(GFP_KERNEL),要用GFP_ATOMIC。 - 不能直接
copy_to_user/copy_from_user。 - 不能调用
printk太多次,否则会造成控制台锁阻塞。
这些限制不是随便说说的。我曾经在一款触摸屏驱动的下半部处理里用了mutex_lock,而另一个线程恰好持锁并准备调度,结果直接触发“scheduling while atomic”内核报错,整个系统崩溃。后来换成线程化中断,把锁放在thread_fn里,一切恢复正常。
另外还有个细节:中断上下文里访问寄存器要用readl/writel,但要确保对应的寄存器地址已经映射过,且操作尽量不要跨总线长时间等待。有些外设访问一个寄存器要几百微秒,在中断里这么干会导致整个系统中断延迟飙升。
5. 移植中常见的中断问题与排查方法
5.1 申请中断失败:-EBUSY、-EINVAL、-ENOMEM的排查
request_irq返回值是驱动工程师最常看的错误码之一。每种错误码都有对应的排查方向。
如果是-EBUSY,说明你要申请的中断号已经被别人占了。常见原因有:设备树里的中断号配错了,比如好几个设备都指向同一个中断;驱动重复注册了两次,第一次忘记释放;或者使用了共享中断但没有传IRQF_SHARED。我之前遇到一个情况,两个I2C设备在设备树里都写了同一个中断号,第二个驱动加载自然失败。
如果是-EINVAL,通常是参数不合法,比如irq为负数,或者handler和thread_fn同时为NULL,又或者中断号对应的irq_desc状态不对。移植时特别要注意platform_get_irq的返回值——旧内核返回0表示失败,新内核很多时候返回负的错误码。如果直接把返回值当成中断号传入request_irq,就会得到-EINVAL。
如果是-ENOMEM,那就是内核分配内存失败,常见于系统内存紧张,或者irqaction分配时用了不合适的GFP标志。这种情况较少见,真遇到了先看看free -m,如果内存没问题,多半是irq_domain映射数量到了上限。
5.2 中断没触发或乱触发:设备树极性、共享中断、去抖
中断没触发,先别急着怀疑硬件,先看/proc/interrupts里对应中断号的计数。如果计数为0,说明中断信号压根没进来;如果计数有增长但你的驱动没反应,才查自己的回调。
设备树极性是头号嫌疑。比如你的外设中断脚低电平有效,但设备树里写了IRQ_TYPE_EDGE_RISING,那只有从低到高的那次跳变才触发,之后维持低电平时不触发,结果就是有中断但只有一次,后面全丢了。排查办法很简单:先用cat /proc/interrupts触发几次外设操作,对比计数增长,如果增长次数远小于预期,八成是极性不对。
乱触发也有几种典型原因。一是GPIO去抖问题,物理按键这类信号会有抖动,一个按下动作会产生多个上升沿,这时需要软件滤波,或者在设备树里配置GPIO的debounce属性。二是共享中断没有正确识别设备ID,导致一个设备中断把另一个设备的handler也唤醒了,虽然无伤大雅但会影响性能。三是电平触发没有清中断源,这个前面说过,会造成中断风暴。
5.3 性能与延迟:中断风暴、softirq堆积、优先级反转
中断风暴是我最头疼的问题,表现是CPU占用率居高不下,/proc/interrupts里某个中断计数疯狂上涨。常见原因就几种:
- 触发方式配成了电平触发,但设备或驱动没有清除中断源。
- 中断处理函数里有耗时操作,处理期间再次触发,导致pending积压。
- GIC配置了错误的中断优先级,导致低优先级中断反复抢占。
定位时可以先用perf top看哪个CPU的softirq或irq占比高,再结合/proc/interrupts找到那个疯狂上涨的中断号,然后看对应驱动代码,重点检查irq_chip的ack和mask操作。
另外有个容易被忽略的点:高优先级中断长时间占用CPU,会导致低优先级中断被饿死,这是“优先级反转”的一种。特别是在有两个共享中断控制器的大系统里,一个网络中断频率极高,把另一个应用中断的延迟拖到几百毫秒。这种时候要给关键中断设置合理的优先级,或者用线程化中断把高优先级中断降低到普通线程调度。
5.4 排查工具:/proc/interrupts、/proc/irq、trace、cat /proc/irq/xxx
排查中断问题,我的工具箱里这几个工具是必备的。
/proc/interrupts是第一个要看的。它按CPU列表显示每个中断号触发的次数。如果某个中断计数在一侧CPU上不涨,说明中断亲和性有问题,或者这个中断只路由到了另一个CPU。
/proc/irq/<num>/目录下面有affinity_hint、smp_affinity等文件。我经常修改smp_affinity把高频中断绑到一个独立CPU上,避免多个中断抢同一个核。写入的是十六进制掩码,比如2表示CPU1。
trace则更适合追踪内核里中断处理的真实路径。打开/sys/kernel/debug/tracing相关节点,设置irq_handler_entry和irq_handler_exit事件,就能看到每个中断进入和退出的时刻、CPU、耗时。我在定位一个中断延迟问题时,靠这个找到了一个网卡底层的spin_lock冲突。
还有个小技巧:有些平台支持cat /proc/irq/<num>/spurious,能看到虚假中断计数。虚假中断次数过多,往往是硬件线缆太长、电气噪声导致,低端板子上很常见。
6. 中断子系统移植的进阶话题与我的体会
6.1 多核环境下的中断亲和性与分发策略
现在的SoC基本都是多核的,GIC可以把中断路由到不同的CPU核心。驱动移植时,如果不做任何设置,中断可能集中在CPU0上,导致CPU0负载很高,其他核心却闲着。
通过/proc/irq/<num>/smp_affinity可以调整亲和性。比如你想把中断绑到CPU2和CPU3,可以写入0xC(二进制1100)。但要注意,有些中断控制器不支持动态设置亲和性,特别是某些GPIO扩展芯片,它的中断经过链式中断处理之后,设置亲和性可能无效。
如果真的对性能有要求,还有个思路是在设备树或驱动里设置interrupts-affinity属性,或者在中断控制器的驱动里实现irq_set_affinity回调。但这属于中断控制器驱动开发的范畴了,普通驱动移植阶段一般不用动。
我比较推荐的实践经验是:先量测默认情况下的中断分布,如果单个CPU中断处理时间超过总CPU占用的40%,再考虑手动绑核。绑核之后,要同时把相关线程的CPU亲和性也调好,否则线程可能还在另一个核上等待,延迟反而增加。
6.2 关于驱动移植中断部分的时间投入和坑位总结
回看我这些年做过的驱动移植,花在中断上的调试时间,差不多能占整个移植周期的一半。剩下的一半里,设备树、时钟、电源管理占大头,真正的数据通路反而比较容易。
所以我的建议是:拿到一个新平台,先不要急着跑驱动,先花半天把中断子系统的基本盘摸清楚。具体做法包括:
- 确认设备树里所有设备节点中断号是否都在
/proc/interrupts里有对应项。 - 确认GIC的
#interrupt-cells和中断控制器的驱动是否正常。 - 用
cat /proc/interrupts在系统空闲时记录一条基线,之后出了任何中断异常,先对比基线。
中断是异步的,问题往往随机发作,不像寄存器读写错误那样稳定复现。所以我格外强调日志意识:真机上开启dynamic_debug打印,挨个打开中断子系统的调试开关,定位问题会比瞎猜快很多。另外,能抓硬件信号就用示波器抓一下GPIO波形,很多时候设备树里的极性错不错,示波器一眼就能看出来。
最后再分享一个小技巧:如果你在移植过程中发现中断号对不上,不要直接在代码里硬编码中断号,一定要从设备树经platform_get_irq取。硬编码在换平台的时候就是一颗定时炸弹,而设备树的方式哪怕中断号变了,只要树改对了,驱动代码一个字都不用动。这也是我在后面无数次“救火”中体会最深的一条经验。