1. 中断子系统到底在解决什么问题搞过Linux驱动移植的人都有一个共同体会GPIO、I2C、SPI这些外设驱动写起来虽然繁琐但至少逻辑是线性的——初始化、读写、卸载按部就班就行。可一旦碰到中断事情就变得微妙起来。按键按下去要响应、网卡收到包要通知协议栈、定时器到期要触发回调这些场景背后都指向同一个内核机制中断子系统。中断子系统的本质是CPU和外设之间的一套“异步通知协议”。外设不需要CPU一直轮询它“你有没有事”而是有事的时候主动拉一根线告诉CPU。听起来简单但内核要处理的问题远不止“收到信号然后调用函数”这么单纯。谁注册了这个中断、中断来了先给谁处理、处理到一半能不能被打断、多个CPU同时收到中断怎么办、中断上下文里不能睡眠怎么保证数据安全——这些问题叠加在一起就构成了一个相当复杂的框架。我这次做的移植工作目标平台是一颗国产SoC内核版本5.10需要把板载的按键、串口和一颗以太网PHY的中断全部打通。过程中把中断子系统从设备树描述到驱动注册、从控制器初始化到上半部下半部拆分完整地捋了一遍。这篇文章就把整个框架拆开讲清楚适合正在做驱动移植、被中断问题卡住的同行参考也适合想从“会用”进阶到“懂原理”的驱动开发者。2. 中断子系统的整体架构与设计思路2.1 从硬件到软件的分层模型中断子系统在Linux内核里是一个典型的分层架构从下往上大致可以分成四层。最底层是硬件中断控制器比如GIC、NVIC或者SoC内部的各种GPIO中断控制器它们负责接收外设的中断信号线做优先级仲裁然后向CPU核心发出中断请求。往上一层是中断控制器驱动也就是irqchip驱动它把不同厂商、不同型号的中断控制器抽象成统一的接口向上提供irq_desc的管理能力。再往上是通用中断处理层这是内核中断子系统的核心负责irq_desc的分配、中断流的处理、上下半部的调度。最上面是设备驱动层也就是我们写驱动时调用的request_irq、free_irq这些接口。这个分层的好处是显而易见的。设备驱动不需要关心底层是GIC还是其他控制器只需要申请一个中断号注册一个处理函数就行。中断控制器驱动也不需要关心上层是谁在用中断只需要把硬件能力通过标准接口暴露出来。中间层做翻译和调度各层职责清晰。我在移植时踩的第一个坑就在这里。设备树里写了一个按键节点interrupt-parent指向GPIO控制器interrupts属性写了引脚号和触发方式。但驱动加载后request_irq一直返回-EINVAL。排查了半天才发现GPIO控制器的节点里缺少interrupt-controller属性导致内核在解析设备树时根本没把它识别成中断控制器映射关系建立不起来。这个问题的根因就是分层模型里“中断控制器驱动”这一层没有正确注册。2.2 设备树中的中断描述逻辑设备树是中断子系统在ARM平台上的入口。一个完整的中断描述涉及三个关键属性interrupt-parent、interrupts和interrupt-controller。interrupt-parent指定这个设备的中断信号接到哪个中断控制器上。如果父节点已经指定了子节点可以继承不必重复写。interrupts属性描述具体的中断信息它的格式由父控制器的#interrupt-cells决定。比如一个GPIO控制器如果#interrupt-cells 2那么interrupts里就要写两个cell通常是引脚编号和触发类型。触发类型在include/dt-bindings/interrupt-controller/irq.h里有定义常用的有IRQ_TYPE_EDGE_RISING、IRQ_TYPE_EDGE_FALLING、IRQ_TYPE_LEVEL_HIGH、IRQ_TYPE_LEVEL_LOW。这里有个经验按键一般用边沿触发因为按下和松开是一个瞬态事件而像PHY芯片的中断引脚很多是低电平持续有效就要用电平触发。选错了触发方式要么中断丢失要么中断风暴。中断控制器的节点本身需要两个属性interrupt-controller空属性标记自己是个控制器和#interrupt-cells描述子节点需要几个cell。如果控制器还支持级联那它自己也要有interrupt-parent和interrupts指向上一级控制器。这种级联结构在SoC里很常见GPIO控制器级联到GIC上外设再挂到GPIO控制器上。2.3 中断号映射从硬件号到虚拟号这是中断子系统里最容易让人迷糊的部分。硬件中断号是中断控制器物理上的编号比如GIC的SPI中断从32开始编号。但Linux内核里用的是虚拟中断号也叫Linux IRQ号。为什么要多一层映射因为系统里可能有多个中断控制器每个都有自己的编号空间直接混用会冲突。虚拟号是一个全局唯一的整数内核通过irq_domain机制来管理映射关系。irq_domain的翻译过程是这样的设备树解析时内核根据interrupt-parent找到对应的irq_domain然后调用domain的xlate函数把interrupts属性里的cell解析成硬件中断号再通过map函数分配一个虚拟中断号建立映射关系。驱动里request_irq用的就是虚拟号。在移植时我建议在驱动probe函数里加一句打印把platform_get_irq返回的虚拟号和设备树里的硬件号都打出来方便对照。有时候设备树写错了虚拟号会返回-EPROBE_DEFER或者负数这时候就要检查irq_domain有没有正确建立。3. 核心数据结构与关键接口解析3.1 irq_desc中断描述符irq_desc是中断子系统里最核心的数据结构每个虚拟中断号对应一个irq_desc实例。它记录了这个中断的所有信息中断号、中断控制器的引用、处理函数链表、中断状态标志、上下半部的统计数据等。irq_desc里的action是一个链表头挂的是irqaction结构。为什么是链表因为Linux允许同一个中断号上注册多个处理函数也就是共享中断。每个irqaction记录了一个处理函数指针、设备名、dev_id、触发标志等信息。中断到来时内核会遍历这个链表依次调用每个handler直到某个handler返回IRQ_HANDLED表示已经处理。irq_desc还有一个重要的字段是irq_data它包含了irq_chip指针和irq_domain指针。irq_chip是中断控制器的操作集定义了mask、unmask、ack、eoi等回调。不同的中断控制器实现自己的irq_chip通用层通过这个接口来控制硬件。我在调试时经常用cat /proc/interrupts来看中断的分布情况。这个文件就是遍历irq_desc数组打印出来的每一行显示中断号、中断次数、中断控制器名称和注册的设备名。如果某个中断的计数一直是0说明要么触发方式配错了要么硬件根本没产生中断。3.2 request_irq与request_threaded_irqrequest_irq是最常用的中断注册接口它的原型是int request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev);irq是虚拟中断号handler是中断处理函数flags是标志位name是设备名dev是传给handler的参数。flags里常用的有IRQF_TRIGGER_RISING、IRQF_TRIGGER_FALLING、IRQF_SHARED、IRQF_ONESHOT等。这里有个细节如果设备树里已经描述了触发方式驱动里就不需要再指定IRQF_TRIGGER_*了内核会从设备树解析。但如果驱动里指定了会覆盖设备树的值。我一般建议触发方式统一在设备树里配驱动里不写这样硬件相关的信息集中在一处便于维护。request_threaded_irq是升级版它允许把中断处理拆成上半部和下半部两个函数int request_threaded_irq(unsigned int irq, irq_handler_t handler, irq_handler_t thread_fn, unsigned long flags, const char *name, void *dev);handler是上半部在中断上下文里执行不能睡眠要尽快返回。thread_fn是下半部在一个内核线程里执行可以睡眠可以做耗时操作。如果handler传NULL内核会用默认的上半部直接唤醒线程执行thread_fn。这个接口在移植时特别有用。比如按键中断上半部只需要确认中断来源、清中断标志然后返回IRQ_WAKE_THREAD唤醒线程下半部里做去抖、上报输入事件这些可能耗时的操作。这样既保证了中断响应的实时性又不会在中断上下文里做危险操作。3.3 中断上下半部的拆分原则上半部和下半部的拆分是中断子系统设计里的精髓。核心原则只有一条上半部做必须立刻做的事下半部做可以延后做的事。什么是必须立刻做的读硬件寄存器确认中断状态、清除中断标志、保存关键数据到内存。这些操作如果拖延可能导致中断丢失或者硬件状态被覆盖。什么是可以延后的数据处理、内存分配、锁操作、与外设的慢速通信。这些操作如果放在上半部会拉长中断关闭时间影响系统实时性。下半部的实现机制有三种softirq、tasklet和工作队列。softirq是静态分配的数量有限一般用于网络、块设备这些高性能场景。tasklet基于softirq实现可以动态注册运行在软中断上下文不能睡眠。工作队列运行在内核线程上下文可以睡眠适合需要等待的场景。在驱动移植中如果下半部不需要睡眠tasklet是最轻量的选择。如果需要睡眠比如要通过I2C读取传感器数据那就必须用工作队列或者threaded irq。我这次移植的PHY中断下半部需要读PHY寄存器走的是MDIO总线虽然MDIO本身不睡眠但为了代码清晰还是用了threaded irq。4. 中断控制器驱动的移植实操4.1 设备树节点的编写与检查中断控制器驱动的移植第一步是设备树。以GPIO中断控制器为例节点大概长这样gpio0: gpioff720000 { compatible vendor,gpio; reg 0x0 0xff720000 0x0 0x1000; gpio-controller; #gpio-cells 2; interrupt-controller; #interrupt-cells 2; interrupts GIC_SPI 32 IRQ_TYPE_LEVEL_HIGH; };关键属性是interrupt-controller和#interrupt-cells。前者告诉内核这是个中断控制器后者告诉内核解析子节点中断时需要几个cell。interrupts属性表示这个控制器本身级联到GIC的哪个中断上。写完之后一定要检查compatible字符串是否和驱动里的of_match_table匹配、reg地址是否和手册一致、interrupts的GIC中断号是否正确。我遇到过一次reg地址写错了一位导致控制器寄存器读写全部失败中断自然也就无法工作。4.2 irq_domain的创建与映射在驱动probe函数里中断控制器驱动需要创建irq_domain。常用的接口是irq_domain_add_linear它创建一个线性映射的domaindomain irq_domain_add_linear(node, num_irqs, vendor_irq_domain_ops, priv);num_irqs是这个控制器支持的中断数量ops里最重要的是xlate和map两个回调。xlate负责把设备树里的cell解析成硬件中断号map负责分配虚拟中断号并建立映射。如果控制器比较简单也可以直接用irq_domain_add_simple它会自动分配虚拟号。但simple方式在中断号较多时效率不高一般推荐linear。创建完domain后还需要实现irq_chip。irq_chip里至少要实现irq_mask、irq_unmask、irq_ack这几个回调。mask用于屏蔽中断unmask用于使能中断ack用于应答中断。有些控制器还需要irq_set_type来设置触发方式。4.3 中断处理流程的注册控制器驱动初始化完成后外设驱动就可以通过platform_get_irq获取虚拟中断号然后request_irq注册处理函数。platform_get_irq内部会调用irq_of_parse_and_map从设备树解析中断信息并映射成虚拟号。这里有个常见问题如果控制器驱动还没加载platform_get_irq会返回-EPROBE_DEFER外设驱动应该返回这个错误内核会稍后重试probe。很多新手驱动里直接把这个错误当成失败处理导致外设永远加载不了。正确的做法是irq platform_get_irq(pdev, 0); if (irq 0) return irq; // 直接返回让内核处理EPROBE_DEFER4.4 中断触发方式的配置陷阱触发方式的配置是移植中最容易出错的地方。设备树里写的是IRQ_TYPE_EDGE_RISING但实际硬件可能是低电平有效结果就是中断要么不来要么来了不停。判断触发方式的方法查硬件手册里中断引脚的电气特性看是边沿敏感还是电平敏感是高有效还是低有效。按键一般用边沿因为按下和松开是瞬态事件。但有些按键电路加了RC滤波信号边沿变缓这时候用电平触发可能更可靠。还有一个坑如果中断引脚是开漏输出外部有上拉电阻那么空闲时是高电平有效时拉低应该配IRQ_TYPE_LEVEL_LOW或者IRQ_TYPE_EDGE_FALLING。如果配成IRQ_TYPE_LEVEL_HIGH中断会一直触发。我在调试PHY中断时手册上写的是低电平有效但设备树里配成了下降沿。结果PHY插入网线时中断能来一次但拔掉网线后中断就不来了因为电平一直保持低边沿触发只认跳变。改成IRQ_TYPE_LEVEL_LOW后问题解决。5. 中断调试与问题排查实录5.1 常用调试手段汇总中断问题的调试内核提供了不少工具。最常用的是/proc/interrupts它能显示每个中断号的中断次数、控制器名称和注册的设备名。如果某个中断计数不增长说明硬件没有产生中断或者触发方式配错了。如果计数增长异常快可能是中断风暴通常是触发方式配错或者中断没有正确应答。/proc/irq/目录下每个中断号有一个子目录里面有smp_affinity、trigger_type等文件。smp_affinity可以设置中断绑定到哪个CPU核心trigger_type可以查看和修改触发方式。调试时可以用echo命令修改trigger_type来验证触发方式是否正确。ftrace也是利器。通过/sys/kernel/debug/tracing/events/irq/可以打开中断相关的事件跟踪看到中断的进入和退出、软中断的触发等。如果中断处理函数里有耗时操作ftrace能直观地显示出来。5.2 典型问题与排查路径问题现象可能原因排查方法request_irq返回-EINVAL中断号无效或触发方式冲突检查设备树interrupts属性确认irq_domain已建立中断计数不增长触发方式错误或硬件未使能用示波器测中断引脚检查irq_chip的unmask是否调用中断计数暴涨中断未应答或电平触发配置错误检查irq_ack实现确认触发方式与硬件匹配系统卡死中断上下文睡眠或死锁检查上半部是否有睡眠操作用lockdep排查锁问题中断偶尔丢失上半部执行时间过长用ftrace测量中断处理时间拆分上下半部5.3 中断上下文编程的禁忌中断上下文里不能做哪些事不能睡眠、不能获取可能睡眠的锁、不能访问用户空间内存、不能调用可能阻塞的函数。这些禁忌的根源是中断上下文没有进程上下文一旦睡眠就没有办法被唤醒会导致系统挂死。具体来说kmalloc要用GFP_ATOMIC而不是GFP_KERNELmutex不能用要用spinlockcopy_to_user不能用要用copy_to_user之外的替代方案。如果确实需要睡眠操作必须放到下半部或者threaded irq里。我踩过的一个坑是在上半部里调用了i2c_transfer去读传感器状态。i2c_transfer在某些情况下会睡眠结果系统随机卡死。后来改成threaded irq上半部只清中断标志下半部做I2C读取问题消失。5.4 共享中断的注意事项共享中断是多个设备共用一根中断线的情况。注册共享中断时flags里必须加IRQF_SHARED而且dev_id必须唯一不能传NULL。中断到来时内核会遍历所有共享这个中断的handler每个handler都要检查是不是自己的设备产生了中断如果是就处理并返回IRQ_HANDLED如果不是就返回IRQ_NONE。这里有个性能问题如果共享中断上的设备很多每个中断都要遍历所有handler效率会下降。所以共享中断一般用在低速设备上高速设备尽量用独立中断。还有一个坑如果某个handler总是返回IRQ_HANDLED即使不是它的中断会导致其他handler永远没机会执行。所以handler里一定要先读硬件状态寄存器确认中断来源不是自己的就立刻返回IRQ_NONE。6. 移植后的验证与性能调优6.1 功能验证的完整流程移植完成后验证要分几步走。第一步是确认中断能注册成功dmesg里没有request_irq失败的错误。第二步是触发硬件事件看/proc/interrupts里对应中断的计数是否增长。第三步是确认处理函数的逻辑正确比如按键中断能正确上报键值网卡中断能正确收包。如果中断计数增长但处理函数没执行可能是handler返回值不对或者中断被错误地屏蔽了。如果处理函数执行了但结果不对就要检查下半部的逻辑和数据传递。6.2 中断延迟的测量与优化中断延迟是从硬件产生中断到处理函数开始执行的时间。测量方法可以用GPIO翻转加示波器也可以在中断处理函数里读时间戳。优化中断延迟的手段包括提高中断优先级、绑定中断到特定CPU、减少上半部工作量、关闭不必要的中断嵌套。在ARM平台上GIC支持中断优先级配置高优先级的中断可以抢占低优先级的。如果系统里有实时性要求高的中断可以在设备树里配优先级或者在irq_chip里实现set_priority回调。6.3 中断负载的监控中断负载过高会导致系统响应变慢。监控方法除了看/proc/interrupts的计数增长速率还可以用mpstat看每个CPU的软中断占比。如果软中断占比超过30%说明下半部处理压力大需要考虑优化算法或者增加CPU核心。我这次移植的以太网中断在千兆满速收包时软中断占比接近50%后来开启了NAPI和RPS把软中断分散到多个CPU核心占比降到15%左右系统流畅度明显提升。6.4 电源管理中的中断处理如果设备支持休眠唤醒中断子系统还要和电源管理配合。唤醒中断需要在设备树里标记wakeup-source驱动里调用enable_irq_wake。休眠时非唤醒中断会被屏蔽唤醒中断保持使能但可能切换到不同的中断控制器比如从GIC切换到always-on的唤醒控制器。这里有个坑enable_irq_wake和disable_irq_wake必须成对调用否则引用计数会不平衡导致休眠失败或者唤醒异常。我在移植时因为漏了一次disable_irq_wake系统休眠后无法唤醒排查了很久才发现是引用计数问题。7. 一些个人经验与建议中断子系统的移植说到底是对硬件和内核机制的双重理解。硬件方面要清楚中断线的电气特性、控制器的寄存器布局、触发方式的物理含义。内核方面要理解irq_domain的映射逻辑、上下半部的拆分原则、中断上下文的编程限制。我的建议是移植前先把硬件手册的中断章节读透把设备树的中断描述写对这是基础。移植中多用/proc/interrupts和ftrace观察中断行为遇到问题先确认硬件有没有产生中断再确认内核有没有正确响应。移植后做压力测试观察中断负载和系统延迟必要时做绑定和优先级调优。还有一点中断子系统的代码在内核里是相对稳定的但不同版本之间还是有差异。比如5.10和6.1在irq_domain的接口上就有细微变化。移植时最好参考和目标内核版本一致的文档和示例代码不要直接照搬老版本的写法。最后分享一个实用技巧如果中断问题实在难以定位可以在irq_chip的mask、unmask、ack回调里加打印观察中断控制器的操作序列。很多时候问题就出在某个回调没有被正确调用或者调用顺序不对。这个方法和ftrace配合使用基本能定位绝大多数中断问题。