第2章:Linux中断子系统架构:分层、描述符与处理流程
中断子系统,说白了就是CPU与外部设备之间的“门铃系统”。设备有事要通知CPU,按一下门铃(触发中断),CPU放下手头活,开门处理。但Linux要管理成千上万的设备,这个门铃系统必须设计得足够精巧。
我早年刚接触内核中断时,总觉得这玩意儿就是个简单的回调函数注册。直到有一次调试一个网卡驱动,发现中断响应延迟高得离谱,才被迫把整个中断子系统从头啃了一遍。嗯,今天我就把这套东西掰开了讲给你听。
2.1 中断子系统分层架构
Linux中断子系统分为三层,每一层各司其职。我习惯用一张图来理解:
这三层分工很明确:
- 硬件相关层:处理具体的中断控制器硬件。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 中断处理流程
一个中断从硬件触发到驱动处理函数执行,中间经过了好几个步骤。我按顺序给你捋一遍:
- 硬件触发:设备拉高中断引脚,中断控制器检测到信号。
- 中断控制器仲裁:如果有多个中断同时到达,控制器按优先级选一个,通知CPU。
- CPU保存现场:CPU保存当前执行上下文(寄存器、程序计数器等),跳转到中断向量表。
- 中断向量查找:根据中断号找到对应的
irq_desc。 - 调用流控函数:执行
desc->handle_irq(),比如handle_fasteoi_irq()。 - 执行注册的处理函数:遍历
action链表,依次调用每个irqaction->handler()。 - 恢复现场:处理完毕,CPU恢复之前保存的上下文,继续执行被中断的任务。
这里面有个关键概念——中断上下文。中断处理函数运行在中断上下文中,不能睡眠,不能调用可能引起阻塞的函数(比如kmalloc(GFP_KERNEL))。
中断处理函数的两半机制:
- 上半部 (Top Half):在中断上下文中执行,要求快速、简短。通常只做必要的硬件操作(比如清除中断标志位),然后把耗时工作推给下半部。
- 下半部 (Bottom Half):在软中断或进程上下文中执行,可以睡眠,可以做复杂操作。实现方式有:软中断、tasklet、工作队列、线程化中断。
我举个例子。网卡收到一个数据包,触发中断:
- 上半部:读取中断状态寄存器,确认是接收中断,把数据包从硬件FIFO拷贝到内核缓冲区,然后调度一个软中断(NET_RX_SOFTIRQ),最后清除中断标志位。
- 下半部:在软中断中,把数据包交给协议栈处理。这个过程可能涉及内存分配、协议解析、socket投递,比较耗时。
为什么要分两半?你想想看,如果所有工作都在中断上下文里做,那系统响应其他中断的能力就会大大降低。我见过一个驱动,在中断处理函数里做了大量内存操作,结果系统一跑网络就卡死。后来改成线程化中断,问题就解决了。
2.4 中断处理流程全景图
下面这张图把整个流程串起来:
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这类可能睡眠的总线,直接用线程化中断。省心。
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抓取,能精确定位中断处理中的性能瓶颈。