
大家好,我是蟹老板~
我组来了个实习僧,一个挺聪明的小伙子,背中断 API 背得飞起。request_irq()、free_irq()、上半部下半部,张嘴就来。
但是他今天竟然对着中断处理那一块代码一脸懵逼,问我:“哥,这个el1_irq是什么?为什么跳转过去之后还要保存上下文?这个eret又是干嘛的?”
那一刻,我看着他笑了,不是笑他菜,是真的觉得这个场景太熟悉了。
Linux中断管理这东西,玄之又玄,仿佛有一层窗户纸,没捅破觉得是天书,捅破了也就那样。而这层窗户纸的核心,其实就是ARM64的异常处理机制。
说句掏心窝子的话,如果你搞不懂ARM64的异常处理,你永远只能停留在“会用”Linux中断API的层面,一旦遇到内核崩了(Oops)、中断不响应、或者是上下文保存恢复出错这种底层问题,你就只能干瞪眼,然后把锅甩给“硬件不稳定”或者“内核版本有bug”。但其实,锅往往在你自己没搞懂底层机制。
所以,今天咱们就来把这层窗户纸彻底捅破,把ARM64异常处理和Linux中断管理的关系理清楚。
下面我尽量用代码和流程说话,但也会夹带一些我踩过的坑。有些观点可能不够严谨——我毕竟不是搞芯片出身的,若有错误,欢迎指正ღ( ´・ᴗ・` )。
流程图:
硬件外设触发中断 │ ▼GIC 置位 IAR,向 CPU 发出 IRQ │ ▼CPU 查询 VBAR_EL1,跳转到 el1_irq / el0_irq │ ▼kernel_entry(保存上下文,切换栈) │ ▼handle_arch_irq → gic_handle_irq() │ ├── 读 GICC_IAR → hwirq │ ├── 写 GICC_EOIR(deactivate) │ └── handle_domain_irq(domain, hwirq) │ │ │ ├── hwirq → virq(irq_find_mapping) │ ├── virq → irq_desc(irq_to_desc) │ └── generic_handle_irq_desc │ │ │ └── irq_desc->handle_irq() │ │ │ ├── 【上半部】驱动中断回调 │ │ ├── 清中断标志 │ │ └── 挂起下半部 │ │ │ └── irq_exit() │ │ │ └── 检查 softirq 标志位 │ │ ▼ ▼kernel_exit(恢复上下文,eret) 【下半部】softirq/tasklet/work │ ▼返回被中断的代码继续执行程序正常运行的时候,CPU 很像一个流水线工人:取指、译码、执行、继续下一条,整个过程非常机械。但现实世界不讲武德,网卡来了数据、键盘按下去了、UART 收到字节、定时器时间到了,这时候 CPU 不可能还傻乎乎继续执行用户代码,它必须停下来去处理更紧急的事情。于是,“异常”就出现了。
很多人以为“异常”就是程序崩了。在 ARM 世界里,异常其实是 CPU 的一种“强制切换状态”。像 IRQ、FIQ、SWI、Data Abort、Undefined Instruction,本质上都是:“别干你现在的活了,先处理这个。”这个动作就是异常。你会发现,ARM 的整个异常机制,本质上像一个极其暴躁的领导。你代码跑得正开心,它啪一下拍桌子:“别跑了!先去处理这个!”然后 CPU 就被强行切过去了。
刚开始学 ARM64 的时候,千万别被老文档带沟里。你可能听过什么“banked register”(分组寄存器),说什么 IRQ 模式有自己专属的 R13,FIQ 有自己专属的 R8-R12。打住!那是 ARMv6 (32位) 的老黄历了。
在 ARM64 (AArch64) 里,CPU 的设计哲学变了。它取消了大部分的分组通用寄存器。这意味着什么?意味着当 IRQ(普通中断)来的时候,CPU 不会像以前那样自动给你换一套寄存器用。
那现场怎么保护?全靠“硬存”!
也就是你在汇编代码里看到的那些 stp x0, x1, [sp]。Linux 内核必须像苦力一样,把当前用到的寄存器一个一个手动压入栈中。这就是为什么 ARM64 的中断入口汇编代码特别长,因为它是在给通用寄存器“擦屁股”。
FIQ(快速中断),在早期的 ARMv6(32 位)架构中,FIQ 拥有专属的硬件特权——独占 R8~R14 寄存器。当 FIQ 触发时,硬件直接切换至这套专用寄存器,省去了软件保存现场的耗时操作,专为工业控制、DSP 数据处理等极致低延迟的实时场景而生。这种“送你一套寄存器”的激进设计,在当时堪称颠覆性创新。
如今 ARM64(AArch64)架构中,这一“硬件魔法”已让位于软件灵活性:X0-X30 是物理上唯一的通用寄存器,但不同异常级别无自动隔离机制:当异常发生时,CPU 不会切换寄存器组,X0-X30 的值保持不变,需软件主动保存现场以防被覆盖,FIQ 触发时,内核必须像处理 IRQ 一样,通过软件手动保存现场(如压栈 X0-X30)。ARM64 中,FIQ 的“快速”本质仅体现为优先级——在 GIC(通用中断控制器)中,高优先级中断可通过 FIQn 信号线直接送达 CPU(无视中断屏蔽状态),而低优先级中断走 IRQn 信号线(受 PSTATE.I/F 位控制)。但进入 CPU 后,两者均由同一套异常处理流程接管,**仅优先级配置不同,无寄存器级硬件特权。
今非往昔啊~ 现 FIQ 已非主流,仅用于固件交互(如 TrustZone 的安全监控调用 SMC)或极少数需要微秒级延迟的极端场景,**现在的主角是 IRQ。**这种转变背后,是 ARM 在实时性与架构统一性间的深刻权衡:ARM64 用软件可控的灵活性取代了硬件特化,通过统一的异常模型实现分层能力——应用程序运行在 EL0,内核在 EL1,Hypervisor 在 EL2,安全监控在 EL3。异常发生时,CPU 自动保存程序状态至 SPSR_ELx、返回地址至 ELR_ELx,并根据 VBAR_ELx 跳转到异常向量表入口。处理完毕时,一条 eret 指令即可从 ELR/SPSR 恢复现场并切换回原执行级别。
第一次理解这个设计的时候,我真的拍大腿,太牛了。这帮做 CPU 的人脑回路真的有点东西。
ARM64 最大支持四个异常级别,EL0 权限最低,EL3 权限最高。当异常发生时,CPU 的异常级别会迁移到更高或维持当前级别,但绝不会降低,也不会有任何异常去到 EL0。常见等级如下所示:
| 异常级别 | 描述 | 典型用途 |
|---|---|---|
| EL0 | ||
| EL1 | ||
| EL2 | ||
| EL3 |
异常发生时,CPU 只做三件事(这三件事是硬件自动做的,非常重要):
前面实习僧提到的 eret 指令?那是异常处理完后的“回家路”。执行 eret 时,CPU 会自动把 SPSR 里的状态恢复回去,并跳转回 ELR 保存的地址继续执行。简单说,eret 就是把上面的过程倒放。
不管你是做驱动开发还是内核调试,ARM64的异常向量表是你必须要啃下的。
ARMv8规范要求每个异常等级维护自己的向量表,基地址分别存于VBAR_EL1、VBAR_EL2、VBAR_EL3寄存器。每个向量表占2KB空间,包含16个条目(entry),每个条目128字节,刚好放下32条指令。
这16个条目怎么组织的呢?ARMv8把异常场景按两个维度切分:
维度1:异常来源和栈指针类型
维度2:异常类型
4×4 = 16,正好填满整个向量表。
Linux内核在entry.S里定义了这张表,看看它是怎么写的:
/* * Exception vectors. */ .align 11ENTRY(vectors) ventry el1_sync_invalid // Synchronous EL1t ventry el1_irq_invalid // IRQ EL1t ventry el1_fiq_invalid // FIQ EL1t ventry el1_error_invalid // Error EL1t ventry el1_sync // Synchronous EL1h ventry el1_irq // IRQ EL1h ventry el1_fiq_invalid // FIQ EL1h ventry el1_error_invalid // Error EL1h ventry el0_sync // Synchronous 64-bit EL0 ventry el0_irq // IRQ 64-bit EL0 ventry el0_fiq_invalid // FIQ 64-bit EL0 ventry el0_error_invalid // Error 64-bit EL0#ifdef CONFIG_COMPAT ventry el0_sync_compat // Synchronous 32-bit EL0 ventry el0_irq_compat // IRQ 32-bit EL0 ventry el0_fiq_invalid_compat // FIQ 32-bit EL0 ventry el0_error_invalid_compat// Error 32-bit EL0#endifEND(vectors)来看看几个你刷代码一定刷到过的symbol:
注意那个带“_invalid”结尾的:el1_irq_invalid、el0_fiq_invalid这些。意思是“理论上不应该进这个入口,如果进了说明你代码有bug”。Linux的处理方式是直接调用bad_mode()——内核panic给你看,告诉你出大事了。你如果调试的时候看到bad_mode调用栈,那十有八九是DAIF掩码配置出了问题。
有些资料说异常向量表是4组每组4条,也有说是4条4组——其实说法不同是一个事。ARMv8的设计是:根据两个维度组合确定目标向量位置,这个过程完全由硬件完成。你只需要把VBAR_EL1设对,剩下的事CPU替你搞定。
但话说回来,背下这张表的结构并不难,难的是理解每个入口后面关联的巨量代码——以及在什么场景下会走到哪个入口,这才是真正的难点。
现在咱们用一个具体例子把这流程走通——比如说你现在正在刷视频号,突然网卡收到一个数据包。这时候ARM64处理器到底做了什么?
CPU还在EL0跑着用户态代码,网卡产生了中断信号送给GIC,GIC路由后向CPU发出IRQ请求。
处理器在每条指令执行完后都会检查这个信号——发现IRQ来了,硬件开始自动“搬东西”:
第一步:把当前处理器状态PSTATE保存到SPSR_EL1寄存器
这个PSTATE里存着啥?当前是AArch64还是AArch32,DAIF掩码的状态,条件标志位……整个程序的执行快照。
第二步:把返回地址保存到ELR_EL1寄存器
注意,IRQ是异步异常,返回地址指向的是“第一条没来得及执行的指令”而不是产生异常的指令。如果是同步异常,返回地址通常等于产生异常那条指令本身——因为这条指令可能要重试(比如缺页异常处理完页表后重新执行访问指令)。
第三步:把异常原因写入ESR_EL1寄存器
ESR_EL1会告诉你这是什么类型的异常。它的EC(Exception Class,bits 31:26)字段是关键——你要是在调试中断跟着代码走,很多分支判断就是读EC字段来确定下一步怎么跳的。
第四步:如果是同步异常且跟地址有关,错误地址写入FAR_EL1寄存器
比如你访问了一个空指针,CPU会把那个坏地址记录在FAR_EL1里。内核oops打印的信息里,faulting address就是从这里读的。
第五步:CPU把DAIF掩码全部置1——关掉所有可屏蔽异常
这步非常重要。为什么?因为这时候你还在中断处理的中间态,内核栈还没完全准备好,如果再来一个中断,上下文就全乱了。等处理程序的入口代码跑完之后,内核会视情况重新打开中断。
第六步:硬件根据VBAR_EL1+异常类型+来源等级计算出向量地址,跳转到目标指令
这一步不需要软件参与——全是硬件搞定的。如果异常来自EL0、AArch64模式、类型为IRQ,那么硬件就会跳转到向量表中的el0_irq入口。
说实话,干开发10年,说实话真正需要理解这个详细流程的情况也遇到过不少,有一次一个血的教训:如果你在中断上半部调用了一个会schedule的函数,CPU一旦切出去,LR和SPSR就可能被覆盖——然后eret回去要么跑飞,要么随机Crash。后来我养成了一个条件反射——看到ISR里调用的任何函数,都下意识地用grep查一遍会不会引起调度。
当你跟着代码跑进el0_irq入口之后,首先执行的是一个叫做kernel_entry的宏。这个宏在entry.S里占据了几十行,干的事却很简单——把所有通用寄存器全部压到当前进程的内核栈上。
为什么必须保存所有寄存器?
因为中断处理程序是用C写的,C编译器假定所有通用寄存器都可以自由使用。如果你不先把中断打断时的那套寄存器保存下来,中断处理跑完之后,原来的程序发现自己x0-x30全变了,那结果可想而知。
简化的伪代码逻辑大概是这样的:
.macro kernel_entry, el sub sp, sp, #S_FRAME_SIZE /* 栈往下走,留出空间 */ stp x0, x1, [sp, #16 * 0] /* 保存 x0-x29 */ stp x2, x3, [sp, #16 * 1] ... stp x28, x29, [sp, #16 * 14] mrs x21, sp_el0 /* 读用户态栈指针 */ mrs x22, elr_el1 /* 读异常返回地址 */ stp lr, x21, [sp, #S_LR] /* 保存这两个关键的 */ mrs x23, spsr_el1 /* 读处理器状态 */ str x23, [sp, #S_PSTATE] /* 保存状态 */.endm然后就是从ESR_EL1寄存器里读出异常原因,根据异常类别分发到不同的handler。以同步异常为例,内核会进入el0_sync的处理逻辑:
el0_sync: kernel_entry 0 mrs x25, esr_el1 /* 读 ESR (Exception Syndrome Register) */ lsr x24, x25, #ESR_ELx_EC_SHIFT /* 提取 EC (Exception Class) 字段 */ cmp x24, #ESR_ELx_EC_SVC64 /* 跟 SVC 系统调用的异常码比较 */ b.eq el0_svc /* 系统调用就走 el0_svc */ cmp x24, #ESR_ELx_EC_DABT_LOW /* 跟数据中止异常码比较 */ b.eq el0_da /* 数据中止就走 el0_da */ cmp x24, #ESR_ELx_EC_IABT_LOW /* 指令取指中止异常码 */ b.eq el0_ia /* 指令中止走 el0_ia */ ... b el0_inv /* 其他——无效异常,报错 */看到没,汇编里就是这么干的:读ESR寄存器,提取EC字段,跟各种已知异常码比较,匹配上就跳对应的处理函数。el0_svc就是系统调用处理入口,el0_da就是缺页处理入口。
而不带“el0_”前缀的那组——像el1_sync、el1_irq——是内核本身跑着跑着出了异常。内核态缺页走的是el1_sync,中断嵌套走的是el1_irq。
估计很多人学到这的时候开始范迷糊——这么多入口,不同的场景,到底哪个场景走哪条路?
其实你只需要记住一个原则:看异常发生的那一刻CPU在哪个EL等级,而不是看异常本身是谁产生的。
换句话说,同样是一个中断IRQ,如果CPU当时在跑应用(EL0),那就走el0_irq;如果CPU当时在内核态(EL1)执行,那就走el1_irq。区别在哪里?栈不同。EL0到EL1需要把用户态栈指针保存到SP_EL0,然后切到使用SP_EL1栈;EL1到EL1只需要在SP_EL1上继续压栈就行。细节不一样,但异常向量表的设计依据是一样的。
这里穿插一个我个人面试别人的经验。我经常会问一个问题:请简述el0_irq和el1_irq的主要区别。大概有一半的人回答“一个在用户态产生中断,一个在内核态产生中断”——这个回答对,但不够深。我更想听到的是stack切换机制和DAIF掩码管理的差异。还有不少面试者分不清异常向量表里的“SP_EL0 vs SP_ELx”分支分别适用什么场景,这在我眼里说明根本没看过entry.S源码——光看博客是不够的。
如果你能说出kernel_entry宏在处理EL0异常时多了一步“读sp_el0保存到栈上”,那就说明你真看了代码。这样的候选者,我直接给加分。
kernel_entry跑完之后,汇编代码通过处理程序把控制权交给C函数,这里才是我们熟悉的Linux内核逻辑登场。
以IRQ为例,el0_irq最终会调用handle_arch_irq这个函数指针。这个指针是由中断控制器驱动——也就是GIC驱动——初始化的。绕了半天,终于从硬件走进了Linux内核的中断管理框架。
GIC(通用中断控制器)接管了中断路由与分发的硬件任务。GICv3的逻辑组件包括Distributor、Redistributor、CPU Interface和可选的ITS模块。一个设备产生的中断先被Distributor接管,给它标上SPI/PPI/SGI/LPI类型,设置优先级和目标CPU核心了;Redistributor负责PPI和SGI管理,并且把Pending的中断交给CPU Interface;CPU Interface把中断信号提供给连接的CPU核心。
Linux内核的GIC驱动会调用set_handle_irq来注册架构相关的中断处理入口,驱动把一个叫gic_handle_irq的函数地址赋给handle_arch_irq。从el0_irq的汇编处理中跳转到gic_handle_irq的调用链大致是这样的:
/* kernel/irq/handle.c */int __init set_handle_irq(void (*handle_irq)(struct pt_regs *)){ handle_arch_irq = handle_irq; ...}/* arch/arm64/kernel/irq.c */void do_IRQ(struct pt_regs *regs){ if (handle_arch_irq) handle_arch_irq(regs);}然后gic_handle_irq的核心逻辑是这样:
/* drivers/irqchip/irq-gic-v3.c */static void gic_handle_irq(struct pt_regs *regs){ u32 irqnr; do { irqnr = gic_read_iar(); /* 读IAR寄存器,获取硬件中断号 */ if (irqnr == 0x3FF) /* 特殊值,表示该CPU没有待处理中断 */ break; gic_write_eoir(irqnr); /* 优先写EOI(GICv3规范) */ if (likely(irqnr > 15 && irqnr < 1020)) { handle_domain_irq(gic_data.domain, irqnr, regs); /* 核心 */ } } while (1);}IAR(Interrupt Acknowledge Register)读出的是硬件中断号(hwirq)。GIC domain负责把hwirq映射到Linux系统的虚拟中断号(virq),这个映射关系记录在irq_domain结构里。
这里必须要讲一下为什么GIC驱动要在handle_domain_irq之前写EOIR寄存器。我调过一个中断风暴的bug——系统在中等负载下频繁触发“nobody cared”错误,设备直接失活。我们当时用的是GICv2,驱动是在处理完中断链之后再写EOI。问题在于:在处理中断链的期间,CPU的DAIF已经打开(softirq需要响应新中断),如果同一中断源因为电平还没清除再次触发,GIC会把这个新中断缓存在硬件里面等到下一个窗口期再输入给CPU——但你处理链已经结束了,这个新中断没赶上趟。
后来升级到GICv3,规范允许提前写EOI(也叫EOI优先模式),驱动程序写EOI但不做deactivate,新中断可以立即被CPU再次接收,中断丢失的概率大大降低。所以你现在看GICv3的驱动,都是读IAR→写EOIR→handle_domain_irq这个顺序。搞错顺序,生产环境的中断丢失问题极难复现。
handle_domain_irq会调用到通用中断子系统,也就是kernel/irq下面的那堆代码。
说到这,咱们终于来到了Linux中断管理的核心数据结构:irq_desc。
每个Linux中断号(virq)都对应一个irq_desc结构。你在驱动里调用的request_irq,本质上就是往对应irq_desc的action链表上挂一个irqaction节点。这个设计的核心是中断共享——同一个硬件中断线可以被多个设备驱动挂载。
具体流程大概是:
struct irq_desc { struct irq_data irq_data; /* 硬件相关数据,包含irq_chip、irq_domain等 */ irq_flow_handler_t handle_irq; /* 中断流控处理函数 */ struct irqaction *action; /* 从中断响应链表,每个挂一个irqaction */ unsigned int depth; /* disable深度计数(0=使能,正数=嵌套disable次数) */ raw_spinlock_t lock; /* 自旋锁,保护该描述符 */ ...};struct irqaction { irq_handler_t handler; /* 你写在驱动里的中断处理函数 */ unsigned long flags; /* IRQF_SHARED、IRQF_ONESHOT等 */ const char *name; /* 设备名字 */ void *dev_id; /* 私有数据指针 */ struct irqaction *next; /* 下一个irqaction节点 */};当你调用request_irq(irq, my_handler, IRQF_SHARED, "my_device", dev),内核首先检查irq是否已经被占用,然后分配一个irqaction结构体,把handler填进去,挂到对应irq_desc的action链表的末尾。
IRQF_SHARED标志意味着这条中断线允许多个设备共享。如果这条中断线被共享,每个设备的handler都必须在处理前先检查是不是自己的设备产生的中断。检查的方法是读取设备状态寄存器——如果设备没标记中断位,handler应立即返回IRQ_NONE;中断系统会沿着action链表继续调用下一个handler。如果所有handler都返回IRQ_NONE,内核会记录一次“spurious interrupt(虚假中断)”,超过一定阈值后直接禁用这条中断线。
这部分的代码逻辑在kernel/irq/manage.c里:
irqreturn_t __irq_wake_thread(struct irq_desc *desc, struct irqaction *action) { ... do { ret = action->handler(irq, action->dev_id); } while (ret == IRQ_WAKE_THREAD); ...}irqreturn_t handle_irq_event(struct irq_desc *desc) { struct irqaction *action = desc->action; irqreturn_t ret = IRQ_NONE; ... do { ret = action->handler(desc->irq_data.irq, action->dev_id); switch (ret) { case IRQ_HANDLED: /* 设备处理成功 */ break; case IRQ_WAKE_THREAD: /* 需要唤醒中断线程 */ break; default: /* IRQ_NONE: 可能是共享中断的其他设备 */ break; } action = action->next; } while (action); ...}说到request_irq,我忍不住想吐槽一下我带过的那些实习生。十个里面有八个注册中断的时候,直接把return IRQ_HANDLED写在handler第一行——设备状态都不查。共享中断的情况下这么做简直是灾难:你的设备没事,别人的设备中断被吃掉了,然后整个系统通信中断。我被这种错误坑过不下三次。这种细节书上是不会教的,但这才是真正的工程经验。
有了入口和注册机制,下一个问题是:中断流控。
这部分对应kernel/irq/chip.c里面的几个标准流控处理函数,最常用的是这几种:
电平触发和边沿触发的区别,写过MCU的同学都应该很清楚。但在Linux内核层面,这两者带来的处理差异很大。
电平触发:中断线只要保持在有效电平,中断就会被一直请求。所以必须先mask中断,然后ack,处理完之后再unmask。如果不先mask,同样一个中断会反复触发,CPU直接被打爆。
边沿触发:中断在处理链路中需要防止同一个interrupt正被别的CPU处理(通过IRQ_INPROGRESS标志判断),处理期间新到的边沿要mark成pending,等当前处理完再触发一次。
GICv3常用的handle_fasteoi_irq则把处理成不同的流程:
void handle_fasteoi_irq(struct irq_desc *desc){ raw_spin_lock(&desc->lock); desc->istate &= ~(IRQS_REPLAY | IRQS_WAITING); if (unlikely(!desc->action || irqd_irq_disabled(&desc->irq_data))) { desc->istate |= IRQS_PENDING; goto out; } handle_irq_event(desc); /* 遍历action链表 */out: if (desc->istate & IRQS_PENDING) /* 有新的中断边沿到达,再处理一次 */ cond_unmask_eoi_irq(desc, NULL); raw_spin_unlock(&desc->lock);}注意desc->lock这个自旋锁。中断处理的上半部必须快速进出,这锁不能持有太久——如果你在handler里调用耗时的操作(比如轮询读设备状态寄存器上千次),这把锁会堵死其他CPU核试图处理这个中断的请求,结果就是系统延迟飙升。正确处理是在下半部做耗时操作,上半部只干最少的事。
光有上半部还不够。Linux要求上半部(hardirq)尽可能短,耗时操作放到下半部。这个设计看似简单,但执行时的各种时序竞态足以让你烧掉几周调试时间。
软中断(softirq) 是最传统的下半部机制。中断上半部通过raise_softirq()标记一个软中断位,待当前hardirq处理完成,内核在irq_exit()中检查是否有待处理的softirq,如果有就调用do_softirq()处理。
这里有段代码是我当年花了两个晚上排查出来的一个问题,特有意思:
void irq_exit(void) { ... if (!in_interrupt() && local_softirq_pending()) invoke_softirq(); ...}注意in_interrupt()检查。如果当前正在处理另一个中断(hardirq嵌套),就不会启动softirq处理——softirq必须在最顶层中断退出时统一处理。这个设计的目的是避免softirq在嵌套中断上下文中执行,因为softirq内部可能触发调度,而嵌套中断上下文不允许调度。
tasklet是软中断的一种封装。tasklet的代码在kernel/softirq.c里,对应的数据结构如下:
struct tasklet_struct { struct tasklet_struct *next; /* 链表下一个 */ unsigned long state; /* TASKLET_STATE_SCHED / TASKLET_STATE_RUN */ atomic_t count; /* 0=使能,非0=禁用 */ void (*func)(unsigned long); /* 用户回调函数 */ unsigned long data; /* 回调函数的参数 */};tasklet的特点是同一个tasklet不会在多个CPU上同时执行。这比softirq安全,比workqueue性能好。但tasklet也有问题——新内核正在逐步取消tasklet机制,因为它容易导致延迟波动。
再说工作队列(workqueue)。它运行在进程上下文,可以睡眠。所以如果下半部需要访问磁盘、申请锁可能导致睡眠,就必须用workqueue。
结构:
static irqreturn_t nvic_uart_handler(int irq, void *dev_id) { struct uart_dev *dev = dev_id; /* 上半部:快速读取,快速清除 */ dev->data = readl(dev->base + UART_DATA_REG); writel(0, dev->base + UART_INT_CLR); /* 清除中断标志 */ /* 下半部:把数据提交给tty层 */ schedule_work(&dev->rx_work); return IRQ_HANDLED;}static void nvic_uart_work_handler(struct work_struct *work) { struct uart_dev *dev = container_of(work, struct uart_dev, rx_work); /* 这里可以做耗时操作 */ tty_insert_flip_char(&dev->port, dev->data, TTY_NORMAL); tty_flip_buffer_push(&dev->port);}去年帮朋友调一个ARM服务器上的NVMe中断抢占问题。NVMe设备的中断频率很高,跑FIO测试的时候CPU hardirq负载直接拉到了100%。原因是上半部做了太多事——每个中断都去扫描完成队列,队列不空不出来。
改法很简单,上半部只做ack中断和唤醒处理线程,实际队列扫瞄工作交给中断线程(request_threaded_irq)。改完CPU负载从100%降到20%左右,I/O延迟也稳定了不少。
我当时的反应就是:“真的绝了,就这么改一下,效果天差地别。”这感觉很像在STM32上调DAM中断——你以为是配置不对,结果发现是上半部干太多活把下一个中断堵死了。
说到这,顺便说说request_threaded_irq这个接口的使用要点。它的原型是:
int request_threaded_irq(unsigned int irq, irq_handler_t handler, irq_handler_t thread_fn, unsigned long irqflags, const char *devname, void *dev_id);如果你把handler设为NULL,而thread_fn非NULL,内核会采用默认的handler搭配你的thread_fn实现全线程化中断处理。这种方式在嵌入式实时系统里用得很多——中断上半部几乎什么都不做,所有处理都交给线程函数。
但全线程化中断也有代价的——响应延迟增加。对实时性要求高的场景(比如电机控制中断),还是需要在handler里直接完成关键操作。
玩了十年Linux,中断性能调优跑不脱这三件事。
第一板斧:中断亲和性(SMP affinity)
多核系统里,通过/proc/irq//smp_affinity把中断绑定到指定的CPU核上。这能避免中断在所有核之间跳来跳去导致的cache失效。比如网卡中断一般绑到CPU0,NVMe中断绑到CPU1-3,定时器中断在每个核上独立触发(PPI类型,不需要绑)。
操作很简单:
echo 2 > /proc/irq/37/smp_affinity # 绑定到 CPU1echo f > /proc/irq/37/smp_affinity # 绑定到 CPU0-3但有个坑,就是绑核后如果那个CPU跑满100%,中断得不到及时响应,延迟反而比不绑更差。所以绑核的同时要配合进程亲和性,把对应的用户态处理进程也绑到同一个核上,数据在同一个cache hierarchy里流转最快。
第二板斧:中断合并
网卡中断可以用NAPI(New API)机制来合并。传统网卡每收一个包触发一次中断,中断风暴很厉害;NAPI的做法是收到第一个包触发中断后关闭中断,用轮询方式收包,收完再打开中断。包速率越高,NAPI省下的中断开销越明显。
第三板斧:处理线程化
正如前面NVMe的案例,request_threaded_irq是性能调优的利器。但要注意线程优先级设置——默认的中断线程优先级是MAX_USER_RT_PRIO/2(即50),如果你在高优先级进程还没运行完的时候中断线程被调度,照样影响延迟。在实时系统里,合理做法是给中断线程设RT优先级。
基于野火 LubanCat 板
步骤一:设备树配置
在设备树中声明按键节点,指定中断父节点和触发方式。注意,我们编写的按键节点是“中断使用者”而非“中断控制器”,所以无需设置 interrupt-controller 标签。
#include <dt-bindings/interrupt-controller/arm-gic.h>button_interrupt { compatible = "button_interrupt"; interrupt-parent = <&pio>; /* 父中断控制器是pio */ interrupts = <PH 9 IRQ_TYPE_LEVEL_LOW>; /* 引脚 + 低电平触发 */ button-gpios = <&pio PH 9 GPIO_ACTIVE_HIGH>; status = "okay";};步骤二:驱动程序中使用 request_irq 注册
#include <linux/interrupt.h>#include <linux/gpio.h>static irqreturn_t button_irq_handler(int irq, void *dev_id){ /* 上半部:只做最紧急的事 */ /* 注意:此处不能调用 msleep / mutex_lock 等可能引起调度的函数 */ printk(KERN_INFO "Button pressed! IRQ: %d\n", irq); return IRQ_HANDLED;}static int button_probe(struct platform_device *pdev){ int irq, ret; irq = platform_get_irq(pdev, 0); /* 从DTS中获取中断号 */ if (irq < 0) return irq; ret = request_irq(irq, button_irq_handler, IRQF_TRIGGER_LOW, /* 低电平触发 */ "button_irq", /* 名称,会出现在 /proc/interrupts 中 */ NULL); /* dev_id,共享中断时用于区分 */ if (ret) dev_err(&pdev->dev, "Failed to request IRQ %d\n", irq); return ret;}static int button_remove(struct platform_device *pdev){ int irq = platform_get_irq(pdev, 0); free_irq(irq, NULL); /* 释放中断资源 */ return 0;}步骤三:验证与调试
加载驱动后,通过以下命令观察中断是否正常触发:
# 查看中断计数(设备名 "button_irq" 对应 request_irq 注册的名称)cat /proc/interrupts | grep button# 实时追踪中断watch -n1 cat /proc/interrupts | grep button步骤四:引入下半部优化
按键消抖或 I2C/SPI 读取等耗时操作不能在上半部执行。推荐使用 Threaded IRQ,只需将 request_irq 替换为 request_threaded_irq——上半部仍负责清中断标志,耗时逻辑安心放进线程回调中,配合 mutex/m sleep 安全可靠。
/* 上半部:只做最紧急的清中断操作 */static irqreturn_t button_hardirq(int irq, void *dev_id){ return IRQ_WAKE_THREAD; /* 唤醒下半部线程 */}/* 下半部线程:可以包含消抖、SPI读取等耗时操作 */static irqreturn_t button_threadfn(int irq, void *dev_id){ /* 这里可以安全使用 msleep / mutex_lock 等 */ msleep(20); /* 消抖延时 */ return IRQ_HANDLED;}ret = request_threaded_irq(irq, button_hardirq, button_threadfn, IRQF_TRIGGER_LOW, "button_irq", NULL);以下展示如何在 ARM64 上用 kprobe 探测 do_el0_svc(系统调用入口)的调用行为,帮助理解异常分发路径:
# 注册 kprobe 探测点,在 do_el0_svc 入口处打印寄存器echo 'p do_el0_svc %x0 %x1' > /sys/kernel/debug/tracing/kprobe_events# 启用 kprobe 事件echo 1 > /sys/kernel/debug/tracing/events/kprobes/enable# 在另一个终端触发系统调用ls /# 查看捕获的追踪数据cat /sys/kernel/debug/tracing/trace# 清理echo 0 > /sys/kernel/debug/tracing/events/kprobes/enableecho '-:p do_el0_svc' >> /sys/kernel/debug/tracing/kprobe_events执行 ls 命令时,用户态通过 svc 触发 EL0 → EL1 的同步异常,经 VBAR_EL1 查表进入 el0_sync → el0_svc → do_el0_svc,kprobe 在 do_el0_svc 入口处拦截,打印寄存器快照。
注意事项:不要在任何带有 noinstr 标记的函数或通过 NOKPROBE_SYMBOL() 明确排除的函数上设置 kprobe 探测点,否则可能引发 kprobe 递归异常,导致系统崩溃。
这是中断管理最常见的应用场景。驱动开发者通过设备树(Device Tree)描述中断资源,在驱动代码中调用 request_irq() 完成注册。ARM64 的设备树使用 interrupt-parent 指定父中断控制器节点,用 interrupts 属性声明中断引脚和触发方式。
网络子系统的 NAPI 机制是 softirq 下半部的典型应用。网卡收到数据包后,上半部关中断、将数据写入环形缓冲区、触发 NET_RX_SOFTIRQ 软中断。下半部的 net_rx_action() 以轮询(polling)方式批量收取数据包,显著减少了高频中断下的上下文切换开销——这正是 softirq 相比 tasklet 的优势所在:同一类型的 softirq 可在多核上同时执行,充分利用 SMP 并发能力以应对万兆网卡级别的吞吐压力。
ARM64 上 kprobe 利用 BRK 指令的异常机制实现函数探测。当 BRK 指令执行时,硬件产生同步异常,CPU 经由异常向量表分发到 el1_dbg 处理函数,再由 kprobe 子系统接管。基于此,开发者可以注册 pre/post handler 完成函数级追踪、性能分析等工作。kretprobe 则在函数入口处通过 BRK 异常寄存器获取上下文信息来抓取返回值。
KVM 利用异常级别的层次设计,将物理中断经由 EL2 路由并注入到 Guest OS(EL1)。具体表现为 KVM 使用 HCR_EL2 寄存器控制哪些中断需要路由到 EL2 由 Hypervisor 截获处理,再通过虚拟中断注入机制通知 Guest。KVM 的 irqfd 接口允许用户空间通过 ioctl 向 vCPU 注入中断。
嵌入式场景(工业控制、自动驾驶)中,中断响应延时直接影响系统安全。ARM64 的 FIQ 模式被部分实时操作系统(RTOS)用来处理超高优先级中断。I-pipe 中断流水线技术则对 ARM64 的物理中断 IRQ 相关代码进行改造,确保实时内核触发的中断能够快速处理。
为什么说“懂异常”才算真正理解 Linux 中断?因为 Linux 中断根本不是凭空出现的,它下面全是 ARM 异常模型。你不懂 CPSR、SPSR、异常模式、vector、LR 修正、banked register,你看 Linux IRQ 永远像在看天书。
很多人学驱动喜欢背 API,背 request_irq、free_irq、spin_lock_irqsave,但这些只是表面。真正底层是 CPU 异常。我工作十年,越往后越发现,技术这东西,你逃不掉底层。以前总觉得“我会写业务就行”,后来线上事故一个接一个,你会发现真正救命的全是底层知识。
尤其 ARM,这玩意特别喜欢给你惊喜。有时候你 debug 一晚上,最后发现是 IRQ stack overflow,或者是 SPSR 被覆盖,再或者是 vector table 映射错了。你说气不气?
如果你现在还看不懂 Linux IRQ,应该怎么办?
别急着看驱动,先干三件事:
写这篇文章的时候,我翻了很多以前的代码,看到一堆 fix irq bug、temporary workaround、TODO,突然有点感慨。我们天天在解决问题,可问题永远解决不完。ARM 异常也是,你以为搞懂 IRQ 就结束了,后面还有 SMP IPI、cache coherency、TLB shootdown、preempt、RCU,像一层层地狱。+
但偏偏,也正因为这样,技术才有意思。你会在某个凌晨,突然搞懂一个以前完全看不懂的机制,那种快乐,挺上头的。
我也不知道这么说对不对,但对很多写底层的人来说,这可能就是继续爱干这行的原因吧。至少,对我来说是。