一、前言
在嵌入式 Linux 系统中,外部设备可能会在任意时刻产生事件,例如按键按下、数据接收完成或传感器状态发生变化。如果 CPU 不断查询设备状态,不仅会浪费处理器资源,也不利于系统响应其他任务。
中断机制能够让设备在事件发生时通知 CPU。驱动程序接收到事件后,可以进行必要的处理,并在适当的时候唤醒等待该事件的进程。
本文围绕 Linux 驱动中的中断处理、阻塞 I/O、等待队列以及工作队列展开介绍。
二、Linux 中断处理
2.1 什么是中断
中断是指 CPU 暂停当前执行流程,转而响应某个需要及时处理的事件。
以按键驱动为例:用户按下按键后,GPIO 状态发生变化,硬件产生中断,内核调用对应的中断处理函数,驱动再根据事件更新状态或通知用户空间。
与轮询相比,中断方式通常能够减少无效的状态检查。
2.2 获取 GPIO 对应的中断号
在较早的 Linux 内核中,常见的做法是通过 GPIO 编号获取中断号:
int irq;
irq = gpio_to_irq(gpio);
if (irq < 0) {
pr_err("gpio_to_irq failed\n");
return irq;
}
需要注意,GPIO 编号和中断号并不一定相同。gpio_to_irq() 的返回值应当进行检查,不能直接假设转换成功。
2.3 申请中断
在支持相应接口的内核中,可以使用 request_irq() 申请中断:
ret = request_irq(irq,
key_irq_handler,
IRQF_TRIGGER_FALLING,
"key_irq",
dev);
if (ret) {
pr_err("request_irq failed\n");
return ret;
}
主要参数包括:
irq:中断号。
key_irq_handler:中断处理函数。
IRQF_TRIGGER_FALLING:示例中指定下降沿触发。
"key_irq":中断名称。
dev:传递给中断处理函数的设备参数。
下降沿只是示例,实际触发方式必须根据硬件电路和设备树配置确定。
2.4 中断处理函数
典型的中断处理函数形式如下:
static irqreturn_t key_irq_handler(
int irq, void *dev_id)
{
/* 执行必要的快速处理 */
return IRQ_HANDLED;
}
中断上下文具有一定限制。处理函数应尽可能短小,不应执行可能睡眠的操作,例如直接获取可能睡眠的互斥锁,或调用需要等待资源的函数。
对于耗时操作,可以考虑将工作交给合适的下半部机制。
2.5 释放中断
在驱动退出或设备移除时,应释放已经申请的中断:
free_irq(irq, dev);
其中传入的设备参数应与申请时使用的参数保持一致。对于共享中断,还需要遵循共享中断的申请和处理规则。
三、阻塞 I/O 与等待队列
3.1 为什么需要阻塞 I/O
假设用户程序读取按键状态,但此时没有任何按键事件。如果驱动不断检查状态,就会形成忙等待。
阻塞 I/O 提供了另一种处理方式:没有数据或事件时,进程进入睡眠状态;事件发生后,再被唤醒继续执行。
这样可以减少无意义的 CPU 占用。
3.2 等待队列的基本用法
较早的 Linux 内核中,可以使用等待队列实现进程等待。
static DECLARE_WAIT_QUEUE_HEAD(wait_que);
static int wait_que_cond;
当条件满足时,驱动可以调用:
wait_que_cond = 1;
wake_up(&wait_que);
这里有两个关键点:
wait_que 用于管理等待该事件的进程。
wait_que_cond 用于表示事件是否已经发生。
不过,上述代码只是概念示例。实际驱动中,如果中断处理函数与进程上下文会同时访问条件变量,就需要考虑并发访问与内存同步问题。对于跨上下文共享的状态,应根据实际场景选择合适的原子变量、锁或其他同步方法。
3.3 让进程等待条件
可以使用 wait_event_interruptible() 等接口:
ret = wait_event_interruptible(wait_que, wait_que_cond != 0);
if (ret)
return ret;
/* 条件满足后继续执行 */
该接口会在条件不满足时让进程等待;当条件满足或等待被信号中断时,调用会返回。
需要注意:条件变量应在唤醒之前正确更新,且在实际驱动中还应处理事件重复、条件清零和多个进程同时等待等情况。
四、中断与等待队列如何配合
一个常见的驱动流程如下:
1.用户程序调用 read()。
2.驱动检查设备是否有事件。
3.如果没有事件,进程进入等待队列睡眠。
4.硬件产生中断。
5.中断处理函数更新事件状态。
6.驱动调用 wake_up() 唤醒等待进程。
7.用户进程被调度运行,重新检查条件并读取事件。
可以看出,中断负责通知内核发生了硬件事件,而等待队列负责管理等待该事件的进程。二者解决的是不同层面的问题。
实际实现时,不应仅仅依赖一次唤醒就认定事件一定可用。进程恢复执行后仍应重新检查等待条件。
五、上半部与下半部
5.1 为什么要区分上下半部
中断发生时,内核需要尽快完成必要处理。如果在中断处理函数中执行复杂计算、耗时操作或可能睡眠的任务,就可能影响系统响应。
因此,驱动通常把工作分为两部分:
上半部:快速响应中断,完成必要的状态确认。
下半部:延后执行相对复杂的后续工作。
不同的下半部机制具有不同的执行上下文和限制。
5.2 常见下半部机制
|-----------------|-------------------|--------------|
| 机制 | 主要特点 | 能否睡眠 |
| 软中断(softirq) | 主要用于内核中的高频、延后处理任务 | 不能 |
| tasklet | 基于软中断机制的较早期延后处理方式 | 不能 |
| 工作队列(workqueue) | 在内核工作线程上下文中执行 | 通常可以 |
工作队列适用于需要调用可能睡眠的接口或执行较复杂处理的任务。软中断和 tasklet 则不能执行会导致睡眠的操作。
5.3 工作队列示例
static void work_handler(struct work_struct *work)
{
/* 在工作线程上下文中处理后续任务 */
wait_que_cond = 1;
wake_up(&wait_que);
}
该函数将条件设为满足,并唤醒等待队列中的进程。
如果要从中断处理函数中安排工作队列,可以使用相应的工作项和调度接口。实际项目中,还要保证工作项初始化正确,并在驱动退出时妥善取消或等待未完成的工作。
此外,示例中的共享变量如果同时被中断、工作线程和进程访问,需要根据并发关系设计同步方案。
六、驱动中的同步与互斥
当多个执行上下文访问同一份数据时,可能出现竞争问题。例如,中断处理函数修改事件状态,而用户进程同时读取或清零该状态。
常见同步机制包括:
原子操作:适合简单的原子计数或状态更新。
自旋锁:适合保护短小的共享数据访问,不能在持锁期间睡眠。
互斥锁(mutex):适合进程上下文中可能睡眠的临界区。
信号量:可用于资源计数或特定的同步场景。
完成量(completion):适合等待某个任务或事件完成。
选择同步机制时,应重点考虑访问发生在哪种上下文中、临界区是否可能睡眠,以及是否需要保护多个变量之间的一致性。
七、常见问题排查
7.1 中断没有触发
建议检查:
1.GPIO 与中断号转换是否成功。
2.设备树中断配置是否正确。
3.触发方式是否与硬件信号变化一致。
4.中断是否成功申请。
5.硬件是否确实产生了相应电平变化。
7.2 进程一直处于阻塞状态
应检查等待条件是否能够被正确修改、唤醒函数是否被执行,以及等待条件是否在进入睡眠前后存在竞争问题。
7.3 使用工作队列后仍然出现问题
应检查工作项是否正确初始化、是否成功调度、共享数据是否受到保护,以及模块卸载时是否存在尚未结束的工作。
7.4 为什么不能在中断处理中随意睡眠
因为硬中断处理函数运行在中断上下文中,不能像普通进程那样主动睡眠等待资源。如果任务需要睡眠,应考虑工作队列等合适机制。
八、总结
中断、等待队列和工作队列是 Linux 驱动开发中的重要组成部分。
中断机制负责响应硬件事件,等待队列负责让进程在条件不满足时等待,工作队列则能够将后续任务交给内核工作线程执行。
学习时应重点理解三者之间的配合关系,并注意中断上下文、进程上下文和并发访问的区别。只有明确执行上下文的限制,才能正确选择同步机制和延后处理方式。