4. 中断处理机制:中断注册、顶半部与底半部
中断,说白了就是硬件在喊你:“嘿,有事儿了!”
在嵌入式Linux里,中断处理是驱动响应外部事件最快的方式。按键按下、数据包到达、DMA传输完成……这些都得靠中断来通知CPU。但中断处理有个硬约束——越快越好。你想想看,CPU在跑中断服务程序的时候,其他中断是关着的,系统响应能力会下降。所以Linux把中断处理拆成了两半:顶半部(Top Half)和底半部(Bottom Half)。
4.1 中断注册:让内核知道谁来处理
注册中断的API很简单,就是 request_irq()。我刚开始写驱动时,觉得这函数没啥好学的,直到有一次在项目里踩了坑……
int request_irq(unsigned int irq,
irq_handler_t handler,
unsigned long flags,
const char *name,
void *dev_id);
| 参数 | 说明 | 个人经验 |
|---|---|---|
irq |
中断号,硬件决定 | 别写死,用 platform_get_irq() 获取 |
handler |
顶半部处理函数 | 必须快,别做耗时操作 |
flags |
中断触发方式 & 共享标志 | IRQF_TRIGGER_RISING 最常用 |
name |
设备名,/proc/interrupts 里能看到 |
起个有意义的名字,方便调试 |
dev_id |
设备标识,共享中断时必须传 | 我习惯传设备结构体指针 |
IRQF_SHARED)时,dev_id 不能为 NULL。否则 free_irq() 会找不到你的处理函数。我曾经因为这个导致中断无法注销,模块卸载时直接死机。
注册中断的时机,我建议放在设备 probe() 函数的末尾。为什么?因为这时候资源都申请好了,硬件也初始化完毕,中断来了不会出问题。
4.2 顶半部:快进快出
顶半部就是 request_irq() 注册的那个函数。它运行在中断上下文里,这意味着:
- 不能睡眠(不能调用
msleep()、wait_event()) - 不能获取信号量(
down()也不行) - 不能访问用户空间
- 不能做复杂的计算
那顶半部该干什么?说白了就三件事:
- 读取硬件状态——确认是不是自己的中断
- 清除中断标志——防止重复触发
- 调度底半部——把剩下的活儿扔出去
static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
struct my_device *dev = (struct my_device *)dev_id;
/* 1. 读状态寄存器,确认中断源 */
u32 status = ioread32(dev->regs + STATUS_REG);
if (!(status & MY_DEVICE_IRQ_MASK))
return IRQ_NONE; // 不是我的中断
/* 2. 清除中断标志 */
iowrite32(MY_DEVICE_IRQ_MASK, dev->regs + CLEAR_REG);
/* 3. 调度底半部 */
tasklet_schedule(&dev->my_tasklet);
return IRQ_HANDLED;
}
IRQ_WAKE_THREAD 可以唤醒一个内核线程来处理剩下的工作。这是 threaded IRQ 的用法,适合那些底半部需要睡眠的场景。
4.3 底半部:tasklet 与 workqueue
底半部是顶半部的“小弟”,负责干那些顶半部不能干的脏活累活。Linux 提供了好几种底半部机制,我工作中最常用的是 tasklet 和 workqueue。
4.3.1 tasklet:轻量级,但不能睡
tasklet 运行在软中断上下文里。它比顶半部宽松一些,但依然不能睡眠。我一般用它来处理:
- 简单的数据拷贝
- 更新驱动内部状态
- 唤醒等待队列
/* 定义 tasklet 处理函数 */
static void my_tasklet_handler(unsigned long data)
{
struct my_device *dev = (struct my_device *)data;
/* 这里可以做一些轻量级操作 */
dev->stats.irq_count++;
wake_up_interruptible(&dev->wq);
}
/* 初始化 tasklet */
tasklet_init(&dev->my_tasklet, my_tasklet_handler, (unsigned long)dev);
/* 在顶半部中调度 */
tasklet_schedule(&dev->my_tasklet);
4.3.2 workqueue:能干重活,能睡觉
workqueue 运行在进程上下文里。说白了,它就是一个内核线程。你可以用它做任何事:
- 访问用户空间数据
- 获取信号量
- 执行耗时的硬件操作
- 调用
msleep()
/* 定义 work 处理函数 */
static void my_work_handler(struct work_struct *work)
{
struct my_device *dev = container_of(work, struct my_device, my_work);
/* 这里可以睡眠,可以做任何事 */
msleep(10);
dev->data_ready = true;
}
/* 初始化 work */
INIT_WORK(&dev->my_work, my_work_handler);
/* 在顶半部中调度 */
schedule_work(&dev->my_work);
我个人习惯用 system_wq(系统默认工作队列)来处理简单的任务。但如果你的驱动需要独占一个工作队列,或者对延迟有要求,那就自己创建一个:
/* 创建专用工作队列 */
dev->my_wq = create_singlethread_workqueue("my_wq");
INIT_WORK(&dev->my_work, my_work_handler);
/* 调度到专用队列 */
queue_work(dev->my_wq, &dev->my_work);
4.4 中断上下文限制:别踩红线
嗯,这里要重点说一下。中断上下文是个“禁区”,很多新手在这里翻车。我整理了一张表,方便你对照:
| 操作 | 顶半部 | tasklet | workqueue |
|---|---|---|---|
| 睡眠(msleep/schedule) | ❌ | ❌ | ✅ |
| 获取信号量 | ❌ | ❌ | ✅ |
| 自旋锁 | ✅ | ✅ | ✅ |
| 访问用户空间 | ❌ | ❌ | ✅ |
| ioread/iowrite | ✅ | ✅ | ✅ |
| printk | ⚠️ 慎用 | ⚠️ 慎用 | ✅ |
kmalloc(GFP_KERNEL),结果系统直接死锁。因为 GFP_KERNEL 可能会触发内存回收,而内存回收需要睡眠。在中断上下文里,必须用 GFP_ATOMIC。
4.5 知识体系:中断处理全景图
下面这张图,是我自己总结的中断处理流程。你看一眼就能明白整个链路:
4.6 如何选择:tasklet 还是 workqueue?
这个问题我经常被问到。我的建议很简单:
- 选 tasklet:任务简单、执行时间短(微秒级)、不需要睡眠
- 选 workqueue:任务复杂、执行时间长(毫秒级)、需要睡眠或访问用户空间
举个例子。我在做网卡驱动时,中断来了之后,顶半部只做两件事:读取 DMA 描述符状态、调度 tasklet。tasklet 里把数据包从 DMA 缓冲区拷贝到 sk_buff,然后交给协议栈。整个过程都在微秒级完成,用 tasklet 正合适。
但有一次我做 USB 摄像头驱动,中断来了之后需要启动一个较长的控制传输。这个操作需要睡眠等待硬件完成。这时候我就用了 workqueue,在进程上下文里慢慢等。