跳到主要内容

4.2 技术准备与内核的中断处理真相

上一节我们聊了那么多关于“门铃”的理论——什么是中断、CPU 如何响应、内核栈如何切换。现在,让我们把视线拉回到现实。作为驱动开发者,我们并不直接去接那些电线,那是内核硬件层和 BSP 的事。我们真正打交道的是内核提供的一层抽象。

但在动手写代码之前,我们需要先确认两件事:

  1. 你的环境准备好了吗?(这听起来像废话,但很多人在编译失败时才发现内核头文件没装)
  2. 当你写下 request_irq() 时,你到底在向谁请求什么?

本节,我们将拆解 Linux 的通用 IRQ 处理层,看看它是如何屏蔽掉底层硬件差异的。然后,我们会通过真实的代码片段(来自 Intel 网卡和 STM32 驱动),一步步拆解 request_irqdevm_request_irq 以及现代的线程化中断模型。


硬件中断与内核的接管流程

大多数外设(网卡、磁盘、键盘、鼠标)都不想被 CPU 轮询。轮询是笨办法,不仅浪费 CPU,还费电。中断的效率在于:只有当事情真的发生时,软件才运行。

让我们从硬件层面快速过一遍流程。虽然你不需要去焊电路板,但你需要理解信号是怎么传到内核的。

现代主板上都有一个中断控制器芯片。

  • 在 x86 上,这叫 IO-APIC (IO-[Advanced] Programmable Interrupt Controller)。
  • 在 ARM 上,通常是 GIC (Generic Interrupt Controller)。

为了方便叙述,我们统一叫它 PIC

中断流动的简化路径

  1. 外设(比如网卡收到了一个包)拉高它连接到 PIC 的线路。
  2. PIC 捕获到这个信号,把它存到一个寄存器里,然后拉高通往 CPU 的中断引脚。
  3. CPU 在每执行完一条指令后,都会检查中断引脚。一旦发现有中断,硬件会自动保存现场,跳转到内核预设的低级处理代码(在 ARM 上通常是 asm_do_IRQ)。
  4. 内核的低级代码代码最终会调用到通用 IRQ 层,查找这张 IRQ 对应的处理函数列表。
  5. 你的驱动函数被调用。

这里有一个反直觉的事实:硬件中断是 Linux 中优先级最高的事情。它会抢占任何正在运行的代码——无论是用户态的浏览器还是内核态的其他线程。除非你用了“线程化中断”,我们在后面会讲到。

类比时间:餐厅呼叫器

你可以把 CPU 想象成餐厅里的大厨,把外设想象成服务员

  1. 旧方案(轮询):大厨每隔两秒跑出去喊:“有菜吗?没菜我继续切墩。”大厨累得半死,菜还可能凉。
  2. 新方案(中断):大厨专心切墩。服务员有菜了就按一下呼叫器(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 来做这件事:

  1. request_irq() - 老派做法,手动管理。
  2. devm_request_irq() - 托管版本,推荐。
  3. request_threaded_irq() - 线程化中断。
  4. 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);

参数极其重要,我们一个一个看:

  1. int irq: 这是你想要注册的 IRQ 编号。
    • 怎么拿到这个号? 这是个经典问题。
    • 现代嵌入式:解析 Device Tree (DTS)。
    • PCI 设备:通过 pci_dev->irq 获取。
    • 老旧平台:可能硬编码(千万别学)。
  2. irq_handler_t handler: 你的处理函数指针。
    • 函数原型是 irqreturn_t (*)(int, void *)
  3. unsigned long flags: 标志位 bitmask。设为 0 表示默认行为。
  4. const char *name: 你的驱动名字。这会出现在 /proc/interrupts 里,方便调试。
  5. 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 边沿触发

这是个硬件概念,但对驱动逻辑有致命影响。

  1. 电平触发

    • 只要信号线上是高电平,中断就一直被触发。
    • 规则:你必须在 handler 里把它“灭掉”(比如写寄存器清零)。如果你不灭,handler 返回后它会立即再次触发。这对于共享中断来说是个噩梦——你的处理函数可能会被疯狂调用。
  2. 边沿触发

    • 只有当信号从低变高(上升沿)的那一瞬间触发一次。
    • 特点:好处理,不容易丢,但在高负载下可能会错过事件。

类比回收:回到呼叫器

  • 边沿触发:呼叫器按钮是个自复位开关。按下去“叮”一声,松手就停。哪怕你一直按着,也只有一声。
  • 电平触发:呼叫器是个拨动开关。拨上去就一直响,直到你把它拨下来(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) {
// 错误处理...
}
// ...
}

逐步拆解

  1. IRQ 来源adapter->pdev->irq。这是 PCI 层帮我们从配置空间读出来的。
  2. Handler: ixgb_intr
  3. Flags: IRQF_SHARED。因为 PCI 规范允许设备共享 IRQ。
  4. Name: netdev->name。这样你在 /proc/interrupts 里能看到 eth0 之类的名字。
  5. 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:

  1. handler (Primary): 这是在硬中断上下文里跑的。要求极快。
    • 如果你不设这个(传 NULL),内核会给你装一个默认的,它只做一件事:返回 IRQ_WAKE_THREAD
  2. thread_fn (Threaded): 这是在内核线程上下文里跑的。
    • 这里你可以几乎干任何事(只要别忘了加锁)。

工作流

  1. 硬件中断发生。
  2. Primary handler 瞬间执行(原子上下文)。
    • 做最少的工作。
    • 返回 IRQ_WAKE_THREAD
  3. 内核唤醒对应的内核线程(名字通常叫 irq/24-eth0)。
  4. Threaded handler 执行(进程上下文),做剩下的脏活累活。

类比回收:回到餐厅

  • 普通中断:大厨亲自冲进包厢,放下菜就跑,什么废话都不敢说。
  • 线程化中断
    1. Primary Handler:服务员听到呼叫器响,立刻冲过去看一眼,发现是 5 号桌。
    2. Threaded Handler:服务员跑回厨房,把单子给大厨,然后大厨开始炒菜(这很花时间)。
    3. 在炒菜期间,服务员(中断系统)可以继续响应其他呼叫器。

⚠️ 重要标志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

列解读

  1. IRQ 编号:第一列(24, 25)。
  2. 中断计数:每个 CPU 核心上的触发次数。这对于负载均衡分析很有用。
  3. 控制器类型IO-APICGIC 等。
  4. 硬件触发类型fasteoi, edge 等。
  5. 设备名:最后一列,eth0i8042。这就是你在 request_irq 里传的那个 name

本节总结

我们走了很远。从硬件信号如何拉高 CPU 引脚,到内核如何用 irq_desc 数组抽象它,再到你如何在代码里用 devm_request_threaded_irq 注册一个处理函数。我们甚至还探讨了如何用 NAPI 应对网络洪水,以及如何用线程化中断让实时任务饿死中断(反过来)。

下一步,我们将深入探讨一种特殊的“半中断”——软中断和 Tasklet。它们是内核处理“稍微不那么急,但还是很急”的任务的核心机制。

准备好了吗?下一节我们不再谈论硬件,而是谈论内核内部的调度魔法。