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() 也不行)
  • 不能访问用户空间
  • 不能做复杂的计算

那顶半部该干什么?说白了就三件事:

  1. 读取硬件状态——确认是不是自己的中断
  2. 清除中断标志——防止重复触发
  3. 调度底半部——把剩下的活儿扔出去
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);
📌 关键点:tasklet 在同一时刻只能在一个 CPU 上运行。如果你的设备中断很频繁,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 知识体系:中断处理全景图

下面这张图,是我自己总结的中断处理流程。你看一眼就能明白整个链路:

Linux 中断处理全景图 硬件中断触发 内核中断子系统(关中断) 顶半部:request_irq 注册的处理函数 读状态 → 清标志 → 调度底半部 底半部调度 tasklet(软中断上下文) 不能睡眠,轻量级 workqueue(进程上下文) 可以睡眠,能干重活 核心原则:顶半部快进快出,底半部按需选择。tasklet 适合简单任务,workqueue 适合复杂操作。 中断上下文不能睡眠,不能访问用户空间,不能获取信号量。

4.6 如何选择:tasklet 还是 workqueue?

这个问题我经常被问到。我的建议很简单:

  • 选 tasklet:任务简单、执行时间短(微秒级)、不需要睡眠
  • 选 workqueue:任务复杂、执行时间长(毫秒级)、需要睡眠或访问用户空间

举个例子。我在做网卡驱动时,中断来了之后,顶半部只做两件事:读取 DMA 描述符状态、调度 tasklet。tasklet 里把数据包从 DMA 缓冲区拷贝到 sk_buff,然后交给协议栈。整个过程都在微秒级完成,用 tasklet 正合适。

但有一次我做 USB 摄像头驱动,中断来了之后需要启动一个较长的控制传输。这个操作需要睡眠等待硬件完成。这时候我就用了 workqueue,在进程上下文里慢慢等。

📌 总结:中断处理的核心就是“快”。顶半部只做最必要的事,剩下的交给底半部。选 tasklet 还是 workqueue,取决于你的底半部要不要睡觉。就这么简单。