1. 中断子系统到底在解决什么问题
搞过 Linux 驱动移植的人都有一个共同体会:GPIO、IIC、SPI 这些外设驱动写起来虽然琐碎,但至少逻辑是线性的——初始化、读写、释放,一条路走到底。真正让人头疼的是中断。按键按下去要响应、网卡收到包要通知协议栈、定时器到期要触发回调,这些事件什么时候来、来的时候 CPU 在干什么、来了之后怎么找到对应的处理函数,全都是不确定的。中断子系统的存在,就是把这堆"不确定"收敛成一套可管理、可扩展、可移植的机制。
我在做 RK 平台的 IIC 驱动移植时,第一次真正意识到中断子系统的分量。当时触摸屏挂在 IIC 总线上,靠一根中断线通知 SoC"有数据了"。驱动代码里就是一句request_irq(),看起来简单得不行,但背后从设备树解析中断号、到 GIC 控制器分发、再到内核调用我的 handler,中间隔着一整套框架。不理解这套框架,一旦中断不来或者来了进错函数,你连从哪查都不知道。
这篇文章面向的是正在做驱动移植、需要跟中断打交道的工程师,不管你是刚接触 Linux 驱动的新手,还是从单片机裸机中断转过来的老手,都能从中找到可复用的思路。我会把中断子系统从硬件到软件的完整链路拆开,重点讲清楚框架为什么这么设计、移植时哪些环节最容易出问题、怎么用工具把问题定位到具体一层。核心关键词就四个:linux、驱动移植、中断子系统、框架。
需要先说明一点:不同内核版本(4.x、5.x、6.x)在中断子系统的细节上有差异,比如irq_domain的映射方式、generic irq的层级结构都在演进。我下面讲的内容以主流 ARM64 平台(RK、全志、i.MX 这类)常见的 5.10 及以上内核为基准,具体到你手上的 BSP,以实际代码为准。凡是我基于常见实践补充的细节,都会明确标出来。
2. 中断子系统的整体框架拆解
2.1 从硬件到软件的四层结构
中断子系统不是一块铁板,它是分层的。理解分层是理解整个框架的前提。我习惯把它从下往上分成四层:
- 硬件层:中断源(按键、网卡、IIC 控制器)、中断控制器(GIC、GPIO 控制器自带的中断逻辑)
- 中断控制器驱动层:
irq_chip,负责操作具体的中断控制器硬件寄存器,比如使能、屏蔽、应答、设置触发方式 - 中断域与映射层:
irq_domain,负责把硬件中断号(hwirq)翻译成 Linux 的虚拟中断号(virq),这是移植时最关键的一层 - 通用中断处理层:
generic irq,提供irq_desc、irqaction、中断线程化等通用机制,向上给驱动提供request_irq()接口
这四层的关系,打个比方:硬件层是"门铃",irq_chip 是"门铃的电路",irq_domain 是"门牌号翻译表",generic irq 是"前台接待流程"。你按门铃(中断触发),电路把信号传上来(irq_chip 操作寄存器),翻译表告诉你这是几号房间(irq_domain 映射),前台按流程通知房间里的人(generic irq 调用 handler)。
为什么非要分这么细?因为 Linux 要支持成百上千种 SoC,每种 SoC 的中断控制器寄存器都不一样。如果让每个驱动都直接操作寄存器,代码会彻底失控。分层之后,驱动只需要关心"我要几号中断",底下的差异全被 irq_chip 和 irq_domain 屏蔽掉了。这就是框架的价值——把变化的部分隔离在底层,把稳定的接口暴露给上层。
2.2 三个核心数据结构
框架再复杂,落到代码上就是几个关键结构体。移植时你打交道最多的就是这三个:
irq_desc:每个中断号对应一个irq_desc,它是中断的"档案袋",记录了中断状态、触发方式、以及挂在它上面的所有irqaction。内核里用irq_desc_tree(基数树)管理所有中断描述符,通过irq_to_desc()拿到。
irqaction:一个中断可以注册多个处理函数,每个 handler 对应一个irqaction。这就是为什么多个设备能共享一根中断线(IRQF_SHARED)。request_irq()的本质就是分配一个irqaction挂到对应irq_desc的链表上。
irq_chip:描述中断控制器的操作集,是一堆函数指针:irq_mask、irq_unmask、irq_ack、irq_set_type等等。移植一个新的中断控制器,核心工作就是实现这个结构体。
我实测下来,调试中断问题时,cat /proc/interrupts能看到每个中断号上挂了哪些 action、触发了多少次,这个信息极其有用。后面排查章节会详细讲怎么用。
2.3 中断号的三次"变身"
这是新手最容易绕晕的地方。一个中断从硬件产生到驱动拿到,中断号经历了三次变化:
- 硬件中断号(hwirq):中断控制器视角的编号,比如 GIC 的 SPI 中断从 32 开始编号,某个设备接在 GIC 的第 50 号线上,hwirq 就是 50
- 虚拟中断号(virq / Linux irq):Linux 全局分配的编号,驱动
request_irq()用的就是这个号 - 设备树里的中断描述:
interrupts = <...>和interrupt-parent,描述的是"接在哪个控制器、控制器的第几号线上"
irq_domain就是负责 hwirq 和 virq 之间转换的。设备树解析时,内核根据interrupt-parent找到对应的 irq_domain,调用它的xlate把设备树描述翻译成 hwirq,再通过map分配一个 virq。这条链路任何一环断了,中断都注册不上。
提示:移植时如果
request_irq()返回-EINVAL或中断号是负数,八成是设备树的中断描述和 irq_domain 对不上,先查设备树,别急着改驱动代码。
3. 核心细节解析与移植实操要点
3.1 设备树里的中断描述怎么写
设备树是中断移植的起点,写错了后面全白搭。一个典型的中断描述长这样:
i2c@ff3d0000 { compatible = "rockchip,rk3399-i2c"; reg = <0x0 0xff3d0000 0x1000>; interrupts = <GIC_SPI 36 IRQ_TYPE_LEVEL_HIGH>; interrupt-parent = <&gic>; clocks = <&cru SCLK_I2C4>; status = "okay"; };这里几个字段必须搞清楚:
interrupt-parent:指向中断控制器节点。如果这个节点本身或父节点已经指定过,可以省略,会继承。我见过有人每个设备都重复写,其实没必要,但写上也没错,反而更清晰。interrupts:中断描述数组。格式由父控制器的#interrupt-cells决定。GIC 是 3 个 cell:<中断类型 中断号 触发方式>。GIC_SPI:共享外设中断,对应 GIC 的 SPI 类型;GIC_PPI是私有外设中断,一般给 CPU 定时器用。IRQ_TYPE_LEVEL_HIGH:高电平触发。触发方式选错是移植中最隐蔽的坑之一,后面细说。
对于 GPIO 中断,写法不一样,因为 GPIO 控制器自己也是个中断控制器:
gpio-keys { compatible = "gpio-keys"; button { gpios = <&gpio0 12 GPIO_ACTIVE_LOW>; interrupts = <12 IRQ_TYPE_EDGE_FALLING>; interrupt-parent = <&gpio0>; linux,code = <KEY_POWER>; }; };注意这里interrupt-parent是&gpio0,interrupts只有 2 个 cell(GPIO 控制器的#interrupt-cells通常是 2)。GPIO 中断的 hwirq 就是 GPIO 编号,但经过 irq_domain 映射后 virq 是另一回事。
3.2 触发方式:边沿还是电平,选错就等着抓瞎
触发方式(IRQ_TYPE_*)是移植时最容易被忽视、出问题又最难查的点。常见取值:
| 触发类型 | 宏定义 | 适用场景 |
|---|---|---|
| 上升沿 | IRQ_TYPE_EDGE_RISING | 按键释放、脉冲信号 |
| 下降沿 | IRQ_TYPE_EDGE_FALLING | 按键按下、低有效信号 |
| 双边沿 | IRQ_TYPE_EDGE_BOTH | 需要捕捉跳变的场景 |
| 高电平 | IRQ_TYPE_LEVEL_HIGH | 大多数外设控制器 |
| 低电平 | IRQ_TYPE_LEVEL_LOW | 低有效的中断线 |
选择逻辑其实很简单:信号是"持续有效"还是"瞬间跳变"。像 IIC、SPI 控制器这种,数据准备好后会一直拉高中断线直到你处理,用电平触发。像按键、外部脉冲这种,信号一闪而过,用边沿触发。
我踩过的坑:某次移植一个传感器,硬件手册写的是"中断输出低有效",我顺手写了IRQ_TYPE_LEVEL_LOW,结果中断疯狂触发,CPU 直接跑满。原因是这个传感器的中断线在数据被读走后不会自动释放,需要软件写寄存器清除,而电平触发在清除前会一直触发。改成IRQ_TYPE_EDGE_FALLING后正常。硬件手册说"低有效"不等于"低电平触发",一定要看信号是持续的还是脉冲的。
注意:电平触发的中断,如果 handler 里没有正确清除中断源,会导致中断风暴(interrupt storm),表现为
cat /proc/interrupts里某个中断号计数飞涨,系统卡死。这是移植现场最常见的翻车方式之一。
3.3 irq_chip 与 irq_domain 的配合
如果你移植的是标准外设(挂在 GIC 下),基本不用碰 irq_chip 和 irq_domain,SoC 厂商的 BSP 已经写好了。但如果你要移植一个自带中断控制器的复合设备(比如某些 PMIC、GPIO 扩展芯片),就得自己实现这两个结构。
irq_chip 的核心是这几个回调:
static struct irq_chip my_irq_chip = { .name = "my-chip", .irq_mask = my_mask, .irq_unmask = my_unmask, .irq_ack = my_ack, .irq_set_type = my_set_type, };irq_mask/irq_unmask:屏蔽和使能某个中断源,操作的是芯片的寄存器irq_ack:应答中断,告诉控制器"我收到了"。电平触发一般不需要 ack,边沿触发通常需要irq_set_type:设置触发方式,如果芯片支持软件配置的话
irq_domain 负责映射,最常用的是线性映射(irq_domain_add_linear),适合中断号连续的情况。初始化时大致是这样:
domain = irq_domain_add_linear(node, NUM_IRQS, &my_irq_domain_ops, chip_data);my_irq_domain_ops里的xlate负责解析设备树,map负责分配 virq 并设置 irq_chip。这套流程在drivers/gpio/gpio-*.c里能找到大量参考,比如gpio-mt7621.c、gpio-pca953x.c,移植时直接抄结构、改寄存器操作就行。
3.4 request_irq 的每个参数都有讲究
驱动里注册中断就一句:
ret = request_irq(irq, my_handler, IRQF_TRIGGER_FALLING, "my-device", dev_id);但每个参数都不能随便填:
irq:从设备树或平台数据拿到的 virq。用platform_get_irq()或irq_of_parse_and_map()获取handler:中断处理函数,返回IRQ_HANDLED或IRQ_NONEflags:触发方式和行为标志。IRQF_SHARED表示共享中断,IRQF_ONESHOT用于线程化中断name:出现在/proc/interrupts里的名字,调试时靠它认中断dev_id:传给 handler 的参数,共享中断时必须非 NULL,否则注销时无法区分
关于IRQF_SHARED:多个设备共用一根中断线时才用。用了它,你的 handler 必须能判断"这个中断是不是我的",不是就返回IRQ_NONE。判断方法通常是读设备的状态寄存器。如果所有共享者都返回IRQ_NONE,内核会打印"nobody cared"并屏蔽这根中断线。
4. 完整移植流程与关键环节实现
4.1 移植前的信息收集
动手写代码前,先把这几样东西找齐,能省掉后面大量返工:
- SoC 的中断控制器手册:确认是 GIC 还是厂商自定义控制器,中断号范围、触发方式支持情况
- 外设的数据手册:中断源有哪些、怎么清除、触发方式是什么
- 参考 BSP:同平台类似外设的驱动,直接拿来对比设备树和驱动写法
- 原理图:确认中断线接在哪个 GPIO 或哪个控制器引脚上
我一般会先在原理图上把中断线标出来,然后在 SoC 手册里查这个引脚对应哪个中断控制器、哪个 hwirq。这一步做扎实,后面设备树基本不会错。
4.2 设备树配置的完整过程
以 RK3399 上一个挂在 IIC 上的触摸屏为例,完整配置分两步。
第一步,确认 IIC 控制器节点本身的中断配置正确(这是控制器自己的中断,不是触摸屏的):
i2c4: i2c@ff3d0000 { compatible = "rockchip,rk3399-i2c"; reg = <0x0 0xff3d0000 0x1000>; interrupts = <GIC_SPI 36 IRQ_TYPE_LEVEL_HIGH>; clocks = <&cru SCLK_I2C4>, <&cru PCLK_I2C4>; clock-names = "i2c", "pclk"; status = "okay"; };第二步,在 IIC 总线下挂触摸屏节点,配置它自己的中断:
&i2c4 { status = "okay"; touchscreen@38 { compatible = "my,touchscreen"; reg = <0x38>; interrupt-parent = <&gpio1>; interrupts = <20 IRQ_TYPE_EDGE_FALLING>; reset-gpios = <&gpio1 21 GPIO_ACTIVE_LOW>; status = "okay"; }; };这里触摸屏的中断接在 GPIO1 的第 20 号脚上,下降沿触发。注意interrupt-parent是&gpio1,不是&gic——因为信号先进 GPIO 控制器,再由 GPIO 控制器汇总上报给 GIC。这个层级关系搞错,中断号就映射不出来。
4.3 驱动侧的中断注册与处理
驱动里获取中断号并注册:
static int my_ts_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct my_ts *ts; int irq, ret; ts = devm_kzalloc(&client->dev, sizeof(*ts), GFP_KERNEL); if (!ts) return -ENOMEM; irq = client->irq; if (irq <= 0) { dev_err(&client->dev, "no irq resource\n"); return -EINVAL; } ret = devm_request_threaded_irq(&client->dev, irq, NULL, my_ts_irq_thread, IRQF_TRIGGER_FALLING | IRQF_ONESHOT, "my-ts", ts); if (ret) { dev_err(&client->dev, "request irq failed: %d\n", ret); return ret; } return 0; }这里用了devm_request_threaded_irq,把中断处理放到内核线程里执行。为什么?因为 IIC 读取数据可能睡眠,而硬中断上下文不能睡眠。线程化中断(threaded irq)就是解决这个矛盾的:硬中断部分只做最紧急的事(比如 ack),耗时的、可能睡眠的处理放到线程里。
IRQF_ONESHOT是线程化中断的标配,保证线程处理完之前中断线保持屏蔽,避免重入。这个标志不加,内核会直接报错拒绝注册。
4.4 中断处理函数的编写要点
handler 的返回值只有两种,含义必须清楚:
IRQ_HANDLED:这个中断是我的,我处理了IRQ_NONE:不是我的中断(共享中断场景)
线程化中断的处理函数返回irqreturn_t,逻辑上跟普通 handler 一样。一个典型的触摸屏中断线程:
static irqreturn_t my_ts_irq_thread(int irq, void *dev_id) { struct my_ts *ts = dev_id; int ret; ret = my_ts_read_data(ts); if (ret < 0) { dev_err(&ts->dev, "read data failed\n"); return IRQ_HANDLED; } input_sync(ts->input); return IRQ_HANDLED; }要点:handler 里不要做耗时操作,不要printk刷屏(会拖慢系统),不要加锁时间过长。中断上下文是系统的"急诊室",处理要快,重活交给线程或工作队列。
5. 常见问题与排查技巧实录
5.1 中断注册失败排查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
request_irq返回-EINVAL | 中断号无效、触发方式冲突 | 检查设备树、cat /proc/interrupts |
返回-EBUSY | 中断线被占用、共享标志缺失 | 查/proc/interrupts看谁占了 |
返回-ENOMEM | 内存不足或 irq_desc 分配失败 | 查内核日志 |
| 中断号是负数 | 设备树解析失败 | of_irq_get()返回值、dmesg |
5.2 中断不触发的排查思路
中断注册成功但 handler 不执行,按这个顺序查:
- 确认硬件真的产生了中断:用示波器或逻辑分析仪量中断线,看有没有跳变。这一步能排除一半问题——很多时候是硬件根本没拉中断线。
- 确认触发方式匹配:边沿触发配了电平信号,或者反过来,都会导致不触发或狂触发。
- 确认中断没被屏蔽:
cat /proc/interrupts看计数,如果一直是 0,说明中断没到 CPU;如果计数在涨但 handler 没执行,说明 handler 没挂上或挂错了。 - 确认 irq_domain 映射正确:dmesg 里搜
irq相关报错,看有没有映射失败的提示。 - 确认 GPIO 方向配置:GPIO 中断要求引脚配成输入,如果被配成输出,中断永远不来。
5.3 中断风暴的应急处理
中断风暴是移植现场最刺激的场景——系统卡死,串口都刷不出来。应急处理:
- 如果还能进系统,
echo <irq> > /proc/irq/<irq>/mask屏蔽掉问题中断 - 如果进不去,改设备树把触发方式改对,或者临时禁用该设备节点
- 根本解决:检查 handler 里有没有正确清除中断源,电平触发尤其要注意
我遇到过一次网卡中断风暴,原因是 PHY 的中断没被正确 ack,网卡一直认为有事件。后来在 handler 里加了一句读 PHY 状态寄存器的操作,问题消失。清除中断源这个动作,硬件手册里往往写得很含蓄,一定要仔细看。
5.4 调试工具速查
cat /proc/interrupts:看每个中断号的触发计数、挂载的 handler 名字cat /proc/irq/<irq>/:看具体中断的配置dmesg | grep -i irq:看中断相关的内核日志echo 1 > /sys/kernel/debug/tracing/events/irq/enable:开 ftrace 的 irq 事件,追踪中断处理耗时
ftrace 这个工具我强烈推荐。中断处理慢导致系统卡顿的时候,用 ftrace 抓一下irq_handler_entry和irq_handler_exit,能精确看到哪个 handler 耗时最长。比盲目加 printk 高效得多。
6. 移植经验与避坑心得
做中断移植这些年,有几个体会是文档里不会写的。
第一,设备树的中断描述一定要跟原理图、SoC 手册三方对齐。我见过太多人直接抄别的项目的设备树,中断号抄错了,然后花两天查驱动代码。中断号、触发方式、interrupt-parent 这三个字段,必须自己对着硬件确认一遍。
第二,触发方式宁可用边沿,不要轻易用电平。除非硬件明确要求电平触发(比如某些共享中断线),否则边沿触发更安全,因为它不会因为清除不及时而反复触发。电平触发一旦 handler 有 bug,就是中断风暴。
第三,线程化中断是趋势,新驱动尽量用devm_request_threaded_irq。它把可能睡眠的操作隔离到线程,硬中断部分极短,对系统实时性友好。老代码里大量用request_irq在硬中断里做 IIC 读写,那是历史包袱,新项目别学。
第四,/proc/interrupts是你最好的朋友。中断问题的排查,80% 的信息都在这一个文件里。养成习惯:注册中断后先cat一下,确认中断号、名字、计数都对,再往下测。
第五,共享中断的 handler 必须能自证身份。返回IRQ_NONE不是可选项,是义务。判断依据通常是读设备状态寄存器,如果读不到有效状态就返回IRQ_NONE。这一点做不好,共享中断线上其他设备的中断会被你的 handler 拖累。
最后分享一个我常用的验证方法:移植完中断后,先写一个最简单的测试——在 handler 里翻转一个 GPIO,用示波器看。如果 GPIO 跟着中断源动,说明整条链路通了;如果不动,再逐层往上查。这个"点灯大法"虽然土,但在中断调试里屡试不爽,比看日志直观得多。
中断子系统的框架看着庞大,但拆开就是"硬件信号 → 控制器驱动 → 映射 → 通用处理 → 你的 handler"这一条线。把每一层的职责和接口搞清楚,移植时就不会迷路。真正难的从来不是框架本身,而是硬件手册里那些没写清楚的细节,和触发方式、清除时机这些需要靠经验积累的判断。多踩几次坑,自然就熟了。