第二讲:中断机制深度解析
Linux中断子系统:从硬件到软件的那一跳
中断,说白了就是硬件在跟CPU喊话:「嘿,我这儿有事儿,你赶紧处理一下!」。在蓝牙驱动里,中断更是家常便饭——数据包到了、连接断开了、DMA传输完成了,全得靠中断来通知。
Linux的中断子系统,我把它比作一个「消息中转站」。硬件产生中断信号,经过中断控制器(GIC、APIC之类的),最终到达CPU。CPU停下手中的活儿,跳转到中断向量表,找到对应的处理函数。嗯,这里要注意,这个跳转过程是有开销的——保存上下文、切换模式、再恢复上下文,每一步都在消耗CPU周期。
我在项目中遇到过一个问题:蓝牙芯片的中断频率太高,导致系统响应变慢。后来发现是中断处理函数里做了太多事情。这就要引出我们接下来要讲的核心概念——上半部和下半部。
上半部与下半部:把紧急的事和重要的事分开
为什么要有上半部和下半部?你想想看,中断处理期间,CPU是关中断的。如果你在中断里磨磨蹭蹭,其他中断就进不来。这就像你接了个紧急电话,但电话里你非要跟对方聊半小时——那其他电话就打不进来了。
所以Linux把中断处理拆成了两部分:
- 上半部(Top Half):关中断执行,只做最紧急的事。比如:读取硬件状态寄存器、清除中断标志、把数据拷贝到内存缓冲区。这些操作必须快,越快越好。
- 下半部(Bottom Half):开中断执行,做剩下的处理。比如:解析数据包、唤醒等待队列、调用上层协议栈。这些操作可以慢慢来,不着急。
我曾经犯过一个低级错误:在上半部里调用了printk打印调试信息。结果中断频率一高,系统直接卡死。后来才意识到,printk在中断上下文里是极其危险的——它可能会触发调度,而中断上下文不允许睡眠。
核心原则:上半部越快越好,下半部越轻越好。
中断上下文:一个特殊的运行环境
中断上下文,说白了就是「CPU正在处理中断时的状态」。在这个状态下,你不能睡眠、不能调度、不能获取信号量。为什么?因为中断上下文没有自己的进程控制块(task_struct),它借用了被中断进程的上下文。如果你在中断里睡眠了,谁来唤醒你?
我习惯用一张表来区分中断上下文和进程上下文:
| 特性 | 中断上下文 | 进程上下文 |
|---|---|---|
| 能否睡眠 | 绝对不能 | 可以 |
| 能否调度 | 不能 | 可以 |
| 能否访问用户空间 | 不能 | 可以 |
| 能否获取信号量 | 不能(会睡眠) | 可以 |
| 能否使用自旋锁 | 可以 | 可以(但要注意) |
嗯,这里有个坑:自旋锁在中断上下文里用起来要特别小心。如果你在中断里持有了一个自旋锁,而同一个CPU上的其他中断也想获取这个锁——那就死锁了。所以中断里用的自旋锁,一定要用spin_lock_irqsave这种变体,它会帮你关掉本地中断。
下半部的三种实现方式:软中断、tasklet、工作队列
Linux提供了三种下半部机制,各有各的适用场景。我按个人习惯排个序:
- 软中断(Softirq):最底层、最高效。但使用起来最麻烦,因为同一个软中断可以在多个CPU上同时运行。我一般不用它,除非是网络子系统这种对性能要求极高的场景。
- tasklet:基于软中断实现,但保证同一个tasklet不会在多个CPU上同时运行。这就省去了很多同步的麻烦。蓝牙驱动里,我90%的下半部都用tasklet。
- 工作队列(Workqueue):运行在进程上下文,可以睡眠。适合那些需要大量处理时间的任务,比如:解析复杂的蓝牙协议包、写日志到文件系统。
我的选择原则:如果下半部里不需要睡眠,用tasklet;如果需要睡眠,用工作队列。别碰软中断,除非你真的很懂它。
实战:蓝牙驱动的中断处理框架
来看一个蓝牙UART驱动的中断处理示例。这是我实际项目中用过的框架,稍微简化了一下:
/* 上半部:中断处理函数 */
static irqreturn_t bt_uart_irq_handler(int irq, void *dev_id)
{
struct bt_uart_dev *dev = dev_id;
unsigned long flags;
/* 读取中断状态寄存器 */
u32 status = readl(dev->base + BT_UART_IRQ_STATUS);
/* 清除中断标志 */
writel(status, dev->base + BT_UART_IRQ_CLEAR);
/* 把数据从FIFO搬到内存缓冲区 */
spin_lock_irqsave(&dev->lock, flags);
bt_uart_read_fifo(dev);
spin_unlock_irqrestore(&dev->lock, flags);
/* 调度下半部 */
tasklet_schedule(&dev->rx_tasklet);
return IRQ_HANDLED;
}
/* 下半部:tasklet处理函数 */
static void bt_uart_rx_tasklet(unsigned long data)
{
struct bt_uart_dev *dev = (struct bt_uart_dev *)data;
/* 解析收到的数据包 */
while (bt_uart_has_packet(dev)) {
struct sk_buff *skb = bt_uart_build_skb(dev);
if (skb) {
/* 交给蓝牙协议栈 */
bt_receive_skb(skb);
}
}
}
你看,上半部只做了三件事:读状态、清标志、搬数据。剩下的解析和分发都交给了tasklet。这样中断处理时间被压缩到了微秒级,系统响应自然就快了。
避坑指南:我曾经在tasklet里调用了msleep,结果系统直接panic。tasklet运行在软中断上下文,同样不能睡眠。如果你需要睡眠,请用工作队列。
中断共享与IRQ号管理
蓝牙芯片通常挂在某个总线上(UART、USB、SDIO),它的中断可能是共享的。比如UART控制器和蓝牙芯片共用同一个IRQ线。这时候,你的中断处理函数必须能判断:这个中断是不是我的设备产生的?
Linux提供了devm_request_irq和free_irq来管理IRQ。我建议用devm_版本,它会在设备卸载时自动释放资源,省得你忘了调用free_irq。
/* 请求中断 */
ret = devm_request_irq(&pdev->dev, irq, bt_uart_irq_handler,
IRQF_SHARED | IRQF_TRIGGER_HIGH,
"bt_uart", dev);
if (ret) {
dev_err(&pdev->dev, "Failed to request IRQ %d\n", irq);
return ret;
}
注意IRQF_SHARED标志——它告诉内核这个IRQ是共享的。如果你的设备不支持共享中断,千万别加这个标志,否则系统会报错。
性能调优:减少中断次数
蓝牙驱动里,中断频率是个大问题。尤其是高速数据传输时,每来一个包就中断一次,CPU根本忙不过来。我常用的优化手段有两个:
- 中断合并(Interrupt Coalescing):让硬件攒够一定数量的数据包再产生中断。比如:每收到10个包才触发一次中断。
- NAPI机制:这是网络子系统里的经典做法。中断来了先关中断,然后轮询收包,直到收完为止再开中断。蓝牙驱动也可以借鉴这个思路。
我记得有一次,蓝牙音频传输总是断断续续。排查后发现是中断频率太高,CPU忙于处理中断,没时间处理音频数据。后来启用了中断合并,把中断频率从每秒几千次降到了几百次,问题就解决了。
小结
中断机制是蓝牙驱动性能的基石。上半部要快、下半部要轻、上下文要分清。记住这三条,你的驱动就不会太差。下一讲我们会深入DMA,看看怎么把数据搬运这件事做得更高效。