3. 中断上下文:顶半部与底半部机制

中断处理,说白了就是CPU正在干活,突然被外设喊停——"有急事!"这时候CPU得放下手头的事,先去处理这个紧急请求。但这里有个矛盾:中断服务程序既要快,又要处理大量数据。怎么办?

Linux内核的解决方案很巧妙——把中断处理拆成两半。上半部分叫顶半部(Top Half),下半部分叫底半部(Bottom Half)。这个设计我最早接触时觉得多此一举,直到自己踩了坑才明白它的精妙之处。

3.1 为什么需要拆成两半?

先想想中断处理的核心约束:

  • 中断上下文不能睡眠——你睡着了谁去响应下一个中断?
  • 中断上下文不能被调度——调度器本身就可能依赖定时器中断
  • 中断处理要快——拖太久会丢失后续中断

但实际驱动要干的事往往很耗时:拷贝数据、处理协议、唤醒等待队列……这些操作放在中断里直接做,系统迟早会崩。

核心思路:顶半部只做最紧急、最轻量的工作(比如读取硬件状态寄存器),然后把耗时操作丢给底半部去慢慢处理。底半部运行在软中断上下文,可以被打断,但依然不能睡眠。

3.2 顶半部(Top Half)—— 快刀斩乱麻

顶半部就是注册到request_irq()的那个回调函数。它运行在真正的硬件中断上下文,IRQ被禁用(或者至少当前中断线被屏蔽)。

顶半部该做什么?我个人习惯遵循"三秒原则"——能在三秒内搞定的才放这里:

  • 读取硬件状态寄存器,确认中断源
  • 清除中断标志位(不清除的话,中断会反复触发)
  • 把数据从硬件FIFO搬到内存缓冲区
  • 标记底半部需要处理

来看一个典型的顶半部代码:

static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
    struct my_device *dev = (struct my_device *)dev_id;
    uint32_t status;

    // 1. 读取状态寄存器
    status = ioread32(dev->regs + REG_STATUS);

    // 2. 检查是不是我们的中断
    if (!(status & INT_PENDING))
        return IRQ_NONE;

    // 3. 清除中断标志
    iowrite32(INT_CLEAR, dev->regs + REG_CLEAR);

    // 4. 把数据搬到内核缓冲区
    dev->rx_data = ioread32(dev->regs + REG_RX_FIFO);

    // 5. 触发底半部处理
    tasklet_schedule(&dev->rx_tasklet);

    return IRQ_HANDLED;
}

我曾经踩过的坑:有一次在顶半部里调用了printk()打印调试信息。结果中断频率一高,控制台直接被刷死,系统响应变得极其缓慢。后来才意识到,printk在中断上下文虽然能用,但会引发控制台锁的竞争。顶半部里尽量别做任何可能阻塞的操作。

3.3 底半部(Bottom Half)—— 善后工作

底半部负责处理那些"不那么紧急但必须做"的事。Linux提供了三种主要机制:

机制 特点 适用场景
软中断(SoftIRQ) 内核静态分配,性能最高 网络、块设备等高性能路径
Tasklet 基于软中断封装,使用简单 大多数驱动场景
工作队列(Workqueue) 运行在进程上下文,可以睡眠 需要睡眠的耗时操作

这里要特别注意:软中断和Tasklet依然运行在中断上下文,不能睡眠。只有工作队列是运行在进程上下文,可以调用msleep()之类的阻塞函数。

3.3.1 Tasklet —— 最常用的底半部

我个人最喜欢用Tasklet,原因很简单——API简洁,不容易出错。

// 定义tasklet处理函数
static void rx_tasklet_handler(unsigned long data)
{
    struct my_device *dev = (struct my_device *)data;

    // 这里可以放心地使用自旋锁
    spin_lock(&dev->lock);
    // 处理收到的数据包
    process_rx_packet(dev->rx_data);
    spin_unlock(&dev->lock);

    // 唤醒等待数据的进程
    wake_up_interruptible(&dev->wq);
}

// 初始化tasklet
tasklet_init(&dev->rx_tasklet, rx_tasklet_handler, (unsigned long)dev);

小技巧:Tasklet在同一时间只能在一个CPU上运行,这天然避免了并发问题。但这也意味着——如果你的设备中断很频繁,Tasklet可能会成为瓶颈。这时候可以考虑用软中断或者多队列。

3.3.2 工作队列 —— 能睡觉的底半部

有些操作确实需要睡眠,比如访问I2C设备、等待DMA完成。这时候就得用工作队列。

static void work_handler(struct work_struct *work)
{
    struct my_device *dev = container_of(work, struct my_device, rx_work);

    // 这里可以调用msleep
    msleep(10);

    // 可以获取信号量
    down(&dev->sem);

    // 处理数据...
}

// 初始化工作队列
INIT_WORK(&dev->rx_work, work_handler);

// 在顶半部中调度
schedule_work(&dev->rx_work);

注意:工作队列虽然可以睡眠,但延迟较大。从触发到执行,中间可能有几十到几百微秒的延迟。实时性要求高的场景慎用。

3.4 中断上下文的限制 —— 这些事千万别做

我见过不少新手在中断里调用kmalloc(GFP_KERNEL),然后系统莫名其妙地死锁。原因很简单——GFP_KERNEL分配内存时可能会触发页面回收,而页面回收需要等待I/O,这就导致了睡眠。

中断上下文中绝对不能做的事:

  • 调用可能导致睡眠的函数:kmalloc(GFP_KERNEL)、mutex_lock()、down()、msleep()、wait_event()
  • 访问用户空间内存:copy_to_user()、copy_from_user()(可能触发缺页异常)
  • 触发调度:schedule()、cond_resched()

那中断上下文里能用什么?

  • 自旋锁(spin_lock)—— 不会睡眠,忙等待
  • 原子操作(atomic_t)
  • 内存屏障(mb()、wmb()、rmb())
  • 硬件寄存器访问(ioread/iowrite)

判断是否在中断上下文:内核提供了in_interrupt()和in_irq()两个宏。前者返回非0表示在中断上下文(包括软中断),后者只在硬件中断上下文返回非0。调试时可以用它们加断言。

3.5 中断处理流程全景图

下面这张图展示了从硬件中断触发到底半部执行完毕的完整流程:

中断处理完整流程 硬件中断触发 CPU 保存现场,关中断 顶半部执行 读取状态、清除标志、搬数据 选择底半部机制 软中断 (SoftIRQ) Tasklet 工作队列 不能睡眠 不能睡眠 可以睡眠 底半部执行完毕后,CPU 恢复现场,开中断 继续执行被中断的进程

3.6 实战经验:如何选择底半部机制

我做了这么多年驱动,总结了一套选择原则:

  1. 优先用Tasklet——90%的场景都够用,代码简单,不容易出错
  2. 需要高性能用软中断——比如网卡驱动,每秒处理几万个包
  3. 需要睡眠用工作队列——但要做好延迟的心理准备

调试技巧:如果怀疑中断处理时间过长,可以在顶半部入口和出口记录时间戳。用jiffies或者ktime_get()都可以。我曾经用这个方法抓到一个驱动——顶半部里竟然做了500微秒的循环等待,直接把系统中断延迟拉高了10倍。

3.7 避坑指南

最后分享几个我踩过的坑:

  • 顶半部里别用printk——频率一高,系统直接卡死
  • Tasklet里别用msleep——它还在中断上下文,睡眠会触发内核警告
  • 工作队列别频繁调度——每次调度都有开销,高频场景用Tasklet
  • 别忘了清除中断标志——否则中断会反复触发,形成死循环

嗯,中断上下文的设计说白了就是"紧急的事赶紧做,不紧急的事稍后做"。这个思想不光在Linux内核里用,很多实时系统也是这个套路。理解了顶半部和底半部的分工,你写驱动时就知道什么该放哪里了。