4、中断处理机制:中断注册request_irq、上半部与下半部(tasklet/workqueue)、中断共享与触发类型

中断,是嵌入式系统里最核心的异步事件处理方式。说白了,就是硬件告诉CPU:“我有事,你快来处理一下。” 我刚开始做驱动时,总觉得中断很简单——注册个函数不就完了?后来踩的坑多了才发现,中断处理的设计好坏,直接决定了系统的实时性和稳定性。

这一章,我们就来把中断的注册、上下半部机制、共享中断和触发类型这些关键点,一个一个说清楚。

4.1 中断注册:request_irq 的完整用法

在Linux内核里,注册一个中断处理函数,最核心的API就是 request_irq。它的原型长这样:

int request_irq(unsigned int irq,
                irq_handler_t handler,
                unsigned long flags,
                const char *name,
                void *dev_id);

每个参数都有讲究,我一个个说:

参数 含义 注意事项
irq 中断号 通常从设备树或平台数据获取,不要写死
handler 中断处理函数指针 返回类型为 irqreturn_t
flags 中断标志位 决定触发类型、共享属性等
name 设备名称 用于 /proc/interrupts 显示
dev_id 设备标识 共享中断时必须唯一,用于注销时区分
我的习惯: 注册中断时,dev_id 我通常传设备结构体指针。这样在中断处理函数里,可以直接通过 dev_id 拿到所有设备上下文,省得用全局变量。

一个典型的中断注册示例:

static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
    struct my_device *dev = (struct my_device *)dev_id;
    // 处理中断...
    return IRQ_HANDLED;
}

static int my_probe(struct platform_device *pdev)
{
    struct my_device *dev;
    int irq, ret;

    dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL);
    irq = platform_get_irq(pdev, 0);
    if (irq < 0)
        return irq;

    ret = request_irq(irq, my_irq_handler,
                      IRQF_TRIGGER_RISING,
                      "my_device", dev);
    if (ret) {
        dev_err(&pdev->dev, "request_irq failed\n");
        return ret;
    }

    dev->irq = irq;
    platform_set_drvdata(pdev, dev);
    return 0;
}
我曾经踩过的坑: 在中断处理函数里调用 printk 或 dev_err 输出调试信息。在中断频繁触发时,这会导致系统响应变慢,甚至触发看门狗复位。中断处理函数里,能不做的事尽量别做。

4.2 上半部与下半部:为什么需要拆分?

中断处理函数执行时,CPU是关中断的(至少是当前中断线被屏蔽)。这意味着,你在中断处理函数里待得越久,系统响应其他中断的能力就越差。你想想看,如果网卡中断来了,你在里面处理了5毫秒,那串口数据可能就丢了。

所以,Linux内核把中断处理拆成了两部分:

  • 上半部(Top Half): 执行时间极短的操作,比如清除中断标志、拷贝硬件寄存器数据到内存。这部分在关中断环境下执行。
  • 下半部(Bottom Half): 耗时的处理逻辑,比如数据解析、协议栈递交、唤醒等待队列。这部分在开中断环境下执行。

判断标准很简单:如果这个操作可以在几十微秒内完成,就放上半部;否则,必须推给下半部。

4.3 下半部的两种主流实现:tasklet 与 workqueue

Linux内核提供了多种下半部机制,但实际项目中最常用的就两个:tasklet 和 workqueue。

4.3.1 tasklet:软中断上下文,不可睡眠

tasklet 运行在软中断上下文,说白了就是不能睡眠、不能阻塞、不能调用可能引起调度的函数(比如 copy_to_user、kmalloc(GFP_KERNEL))。它的优点是开销小,执行快。

使用步骤:

  1. 定义并初始化 tasklet
  2. 在上半部中调度 tasklet
  3. 实现 tasklet 处理函数
// 定义 tasklet 及其处理函数
void my_tasklet_handler(unsigned long data)
{
    struct my_device *dev = (struct my_device *)data;
    // 处理数据,注意不能睡眠
}

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

// 上半部中调度
static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
    struct my_device *dev = (struct my_device *)dev_id;

    // 保存硬件数据到内存
    dev->hw_data = readl(dev->regs + DATA_REG);

    // 调度 tasklet
    tasklet_schedule(&my_tasklet);

    return IRQ_HANDLED;
}
关键点: tasklet 在同一时间只能在一个CPU上执行,不会并发。但不同 tasklet 可以在不同CPU上并行。如果你的设备中断频率很高,tasklet 是首选。

4.3.2 workqueue:进程上下文,可以睡眠

workqueue 运行在进程上下文,可以睡眠、可以调用 copy_to_user、可以申请大块内存。代价是调度开销比 tasklet 大,实时性稍差。

使用步骤:

  1. 定义 work_struct 并初始化
  2. 在上半部中调度 work
  3. 实现 work 处理函数
// 定义 work 及其处理函数
void my_work_handler(struct work_struct *work)
{
    struct my_device *dev = container_of(work,
                        struct my_device, my_work);
    // 可以睡眠,可以调用 copy_to_user
    copy_to_user(dev->user_buf, dev->data, dev->data_len);
    wake_up_interruptible(&dev->waitq);
}

// 在 probe 中初始化
INIT_WORK(&dev->my_work, my_work_handler);

// 上半部中调度
static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
    struct my_device *dev = (struct my_device *)dev_id;

    // 保存数据
    dev->data_len = readl(dev->regs + LEN_REG);
    memcpy_fromio(dev->data, dev->regs + DATA_REG, dev->data_len);

    // 调度 work
    schedule_work(&dev->my_work);

    return IRQ_HANDLED;
}
我的选择原则: 如果下半部只需要做数据拷贝、状态更新、唤醒等待队列,我用 tasklet。如果下半部需要访问用户空间、需要申请大块内存、或者需要调用可能睡眠的驱动接口,我用 workqueue。没有的好坏,只有合不合适。

4.4 中断共享:多个设备共用一根中断线

在嵌入式系统里,中断线是稀缺资源。多个设备共用一根中断线的情况很常见。共享中断的注册,靠的是 IRQF_SHARED 标志。

共享中断的几个硬性要求:

  • 所有共享该中断的驱动,注册时都必须指定 IRQF_SHARED
  • 每个驱动的 dev_id 必须唯一(通常传设备结构体指针)
  • 中断处理函数必须检查中断是否来自自己的设备
static irqreturn_t my_shared_handler(int irq, void *dev_id)
{
    struct my_device *dev = (struct my_device *)dev_id;

    // 检查中断状态寄存器,判断是否是自己设备的中断
    if (!(readl(dev->regs + INT_STAT) & dev->int_mask))
        return IRQ_NONE;  // 不是我的中断

    // 处理中断
    writel(dev->int_mask, dev->regs + INT_CLR);
    tasklet_schedule(&dev->tasklet);

    return IRQ_HANDLED;
}

// 注册时指定 IRQF_SHARED
ret = request_irq(irq, my_shared_handler,
                  IRQF_TRIGGER_LOW | IRQF_SHARED,
                  "my_device", dev);
我曾经遇到的故障: 共享中断时,某个驱动的中断处理函数返回了 IRQ_HANDLED,但实际上并没有处理中断(因为没检查状态寄存器)。导致其他设备的中断被“吃掉”,系统出现随机性故障。排查了整整两天才找到原因。记住:共享中断的处理函数,一定要先检查中断是否来自自己的设备。

4.5 触发类型:边缘触发 vs 电平触发

中断触发类型,是硬件设计时就定好的。驱动必须和硬件匹配,否则中断要么不触发,要么疯狂触发。

触发类型 标志位 特点 典型场景
上升沿触发 IRQF_TRIGGER_RISING 信号从低到高跳变时触发 按键中断、GPIO中断
下降沿触发 IRQF_TRIGGER_FALLING 信号从高到低跳变时触发 边沿敏感的通信协议
高电平触发 IRQF_TRIGGER_HIGH 信号为高电平时持续触发 共享中断、电平敏感设备
低电平触发 IRQF_TRIGGER_LOW 信号为低电平时持续触发 共享中断、唤醒源

电平触发和边缘触发有个重要区别:电平触发如果不在中断处理函数里清除中断源,中断会一直触发,导致系统卡死。边缘触发如果丢失了边沿,中断就永远丢了。

我的建议: 对于共享中断,必须使用电平触发。因为电平触发可以让中断控制器知道中断是否已经被处理。边缘触发在共享场景下,很容易丢失中断。

4.6 知识结构总览

下面这张图,把中断处理的整个流程和关键决策点串起来了。你可以把它当作调试时的检查清单。

ARM Linux 中断处理机制总览 硬件中断源 中断控制器 (GIC) request_irq 注册中断处理函数 上半部 (Top Half) 关中断环境,快速处理 下半部 (Bottom Half) 开中断环境,耗时处理 清除中断标志 拷贝硬件寄存器数据 调度下半部 tasklet(软中断上下文) workqueue(进程上下文) 共享中断 → IRQF_SHARED + 电平触发 触发类型:边缘触发 / 电平触发

4.7 调试与故障定位实用技巧

中断相关的故障,往往是最难排查的。我分享几个实战中积累的经验:

  • 查看中断统计: cat /proc/interrupts 可以看到每个中断的触发次数。如果某个中断次数不增长,说明中断没触发;如果疯狂增长,说明中断没被正确清除。
  • 测量中断延迟: 用 ftrace 的 irqsoff 追踪器,可以测量关中断的时间。如果关中断时间过长,说明上半部处理太慢。
  • 共享中断排查: 先确认所有共享驱动都正确检查了中断状态寄存器。可以用 printk 临时打印每个驱动的 IRQ_NONE 返回情况。
  • 触发类型不匹配: 用示波器抓中断引脚的波形,确认实际触发边沿/电平与驱动配置一致。这是最直接的方法。
一个小技巧: 在调试阶段,可以在 request_irq 之后用 cat /proc/interrupts | grep my_device 确认中断号、触发次数和设备名称是否正确。这个命令我几乎每天都会用。

中断处理是驱动开发的核心能力。理解上半部和下半部的分工,掌握 tasklet 和 workqueue 的适用场景,处理好共享中断的细节,你就能应对绝大多数中断相关的开发任务了。