1. 中断子系统框架全景一次中断从硬件到驱动的完整旅程做Linux驱动移植最绕不开的一个坎就是中断。我之前接过一个项目把一套在A平台跑得好好的外设驱动搬到B平台结果设备树改完、时钟配好、寄存器映射也对了板子一跑就是卡死、丢中断、中断风暴轮着来折腾了将近一周才缓过劲。那次之后我算是彻底明白一件事不懂中断子系统的整体框架驱动移植就是盲人摸象。先说结论Linux中断子系统的整体框架可以抽象成三层——硬件中断源、中断控制器、CPU核。硬件中断源就是外设比如I2C控制器、网卡、GPIO按键它们在有事件发生时拉高或拉低一根中断线中断控制器是中间的“调度员”最常见的是ARM平台上的GICGeneric Interrupt Controller它负责收集各路中断信号、按优先级排队、决定送给哪个CPU核CPU核收到中断后会跳到一个固定的入口地址开始执行内核预先注册好的处理逻辑。在Linux内核里这三层分别对应了三套软件结构。硬件中断源对应struct irq_desc里的action链表每个action就是驱动通过request_irq()注册的中断处理函数中断控制器对应struct irq_chip里面是irq_mask、irq_unmask、irq_set_type、irq_ack这些回调也就是操作GIC或者GPIO控制器寄存器的方法CPU核对应irq_cpustat_t这类数据结构记录中断在每个核上的统计信息。这三层通过一个叫irq domain的映射机制串起来后面我会专门讲。一次完整的中断旅程是这样的外设产生中断信号GIC判断优先级后将这条中断分发给某个CPU核CPU响应后进入内核的irq_handler汇编入口然后会跳到handle_arch_irq()再到gic_handle_irq()从GIC寄存器里读出中断号转成Linux的IRQ号找到对应的irq_desc调用handle_irq_desc()最终执行到驱动注册的那个handler。整个过程从硬件信号到驱动回调可能是几微秒到几十微秒的级别任何一环脱落驱动的中断功能就会出问题。搞懂这个链路最直接的价值在哪里就是你调试的时候能分清责任。设备树中断属性写错了报错会出现在request_irq()阶段中断控制器驱动没配好问题在/proc/interrupts里能看到异常外设本身的中断标志没清干净就会表现为中断一直被触发、CPU占用率飙高。分清了责任排查就快了。2. 设备树里埋着的地雷interrupt属性与irq domain的映射关系在驱动移植里设备树中的中断相关属性往往是第一个坑。接触过RK、全志、NXP这些主流SoC的朋友应该都有体会不同平台的中断控制器型号不同设备树写法也会有些差异但核心逻辑是一致的interrupt-parent指定中断控制器interrupts指定中断号和触发方式。看一个典型的I2C控制器节点i2c0: i2cff160000 { compatible rockchip,rk3399-i2c; reg 0x0 0xff160000 0x0 0x1000; interrupts GIC_SPI 53 IRQ_TYPE_LEVEL_HIGH; interrupt-parent gic; ... };这里GIC_SPI表示这是一个共享外设中断Shared Peripheral Interrupt对应的还有GIC_PPI表示私有外设中断这个宏定义在dt-bindings/interrupt-controller/arm-gic.h里。后面的53是硬件中断号IRQ_TYPE_LEVEL_HIGH是触发类型。硬件工程师给你的原理图上如果标了“IRQ_LINE 53”你就要能在设备树里找到这个数字的对应位置。但注册驱动时用的IRQ号不等于这个“53”。驱动里一般通过platform_get_irq()从设备树获取IRQ号这个函数会调用irq_of_parse_and_map()也就是经过irq domain翻译之后得到的Linux IRQ号。所以设备树里写的是硬件中断号驱动里拿到的是Linux IRQ号中间的翻译过程就是irq domain。irq domain的结构可以理解成一个“翻译官”。GIC驱动初始化时会创建一个domain并且把自己支持的硬件中断范围注册进去比如GIC-400支持0到31的PPI、32到1019的SPI。当驱动请求翻译时domain的map回调负责创建对应的irq_desc并关联irq_chip。这个翻译关系可以在/proc/irq/目录下看到实际分配结果。移植驱动踩得最多的一个坑就是中断号写错位。比如原来的平台用的是GPIO中断硬件中断号是32你搬到新平台后依然想在设备树里写32但新平台的32可能完全不是同一个中断。所以每次移植都必须对照新平台的芯片手册和官方设备树重新确认外设挂在哪个中断控制器下面、硬件中断号是多少。不要想当然沿用旧值。另外一个容易忽略的点是触发类型。有的平台对电平触发和边沿触发的处理逻辑差异很大。比如GPIO按键在中断子系统中一般用边沿触发而I2C、UART这类总线设备通常用电平触发。如果设备树里写错了可能出现中断风暴或者漏中断。判断依据很简单电平触发要求中断处理函数返回时中断源已经被清除否则会一直触发边沿触发只要捕捉到跳变沿即可但短脉冲可能漏掉。关于irq domain还有irq_domain_add_simple()、irq_domain_add_linear()、irq_domain_add_tree()几种不同的添加方式这不是移植时改来改去的地方但理解它们能帮你读懂芯片厂给的BSP代码。linear适合中断号连续的情况tree适合中断号稀疏的情况simple现在基本不推荐了。查问题时如果发现某种中断类型映射不到IRQ号大概率是domain创建的size范围没覆盖到。3. 驱动侧的中断注册request_irq系列API和线程化中断的取舍驱动移植时中断注册的API选择直接影响后续稳定性。最基础的是request_irq()它的本质是request_threaded_irq()的一个封装把线程处理函数设为NULL。所以严格来说Linux里所有中断注册最终都走request_threaded_irq()只是有的用了线程化有的只用顶半部。先看普通注册方式static irqreturn_t my_irq_handler(int irq, void *dev_id) { /* 读取硬件寄存器确认中断源 */ /* 清除中断标志 */ return IRQ_HANDLED; } int ret request_irq(irq_num, my_irq_handler, IRQF_TRIGGER_RISING | IRQF_SHARED, my_device, dev_id); if (ret) { dev_err(pdev-dev, request_irq failed: %d\n, ret); return ret; }这段代码有两个细节值得琢磨。第一个是dev_id参数在共享中断里它必须是非空且唯一的因为内核会用它来区分同一个IRQ号上注册的多个handler。如果用NULLfree_irq()时内核没法精确匹配会报IRQ handler type mismatch之类的错误。第二个触发类型标志注册时指定的IRQF_TRIGGER_RISING和驱动初始化时设备树里配置的触发类型会做校验不一致的话有的内核版本会警告甚至拒绝注册。线程化中断是处理耗时操作的关键。普通中断处理函数运行在原子上下文不能调用可能睡眠的函数比如mutex_lock、kmalloc(..., GFP_KERNEL)、msleep都不行。一旦你违反这个规则内核会报BUG: sleeping function called from invalid context严重时直接死锁。线程化中断就是把handler放到内核线程里执行可以安全地使用上述阻塞操作。注册线程化中断有两种方式。一是直接调用request_threaded_irq()并传入线程处理函数static irqreturn_t my_irq_thread(int irq, void *dev_id) { /* 这里可以安全地使用mutex和其他可能睡眠的函数 */ return IRQ_HANDLED; } request_threaded_irq(irq_num, my_irq_handler, my_irq_thread, IRQF_TRIGGER_RISING, my_device, dev_id);这里要注意my_irq_handler依然在硬中断上下文运行它通常是快速处理比如读状态寄存器、禁用中断然后返回IRQ_WAKE_THREAD内核才会唤醒线程去执行my_irq_thread。二是注册时只传主handler线程函数设为NULL等运行时调用irq_wake_thread()这种方式在有些驱动里用来处理唤醒事件。我个人的建议是简单的状态读取、标志清除、唤醒tasklet或workqueue的操作用普通handler就够了涉及I2C读写、硬件时序操作、需要和用户态交互的复杂逻辑优先选线程化中断。项目里我就见过一个把I2C读写放进普通中断的驱动结果每次触发都报调度器警告改成线程化之后整个世界安静了。还有一类特殊的中断注册要单独说就是共享中断。多个设备共用同一个IRQ号时必须全部以IRQF_SHARED注册而且每个handler里第一件事就是检查自己的设备是否真的有中断产生没有就返回IRQ_NONE让内核继续调用下一个handler。如果不做这个检查一个设备触发中断会唤醒所有注册者性能差还是小事有的驱动会误操作别人的寄存器那就成了大问题。4. 移植中高频出现的5类中断Bug复盘与排查链路驱动移植的中断问题翻来覆去就是那几类。我结合自己做过的几个项目把最常见的5类问题和排查思路整理一下。第一类是中断风暴中断一直触发CPU占用率彪到100%。排查链路从/proc/interrupts看对应中断号的计数开始如果它一直在涨说明硬件信号没有停。接着要确认handler有没有正确清除硬件中断标志。很多外设的中断标志是写1清零写0是无效操作。我见过一个驱动开发人员习惯性写0结果中断标志永远清不掉死循环。还有一种是没关中断源就去做耗时操作在返回前又被置起。建议在handler的最前面读状态寄存器、最后面确认标志已清。第二类是中断完全不触发。先查设备树中断号是否映射到了正确的Linux IRQ号可以通过/proc/interrupts确认有没有注册成功。再查触发类型有的芯片的GPIO中断需要配置上拉/下拉设备树里少了pinctrl相关配置也可能导致信号根本没到中断控制器。另外要检查时钟有没有开有些外设的中断来自其内部模块时钟时钟关了中断就永远不会有。第三类是中断号错乱。比如A设备触发中断B设备的handler却被执行了。这种基本可以断定是设备树中断号写错或者多个设备的中断连到了同一个物理中断线上。排查手段是把/proc/interrupts各个中断号的计数变化和实际外设操作对照很快就能分辨。第四类是共享中断互相干扰。两个设备注册到同一个IRQ号但没有正确处理IRQ_NONE导致一个设备的事件会触发所有共享handler。解决方法是每个handler开头都读取自己的硬件状态寄存器确认设备有事件才继续处理否则返回IRQ_NONE。第五类是中断上下文里干了不该干的事。这类问题一般不会立刻导致功能异常而是随机panic或死锁很难定位。排查方法是用CONFIG_PROVE_LOCKING和CONFIG_DEBUG_ATOMIC_SLEEP重新编内核打开这些选项后在中断上下文非法睡眠时内核会直接给你打出来相关调用栈非常直观。改法就是把耗时操作移到线程化中断、tasklet或workqueue里。这些Bug里面最隐蔽也最有意思的是中断风暴。之前做过一个nor flash驱动的移植现象是系统起来没几秒就卡死/proc/interrupts的计数每秒跳几十万。最初以为是GIC配置问题看了半天GIC的错误中断寄存器也没发现异常。最后是在示波器上看到flash的INT引脚一直是低电平才意识到这个外设的INT是电平触发而驱动注册成了边沿触发导致中断线低电平期间CPU被反复触发。改成IRQF_TRIGGER_LOW之后问题立刻消失。所以说触发类型不仅是软件层面的事情必须回到硬件信号的实际电平特性上去判断。5. 内核自带的中断体检工具/proc、dmesg和irq tracing定位法中断问题排查不全是靠看代码和翻手册内核提供了几个非常直接的工具用好它们能省掉大量盲目试验的时间。/proc/interrupts是第一必看项。它的每一行是一个IRQ号每一列是一个CPU核上的触发次数。正常状态下各中断号的计数值平滑增长如果你的外设没操作但某个中断号计数狂涨就是中断风暴的信号。还可以对比前后两次读取的差值配合外设的实际操作用来判断中断是否正常产生。/proc/irq/irq_num/目录下有几个可调参数比如smp_affinity可以设置中断绑定到哪个CPU核。中断绑核在性能优化时很实用尤其是在多核处理器上把网卡中断和业务进程绑到同一个核可以减少缓存抖动和跨核调度开销。移植时如果发现中断全部打到CPU0上导致单核繁忙可以手动调整。还有一个容易忽略的是/proc/irq/irq_num/spurious它记录了被内核判定为“虚假中断”的次数。如果这个数字异常增长说明中断信号有问题可能是硬件干扰、触发类型配置错误或者中断没有及时ack。dmesg里的中断相关日志也很关键。中断注册失败、dev_id不匹配、触发类型冲突都会打印告警。建议在移植阶段把内核的CONFIG_DEBUG_IRQ、CONFIG_PROVE_LOCKING、CONFIG_DEBUG_ATOMIC_SLEEP都打开虽然会影响一些性能但能换来极其明确的错误输出排查问题的速度快好几倍。更精确的定位工具是tracepoint和ftrace。举个例子我想确认一次中断从触发到handler执行到底花了多久可以这样操作echo 0 /sys/kernel/debug/tracing/tracing_on echo irq_handler_entry /sys/kernel/debug/tracing/set_event echo irq_handler_exit /sys/kernel/debug/tracing/set_event echo irq_disable /sys/kernel/debug/tracing/set_event echo irq_enable /sys/kernel/debug/tracing/set_event echo 1 /sys/kernel/debug/tracing/tracing_on # 复现触发中断的操作 cat /sys/kernel/debug/tracing/tracetrace输出里能看到每次中断的进入和退出时间戳两个时间戳之差就是这个handler的耗时。如果handler在几千毫秒这个量级滞留就只能考虑线程化中断。类似地也可以用irq_enable和irq_disable来观察一个中断被屏蔽的时间排查中断丢失的问题。我习惯的排查顺序是这样的先看/proc/interrupts确认问题方向是没触发、触发过多还是触发了但没到驱动再看dmesg找内核自己报的错然后回到设备树确认中断号和触发类型实在不行才用tracepoint去测精确的时间线。按这个顺序走下来大部分中断问题都能在半小时内定位到根因而不是靠碰运气改配置。6. 移植前的必做功课中断子系统相关的内核配置与硬件确认清单最后说一说真正动手之前应该做哪些准备。这部分的收益是最高的因为很多中断问题的根源在开始移植前就埋下了。首先是内核配置。中断子系统相关的选项是CONFIG_GENERIC_IRQ这个是基础一般都会打开。接着是CONFIG_ARM_GIC系列确保当前平台的GIC驱动被编译进内核。还有一个容易被忽视的CONFIG_IRQ_DOMAIN现在几乎所有平台都依赖它来做中断号映射。如果设备树解析中断时总是报错先检查这几个配置是否正常开启。其次是硬件确认。拿着原理图和芯片手册把每个外设的中断引脚接到哪个中断控制器、硬件中断号是多少、电平极性是高还是低列一张表。这张表就是你改设备树的依据。不要直接抄旧平台设备的参数同一款外设挂在不同类型的中断控制器下中断号的语义完全不一样。然后是BSP代码研读。每个Linux BSP发布会带一个中断控制器的驱动实现阅读drivers/irqchip/下对应平台的代码搞清楚它的irq domain是怎么建立的、irq_chip里的回调是否有特殊处理、是否支持irq_set_type。有些平台的GIC驱动默认不解析设备树里的触发类型需要在board代码里调用irq_set_irq_type()这种情况不提前发现设备树怎么改都没用。我做中断移植会额外关注芯片手册上的“interrupt clearing”小节。很多外设的中断清除不是简单的写寄存器涉及到读后清read-to-clear、写1清write-1-to-clear、以及需要对某个状态寄存器先读再写的顺序要求。驱动注册handler之前不弄清楚这一点后面中断风暴几乎是必然的。内核文档里还有一篇叫Documentation/core-api/genericirq.rst的虽然是新的文档体系但里面关于struct irq_chip和struct irq_domain的描述非常值得仔细读。我在带新人时总是让他们先读这部分文档再去改代码效果比直接看网上零散的博客好得多。把框架吃透再进入实际的代码搬运你才能知道哪些地方能直接沿用旧平台哪些地方必须重写。
