第4章:中断处理函数编写:顶半部与底半部、中断上下文限制、编写规范

中断处理函数怎么写,是很多Linux驱动开发者入门时最头疼的问题。我刚开始接触这块时,也踩过不少坑——比如在中断里调了kmalloc(GFP_KERNEL),结果系统直接死给你看。嗯,今天我们就来彻底搞明白这件事。

4.1 顶半部与底半部:为什么需要拆成两半?

先问一个问题:中断来了,CPU正在处理A任务,突然被B中断打断。如果B的处理时间很长,A就得一直等着。更糟的是,如果中断里又来了中断,系统可能直接崩掉。

所以Linux把中断处理拆成了两半:

  • 顶半部(Top Half):硬中断上下文,必须快,越快越好。只做最紧急的事,比如清中断标志、拷贝硬件寄存器。
  • 底半部(Bottom Half):软中断/任务/workqueue,可以慢一点。做剩下的耗时操作,比如数据解析、唤醒等待队列。

核心原则:顶半部只做“必须关中断才能做的事”,其余全部扔给底半部。

我在项目中遇到过一位同事,把整个网络协议栈解析都放在顶半部里。结果呢?网卡一跑满,系统响应延迟直接飙到秒级。后来改成顶半部只收包、底半部做协议解析,问题就解决了。

4.2 中断上下文限制:哪些事不能做?

顶半部运行在中断上下文里。说白了,这个上下文里没有进程的概念,你不能睡眠、不能调度、不能拿锁等资源。我列个表,你一看就明白:

操作 顶半部 底半部(tasklet/workqueue) 说明
kmalloc(GFP_KERNEL) ❌ 禁止 ✅ 允许 GFP_KERNEL可能睡眠,顶半部只能用GFP_ATOMIC
copy_from_user() ❌ 禁止 ✅ 允许 涉及缺页异常,可能睡眠
mutex_lock() ❌ 禁止 ✅ 允许 mutex可能睡眠,顶半部只能用spin_lock()
printk() ⚠️ 谨慎 ✅ 允许 printk在顶半部可用,但太多会拖慢系统
访问用户空间内存 ❌ 禁止 ✅ 允许 用户页可能被换出,访问会触发缺页
触发调度(schedule) ❌ 禁止 ✅ 允许 中断上下文没有进程概念

我曾经犯过的错:在顶半部里调了wake_up_interruptible(),以为只是唤醒进程没问题。结果唤醒路径里调了try_to_wake_up(),里面拿了rq锁,和别的CPU形成死锁。从那以后,我所有唤醒操作都放到底半部去做。

4.3 底半部机制对比:选哪个?

Linux提供了三种底半部机制。我按自己的使用经验排了个序:

机制 特点 适合场景 我的建议
软中断(SoftIRQ) 最快,但不可睡眠,不能重调度 网络收包、块设备IO 内核子系统内部用,驱动一般不直接碰
tasklet 基于软中断,串行执行,不可睡眠 简单数据处理、状态更新 老驱动用得多,新代码推荐用workqueue
workqueue 基于内核线程,可睡眠,可重调度 复杂操作、IO操作、锁竞争 我90%的场景都用它

你想想看,如果你的底半部需要访问I2C设备、需要等DMA完成、需要调msleep(),那workqueue是唯一选择。tasklet里不能睡眠,软中断更不行。

4.4 编写规范:一个靠谱的中断处理函数长什么样?

我总结了一套模板,照着写基本不会出问题:

// 顶半部:只做最紧急的事
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 + INT_STATUS);

    // 2. 清除中断标志(硬件要求)
    iowrite32(status, dev->regs + INT_CLEAR);

    // 3. 把数据从硬件FIFO搬到内存缓冲区
    //    注意:只搬少量关键数据
    dev->rx_data = ioread32(dev->regs + RX_FIFO);

    // 4. 调度底半部
    schedule_work(&dev->work);

    return IRQ_HANDLED;
}

// 底半部:做剩下的所有事
static void my_work_handler(struct work_struct *work)
{
    struct my_device *dev = container_of(work, struct my_device, work);

    // 1. 解析数据(可以慢)
    // 2. 唤醒等待队列
    // 3. 通知用户空间
    // 4. 如果有需要,重新使能中断
}

个人习惯:我每次写中断处理函数,都会在顶半部末尾加一行注释——/* 所有耗时操作已移交底半部 */。这样代码审查时,别人一眼就能看出你的设计意图。

4.5 避坑指南:我踩过的五个坑

  1. 中断里调了kmalloc(GFP_KERNEL):系统直接panic。记住,顶半部只能用GFP_ATOMIC。
  2. 忘了清除中断标志:中断一直触发,CPU被锁死在中断里。我调试过一个网卡驱动,就是这个问题,查了两天才发现。
  3. 底半部里拿了自旋锁又睡眠:自旋锁持有期间不能睡眠,否则其他CPU等锁等到天荒地老。
  4. 中断共享时没检查设备状态:多个设备共享一个IRQ线,你的处理函数被调用了,但中断不是你的设备产生的。一定要先读状态寄存器确认。
  5. workqueue没初始化就调度:schedule_work()前必须调INIT_WORK()。我见过有人把初始化放在probe之后,结果中断先来了,直接oops。

4.6 知识结构图:中断处理全流程

下面这张图把顶半部、底半部、中断上下文限制串在一起,方便你理解整体流程:

中断处理全流程结构图 中断处理全流程:顶半部 → 底半部 硬件中断触发 顶半部(硬中断上下文) 清中断标志 · 读硬件寄存器 调度底半部 · 不可睡眠 选择底半部机制 软中断(SoftIRQ) 最快 · 不可睡眠 内核子系统专用 tasklet 串行执行 · 不可睡眠 简单数据处理 workqueue 可睡眠 · 可重调度 复杂操作首选 底半部处理完成 数据解析 · 唤醒进程 · 通知用户空间

4.7 总结:记住这三条

  • 顶半部要快:只做硬件交互和底半部调度,其他一律不放。
  • 中断上下文不能睡眠:锁用spin_lock,内存用GFP_ATOMIC,用户空间别碰。
  • 底半部选workqueue:除非你有特殊理由,否则workqueue是最安全、最灵活的选择。

嗯,中断处理函数写好了,你的驱动就成功了一半。剩下的就是调试和验证了——这部分我们后面章节会详细讲。