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 中断处理流程全景图
下面这张图展示了从硬件中断触发到底半部执行完毕的完整流程:
3.6 实战经验:如何选择底半部机制
我做了这么多年驱动,总结了一套选择原则:
- 优先用Tasklet——90%的场景都够用,代码简单,不容易出错
- 需要高性能用软中断——比如网卡驱动,每秒处理几万个包
- 需要睡眠用工作队列——但要做好延迟的心理准备
调试技巧:如果怀疑中断处理时间过长,可以在顶半部入口和出口记录时间戳。用jiffies或者ktime_get()都可以。我曾经用这个方法抓到一个驱动——顶半部里竟然做了500微秒的循环等待,直接把系统中断延迟拉高了10倍。
3.7 避坑指南
最后分享几个我踩过的坑:
- 顶半部里别用
printk——频率一高,系统直接卡死 - Tasklet里别用
msleep——它还在中断上下文,睡眠会触发内核警告 - 工作队列别频繁调度——每次调度都有开销,高频场景用Tasklet
- 别忘了清除中断标志——否则中断会反复触发,形成死循环
嗯,中断上下文的设计说白了就是"紧急的事赶紧做,不紧急的事稍后做"。这个思想不光在Linux内核里用,很多实时系统也是这个套路。理解了顶半部和底半部的分工,你写驱动时就知道什么该放哪里了。