第2章:Linux中断子系统架构:分层、描述符与处理流程

中断子系统,说白了就是CPU与外部设备之间的“门铃系统”。设备有事要通知CPU,按一下门铃(触发中断),CPU放下手头活,开门处理。但Linux要管理成千上万的设备,这个门铃系统必须设计得足够精巧。

我早年刚接触内核中断时,总觉得这玩意儿就是个简单的回调函数注册。直到有一次调试一个网卡驱动,发现中断响应延迟高得离谱,才被迫把整个中断子系统从头啃了一遍。嗯,今天我就把这套东西掰开了讲给你听。

2.1 中断子系统分层架构

Linux中断子系统分为三层,每一层各司其职。我习惯用一张图来理解:

Linux中断子系统分层架构 上层:设备驱动层 request_irq() / devm_request_irq() / threaded IRQ 驱动开发者只需注册中断处理函数,无需关心底层硬件细节 中间层:通用中断处理层 (Generic IRQ Layer) 中断描述符管理 / 中断流控 / 中断路由 / 中断共享 irq_domain / irq_chip / irq_desc 核心数据结构 提供与体系结构无关的通用中断处理框架 下层:硬件相关层 (Arch & Controller) GIC (ARM) / APIC (x86) / 中断控制器驱动 处理CPU与中断控制器之间的硬件交互 中断向量表 / 中断屏蔽 / 中断优先级 驱动层 核心层 硬件层

这三层分工很明确:

  • 硬件相关层:处理具体的中断控制器硬件。ARM用GIC,x86用APIC,RISC-V用PLIC。这一层负责把物理中断信号转换成内核能理解的数字编号。
  • 通用中断处理层:这是核心。它管理中断描述符、处理中断路由、实现中断流控。说白了,它把千奇百怪的硬件差异统一成一个标准接口。
  • 设备驱动层:你写驱动时接触的就是这层。调用request_irq()注册一个回调函数,完事。
我的经验:调试中断问题时,先搞清楚问题出在哪一层。如果中断根本没触发,大概率是硬件层或设备树配置问题。如果中断触发了但处理不对,那问题在驱动层或通用层。

2.2 中断描述符 (irq_desc)

中断描述符是内核管理中断的核心数据结构。每个中断号对应一个irq_desc结构体。你可以把它想象成每个中断的“档案袋”,里面装着这个中断的所有信息。

来看看这个结构体的关键字段:

struct irq_desc {
    struct irq_common_data  irq_common_data;
    struct irq_data         irq_data;
    unsigned int __percpu   *kstat_irqs;
    irq_flow_handler_t      handle_irq;    // 中断流控处理函数
    struct irqaction        *action;       // 中断处理动作链表
    unsigned int            status_use_accessors;
    struct list_head        pending;       // 待处理中断链表
    struct rcu_head         rcu;
    struct kobject          kobj;
    int                     parent_irq;
    struct module           *owner;
    const char              *name;
};

几个关键点:

  • handle_irq:中断流控函数。它决定中断到来后怎么处理——是立即执行,还是先屏蔽再处理,还是唤醒一个内核线程。ARM上最常见的是handle_irq_event()。
  • action:这是一个链表。为什么是链表?因为多个设备可以共享同一个中断号。每个irqaction代表一个注册的中断处理函数。
  • pending:当中断被屏蔽时,新来的中断会挂到这个链表上,等重新使能后再处理。
注意:中断描述符是在系统启动时静态分配的,数量由NR_IRQS决定。嵌入式系统上这个值通常不大,但x86上可能上千。我曾经在项目里遇到中断号超出范围导致系统崩溃的问题,排查了半天才发现是设备树里配了个超大的中断号。

2.3 中断处理流程

一个中断从硬件触发到驱动处理函数执行,中间经过了好几个步骤。我按顺序给你捋一遍:

  1. 硬件触发:设备拉高中断引脚,中断控制器检测到信号。
  2. 中断控制器仲裁:如果有多个中断同时到达,控制器按优先级选一个,通知CPU。
  3. CPU保存现场:CPU保存当前执行上下文(寄存器、程序计数器等),跳转到中断向量表。
  4. 中断向量查找:根据中断号找到对应的irq_desc。
  5. 调用流控函数:执行desc->handle_irq(),比如handle_fasteoi_irq()。
  6. 执行注册的处理函数:遍历action链表,依次调用每个irqaction->handler()。
  7. 恢复现场:处理完毕,CPU恢复之前保存的上下文,继续执行被中断的任务。

这里面有个关键概念——中断上下文。中断处理函数运行在中断上下文中,不能睡眠,不能调用可能引起阻塞的函数(比如kmalloc(GFP_KERNEL))。

中断处理函数的两半机制:

  • 上半部 (Top Half):在中断上下文中执行,要求快速、简短。通常只做必要的硬件操作(比如清除中断标志位),然后把耗时工作推给下半部。
  • 下半部 (Bottom Half):在软中断或进程上下文中执行,可以睡眠,可以做复杂操作。实现方式有:软中断、tasklet、工作队列、线程化中断。

我举个例子。网卡收到一个数据包,触发中断:

  • 上半部:读取中断状态寄存器,确认是接收中断,把数据包从硬件FIFO拷贝到内核缓冲区,然后调度一个软中断(NET_RX_SOFTIRQ),最后清除中断标志位。
  • 下半部:在软中断中,把数据包交给协议栈处理。这个过程可能涉及内存分配、协议解析、socket投递,比较耗时。

为什么要分两半?你想想看,如果所有工作都在中断上下文里做,那系统响应其他中断的能力就会大大降低。我见过一个驱动,在中断处理函数里做了大量内存操作,结果系统一跑网络就卡死。后来改成线程化中断,问题就解决了。

2.4 中断处理流程全景图

下面这张图把整个流程串起来:

中断处理完整流程 外设设备 中断控制器 CPU核 中断向量表 中断信号 IRQ 查表 保存现场 查找 irq_desc 调用 handle_irq() 执行 action 链表 下半部处理 (SoftIRQ/Thread) 硬件阶段 内核通用处理阶段 驱动处理阶段

2.5 中断共享与中断线程化

讲两个实际开发中经常遇到的概念。

中断共享:多个设备共用同一个中断号。这在PCI总线上很常见。实现原理就是action链表——每个设备注册自己的irqaction,中断触发时内核遍历链表,调用每个设备的处理函数。你的处理函数第一件事就是检查是不是自己的设备产生了中断,如果不是,立即返回IRQ_NONE。

static irqreturn_t my_device_irq_handler(int irq, void *dev_id)
{
    struct my_device *dev = dev_id;
    // 先检查是不是自己的设备
    if (!(readl(dev->regs + STATUS_REG) & INT_PENDING))
        return IRQ_NONE;  // 不是我的中断,跳过

    // 处理中断
    // ...

    return IRQ_HANDLED;
}

中断线程化:这是内核提供的一种机制,把中断处理函数放到一个内核线程里执行。这样处理函数就可以睡眠了,不再受中断上下文的限制。使用request_threaded_irq()来注册:

request_threaded_irq(irq, irq_handler_top, irq_handler_thread,
                     IRQF_TRIGGER_RISING, "my_device", dev);

第一个函数是上半部(可以为NULL),第二个是线程化的下半部。我建议,如果你的中断处理逻辑比较复杂,或者需要访问I2C/SPI这类可能睡眠的总线,直接用线程化中断。省心。

我曾经踩过的坑:有一次在ARM平台上调试一个触摸屏驱动,中断处理函数里调用了i2c_transfer()来读取触摸坐标。结果系统频繁死机,查了半天才发现I2C传输可能引起调度,而中断上下文不允许调度。改成线程化中断后,问题立刻解决。

2.6 中断调试常用手段

最后分享几个我常用的调试方法:

方法 命令/工具 用途
查看中断统计 cat /proc/interrupts 查看每个中断号的触发次数、CPU亲和性
查看中断描述符 cat /proc/irq/<irq>/ 查看特定中断的详细信息
动态追踪 trace-cmd / ftrace 追踪中断处理函数的调用栈和耗时
中断延迟测量 cyclictest 测量系统中断响应延迟
内核调试选项 CONFIG_DEBUG_IRQ 开启中断调试,打印额外信息

我个人最常用的是/proc/interrupts。看中断次数是否在预期范围内,能快速判断设备是否正常工作。如果中断次数为0,那问题大概率在硬件或设备树配置上。

调试小技巧:在handle_irq()函数里加trace_printk(),可以实时看到每个中断的触发时间和处理耗时。配合trace-cmd抓取,能精确定位中断处理中的性能瓶颈。