Linux中断机制深度解析:从上半部到多核亲和性

Linux中断机制深度解析:从上半部到多核亲和性 1. 先把中断这件事想清楚CPU如何被“叫停”1.1 中断、异常与系统调用三个容易被混为一谈的概念很多从应用开发转过来读内核源码的朋友第一次接触“中断”时往往会把中断、异常、系统调用三个概念搅在一起。我在早期读《深入理解Linux内核》时也在这个地方卡过很久。其实只要抓住一个根本区别就能理清中断是异步的异常是同步的系统调用是主动的。硬件设备需要CPU介入时会通过中断控制器向CPU发送一个电信号CPU收到后在当前指令边界处停下来跳转到预先设定好的处理入口这就是中断。整个过程与CPU正在执行的指令流没有时间上的必然关联所以叫异步。异常则正好相反是CPU在执行某条指令时“自己发现问题”比如除零、缺页、非法指令它和当前指令是严格同步的。而系统调用是用户态程序主动触发int 0x80或syscall指令本质上也属于异常的一种只是它是有意为之。理解这个区分有什么用最直接的影响是中断处理上下文处于原子状态不能睡眠而异常处理中的缺页异常其实是可以睡眠的因为它可能触发磁盘IO。如果把这个搞混写驱动时大概率会在中断处理函数里掉进睡眠最终导致内核崩溃或死锁。1.2 从硬件到内核一次完整的中断旅程以x86平台为例一次典型的中断完整路径大概是这样的网卡收到数据包后通过PCIe总线向中断控制器IO-APIC发送中断请求IO-APIC根据路由表把中断分配给某个CPU的Local APICCPU收到中断信号后硬件自动完成一系列压栈操作根据中断向量号在IDT中断描述符表中找到对应的门描述符跳转到入口代码入口代码保存现场调用内核的公共处理函数最终通过irq_desc找到注册在该中断号上的所有处理函数并逐一执行。这套流程在《Intel SDM》和内核源码里都有明确依据但我想强调一个容易被忽略的点硬件中断号不等于内核里的IRQ号更不等于/proc/interrupts里看到的那个数字。x86上硬件向量号、IO-APIC的引脚号、内核IRQ号之间要通过irq domain和映射关系做转换而在ARM平台上这个映射就更加复杂因为GIC通用中断控制器有自己的中断ID设备树里写的interrupts属性对应的又是另一套编号。很多人在移植驱动时发现request_irq注册的号跟芯片手册对不上原因就在这里。2. 上半部与下半部Linux中断处理的分裂艺术2.1 为什么必须拆成两半中断上下文里的“不能眠之痛”中断处理函数运行在中断上下文这个上下文的特点非常鲜明没有进程概念current宏指向的是被打断时的那个进程但这个指向没有实际意义不能睡眠不能调用可能引起睡眠的函数不能用普通信号量不能访问用户空间内存。为什么不能睡眠因为中断发生在任意进程上下文中如果处理函数睡眠不仅不知道要把哪个进程挂起还会导致中断处理被无限期阻塞其他中断无法得到响应。试想一个场景网卡中断到来你如果不立刻处理并清掉中断标志后续的数据包会一直触发中断可你的处理函数还在睡眠等一个事件系统就卡死了。所以内核把中断处理拆成两半。上半部hardirq只做最紧急的事读取硬件状态、清中断标志、拷贝必要数据到内存然后登记一个下半部任务立刻返回。下半部在更宽松的上下文里完成耗时工作比如协议栈处理、数据递送。这种“快速响应 延后处理”的思路本质上是把硬实时的优先级让给响应性把复杂的逻辑交给可调度的上下文。2.2 tasklet、softirq、workqueue下半部的三种老面孔下半部的实现机制在Linux演进中出现过多次方案BH机制Bottom Half、tasklet、softirq、workqueue以及后来的threaded irq。到现代内核里实际开发中接触最多的还是tasklet、softirq和workqueue。先看softirq。它是在内核编译时就静态定义的一组软中断每种softirq有一个索引号HI_SOFTIRQ、TIMER_SOFTIRQ、NET_TX_SOFTIRQ、NET_RX_SOFTIRQ、BLOCK_SOFTIRQ、TASKLET_SOFTIRQ等。软中断执行时机是中断返回前夕或者被ksoftirqd内核线程唤醒时。它的特点是同一个softirq可以在多个CPU上并发执行所以处理函数必须自行保证可重入。tasklet是建立在softirq之上的简化设施同一个tasklet在任意时刻只能在一个CPU上执行不同的tasklet可以在不同CPU上并行。这个特性让tasklet无需考虑复杂的并发问题非常适合驱动开发者使用。但它毕竟还是运行在软中断上下文不能睡眠。workqueue就走得更远了——它运行在进程上下文背后是内核工作线程可以用信号量、可以睡眠、可以执行可能阻塞的操作。代价是延迟更大实时性更差。给驱动选下半部机制时我的经验是能tasklet解决的就用tasklet需要在处理里做文件操作、获取普通锁、等待硬件响应这类可能睡眠的事情就老实交给workqueue。三种机制的适用场景我习惯这样区分机制运行上下文是否可睡眠并发特征典型用途softirq软中断上下文否同一softirq可多CPU并行网络收发、块设备、定时器tasklet软中断上下文否同一tasklet任意时刻在一个CPU执行简单驱动下半部、数据汇总workqueue进程上下文是由工作线程调度执行复杂处理、可能睡眠的驱动逻辑2.3 threaded irq让中断函数变成一个内核线程我刚开始学着写驱动时老驱动里到处是request_irq配合tasklet的写法。后来读到新内核的驱动代码发现很多地方变成了request_threaded_irq或devm_request_threaded_irq当时不太理解原本用tasklet挺好的为何要多此一举搞个线程实际上threaded irq解决了一个非常实际的问题很多中断处理根本没法用tasklet写对。比如触摸屏驱动中断来了要读取I2C寄存器而I2C控制器访问本身就可能睡眠再比如某些USB设备的中断处理涉及较重的协议交互。这些逻辑放在原子上下文里要么写起来极其别扭要么干脆不可能实现。threaded irq的做法是为每个中断创建一个内核线程中断上半部只做必要的硬件ack和禁用操作然后唤醒线程由线程在进程上下文里执行真正的处理。这样驱动作者可以在“中断处理函数”里放心使用互斥锁、usleep_range、i2c_transfer这些会睡眠的接口。从实时性角度看threaded irq配合RT补丁后还能支持优先级继承、自定义调度策略这类实时特性比普通软中断机制灵活得多。3. 多核时代的必经之路中断亲和性与均衡3.1 irqbalance与smp_affinity先看懂内核怎么分发中断单核时代所有中断都挤在一个CPU上没人关心分发问题。到了多核平台如果所有网卡中断都落在CPU0上很快就能看到CPU0的软中断占用冲到100%而其他核闲着看戏。中断亲和性IRQ affinity就是把中断定向到指定CPU集合的机制。每个中断都有一个smp_affinity属性由bitmap表示可以处理该中断的CPU集合。比如4核平台/proc/irq/24/smp_affinity里的值如果是f就表示四个CPU都能处理24号中断。写1二进制0001表示只允许CPU0处理写2二进制0010表示只允许CPU1处理。现代发行版默认会启动irqbalance守护进程它周期性评估各CPU的中断负载和繁忙程度动态调整smp_affinity。但irqbalance并不总是最优——它倾向于把中断分散到所有核上而有些场景恰恰需要把某个高频率中断锁死在特定CPU上配合进程的CPU亲和性绑核使用以获得更好的cache命中率。3.2 /proc/interrupts排查中断风暴的第一现场排查中断相关问题我第一个动作永远是查看/proc/interrupts。这个文件按中断号列出了每个CPU上的累计触发次数、所属设备、中断名称。简单说如果某个中断号的触发次数以肉眼可见的速度疯涨基本可以断定中断风暴irq storm已经发生。有一种典型情况设备驱动没正确屏蔽未处理的中断源硬件持续拉高中断线CPU不断进入中断处理但驱动什么有效工作都没做只是不断进入、返回、再进入。此时系统表现为整体卡顿但负载看起来并不高因为中断处理不算在普通进程负载里。通过/proc/interrupts观察哪个中断号异常增长再从irq_desc里找到注册的处理函数基本就能锁定问题驱动。顺带一提top命令里%hihardirq时间和%sisoftirq时间两个指标在中断问题排查中非常关键。如果%si持续很高而业务进程CPU占用一般多半是网络软中断或块设备软中断过重这时候配合/proc/softirqs看哪个类型的软中断次数异常能进一步缩小范围。3.3 网卡多队列的中断均衡从RPS/RFS到实际配置moderne 服务器网卡普遍支持多队列RSS每个队列可以独立映射到不同中断号再绑到不同CPU核心。比如一个4队列的万兆网卡装好后/proc/interrupts里能看到4个中断号分别对应eth0-TxRx-0到3。把四个中断号用smp_affinity分别绑定到4个CPU核再配合应用程序的NUMA策略大流量场景下的吞吐量会有肉眼可见的改善。对于不支持多队列的虚拟网卡内核还提供了RPSReceive Packet Steering和RFSReceive Flow Steering作为软件层面的分发手段。RPS允许把收到的报文从硬中断所在的CPU分发到指定CPU列表通过/sys/class/net/eth0/queues/rx-0/rps_cpus设置RFS则根据流的CPU使用情况做更精细的调度把同一流的报文尽量送到处理该流socket的CPU上。我实测过在单队列virtio网卡的云主机上适当开启RPS能让小包吞吐提升明显核心就是让软中断处理负载分散到多核。配置中断亲和性和RPS时有一件事必须特别留意要避开繁忙的CPU和同一物理核的超线程兄弟。如果两个高负载中断绑到同一个物理核的两个逻辑CPU上实际上还是在抢同一份执行资源。检查架构拓扑时可以用lscpu -p看Thread、Core、Socket的对应关系把中断分散到不同物理核上才真正有效。4. 实战中那些让系统“翻车”的中断问题4.1 request_irq返回-EBUSY共享中断不是你想的那样很多驱动初学者在注册共享中断时遇到过这个问题明明代码逻辑看着没问题request_irq就是返回-ENODEV或-EBUSY。先说-EBUSY这通常意味着想要注册的IRQ号已经被其他设备独占注册了而且你的flags里没有包含IRQF_SHARED标志。内核的规则是共享中断必须由所有参与者都显式声明IRQF_SHARED才允许注册而且内核无法验证设备是否真的支持共享中断只能靠驱动作者自觉。我有一个比较深刻的教训某次在ARM平台上移植一个GPIO按键驱动GPIO对应的中断同时也是触摸屏的中断源我注册时没加IRQF_SHARED结果直接注册失败。当时排查了很久最后通过cat /proc/interrupts发现该中断号已经挂在触摸屏驱动名下才反应过来是共享问题。所以在硬件设计允许的情况下我建议尽量为不同外设分配独立的中断号共享中断虽然省中断线但处理函数里必须通过设备状态寄存器自行判断是不是自己的中断多了一层开销不说排查起来也更费劲。4.2 中断处理函数里睡眠教科书级错误与真实表现中断上下文不能睡眠这句话几乎所有内核编程的书都会写。但“不能睡眠”到底是怎么个不能法很多文章没讲透。在原子上下文里如果调用了一个可能导致睡眠的函数比如spin_lock持有期间调用kmalloc(GFP_KERNEL)、mutex_lock、synchronize_irq内核会调用BUG_ON或触发schedule while atomic的警告系统可能直接oops、死锁也可能只是打印一条警告然后接着跑具体表现取决于当时的锁状态。真实场景中更隐蔽的是“间接睡眠”你在中断处理函数里本来没直接调用睡眠函数但某个函数内部可能有睡眠路径。比如早期的调试打印printk在某些配置下可能触发控制台锁阻塞你现在这个上下文是持锁状态再嵌套就可能死锁。原则上中断处理函数里连可能间接引起调度行为的函数都要尽量避免。如果实在于心不安就把复杂逻辑扔到workqueue里或者用irqthread让内核帮你管理上下文。4.3 中断耗时过长引发的“全军覆没”一个性能问题的定位过程有一次我们在做嵌入式网络设备性能测试时发现设备在跑满小包流量时整机ping延迟从0.3毫秒飙升到200多毫秒而且CPU0的si占用率几乎100%其他CPU却几乎空闲。最初怀疑是设备驱动处理逻辑太重后来用perf top一看热点全在checksum计算和skb拷贝函数上再深入分析才发现网卡只有单队列所有中断都打在CPU0上导致CPU0的软中断处理成为瓶颈。这个问题的完整修复方案是分三步走第一步优化驱动把checksum offload打开让网卡硬件而不是CPU来做校验和第二步把中断绑到相邻的两个核上扩大处理资源池第三步是给应用进程设置CPU亲和性让业务进程尽量跑在中断比较少的核上。三步做完ping延迟回到了0.4毫秒。这个案例说明中断处理的性能问题往往不是单点问题硬件卸载、内核参数、进程调度策略要放在一起综合考虑。5. 我眼里的中断机制演进与实时性之争5.1 从硬中断到软中断内核自身的平衡术中断机制在Linux内核里像是一场持续的平衡术表演。硬中断追求的是快速响应但响应太快又会导致上下文切换开销过大软中断把处理延后获得了更灵活的调度空间但又引入了延迟和并发问题。早期Linux 2.4的BH机制是全局串行的一个CPU执行BH时其他CPU的BH全部被锁住到了2.6引入softirq和per-CPU的ksoftirqd才算真正把下半部并行化能力打开。现在的新内核在中断路径上还有一个很值得关注的设计趋势中断子系统越来越依赖irq domain把硬件中断控制器映射、设备树中断解析、运行时电源管理串在一起。你在ARM64平台上看设备树的中断属性在ACPI固件里看中断资源最终都要通过irq domain机制统一翻译成内核IRQ号。做驱动移植时如果只看芯片手册而不看irq domain的映射逻辑很容易在中断号上栽跟头。5.2 中断线程化与PREEMPT_RT实时性对中断机制的改造标准内核里中断处理占用的时间并不会被调度器精确管理硬中断路径随时抢占当前进程执行这在实时性要求高的工业控制、机器人、音频处理场景是非常糟糕的。PREEMPT_RT补丁现在mainline中已经大量合入的核心思路之一就是把几乎所有的中断处理线程化。硬中断变为一个极简的信号发出者剩下的逻辑全部在可调度的内核线程中执行交给实时调度器统一管理优先级。这个改造带来的一个有意思的变化是“关中断”这个操作的影响变小了。在传统的UP系统里关中断被用来保护临界区但代价是可能丢失中断引起延迟。RT内核把中断线程化之后驱动里对关中断的依赖大大降低更多采用可调度的互斥机制来保护共享资源。我在做实时性调优时经常通过cyclictest观察线程调度延迟对比普通内核和RT候选版本中断线程化带来的最直观收益就是最坏情况延迟的确定性大幅提升抖动从几十微秒降到了个位数微秒。从实用的角度来说如果你在做嵌入式Linux实时方案建议直接选用带RT特性的内核配置同时在驱动设计中尽量避免在硬中断上下文做重活把设备数据采集和协议处理全部放在线程上下文里配合setsched设置合适的实时优先级。这套组合拳下来定时精度和响应确定性都会比默认内核好很多。最后再分享一个排查中断问题的小习惯我每次拿到一个新的板子第一件事就是保存一份/proc/interrupts、/proc/softirqs和/proc/sched_debug的快照标注好当时的负载环境。等系统出问题时再抓一份对比很多时候问题原因一眼就能看出来。中断机制虽然底层的逻辑复杂但只要先建立起“中断是异步事件”“处理必须分阶段”“资源要在多核之间平衡”这三个基本认知后续看源码、调性能、查问题都会顺很多。