中断与底半部机制优化:中断上下文与进程上下文、中断注册与性能开销、tasklet/workqueue/threaded IRQ对比与选择、中断负载均衡(IRQ affinity)

中断,是嵌入式系统响应外部事件的「神经末梢」。但很多驱动工程师写中断处理函数时,习惯把大量工作直接塞进 handler 里。我早年也这么干过,结果系统响应慢得像蜗牛,连按键都丢中断。后来才明白——中断上下文里,你碰都不能碰那些会睡眠的东西。

中断上下文 vs 进程上下文

这两个概念,说白了就是「谁在干活」的问题。

  • 中断上下文:硬件中断触发后,CPU 自动跳转到中断向量表,关中断或开中断,执行 handler。此时没有进程的概念,不能睡眠,不能调度,不能拿信号量。
  • 进程上下文:普通进程跑在用户态或内核态,可以睡眠、可以调度、可以等锁。

我见过有人在中断 handler 里调用 msleep(),结果系统直接死锁。为什么?因为中断上下文里调度器根本不会去切换你,你睡下去就没人叫醒你。

核心原则:中断 handler 里只做最紧急、最轻量的事——读寄存器、清标志、唤醒底半部。其余所有耗时操作,全部扔到底半部去。

中断注册与性能开销

注册中断用 request_irq() 或 devm_request_irq()。参数里有个 flags,很多人不注意,其实这里藏着性能陷阱。

// 典型的中断注册
ret = request_irq(irq_num, my_handler,
                  IRQF_TRIGGER_RISING | IRQF_SHARED,
                  "my_device", dev);
if (ret) {
    pr_err("Failed to request IRQ %d\n", irq_num);
    return ret;
}

性能开销主要来自三方面:

  1. 中断触发频率:每秒几万次的中断,handler 哪怕只多花 1us,CPU 就被吃掉 10%。
  2. 中断共享:多个设备共享一根 IRQ 线,每次中断都要遍历所有 handler,直到找到匹配的那个。我在一个项目里遇到过 4 个设备共享一个 IRQ,每次中断都要轮询一遍,性能直接腰斩。
  3. 中断嵌套:如果 handler 里开了中断,新的中断可能打断当前处理,导致栈溢出或优先级反转。
我的习惯:注册中断时尽量用 IRQF_NO_SUSPEND 和 IRQF_ONESHOT 组合,尤其是 threaded IRQ 场景。前者保证系统休眠时还能响应,后者防止中断重入。

底半部机制:tasklet / workqueue / threaded IRQ

Linux 提供了三种主流底半部机制。我按自己的经验画了张对比图,帮你快速选型。

底半部机制选型决策图 中断触发 处理时间 < 1ms? 是 tasklet 软中断上下文,不可睡眠 否 workqueue 进程上下文,可睡眠 需要内核线程管理? threaded IRQ 内核线程,可设置优先级 选型建议: • 高频、轻量 → tasklet(如 GPIO 按键、DMA 完成) • 低频、重量 → workqueue(如 I2C 读写、文件操作) • 需要线程优先级控制 → threaded IRQ(如实时性要求高的传感器)

tasklet

tasklet 跑在软中断上下文,说白了就是「中断的下半截」。它不能睡眠,不能拿信号量,但执行很快。我一般用它处理那些确定能在几百微秒内完成的事,比如清 FIFO、更新统计计数。

// tasklet 示例
void my_tasklet_handler(unsigned long data)
{
    struct my_dev *dev = (struct my_dev *)data;
    // 快速处理,不能睡眠
    writel(dev->reg_base + STATUS, 0);
}

DECLARE_TASKLET(my_tasklet, my_tasklet_handler, (unsigned long)dev);

// 中断 handler 里调度
irqreturn_t my_handler(int irq, void *dev_id)
{
    tasklet_schedule(&my_tasklet);
    return IRQ_HANDLED;
}

workqueue

workqueue 跑在进程上下文,可以睡眠、可以等锁。适合那些需要访问 I2C、SPI 或操作文件系统的场景。但要注意——workqueue 的调度延迟不确定,如果中断频率很高,workqueue 可能来不及处理。

// workqueue 示例
struct work_struct my_work;

void my_work_handler(struct work_struct *work)
{
    // 可以睡眠,可以调用 i2c_transfer()
    msleep(10);
}

// 中断 handler 里调度
irqreturn_t my_handler(int irq, void *dev_id)
{
    schedule_work(&my_work);
    return IRQ_HANDLED;
}
我曾经踩过的坑:在 workqueue 里调用了 msleep(),但中断频率是 10kHz,workqueue 根本来不及消化,系统内存被 pending 的 work 撑爆。后来改用 threaded IRQ 并限制线程优先级,才稳住。

threaded IRQ

threaded IRQ 是内核 2.6.30 之后引入的机制。它把中断 handler 分成两部分:一个极简的「快速 handler」(可选),和一个内核线程。线程可以设置优先级,适合实时性要求高的场景。

// threaded IRQ 注册
ret = request_threaded_irq(irq_num,
                           my_fast_handler,  // 快速 handler,可为 NULL
                           my_thread_fn,     // 线程函数
                           IRQF_TRIGGER_RISING | IRQF_ONESHOT,
                           "my_device", dev);

static irqreturn_t my_thread_fn(int irq, void *dev_id)
{
    // 这里可以睡眠,可以拿 mutex
    struct my_dev *dev = dev_id;
    mutex_lock(&dev->lock);
    // 处理数据
    mutex_unlock(&dev->lock);
    return IRQ_HANDLED;
}

三种机制对比

特性 tasklet workqueue threaded IRQ
上下文 软中断 进程 内核线程
可睡眠 否 是 是
调度延迟 低(us 级) 中(ms 级) 可控(可设优先级)
适用场景 高频、轻量 低频、重量 实时性要求高
并发限制 同类型串行 可并行 可并行

中断负载均衡(IRQ affinity)

多核处理器上,中断默认都落在 CPU0 上。如果外设中断频率很高,CPU0 会被打满,其他核却在看热闹。IRQ affinity 就是用来把中断分散到不同核上的。

设置方法很简单:

# 查看当前 IRQ 的 CPU 亲和性
cat /proc/irq/123/smp_affinity

# 设置 IRQ 123 只跑在 CPU2 上(位掩码 0x04)
echo 04 > /proc/irq/123/smp_affinity

# 或者用 irqbalance 自动均衡
systemctl start irqbalance

但要注意,不是所有中断都适合分散。比如网卡中断,如果分散到多个核,反而可能因为 cache 失效导致性能下降。我一般这样分配:

  • 高频、数据量大(如网卡、NVMe):绑定到专用核,避免核间切换。
  • 低频、控制类(如 GPIO、I2C):可以共享一个核,或者交给 irqbalance 自动管理。
  • 实时性要求高(如音频、电机控制):绑定到隔离核(isolcpus),避免被其他进程干扰。
我的经验:在项目里调试一个 4G 模块,中断频率 2kHz,CPU0 占用率 80%。把 IRQ 绑定到 CPU2 后,CPU0 降到 20%,系统整体吞吐量提升了 30%。但要注意,绑定后要监控 CPU2 的负载,别把某个核打满。

嗯,中断优化这件事,说白了就是「把对的事放在对的地方」。中断上下文只做最紧急的事,底半部根据场景选 tasklet、workqueue 还是 threaded IRQ,最后用 IRQ affinity 把负载摊平。这三板斧用好了,系统性能基本不会差到哪去。