1. 三层中断编号的换算:先搞懂hwirq、virq和irq number
做嵌入式Linux这些年,几乎每个外设驱动开发者都会在中断这堵墙上撞几次。前阵子接手一个“外设中断偶尔不来”的调试任务,板子看起来什么都有,中断就是丢。那天从下午查到深夜,最后发现根本不是硬件拉线问题,也不是设备树配错,而是我对IRQ子系统里“编号”这个基础概念存在盲区。Linux 6.6内核的IRQ子系统,核心数据结构其实就那么几个,但如果你不知道某个结构体里的字段代表什么,排查路径就会绕非常大。
先说最基础也是最容易搞混的三层编号:hwirq、virq、irq number。很多驱动代码里混着用这几个词,但它们的含义完全不同。
hwirq是硬件中断控制器真正识别的线号。拿GIC举例,一个SPI中断在硬件层面就是某个INTID,这个ID是芯片手册里写死的,软件不能随便改。virq是内核为这条硬件线分配的Linux全局中断号,也就是你在/proc/interrupts第一列看到的那串数字。至于irq number,大多数人嘴里说的就是virq,但有些文档也会拿它指hwirq,所以看代码时先确认上下文再判断含义。
我们用irq_domain把hwirq映射成virq。最简单的创建方式是:
#include <linux/irqdomain.h> unsigned int irq; struct irq_domain *domain; irq = irq_create_mapping(domain, hwirq);这段代码执行后,硬件中断线hwirq就有了一个对应的Linux中断号irq。之后不管是用request_irq还是request_threaded_irq,传入的都是这个irq,而不再是hwirq。
1.1 为什么不能直接用hwirq注册中断
我遇到过不少同事在开发阶段图省事,直接把硬件中断号填进request_irq。在简单的中断控制器上,比如只有32条线的老式GPIO控制器,hwirq和virq恰好相等,这种做法确实能跑通。可一旦换成级联控制器,或者控制器支持MSI、支持稀疏中断号,hwirq和virq就不再有任何数学关系。
举个例子,一个系统里有两颗中断控制器:主控制器管理SPI中断,子控制器挂在主控制器的某条线上,管理16个GPIO中断。两个控制器各自的hwirq都是从0开始编号的,但Linux必须给它们分配不同的virq,否则irq_to_desc查表时必乱。irq_domain存在的意义,就是在这两个编号空间之间做翻译。
/* 用hwirq反查virq,调试时非常有用 */ unsigned int virq = irq_find_mapping(domain, hwirq);如果irq_find_mapping返回0,说明这个hwirq还没有建立映射。排查中断问题时,第一件事就是确认映射存在。我以前在某个平台上遇到/proc/interrupts里根本没有对应中断,接着用这条反查函数验证,发现设备树里的interrupts = <&gpio 7 IRQ_TYPE_LEVEL_LOW>对应的子控制器hwirq根本没被创建映射,问题一下就定位到了控制器驱动的irq_domain_ops->map回调没被正确填充。
1.2 irq_to_desc:从中断号到描述符的一步跨越
virq是用户看到的表象,内核真正操作的对象是struct irq_desc。每个virq都对应一个irq_desc,这个结构体承载着中断的所有状态、处理函数链、控制器回调、亲和性掩码等。
#include <linux/irqdesc.h> struct irq_desc *desc; desc = irq_to_desc(irq); if (desc) /* 只有在持锁或知晓上下文安全时,才可以直接读写desc字段 */irq_to_desc是个很廉价的查询,本质是查一张以irq为索引的基数树或数组。它廉价但下发的权限并不廉价:拿到desc是一回事,能不能安全改它的字段是另一回事。很多新手驱动开发者喜欢直接desc->action遍历处理函数,这是典型踩坑姿势,我在后面专门展开讲。
1.3 特殊编号系统:percpu中断与irq 0的坑
还有一类中断不走普通映射流程,它们叫做percpu中断,典型代表是ARM GIC上的PPI和IPI。这类中断在每个CPU上都有独立实例,desc里的percpu_enabled字段专门记录它在哪个CPU上被使能。你可以从/proc/interrupts看到它们,但它们的action结构体用的是percpu_dev_id,和普通共享中断不是一套字段。
另外,irq 0在x86平台上属于历史遗留,通常对应定时器。但在ARM、RISC-V平台上,irq 0可能是完全正常的SPI号。写驱动时千万别写死“irq 0就是无效中断”,这种判断逻辑换个架构就翻车。判断中断号是否有效,标准做法是:
if (!irq || irq <= 0) return -EINVAL;在支持NO_IRQ被定义为0的内核里,这个判断没错,但它不等价于“irq 0一定无效”。完整判断要结合irq_to_desc(irq) == NULL来确认。记得我第一次在RISC-V平台上调试,发现某外设中断号正好是0,按旧的x86习惯直接返回-EINVAL,硬是让驱动加载失败,排查很久才发现是这段历史包袱代码在作祟。
搞懂编号关系后,一切就都围绕irq_desc展开了。
2. irq_desc:中断描述符内部拆解与正确访问方式
struct irq_desc定义在include/linux/irqdesc.h,它可以说是整个IRQ子系统的心脏。Linux 6.6里,这个结构体大约长这样,省略了调试和统计相关字段:
struct irq_desc { struct irq_common_data common; /* 挂在公共数据上的亲和性、msi_desc等 */ struct irq_data irq_data; /* 和具体chip、domain、hwirq强相关 */ unsigned int irq; /* 本条中断的Linux中断号 */ struct irqaction *action; /* 处理函数链表 */ unsigned int istate; /* 内部状态,IRQS_ACTIVE等 */ const char *name; /* 中断名称,会显示在/proc/interrupts */ struct cpumask *percpu_enabled; /* percpu中断哪些CPU使能了 */ cpumask_var_t affinity; /* 亲和性掩码 */ irq_flow_handler_t handle_irq; /* 中断进入后的第一级处理函数 */ ... };不要把irq_desc想成一个只存数据的容器,它内部还带一把原始自旋锁。这把锁保护action链表、istate、affinity等字段。内核里大量路径,比如__setup_irq、free_irq、irq_set_affinity_locked,都会拿这把锁。驱动开发者如果自己绕过API去改字段,很容易和内核内部路径竞争,轻则数据不一致,重则死锁。
2.1 一分为二:irq_data和irq_common_data
irq_desc内部最让人困惑的是为什么拆成irq_data和irq_common_data两块。简单理解:irq_common_data是“即使中断控制器变了也不会变”的公共部分,irq_data是“跟着芯片和domain走”的专用部分。
struct irq_common_data { unsigned int state_use_accessors; void *handler_data; struct msi_desc *msi_desc; cpumask_var_t affinity; }; struct irq_data { unsigned int irq; unsigned long hwirq; unsigned int node; struct irq_chip *chip; struct irq_domain *domain; void *handler_data; void *chip_data; struct irq_data *parent_data; };调试时最常用的是irq_data里的chip和domain,它们告诉你这条中断由谁处理、处于哪个映射域。chip_data是中断控制器驱动的私有数据,比如控制器寄存器基址、内联的chip状态结构体,都会挂在这里。写chip驱动时,一定要记得把自定义数据塞进chip_data,而不是塞进handler_data——handler_data是给中断处理函数用的,语义完全不同。
2.2 action链表与istate:中断处理函数是怎么挂上去的
desc->action指向一个单向链表,每个节点是struct irqaction。共享中断时,多个设备共用同一个virq,它们各自的irqaction通过next指针串起来:
struct irqaction { irq_handler_t handler; void *dev_id; void __percpu *percpu_dev_id; struct irqaction *next; irq_handler_t thread_fn; struct task_struct *thread; unsigned int irq; unsigned int flags; const char *name; ... };istate记录的是中断内部状态,比如IRQS_ACTIVE表示中断正在被处理,IRQS_DISABLED表示被禁用。有些调试者直接从istate判断中断是否处于“被屏蔽”状态,这是可行的,但读之前必须持有desc->lock或者至少在关中断的临界区里,否则读到的是半更新状态,容易误判。
2.3 affinity与percpu_enabled:CPU亲和性的数据底座
affinity字段是个cpumask_var_t,表示这条中断可以被投递到哪些CPU上。irq_set_affinity系列API最终都会落到这个字段以及irq_chip->irq_set_affinity回调。如果你看到/proc/irq/N/smp_affinity写进去没生效,先看chip有没有实现irq_set_affinity。没有实现就等价于不支持亲和性设置,内核只能返回-EINVAL。
percpu_enabled是percpu中断专属的CPU掩码。普通中断写irq_set_affinity,percpu中断则用irq_set_percpu_dev_flags、irq_percpu_enable这类API。两者操作的不是同一个字段,别混。
说到访问方式,我强烈建议普通驱动开发者只用导出的接口,不要试图直接拿desc干活。比如:
- 读亲和性:
irq_get_irq_affinity(irq) - 读chip:
irq_get_chip(irq) - 读domain:
irq_get_domain(irq) - 处理函数链:直接看
/proc/interrupts或者debugfs,而不是遍历desc->action
内核维护这些接口是有原因的:内部加锁、状态检查、livepatch兼容都在那一层。绕过接口等于裸奔。
3. irq_chip与irq_domain:控制器能力与映射关系怎么串起来
irq_chip描述中断控制器的能力,irq_domain描述硬件中断号到Linux中断号的映射关系。这两个结构体在调试中出现的频率极高,很多“中断不生效”的问题最后都落在chip回调缺失或者domain映射错误上。
3.1 irq_chip回调表:每个回调何时被调用
struct irq_chip是一个函数指针大集合,核心回调如下:
| 回调 | 作用 | 典型调用时机 |
|---|---|---|
irq_mask/irq_unmask | 屏蔽/解除屏蔽某条中断线 | disable_irq、enable_irq、mask/unmask路径 |
irq_ack | 应答中断,通常清硬件pending位 | 边沿触发中断的flow handler开头 |
irq_eoi | 中断处理结束后通知控制器EOI | handle_fasteoi_irq处理完调用 |
irq_enable/irq_disable | 使能/禁用 | 首次enable_irq、完整禁用 |
irq_set_type | 设置触发类型(边沿/电平) | irq_set_irq_type、request_irq带IRQF_TRIGGER_*时 |
irq_set_affinity | 设置CPU亲和性 | irq_set_affinity |
irq_retrigger | 软件触发一次中断 | 中断丢失或唤醒场景 |
新手最容易漏掉的是irq_set_type。如果你注册中断时传了IRQF_TRIGGER_LOW,但chip没有实现irq_set_type,内核会在__irq_set_trigger里返回失败,request_irq直接返回-EINVAL。遇到这种报错,先检查chip是不是漏了回调。
3.2 irq_domain的线性映射和基数树映射
irq_domain可以选择不同的映射后端。线性映射适合中断号密集、总数可控的控制器,直接用一个数组完成hwirq -> virq的反查,查找快,浪费一点内存。基数树映射则适合中断号稀疏、数量大的场景,比如MSI。
创建线性domain的经典代码:
struct irq_domain *domain; domain = irq_domain_add_linear(parent_domain, 256, &my_domain_ops, host_data); if (!domain) return -ENOMEM;其中256是最大hwirq个数,my_domain_ops要提供map、xlate等回调。xlate负责把设备树里的interrupts属性翻译成hwirq和触发类型,写错之后最常见的现象是设备树解析没问题、映射也建立了,但触发方式完全不对。
3.3 parent_data:级联控制器的父子关系
现在大多数SoC都有两级甚至三级中断控制器。以某颗带GPIO子中断控制器的SoC为例:主控制器的一条中断线作为父中断,子控制器挂在它下面。子控制器的irq_data->parent_data就要指向父中断对应的irq_data。
struct irq_data *child_data; struct irq_data *parent_data; parent_data = irq_get_irq_data(parent_irq); child_data->parent_data = parent_data;如果不设置parent_data,中断掩码、亲和性、电源管理等操作就无法正确传递到父中断上。表现为子中断的irq_mask调了,但父中断还开着,导致中断风暴一直往CPU上打。这个问题在只有单级控制器的平台上完全不会暴露,换到级联SoC才出现,非常隐蔽。
4. 从request_irq到中断处理函数运行:完整链路追踪
明白了结构体,再看一条中断从注册到触发的完整生命周期,理解会更深。
4.1 request_irq背后发生了什么
request_irq最终会调用request_threaded_irq,然后进入__setup_irq。关键点包括:
- 分配
irqaction结构体,拷贝handler、dev_id、flags、name。 - 如果该
irq上已经挂其他irqaction,检查共享中断的兼容性:IRQF_SHARED必须都置位,且同一个dev_id不能重复注册。 - 把新
irqaction挂到desc->action链表,挂的时候可能按action->irq排序,也可能直接头插,具体看内核版本和irq_settings。 - 如果这是第一条action,还会调用
irq_startup或__irq_do_set_trigger,让chip真正使能这条中断。
irqaction链表上同一中断可以有多个handler,触发时内核会从头遍历,逐个执行。任何handler返回IRQ_HANDLED不代表后面的handler不执行,共享中断就是轮流执行,只有返回IRQ_NONE且所有handler都返回IRQ_NONE时,内核才认为这条中断没有被处理。
4.2 硬件中断进来后:generic_handle_irq_desc
以ARM GIC为例,中断触发后,汇编入口会把中断号传给generic_handle_irq,内核转换出desc,调用desc->handle_irq(desc)。handle_irq是flow handler,它决定如何处理这条中断。
常见的flow handler有四个:
handle_simple_irq:什么都不管,直接调用action链,适合没有硬件mask/ack概念的控制器。handle_level_irq:电平触发专用,进入后先mask,再调用action,最后unmask。防止中断处理函数还没跑完,同一电平再次触发。handle_edge_irq:边沿触发专用,进入后先ack清pending,再调用action。处理途中新来的边沿会被记录,不会丢太多。handle_fasteoi_irq:现代控制器通用,适合有EOI概念的GIC,进入后直接调用action,结束后写EOI,不需要显式mask/unmask。
flow handler不是随便选的。由irq_set_irq_type决定的触发方式,会影响最终挂载哪个flow handler。request_irq阶段传入IRQF_TRIGGER_*,内核会调用__irq_set_trigger,再据此设置desc->handle_irq。
4.3 线程化中断和强制线程化
request_threaded_irq(irq, handler, thread_fn, flags, name, dev_id)是完整版本。如果传入thread_fn,内核会为这条中断创建内核线程,handler是快速处理部分,thread_fn是慢速处理部分。handler运行在硬中断上下文,thread_fn运行在普通进程上下文,可以调用msleep、mutex_lock这类可能睡眠的函数。
IRQF_ONESHOT强制要求中断在thread_fn执行期间保持mask状态,常用于需要严格序列化的设备,比如某些触控芯片。如果忘传IRQF_ONESHOT,thread_fn还在处理,中断再次触发,硬件状态就会乱,典型表现是中断风暴或者数据错位。这个坑我踩过不止一次。
5. 我在这套机制上踩过的坑:锁、共享中断与flow handler
讲几个真实踩坑场景,每一个都对应IRQ子系统的某个核心概念。
5.1 在中断上下文里直接改irq_desc的field
某个项目里,我在中断处理函数中想动态调整亲和性,图省事写了类似desc->affinity = ...的代码。结果系统运行一段时间后神秘死锁,崩溃栈停在__irq_set_affinity的锁等待上。原因是我绕过API改了desc->affinity,但__irq_set_affinity内部拿锁后还要检查irqd_is_setaffinity_pending这类状态位,我改的字段和状态位不一致,导致内核路径走到了错误的判断分支。
正确的做法是使用irq_set_affinity_hint或irq_set_affinity,让内核自己处理锁和状态一致性。如果你确实想直接读desc->action,也必须处于desc->lock保护范围内,或者使用irq_get_irq_data配合锁。简单说,内核导出的API就是让你不要碰内部结构体的。
5.2 共享中断的dev_id不能随便传NULL
共享中断的dev_id是区分不同设备的关键。free_irq的时候,内核会根据dev_id找到对应的irqaction再删除。如果注册时都传NULL,free_irq就只能找到第一个匹配的action,很可能把别人的处理函数也删掉。结果就是中断还在跑,但处理函数没了,系统静默丢中断。
我调试过一个USB控制器,现象是插拔几次后设备不响应,查到最后就是共享中断的dev_id传了NULL,导致free_irq误删了相邻设备的中断处理函数。后来所有共享中断的dev_id都改用设备私有结构体指针,问题立刻消失。
5.3 flow handler与真实硬件触发方式不匹配
有一块板子,某传感器芯片输出的是电平信号,结果驱动里用了handle_edge_irq。电平信号维持时间很长,边沿检测只在上升沿触发一次,之后电平一直为高,控制器又不会有新边沿,中断就“永久消失”了。排查时/proc/interrupts上计数不再增加,但硬件引脚明明还拉着高电平。
解决方法是把中断类型改成电平触发,让flow handler切到handle_level_irq。对应操作就是在设备树或驱动里设置IRQF_TRIGGER_HIGH或IRQF_TRIGGER_LOW。这里有个经验:如果中断只发生一次,之后再也不来,优先怀疑边沿和电平不匹配;如果是中断风暴,优先怀疑缺少ack/EOI。
5.4 调试手段:ftrace与debugfs
排查IRQ问题,我最先看的永远是这几个入口:
cat /proc/interrupts cat /proc/irq/<irq>/smp_affinity cat /sys/kernel/debug/irq/irqs/<irq>/proc/interrupts能看到每个CPU上的中断计数和注册名。debugfs下的irq文件能看到desc->handle_irq、chip名、action链、domain名等。比如某条中断显示handle_edge_irq,但你确认硬件源是电平,基本就能定位问题。
ftrace的irq事件也很有用:
echo 'irq:*' > /sys/kernel/debug/tracing/set_event cat /sys/kernel/debug/tracing/trace会看到irq_handler_entry和irq_handler_exit成对出现。如果只有entry没有exit,说明处理函数长时间不返回,大概率在中断上下文里睡眠或死循环。
6. 一次“外设中断消失”的排查过程
最后分享一个完整案例,是我在某个GPIO子控制器平台上排查“按键中断按一次之后失效”的过程。
现象:按键按下第一次,驱动能收到中断,打印一次;再按,完全没有输出。硬件用示波器看过,按键按下时GPIO电平变化正常。
第一步:看/proc/interrupts。按键中断的计数只增加一次,之后不再增加,说明中断根本没有进入系统,不是驱动处理函数问题。
第二步:看debugfs中断信息。发现该中断的handle_irq是handle_edge_irq,而设备树里配置的是IRQ_TYPE_EDGE_BOTH。按键按下产生下降沿,松开产生上升沿,两个边沿都有。理论上有两个中断,但只触发了一个。
第三步:查父中断控制器的mask状态。发现父中断的irq_mask被设置了,也就是第一次中断触发后,mask了这条线,但后续没有任何unmask动作。问题出在handle_edge_irq里,如果chip没有正确的irq_ack或mask/unmask配合,边沿中断会在处理完后保持mask状态。
第四步:对比两个控制器的chip实现,发现子控制器的irq_ack回调是空的。handle_edge_irq要求chip提供ack来清pending,否则内核认为中断还在pending,加上某些硬件实现里mask后不会自动unmask,就出现了“第一次能触发,之后永久哑火”的症状。
修复方式是给子控制器的irq_ack回调补上寄存器操作,同时把设备树触发方式改成IRQ_TYPE_LEVEL_LOW配合handle_fasteoi_irq。改完后,连续按10次按键,/proc/interrupts计数正常增长,问题消失。
这类问题排查多了会发现,IRQ子系统的数据结构并不复杂,难点在于理解每个字段背后的时序和约束。irq_desc是这个系统的中心,irq_data连接硬件,irq_chip提供能力,irq_domain提供映射,irqaction挂载处理函数。把这五个结构体串成一条链路,绝大多数中断问题都能沿着这条链路找到答案。我习惯在接手新平台时,先打印一遍全部中断的debugfs信息,再对照芯片手册确认每个控制器的chip回调是否齐全,这套流程能帮我绕开很多藏在“看起来正常”背后的坑。