4.2 技术准备与内核的中断处理真相
上一节我们聊了那么多关于“门铃”的理论——什么是中断、CPU 如何响应、内核栈如何切换。现在,让我们把视线拉回到现实。作为驱动开发者,我们并不直接去接那些电线,那是内核硬件层和 BSP 的事。我们真正打交道的是内核提供的一层抽象。
但在动手写代码之前,我们需要先确认两件事:
- 你的环境准备好了吗?(这听起来像废话,但很多人在编译失败时才发现内核头文件没装)
- 当你写下
request_irq()时,你到底在向谁请求什么?
本节,我们将拆解 Linux 的通用 IRQ 处理层,看看它是如何屏蔽掉底层硬件差异的。然后,我们会通过真实的代码片段(来自 Intel 网卡和 STM32 驱动),一步步拆解 request_irq、devm_request_irq 以及现代的线程化中断模型。
硬件中断与内核的接管流程
大多数外设(网卡、磁盘、键盘、鼠标)都不想被 CPU 轮询。轮询是笨办法,不仅浪费 CPU,还费电。中断的效率在于:只有当事情真的发生时,软件才运行。
让我们从硬件层面快速过一遍流程。虽然你不需要去焊电路板,但你需要理解信号是怎么传到内核的。
现代主板上都有一个中断控制器芯片。
- 在 x86 上,这叫 IO-APIC (IO-[Advanced] Programmable Interrupt Controller)。
- 在 ARM 上,通常是 GIC (Generic Interrupt Controller)。
为了方便叙述,我们统一叫它 PIC。
中断流动的简化路径:
- 外设(比如网卡收到了一个包)拉高它连接到 PIC 的线路。
- PIC 捕获到这个信号,把它存到一个寄存器里,然后拉高通往 CPU 的中断引脚。
- CPU 在每执行完一条指令后,都会检查中断引脚。一旦发现有中断,硬件会自动保存现场,跳转到内核预设的低级处理代码(在 ARM 上通常是
asm_do_IRQ)。 - 内核的低级代码代码最终会调用到通用 IRQ 层,查找这张 IRQ 对应的处理函数列表。
- 你的驱动函数被调用。
这里有一个反直觉的事实:硬件中断是 Linux 中优先级最高的事情。它会抢占任何正在运行的代码——无论是用户态的浏览器还是内核态的其他线程。除非你用了“线程化中断”,我们在后面会讲到。
类比时间:餐厅呼叫器
你可以把 CPU 想象成餐厅里的大厨,把外设想象成服务员。
- 旧方案(轮询):大厨每隔两秒跑出去喊:“有菜吗?没菜我继续切墩。”大厨累得半死,菜还可能凉。
- 新方案(中断):大厨专心切墩。服务员有菜了就按一下呼叫器(PIC)。呼叫器响了,大厨手里的刀一停(保存现场),跑去接单(中断处理程序),处理完回来继续切。
但是,这个类比有个地方不完美:大厨可以自己决定接单的顺序。而在真正的硬件世界里,中断(呼叫器)一旦响,大厨必须立刻停下手里的一切去接单,不管他正在切多贵的鱼子酱。这就是原子性的来源。
NAPI:当“呼叫器”响得太快
说到网卡,有一个重要的现代机制必须提一下。在早期的老式网卡(10M/100M 时代),每来一个包就触发一次中断是很正常的。但现在如果按这个逻辑,10 Gbps 的网卡每秒钟可能会触发数百万次中断。这会让 CPU 陷入一种叫 Livelock(活锁) 的状态——它忙着处理中断,根本没空干别的,系统就像死机了一样。
为了解决这个问题,现代操作系统(包括 Linux)和网络驱动引入了 NAPI (New API)。 它的思路是混合模式:
- 平时用中断,一旦来了第一个包,驱动就关闭中断,切换到轮询模式,一口气把那波包全收完。
- 收完后再重新开启中断,等待下一波。
这在代码里通常体现为在中断处理函数里调用 napi_schedule()。我们会在后面的代码示例中看到它。
分配硬件 IRQ:从 request_irq 说起
作为驱动作者,你的核心任务之一就是“捕获”中断。但问题是,不同的平台,中断路由方式天差地别(PCI 设备通过总线配置读取,嵌入式设备通过 Device Tree)。为了不逼死驱动开发者,Linux 内核提供了一个通用 IRQ 处理层。
这意味着你写的代码,理论上可以在 x86、ARM 甚至 RISC-V 上编译运行,而不用修改一行代码。
内核维护着一个中断描述符数组,索引就是 IRQ 编号。每个 IRQ 下面挂着一个链表,链表上的节点就是注册了的处理函数(struct irqaction)。
你的任务就是:把你的函数挂到这个链表上去。
内核提供了四种主要 API 来做这件事:
request_irq()- 老派做法,手动管理。devm_request_irq()- 托管版本,推荐。request_threaded_irq()- 线程化中断。devm_request_threaded_irq()- 托管的线程化中断,最推荐。
让我们按顺序拆解它们。
request_irq() - 经典的入口
这就像是在用户空间调用 sigaction() 注册信号处理函数一样,只不过这是给内核用的。
#include <linux/interrupt.h>
int __must_check
request_irq(unsigned int irq,
irq_handler_t (*handler_func)(int, void *),
unsigned long flags,
const char *name,
void *dev);
参数极其重要,我们一个一个看:
int irq: 这是你想要注册的 IRQ 编号。- 怎么拿到这个号? 这是个经典问题。
- 现代嵌入式:解析 Device Tree (DTS)。
- PCI 设备:通过
pci_dev->irq获取。 - 老旧平台:可能硬编码(千万别学)。
irq_handler_t handler: 你的处理函数指针。- 函数原型是
irqreturn_t (*)(int, void *)。
- 函数原型是
unsigned long flags: 标志位 bitmask。设为 0 表示默认行为。const char *name: 你的驱动名字。这会出现在/proc/interrupts里,方便调试。void *dev: 这是一个“私有数据”指针。- 当中断发生时,这个指针会被传回给你的处理函数。
- 关键点:如果你使用了共享中断(
IRQF_SHARED),这个参数必须非空。否则内核怎么知道该释放谁?如果实在没东西传,传THIS_MODULE也是个办法。
返回值:遵循内核惯例,0 成功,负数失败。既然加了 __must_check,你必须检查返回值。
类比回收:回到餐厅 记得那个呼叫器吗?
request_irq就是你在厨房的黑板上写下: “如果 5 号呼叫器响了,请叫张三去处理。”其中,
dev参数就像是告诉大厨:“张三手里拿着这盘菜。”差别在于:在餐厅里,只有一个张三。但在 Linux 里,如果 5 号呼叫器是共享的(比如好几张桌子共用一个铃),大厨需要喊:“是谁点的 5 号菜?这是你的凭证。” 只有凭证对上了,人才会去干活。
释放 IRQ:free_irq()
既然借了,就得还。通常在驱动的 remove() 或 disconnect() 方法里调用。
void *free_irq(unsigned int irq, void *dev_id);
注意第二个参数必须和注册时传的那个 dev 指针一样。
⚠️ 踩坑预警:
- 如果是共享中断,在调用
free_irq()之前,务必先在板子上禁用这个中断。否则,释放过程中如果中断来了,而你原本的 handler 已经解绑了,系统可能会懵圈。 free_irq()会等待当前正在执行的所有 handler 完成才返回。这意味着如果你在 handler 里把自己卡死了,free_irq就永远不会返回。
中断标志位:IRQF_ 们
那个 flags 参数是一个位掩码,用来控制中断的行为。它们定义在 <linux/interrupt.h> 里。
-
IRQF_SHARED: 这是最常见的标志之一。它允许多个设备共用同一个 IRQ 号。- 强制要求:必须为每个设备提供唯一的
dev_id。 - 典型场景:PCI 设备,老式的 ISA 设备。
- 后果:如果你的中断处理函数被叫醒了,你必须先去读硬件寄存器确认是不是你的设备触发的。如果不是,赶紧返回
IRQ_NONE。
- 强制要求:必须为每个设备提供唯一的
-
IRQF_ONESHOT: 这是给线程化中断用的。- 含义:当 hardirq(上半部)执行完后,不要立即重新开启这个 IRQ。
- 目的:保持 IRQ 线路处于关闭状态,直到对应的线程处理函数跑完。这对于电平触发中断至关重要,否则会形成“中断风暴”。
-
IRQF_TRIGGER_*: 这些标志用来指定电气触发特性(上升沿、下降沿、高电平、低电平)。- 通常,这些是由 Device Tree 或内核 BSP 代码配置好的,驱动里很少手动去设。但如果你在写一些裸机驱动,可能会用到。
电平触发 vs 边沿触发
这是个硬件概念,但对驱动逻辑有致命影响。
-
电平触发:
- 只要信号线上是高电平,中断就一直被触发。
- 规则:你必须在 handler 里把它“灭掉”(比如写寄存器清零)。如果你不灭,handler 返回后它会立即再次触发。这对于共享中断来说是个噩梦——你的处理函数可能会被疯狂调用。
-
边沿触发:
- 只有当信号从低变高(上升沿)的那一瞬间触发一次。
- 特点:好处理,不容易丢,但在高负载下可能会错过事件。
类比回收:回到呼叫器
- 边沿触发:呼叫器按钮是个自复位开关。按下去“叮”一声,松手就停。哪怕你一直按着,也只有一声。
- 电平触发:呼叫器是个拨动开关。拨上去就一直响,直到你把它拨下来(Ack)。
- 如果你忘了拨下来,大厨就会一直拿着菜刀站在你门口看着你。
实战:Intel 网卡驱动代码剖析
光说不练假把式。让我们看看真实的代码——Intel 的 82597EX (IXGB) 10GbE 网卡驱动。
注册中断 (drivers/net/ethernet/intel/ixgb/ixgb_main.c):
static int ixgb_up(struct ixgb_adapter *adapter)
{
struct net_device *netdev = adapter->netdev;
int err, irq_flags = IRQF_SHARED;
err = request_irq(adapter->pdev->irq, ixgb_intr, irq_flags,
netdev->name, netdev);
if (err) {
// 错误处理...
}
// ...
}
逐步拆解:
- IRQ 来源:
adapter->pdev->irq。这是 PCI 层帮我们从配置空间读出来的。 - Handler:
ixgb_intr。 - Flags:
IRQF_SHARED。因为 PCI 规范允许设备共享 IRQ。 - Name:
netdev->name。这样你在/proc/interrupts里能看到eth0之类的名字。 - Dev:
netdev。这是一个结构体指针,内核可以用它反向找到 adapter 私有数据(netdev_priv)。
释放中断:
static void ixgb_down(struct ixgb_adapter *adapter, bool kill_watchdog)
{
// ...
napi_disable(&adapter->napi); /* 必须先禁用 NAPI */
ixgb_irq_disable(adapter); /* 硬件层禁用中断 */
free_irq(adapter->pdev->irq, netdev);
}
注意看顺序:先禁用 NAPI,再禁用硬件中断,最后释放 IRQ。这很重要。
实现中断处理函数
你的 handler 是在中断上下文中运行的。这是一个非常严格的原子环境。
函数签名:
static irqreturn_t my_handler(int irq, void *dev_id)
{
// ...
}
返回值类型:irqreturn_t。
它其实是个枚举:
IRQ_NONE(0): 不是我的中断,或者我没处理完。IRQ_HANDLED(1): 我处理完了。IRQ_WAKE_THREAD(2): 我处理了一点,现在唤醒内核线程接着干。
中断上下文的铁律: 你的 Handler 处于原子上下文。
- ❌ 不能睡眠: 任何可能调用
schedule()的函数都禁止。- ❌
copy_to_user()/copy_from_user(): 可能触发缺页异常,导致睡眠。 - ❌
kmalloc(..., GFP_KERNEL): 必须用GFP_ATOMIC。 - ❌
mutex_lock(): 必须用spinlock。
- ❌
- ❌ 不能执行太久: 虽然没有硬性时间限制,但通常建议在几十微秒内搞定。如果超过了,就得考虑下半部机制。
类比回收:回到餐厅 你的 handler 是大厨冲进包厢处理突发事件。
- 他不能停下来接电话(睡眠)。
- 他不能聊半小时天(耗时太长)。
- 他必须快速判断“是不是这桌的菜”,如果是,把菜端上去或者把客人安抚好,然后立刻跑回厨房。
如果客人需要投诉半小时(大任务),大厨应该说:“您稍等,我叫经理来。”这就像是
IRQ_WAKE_THREAD。
代码实战:键盘控制器中断 (i8042)
看看经典的键盘/鼠标控制器驱动是怎么做的 (drivers/input/serio/i8042.c):
static irqreturn_t i8042_interrupt(int irq, void *dev_id)
{
unsigned char str, data;
str = i8042_read_status(); // 1. 读状态寄存器
data = i8042_read_data(); // 2. 读数据
// 3. 把数据交给上层输入子系统
if (likely(serio && !filtered))
serio_interrupt(serio, data, flag);
return IRQ_RETVAL(ret);
}
快准狠。读寄存器,传数据,返回。没有任何拖泥带水。
现代玩法:托管资源 (devm_request_irq)
传统的 request_irq 有个大麻烦:如果你忘了在 remove 里调用 free_irq,或者 remove 里的某条路径 return 了导致 free_irq 没跑,这个 IRQ 就泄露了。以后你再 insmod,就会注册失败。
现代内核推荐使用 Devres (Managed Resources) 机制。
API:
int __must_check
devm_request_irq(struct device *dev, unsigned int irq,
irq_handler_t handler,
unsigned long irqflags, const char *devname,
void *dev_id);
区别只在于第一个参数:struct device *dev。
你把它传进去,内核就会记录这个 IRQ 属于这个 device。当这个 device 被移除(驱动卸载)时,内核会自动帮你调用 free_irq。
实战案例:
static int my_driver_probe(struct platform_device *pdev)
{
struct resource *res;
int ret;
// 1. 获取 IRQ 资源 (从 Device Tree 或平台数据)
res = platform_get_resource(pdev, IORESOURCE_IRQ, 0);
if (!res) {
dev_err(&pdev->dev, "get interrupt resource failed.\n");
return -ENXIO;
}
// 2. 注册托管中断
ret = devm_request_irq(&pdev->dev, res->start, my_irq_handler,
IRQF_TRIGGER_HIGH, pdev->name, pdev);
if (ret) {
dev_err(&pdev->dev, "request interrupt failed.\n");
return ret;
}
// 哪怕这里 return ret; 出错,不需要手动 free_irq
return 0;
}
// 注意:这里不需要 remove 函数来 free_irq,内核全包了
这就像是用智能指针(std::shared_ptr),自动管理生命周期。
终极话题:线程化中断
还记得我们说“中断上下文不能睡眠”吗?这给处理大流量数据(如大包网络传输)带来了很大麻烦。如果你真的需要在中断里做点稍微复杂的事,或者你不确定会不会睡眠,有没有办法?
有,那就是线程化中断。
它是 Linux 从实时 Linux (PREEMPT_RT) 项目借来的思想,并合并进了主线内核(2.6.30)。它的核心是: 把中断处理变成一个内核线程。
既然是线程,那就可以睡眠,可以被调度,可以被其他高优先级线程抢占。
request_threaded_irq()
int __must_check
request_threaded_irq(unsigned int irq,
irq_handler_t handler, // Primary handler
irq_handler_t thread_fn, // Threaded handler
unsigned long flags,
const char *name, void *dev);
这里有两个 handler:
handler(Primary): 这是在硬中断上下文里跑的。要求极快。- 如果你不设这个(传 NULL),内核会给你装一个默认的,它只做一件事:返回
IRQ_WAKE_THREAD。
- 如果你不设这个(传 NULL),内核会给你装一个默认的,它只做一件事:返回
thread_fn(Threaded): 这是在内核线程上下文里跑的。- 这里你可以几乎干任何事(只要别忘了加锁)。
工作流:
- 硬件中断发生。
- Primary handler 瞬间执行(原子上下文)。
- 做最少的工作。
- 返回
IRQ_WAKE_THREAD。
- 内核唤醒对应的内核线程(名字通常叫
irq/24-eth0)。 - Threaded handler 执行(进程上下文),做剩下的脏活累活。
类比回收:回到餐厅
- 普通中断:大厨亲自冲进包厢,放下菜就跑,什么废话都不敢说。
- 线程化中断:
- Primary Handler:服务员听到呼叫器响,立刻冲过去看一眼,发现是 5 号桌。
- Threaded Handler:服务员跑回厨房,把单子给大厨,然后大厨开始炒菜(这很花时间)。
- 在炒菜期间,服务员(中断系统)可以继续响应其他呼叫器。
⚠️ 重要标志:IRQF_ONESHOT。
如果你使用了线程化中断,通常必须加上 IRQF_ONESHOT。因为你的线程处理函数跑得很慢,如果不一直把中断线关掉,硬件会一直往 CPU 发中断,导致 CPU 瘫掉。
托管版本:当然,也有 devm_request_threaded_irq(),这是现在的最佳实践。
代码实战:STM32 驱动
看看 ST 的 I2C 驱动怎么用的 (drivers/i2c/busses/i2c-stm32f7.c):
static int stm32f7_i2c_probe(struct platform_device *pdev)
{
// ...
ret = devm_request_threaded_irq(&pdev->dev, irq_event,
stm32f7_i2c_isr_event, // Primary (Hardirq)
stm32f7_i2c_isr_event_thread, // Thread fn
IRQF_ONESHOT,
pdev->name, i2c_dev);
// ...
}
这里 Primary handler 做硬件检查,Thread handler 做实际的 I2C 数据搬运。
为什么使用线程化中断?
除了“我想睡眠”这个理由之外,还有一个更深层的原因:优先级控制。
在标准 Linux 里,硬件中断的优先级极高,它会抢占任何用户态进程,哪怕你的进程是实时优先级 99。 但在实时系统里,这很糟糕。一个网络中断风暴可能会把你的关键控制任务饿死。
使用了线程化中断后,中断处理变成了一个优先级为 50 的内核线程。 如果你有一个用户态的实时任务(SCHED_FIFO, prio 60),它就可以抢占中断处理线程!
这就把不可控的硬中断变成了可控的线程。这才是 RT-Linux 的核心逻辑之一。
上下文检查工具
有时候你写了一段代码,但不确定它是不是在中断上下文里运行。内核提供了宏来帮你:
if (in_irq())
// 我在硬中断上下文
if (in_softirq())
// 我在软中断上下文
if (in_task())
// 我是普通进程
还有一个宏 might_sleep(),如果你在禁止睡眠的上下文里调用了它,内核会立即 OOPS(如果打开了 CONFIG_DEBUG_ATOMIC_SLEEP)。
查看 IRQ 信息:/proc/interrupts
最后,让我们看看如何验证我们的工作。读一下 /proc/interrupts:
$ cat /proc/interrupts
CPU0 CPU1
24: 1234 5678 IO-APIC 24-fasteoi eth0
25: 10 20 IO-APIC 25-edge i8042
列解读:
- IRQ 编号:第一列(24, 25)。
- 中断计数:每个 CPU 核心上的触发次数。这对于负载均衡分析很有用。
- 控制器类型:
IO-APIC或GIC等。 - 硬件触发类型:
fasteoi,edge等。 - 设备名:最后一列,
eth0或i8042。这就是你在request_irq里传的那个name。
本节总结
我们走了很远。从硬件信号如何拉高 CPU 引脚,到内核如何用 irq_desc 数组抽象它,再到你如何在代码里用 devm_request_threaded_irq 注册一个处理函数。我们甚至还探讨了如何用 NAPI 应对网络洪水,以及如何用线程化中断让实时任务饿死中断(反过来)。
下一步,我们将深入探讨一种特殊的“半中断”——软中断和 Tasklet。它们是内核处理“稍微不那么急,但还是很急”的任务的核心机制。
准备好了吗?下一节我们不再谈论硬件,而是谈论内核内部的调度魔法。