4、中断共享:多个设备共享同一中断号时的驱动设计策略

嵌入式系统里,中断线是稀缺资源。尤其汽车电子,一个 SoC 可能挂了几十个外设,但中断控制器能给你的中断号就那么几十个。怎么办?共享中断。

说白了,就是多个设备共用一根中断线、同一个中断号。听起来简单,做起来坑不少。我最早接触这个是在一个车载信息娱乐项目里,触摸屏和加速度传感器共用了同一个 GPIO 中断。当时年轻,没仔细看芯片手册,结果一碰屏幕系统就死机……嗯,从那以后我对共享中断就特别上心。

4.1 共享中断的硬件基础

不是所有中断控制器都支持共享。你得先确认硬件支不支持这个玩法。

中断控制器类型 是否支持共享 典型场景
GPIO 中断(边沿触发) 不支持 按键、传感器
GPIO 中断(电平触发) 支持 多设备共享一根 GPIO 线
GIC(通用中断控制器) 支持 ARM SoC 标准外设
级联中断控制器 视具体实现 复杂多核系统

关键点:边沿触发不能共享。为什么?因为边沿触发只产生一个脉冲,如果两个设备同时触发,中断控制器只能捕获一个边沿,另一个就丢了。电平触发就没这个问题——只要电平还在,中断就一直挂着,直到所有设备都处理完。

注意: 我曾经在一个项目里用边沿触发做共享中断,结果设备 B 的中断永远丢失。排查了两天才发现是触发方式选错了。电平触发 + 共享中断,这是铁律。

4.2 Linux 共享中断的注册机制

Linux 内核里,共享中断通过 request_irq() 的最后一个参数 irqflags 来控制。关键标志是 IRQF_SHARED。

// 共享中断注册示例
int dev1_irq;
int ret;

// 设备1注册共享中断
ret = request_irq(irq_num, dev1_interrupt_handler,
                  IRQF_SHARED | IRQF_TRIGGER_HIGH,
                  "dev1", &dev1_data);
if (ret) {
    pr_err("dev1: failed to request shared irq %d\n", irq_num);
    return ret;
}

// 设备2注册同一个中断号
ret = request_irq(irq_num, dev2_interrupt_handler,
                  IRQF_SHARED | IRQF_TRIGGER_HIGH,
                  "dev2", &dev2_data);
if (ret) {
    pr_err("dev2: failed to request shared irq %d\n", irq_num);
    free_irq(irq_num, &dev1_data);
    return ret;
}

这里有个细节:dev_id 参数(最后一个)在共享中断里必须唯一。内核靠它来区分不同的设备。我习惯用设备结构体指针作为 dev_id,既唯一又方便在中断处理里拿到设备上下文。

4.3 中断处理函数的编写要点

共享中断的处理函数和普通中断不一样。你不能假设每次触发都是你的设备。必须做两件事:

  1. 检查设备是否真的产生了中断——读设备的中断状态寄存器
  2. 如果不是你的设备,立即返回 IRQ_NONE
static irqreturn_t dev1_interrupt_handler(int irq, void *dev_id)
{
    struct dev1_data *data = (struct dev1_data *)dev_id;
    u32 status;

    // 第一步:读设备中断状态
    status = readl(data->base + INT_STATUS_REG);
    if (!(status & DEV1_INT_MASK)) {
        // 不是我的中断,返回 IRQ_NONE
        return IRQ_NONE;
    }

    // 第二步:清除中断标志
    writel(DEV1_INT_MASK, data->base + INT_CLEAR_REG);

    // 第三步:处理实际中断逻辑
    // ... 你的业务代码 ...

    // 第四步:返回 IRQ_HANDLED
    return IRQ_HANDLED;
}
核心原则: 共享中断处理函数必须是一个「好公民」。不抢别人的中断,也不漏掉自己的中断。返回 IRQ_NONE 让内核继续调用下一个处理函数。

4.4 共享中断的驱动设计策略

我总结了几条实战策略,都是踩过坑换来的:

策略一:中断处理尽量轻量

共享中断的触发频率可能是所有设备的总和。你的处理函数必须快。把耗时操作扔进 tasklet 或 workqueue。

static irqreturn_t dev_shared_handler(int irq, void *dev_id)
{
    struct my_dev *dev = (struct my_dev *)dev_id;

    if (!check_my_interrupt(dev))
        return IRQ_NONE;

    // 只做必要的事:调度下半部
    tasklet_schedule(&dev->tasklet);

    return IRQ_HANDLED;
}

策略二:中断标志清除时机

电平触发的中断,必须在处理完所有设备后再清除中断标志。如果先清标志再处理,另一个设备的中断可能被误清。

小技巧: 我习惯在共享中断处理函数里先收集所有设备的中断状态,统一处理完后再统一清除。这样不会丢中断。

策略三:设备优先级排序

内核遍历共享中断的处理函数链表时,按注册顺序调用。把实时性要求高的设备放在前面注册。

设备类型 注册顺序 原因
CAN 控制器 第1 实时性要求最高
GPIO 按键 第2 用户交互不能卡顿
温度传感器 第3 慢速设备,可以等

4.5 共享中断的调试与排查

共享中断出问题,最常见的现象是:系统莫名其妙卡死,或者某个设备完全没反应。

我的排查步骤:

  1. 看 /proc/interrupts——确认每个设备的中断计数是否正常增长
  2. 检查中断触发方式——是不是用了边沿触发?赶紧改电平触发
  3. 确认 dev_id 唯一性——两个设备用了同一个指针?内核会报错
  4. 加调试打印——在中断处理函数入口打印设备名,看谁在抢中断
# 查看中断统计
cat /proc/interrupts | grep shared_irq_num

# 输出示例:
#  42:     12345     67890     GIC  42  dev1, dev2
#  ^^       ^^^^     ^^^^
# 中断号   dev1计数  dev2计数

如果 dev1 的计数一直不涨,说明它的中断被 dev2 吃掉了。这时候检查 dev2 的处理函数是不是忘了返回 IRQ_NONE。

踩坑实录: 我遇到过一种情况——设备 A 的中断状态寄存器在读取后自动清零。设备 B 的处理函数先读,发现状态位为 0,返回 IRQ_NONE。但设备 A 再读时,状态已经没了。最后两个设备都返回 IRQ_NONE,内核认为这是伪中断,直接禁用了这个中断号。解决方案:每个设备读自己的状态寄存器,不要依赖全局状态。

4.6 共享中断的架构图

下面这张图展示了共享中断的完整流程。从硬件触发到内核分发,再到各个设备处理函数。

共享中断处理流程 硬件中断触发 中断控制器分发 内核中断子系统 设备1处理函数 设备2处理函数 设备3处理函数 IRQ_NONE / IRQ_HANDLED IRQ_NONE / IRQ_HANDLED IRQ_NONE / IRQ_HANDLED 内核按注册顺序遍历处理函数链表,直到某个函数返回 IRQ_HANDLED 或遍历结束 如果所有函数都返回 IRQ_NONE,内核认为发生伪中断,可能禁用该中断号

4.7 共享中断的注意事项

  • 不要用 spin_lock_irqsave 锁共享资源——中断处理函数里如果自旋锁持有时间过长,其他设备的中断会被阻塞。用 spin_lock 或 raw_spin_lock 替代。
  • 注意中断嵌套——共享中断里,一个设备的中断处理可能被另一个设备的中断打断。如果你的设备有共享资源,记得加锁。
  • 卸载驱动时顺序释放——先注册的要后释放。内核在 free_irq 时会检查 dev_id 是否匹配,顺序错了会报错。
我的习惯: 在设备结构体里加一个 atomic_t 类型的引用计数。中断处理函数里先 atomic_inc,处理完 atomic_dec。卸载时等待计数归零再 free_irq。这样能避免中断处理函数还在跑,设备已经被卸载的竞态问题。

共享中断说难不难,说简单也不简单。核心就三点:电平触发、检查设备状态、快速返回。把这三点记牢了,大部分共享中断的问题都能搞定。剩下的,就是多读芯片手册,多看 /proc/interrupts,多写测试代码。嗯,实践出真知。