做ARM64底层开发的朋友大概都有这种体会中断处理说简单也简单说复杂也复杂。简单在它本质就是“一个信号进来、CPU跳到一个固定地址、跑完处理再回来”复杂在它夹在硬件和软件之间任何一个环节错位轻则某个外设没反应重则整个系统卡死。最近我在一块ARM64四核平台上梳理中断链路从GIC配置到Linux的irq描述符再到驱动回调绕了不少弯路这篇就把ARM64上Linux中断处理的完整链路拆一遍以GICv3为主GICv2的相关差异也会点出来。这篇内容适合几种人看准备从x86切到ARM64做内核移植或者BSP开发的在ARM64平台上调外设中断发现中断不进、行为不对的以及纯粹想搞明白“硬件中断号→Linux irq号→handler执行”这条链路的人。整个分析以Linux 5.x内核代码为主线涉及的汇编和C代码我会尽量用大白话讲不要求你先精通ARM汇编。1. 先搞清楚中断到底怎么“进”CPU1.1 异常等级和中断向量表ARM64把运行环境划分成EL0、EL1、EL2、EL3四个异常等级对应关系大致是EL0跑用户态应用EL1跑内核EL2跑虚拟化HypervisorEL3跑固件和安全监控。平常说的“内核态”在ARM64上就是EL1。这里有个容易忽略的点中断进来时CPU是带着当前异常等级去查向量表的查的是VBAR_EL1寄存器指向的那个表。如果中断在EL0用户态发生时进来了走el0_irq路径如果中断在EL1内核态发生时进来了走el1_irq路径。两条路径的开销和收尾动作不一样因为用户态被打断后要检查信号、调度等内容内核态被打断则要谨慎很多不能随便碰用户态的东西。向量表本身是16个entry每个entry 128字节按“从哪个栈模式来什么异常类型”组织。栈模式分成SP0当前EL用SP_EL0和SPx当前EL用自身栈指针异常类型是sync、irq、fiq、serror。Linux在arch/arm64/kernel/entry.S里用kernel_ventry宏把这个表铺出来irq对应的入口就是el0_irq和el1_irq。我最初看这段汇编时最大的疑惑是表里写了四种类型为什么只提irq因为ARM64上fiq默认留给安全世界普通Linux不碰它后来内核支持pseudo-NMI才把fiq也纳入到不可屏蔽中断的范畴。这里有个新手容易误解的点中断不是“任意时刻都立即执行”的。一旦进入异常处理硬件自动把PSTATE的I位置1禁止IRQ嵌套。也就是说你在中断上下文里又来了一个新中断它不会马上打断当前处理而是要等当前处理完、eret回去之后再根据硬件优先级决定是否触发。很多用户看到的“丢中断”现象本质上是这个屏蔽位带来的排队效应而不是中断真的从硬件上丢了。1.2 GIC分发器、重分发器和CPU接口ARM64平台标准的中断控制器是GIC现在主流是GICv3。GICv3把硬件拆成两块一个全局的DistributorGICD每个CPU核有一个RedistributorGICR另外CPU接口不再走内存映射而是通过系统寄存器ICC_*来访问。GICv2则是Distributor加每个CPU的CPU接口GICCCPU接口是内存映射寄存器。为什么要强调这个差异因为代码路径完全不同。GICv3下驱动直接读ICC_IAR1_EL1拿到中断号写ICC_EOIR1_EL1完成EOIGICv2则是读GICC_IAR、写GICC_EOIR。如果你在移植老驱动看到中断处理代码里出现“写某个内存地址来EOI”多半是GICv2时代的写法搬到GICv3平台要换成系统寄存器操作。最典型的坑是某些平台把GICv3的Distributor内存映射当成GICv2用读出来的IAR永远不对中断号全是错的。GICv3里还有一个GICv2没有的概念Redistributor维护每个CPU私有的中断状态并负责LPI的配置。LPI是GICv3才有的中断类型专给MSI中断用的中断号从8192起。为什么LPI要做成独立的因为MSI中断数量大、动态分配和传统SPI、PPI固定编号的逻辑完全不同不能塞在同一个固定表里管理。1.3 中断分类SGI、PPI、SPI、LPIGIC把中断分成四类理解了这四类后面看/proc/interrupts就顺了类型中断号范围说明SGI0 ~ 15软件触发中断主要用来做核间通信IPI比如调度、函数调用PPI16 ~ 31私有外设中断每个核独享一份比如本地定时器、PMUSPI32 ~ 1019共享外设中断外设挂在任意核上由路由配置决定具体给哪个核LPI8192 起基于消息的中断给MSI/MSI-X使用由ITS管理Linux里SGI被用来做IPIreschedule、function call、call function single、irq work这些你们看cat /proc/interrupts时开头那几行IPI0、IPI1之类的就是SGI。PPI里有per-CPU的clockevent也就是每个核的tick中断。SPI是普通外设中断的大头网卡、串口、GPIO这些基本都是SPI。LPI走MSINVMe这类PCIe设备用的就是它。这四类的处理策略从硬件到软件都不一样。PPI天然是per-CPU的你不能把它路由到别的核SGI是“写一个寄存器就能让目标核跳进中断”SPI可以设置亲和性让它在指定核集合里选择LPI靠ITS把消息路由到具体核。所以Linux里这四类中断走的irq chip回调也不同后面讲处理流程时会看到。2. 从设备树到Linux irq号中断映射的关键链路2.1 设备树里一段“interrupts”背后发生了什么在设备树里一个外设要声明中断通常写类似这样的片段serialfe000000 { compatible vendor,serial; reg 0x0 0xfe000000 0x0 0x1000; interrupt-parent gic; interrupts GIC_SPI 12 IRQ_TYPE_LEVEL_HIGH; };注意GIC_SPI这个宏展开是0第二格12是逻辑中断号不是GIC寄存器里的实际编号。GIC的DT binding规定第一格是类型0代表SPI1代表PPI第二格是序号第三格是触发类型。Linux解析时irqchip驱动里的翻译函数会做一个偏移SPI的hwirq要加32PPI要加16。也就是说你在设备树里写SPI 12实际对应的GIC硬件中断号是443212。为什么这么设计因为GIC寄存器的中断编号里0到15是SGI16到31是PPI32到1019是SPI。设备树为了可读性把这段偏移隐藏了但你在内核日志里看到的hwirq、在调试符号里看到的中断号都是带偏移的。很多刚上手的人在这块翻车设备树里写spi 100去/proc/interrupts里找100号怎么找都找不到其实要找132号。2.2 irq_domainhwirq到virq的翻译官现代内核里硬件中断号hwirq和Linux软件中断号irq/virq是分开的中间靠irq_domain做映射。为什么不直接拿硬件号用因为每个中断控制器都有自己的编号规则再加上多级中断控制器嵌套GPIO控制器上面又挂一个GIC硬件号完全可能冲突。irq_domain本质就是一张“硬件号→软件号”的映射表常见有线性域linear domain、树形域tree domain等。对于GICv3驱动初始化时用irq_domain_create_linear创建一个线性域hwirq范围从0到最大中断号。设备驱动调用platform_get_irq时内核走of_irq_get→irq_create_of_mapping在这个域里查一下如果这个hwirq还没映射就分配一个新virq并且在gic_irq_domain_map里设置这个irq对应的chip和handler。第一次分配之后的irq_find_mapping就很快了本质就是查表。这里有个很实用的排查思路怀疑中断号不对时看/sys/kernel/debug/irq_domain_mapping内核需要开CONFIG_IRQ_DOMAIN_DEBUG或者/proc/interrupts里的第一列。有时外设寄存器读出来状态是对的但中断就是不进handler十有八九是设备树的interrupt-parent指错了中断被映射到别的控制器上去了。2.3 中断申请报“already mapped”是什么情况我再分享一个实际经验驱动里request_irq报“IRQ xx already mapped”这类错。这类问题的根源多半不是驱动本身而是某个hwirq被两个irq_domain重复映射或者同一个外设的多个中断源在设备树里写成了同一个SPI号。还有一种常见错法把PPI和SPI的序号搞混。PPI是per-CPU的如果用SPI方式申请一个本该是PPI的中断行为会非常诡异CPU0申请成功了但硬件触发时可能路由到别的核导致那边没有对应handler整个中断看起来就是“偶尔进、经常丢”。我排查过一个模拟器上的案例最后就是binding第一个字段写错把PPI写成了SPI。3. 一条中断从进入到出去的完整旅程3.1 el1_irq入口保存现场、切中断栈、关嵌套以外设触发、CPU正处于内核态为例GIC把信号送到CPU核的irq线CPU硬件查VBAR_EL1指向的向量表跳到el1_irq。接下来这段汇编是每次中断的固定开销把通用寄存器压到当前任务的内核栈里判断当前是否已经在irq栈上如果不在切换到每CPU专用的irq栈把lr、sp、pstate这些现场信息整理成pt_regs关闭irq其实硬件已经关了清一些debug状态位调用C函数handle_arch_irq。为什么要单独搞一个irq栈因为在深调用栈的任务上比如文件系统写文件调了很深栈剩余空间可能很少。中断处理如果直接在任务栈上继续用很容易爆栈。ARM64为每个CPU分配了一个独立的irq栈大小由内核配置决定通常是16K中断进来就切过去等处理完再切回来。这个细节对排查栈溢出很重要看到“kernel stack overflow”日志很多其实是irq栈溢出不是任务栈。entry.S里这段切换逻辑用irq_stack_entry宏实现它会从irq_stack_ptr这个per-CPU变量里取栈底然后比较当前sp和irq栈地址区间。如果当前sp已经落在irq栈范围内说明发生了中断嵌套就不再切栈直接在原栈处理。3.2 gic_handle_irqIAR读一次中断号见分晓handle_arch_irq是一个函数指针由GIC驱动初始化时通过set_handle_irq(gic_handle_irq)注册。也就是说不管底层是GICv2还是GICv3入口汇编都不用变只要换这个函数指针就行。这算一种简单的适配层设计移植时最好先确认这个指针被正确设置了。GICv3的gic_handle_irq逻辑核心是读ICC_IAR1_EL1寄存器拿到一个中断号irqnr然后分情况处理如果irqnr小于16是SGI通常是IPI走smp_cross_call这些逻辑如果irqnr等于1023是spurious中断直接EOI返回否则把irqnr当作hwirq通过generic_handle_domain_irq(gic_data.domain, irqnr)分发。这里有两个关键点。第一ICC_IAR1_EL1这个读操作不只是拿号它还隐式完成了“中断优先级下降”动作让比当前中断优先级更高的中断可以被继续处理。第二拿到号之后不能拖太久才EOI因为拖得越久同优先级或更低优先级的中断都被压着系统等效中断延迟就越高。我还踩过一个很典型的坑GICv3有EOImode配置如果内核配置为EOImode1分开的EOI模式那么写EOIR只做优先级下降真正的deactivate要再写ICC_DIR_EL1。如果你从老代码抄了个只写EOIR的驱动而系统跑在split模式下中断处理完了硬件状态还是active下一次中断永远进不来。这个问题特别难查因为逻辑分析仪看硬件中断明明触发了CPU也响应了但系统就像断了一根弦。建议遇到“中断只进一次”的现象优先查EOImode和EOI相关寄存器配置。3.3 generic_handle_domain_irq到handler执行generic_handle_domain_irq做的事情分两步先用irq_find_mapping把hwirq翻译成virq再调用generic_handle_irq。后者根据virq取出irq_desc调用desc-handle_irq。这里有个容易混淆的概念desc-handle_irq不是驱动注册的那个handler而是中断控制器通用处理函数比如handle_fasteoi_irq、handle_percpu_devid_irq、handle_edge_irq。真正的驱动handler挂在desc-action链表上由handle_irq_event去遍历执行。以GICv3最常见的SPI为例gic_irq_domain_map里给SPI设置的是handle_fasteoi_irq给PPI设置的是handle_percpu_devid_irq。为什么SPI用fasteoi因为GICv3有EOI机制标准做法是mask掉中断处理完action链再unmask最后写EOI完成deactivate。这个过程和旧式的“电平中断mask/unmask”模型不同fasteoi依赖硬件EOI语义如果硬件顺序不对会出现中断一直被mask住的问题。驱动的handler如果返回IRQ_HANDLED中断处理视为完成如果返回IRQ_WAKE_THREAD则唤醒对应的中断线程irq_thread在进程上下文执行thread_fn。这就是中断线程化。线程化不是为了慢是为了把大量耗时逻辑从原子上下文剥离让实时系统里中断延迟更可预测。平时写驱动request_irq没传thread_fn时默认的primary handler是irq_default_primary_handler它直接返回IRQ_WAKE_THREAD于是中断会跑到内核线程里处理。注意这带来的一个副作用中断action的执行不完全在“中断上下文”里依赖中断上下文写法的驱动要特别小心。3.4 irq_exit与软中断中断的下半场硬中断处理函数返回之后入口汇编还有一个收尾动作调用irq_exit然后判断是否有pending的softirq。如果有且当前不在硬中断上下文就调用__do_softirq去跑软中断如果当前是在idle进程下被打断或者软中断积压太多则唤醒ksoftirqd线程去慢慢处理。这正是看网络收包路径为什么要关心软中断的原因网卡中断handler只做把skb挂到队列这种轻活真正的协议栈处理在NET_RX_SOFTIRQ里完成。如果只盯着/proc/interrupts计数看负载看不出全貌还得看/proc/softirqs。很多“网络延迟高但CPU占用不高”的系统瓶颈往往在软中断分布不均某个核的NET_RX_SOFTIRQ涨得飞快别的核闲得发呆。软中断执行完后中断入口才真正恢复现场、执行eret。所以一条完整的中断流程实际包含硬中断、可能执行的软中断、以及返回前的调度判断preempt_schedule_irq。这也是为什么ARM64上测中断延迟不能只看从硬件触发到handler的时间还要看从handler结束到用户态恢复的完整时间。4. 亲和性、中断栈与调优实操4.1 用smp_affinity把中断钉在指定核上SPI中断默认允许在多个核间路由。Linux通过/proc/irq/{irq}/smp_affinity控制里面是一个十六进制CPU位图。比如把45号中断绑到CPU2和CPU3echo 0xc /proc/irq/45/smp_affinity这里的0xc二进制是1100表示CPU2和CPU3。注意位图是从CPU0开始往高位排的不是从1开始数。我见过一种让人抓狂的写法以为0x10是CPU16其实那是CPU4。新内核还提供smp_affinity_list直接写2-3这种区间更直观。写之前先看当前值改完立刻看/proc/interrupts对应行的计数变化确认没有忙等。GICv3实现亲和性靠的是Distributor里的GICD_IROUTER寄存器每个SPI有一个路由寄存器Linux的gic_set_affinity会更新它。但要注意有些平台的安全固件会把GICD的一部分寄存器锁住普通EL1不能写这时候读smp_affinity能读写了却不生效。现实里真遇到过驱动用irq_set_affinity设置亲和性报成功但中断计数纹丝不动最后确认是固件把路由寄存器设成了只读。4.2 irqbalance省心但不总是省心发行版默认会启动irqbalance它定期扫描/proc/interrupts的计数变化按负载把中断尽量均匀地分到各个核。这个守护进程对通用服务器是好事但对嵌入式实时场景常常帮倒忙它可能把一个需要极低延迟的中断从专用核上挪走导致延迟抖动。我的做法是在需要确定性延迟的平台上关掉irqbalance手动把关键中断的亲和性固定。比如网卡rx队列中断按队列数平均分给多核但每个核只绑定固定队列然后用cpuset或chcpu把业务线程钉在对应核上让业务核和中断核尽可能重合减少跨核唤醒。如果实在要让irqbalance工作至少要配置它的--hintpolicyexact让它不要随意违背驱动的affinity hint。4.3 IRQ栈溢出现象和定位前面提过ARM64有独立irq栈。irq栈溢出最常见的原因不是栈太小而是中断handler里写了过于重量级的调用在硬中断里直接调printk、加锁顺序不当导致递归甚至handler里又触发了同类型中断递归嵌套会把irq栈一层层吃光。内核日志里出现kernel stack overflow或stack overflow字样基本就是栈问题。排查手段有几个先打开CONFIG_DEBUG_STACK_USAGE看内核日志打印的栈占用峰值再用ftrace的stacktrace追踪器看最深调用链。我见过一个案例中断handler里调了某个驱动的调试接口那个接口内部又有msleep——sleep在中断上下文里本身就该报警但它没报因为写代码的人用忙等替代了sleep结果把栈和循环都搞炸了。还有一个经验irq handler里不要用kmalloc的某些变体不要碰信号量不要调用可能睡眠的锁。非要这些功能就把耗时代码放到中断线程里。中断线程栈是普通内核栈默认16K比irq栈宽裕而且允许睡眠适合做数据处理。4.4 PPI、IPI和pseudo-NMI需要注意的点/proc/interrupts里除了外设SPI中断还有很多PPI和IPI条目。PPI包括每个核的arch_timer也就是tickIPI包括reschedule和function call。这些中断每时每刻都在增加很多人一看计数那么高就慌以为中断风暴。其实看的时候要先分清IPI计数高不代表异常只说明核间通信频繁。比如多线程程序不断schedulereschedule IPI自然高。ARM64的pseudo-NMI则是另一个话题。内核开CONFIG_ARM64_PSEUDO_NMI后可以把某些中断配置成fiq信号作为近似NMI使用。这种中断不会被普通irq屏蔽适合做调试、看门狗这类需要“即使系统乱成一团也要先跑”的逻辑。但启用它之后GIC的优先级配置会被Linux接管普通中断的优先级都会被统一调整如果平台固件也依赖GIC优先级很容易互相踩踏。所以非必要不开启真要用先确认固件侧没有抢占GIC优先级的逻辑。5. 常见问题与排查心得5.1 中断全挤在CPU0换不过来现象是/proc/interrupts里某一行显示CPU0列的数字不断涨其他CPU列基本不动。先执行cat /proc/irq/{irq}/smp_affinity确认是不是全F所有核。如果全F但实际只在CPU0多半是有别的机制绑死了比如irqbalance没跑但驱动初始的affinity_hint把中断绑在了CPU0或者这个中断本身是PPI。PPI本质就是per-CPU它只列在所属CPU那一列不参与全局调度。如果是SPI但绑死在CPU0检查irq_set_affinity是否被固件或驱动内部覆盖。另一种可能该中断的handler是传统共享中断写法内核在兼容模式下没按新路由逻辑走。5.2 中断风暴和spurious中断中断风暴有两种常见形态。一种是硬件不停拉高中断线handler清了状态位也没用中断被反复触发另一种是共享中断里某个设备误触发导致所有共享该IRQ的驱动都被唤醒。排查要看/proc/interrupts里对应行的计数是否远超实际流量以及内核日志里有没有nobody cared和disable_irq字样。内核在连续触发但没人正确处理时会最终disable这个irq。遇到这种情况先用perf确认哪个IRQ在爆量再逐个关设备驱动做二分。共享IRQ的场景确认驱动是否声明了IRQF_SHARED。还有个容易被忽略的点有些GPIO控制器把多个引脚合并成一个中断源上报驱动里没做挂起状态的读取和一次性清除导致中断风暴。这种情况下需要在handler里读挂起寄存器把所有pending位全部清掉再返回。5.3 中断不触发或只触发一次这是排查里比较花时间的故障。按经验我一般从硬件到软件逐层确认用cat /proc/interrupts看中断是否出现在预期CPU上、计数是否增加计数增加但业务没反应问题在handler或业务逻辑计数根本不增加查设备树映射对不对、设备是否进低功耗且中断没配置唤醒、GIC对应enable位是否被置上检查中断类型和触发方式。GIC寄存器里中断触发类型是配置出来的设备树写的IRQ_TYPE_LEVEL_HIGH要能在驱动初始化时通过irq_set_type生效。如果设备实际是低电平触发却配成高电平电平状态没变化中断永远不会来如果“只触发一次”优先怀疑EOI/deactivate流程特别是GICv3的EOImode split模式。我遇到过一次只触发一次的案例驱动在handler里做了disable_irq_nosync寄希望于后面enable_irq恢复但enable的时机和硬件active状态对不上导致GIC认为中断还没处理完后续触发全部在路上排队。这种问题的特征就是日志里能看到第一次中断之后就死寂。5.4 实用命令和工具速查目的命令说明查看中断分布cat /proc/interrupts第一列是virq后面按CPU列显示计数查看软中断分布cat /proc/softirqs看软中断计数是否集中设置亲和性echo 0xc /proc/irq/{irq}/smp_affinity十六进制位图用区间设置亲和性echo 2-3 /proc/irq/{irq}/smp_affinity_list新内核支持跟踪中断进入perf record -e irq:irq_handler_entry -a结束后perf script分析跟踪关中断时长echo irqsoff /sys/kernel/tracing/current_tracer配合trace-cmd record使用查看中断栈占用开CONFIG_DEBUG_STACK_USAGE日志里会打印栈剩余量我平时最常用的组合是watch -d -n1 cat /proc/interrupts /proc/softirqs两步确认硬中断和软中断的分布变化再决定要不要上perf做深度追踪。这套流程不能说解决所有问题但能把八成“中断异常”快速定位到具体环节。回到开头那句话中断处理本质就是一段固定的“进、查、分、回”流程难不在代码难在硬件细节和软件模型的层层嵌套。GICv3相比GICv2多了不少特性但核心的EOI、affinity、irq_domain映射逻辑没有变。把这条链路彻底跑通一次之后再遇到什么“中断诡异”你至少知道该看哪一层了。我个人体会最深的一点调中断永远先看硬件行为和内核软件行为的对应关系。寄存器读出来的中断号、/proc/interrupts里的递增计数、handler输出日志三者能对得上问题基本就解决了一半剩下的一半靠的是对EOI时机和亲和性的耐心核对。希望这篇梳理能让你在下一块板子上少走几个弯路。