ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

中断处理流程全解析:从硬件触发到CPU执行的完整链路

中断处理流程全解析:从硬件触发到CPU执行的完整链路

中断处理流程这个问题,教科书里往往几句话就带过去了,但真正在驱动开发、系统调优或者排查性能抖动的时候,才发现从设备触发到CPU执行再到下半部收尾,是一条牵一发动全身的链路。我最近维护一个跨平台系统时,就被“网络吞吐上不去”和“USB设备偶发失联”两个问题反复折腾,最后根因都落在中断处理的细节上。这篇文章把我梳理出来的完整数据流、内核层的实际路径,以及实测验证过的排查和调优方法都整理出来。如果你正准备啃驱动代码,或者正在被莫名奇妙的CPU打满、中断风暴折磨,这篇应该能帮你把“中断处理流程”从概念变成真正可用的知识。

1. 一次中断的全旅程:从设备引脚到CPU入口

1.1 先分清三类“打断”:中断、异常与系统调用

很多人把中断、异常和系统调用混在一起说,但它们对CPU的打断方式完全不同。外部设备中断是异步的,网卡收到帧、磁盘完成一次IO、USB设备拔插,这类事件随时可能发生,CPU正在执行哪条指令跟它没有必然关系。异常是同步的,缺页、除零、非法指令都来自当前正在执行的这条指令本身。系统调用则是用户程序主动发起的一种特殊转跳,它借助的入口机制和异常类似,但语义是“请内核帮我做事”。

区分这三类的意义在于,驱动开发里接触最多、也最容易出问题的就是外部设备中断。外部中断不依赖当前进程是谁,也不依赖当前指令是什么,所以CPU必须有一套专门机制去响应它,这套机制从硬件开始,一路延伸到软件的处理函数。

1.2 中断控制器:所有设备请求的“交警”

一台机器上有几十上百个设备,CPU不可能给每个设备都单独留一根中断引脚。实际的做法是先把所有设备的中断请求线汇聚到中断控制器上。中断控制器负责几件事:记录哪些中断线处于有效状态,根据优先级决定先上报谁,然后向CPU发起一条统一的中断请求信号,同时把具体的中断向量编号带出来。

CPU会在每条指令执行完毕的边界采样这个请求信号。采样的意思是,只要当前指令还没执行完,中断就只能等着;一旦本条指令结束,且当前允许中断(标志位打开),CPU才会进入响应流程。这就是为什么说中断“不会撕裂一条正在执行的指令”,它的响应时机是指令边界。

在x86平台上,传统方案是外接可编程中断控制器,现代多核平台则用本地APIC和IO-APIC组合,把一个外设中断硬件定向到某个指定CPU。ARM平台的通用中断控制器也承担同样职责,只是分发逻辑更灵活。驱动开发者不一定要摸清楚硬件内部的每个寄存器,但“设备→控制器→CPU”这条链路必须清楚,因为中断向量、IRQ编号、CPU亲和性这些概念全都建立在这条链上。

1.3 硬件保存最精简现场,剩下的交给软件

CPU真正响应中断时,动作非常克制。它不会把几十个通用寄存器全都存一遍,那样会拖累每一次中断的延迟。硬件只保存能“继续执行”的最小信息,比如返回地址、CPU状态标志,然后切换到内核栈,根据中断向量查到对应的处理入口并跳过去。

剩下的大量现场保存工作,由中断入口汇编代码完成。这段代码会把所有通用寄存器、段寄存器等信息压入内核栈,组织成一个完整的寄存器帧,然后调用一个通用的C入口函数。C入口函数拿到中断向量之后,去中断描述表里找到对应的描述项,再沿着这条描述项上的处理函数链表,逐个调用设备注册的中断处理程序。

这套逐层传递的设计,好处是不同类型的中断可以共用一套入口逻辑,设备驱动不需要关心硬件是怎么把请求送到CPU的,只需要注册一个函数,并告诉内核“我这个函数处理哪个IRQ”。实际代码里,申请中断接口就是做这件事。

1.4 为什么整条路径的延迟必须可控

中断处理是延迟敏感路径,从设备发出请求到处理函数开始执行,中间的时延会直接决定吞吐上限。举个例子,一块网卡跑万兆,按最小64字节报文计算,每秒大概有千万级的数据包,平均每个包的到达间隔只有几十纳秒。如果每来一个包都打断CPU一次,而中断响应和处理要花几十微秒,那CPU根本忙不过来。

所以内核的惯用手法是把入口路径尽量精简,把真正的重活放到下半部去做。这也是后续内容反复出现的主题:中断处理函数本身不是用来“干完所有事情”的,它只负责最紧急、最短的一部分工作,剩下的延后执行。理解了这一点,再看驱动程序里的各种中断处理代码,很多取舍就一目了然了。

2. 中断上下文里的“禁区”清单

2.1 中断上下文到底是什么

中断处理函数运行的时候,CPU没有普通进程的影子。它虽然处于某个进程的内核栈上,但当前进程不能被调度,用户态地址空间也不可用。操作系统对这种情况有专门的术语叫“中断上下文”,它的本质是一个非常受限的原子环境:任何可能导致睡眠的操作,在这里都不被允许。

为什么不允许睡眠?因为睡眠意味着让出CPU,等某个条件满足后再被唤醒。唤醒依赖调度器,而调度器本身又依赖定时器中断来驱动。你在中断上下文里等一个需要调度器来唤醒的事件,等于快递员被困在电梯里等电梯修好,死锁是必然的。内核里如果检测到中断上下文里发生睡眠,会直接打印“scheduling while atomic”之类的错误。

2.2 禁区的完整清单

整理一下我在实际阅读驱动代码时经常对照的规则:

  • 不能睡眠,一切可能触发调度的操作都不行。
  • 不能拿互斥锁,因为互斥锁在等待时会睡;保护中断路径的数据要用自旋锁或原始自旋锁。
  • 不能使用普通内存分配标志,内存回收可能触发睡眠;只能用原子分配方式。
  • 不能直接访问用户态内存地址,因为缺页处理在中断上下文里无法满足。
  • 不能长时间持有自旋锁,否则其他核会被卡死。
  • 不能频繁调用调试输出接口,打印超时也会拖慢整个中断路径。

这些规则听起来吓人,但写驱动时只要记住一个总原则:中断处理函数里,你是个“不能等”的角色。所有需要等待资源、等待锁、等待硬件响应的操作,都应该移出去。

2.3 为什么规则如此严格:一个生活化类比

普通代码像在窗口排队办事,等一会儿没关系。中断处理像消防员接到警情出警,不可能在火场里先排队申请工具,也不可能坐到椅子上睡一觉再起来灭火。中断处理期间,CPU相当于“被征用”了,它服务的对象就是当前这个硬件设备,其他所有进程都得暂停。这个暂停时间越长,整个系统的响应能力越差。

更专业一点说,中断关闭期间,其他所有设备的中断请求都会被挂起,硬件FIFO可能会溢出,定时器可能会抖。曾经有一段时间,工程师们发现中断处理过于庞大导致音频卡顿、鼠标移动不跟手,后来内核引入下半部机制,本质上就是意识到“中断处理不能大包大揽”。

2.4 中断处理的时长哲学

那中断处理函数到底应该干多少事情?以网络驱动为例,它至少要读取设备状态寄存器,确认中断来源,清除中断标志,然后把收到的数据描述符挂到队列里,最后启动下半部。这些操作加起来应该尽最大努力控制在很短的时间内完成。协议栈解析、数据校验、用户态唤醒,这些都不算“紧急”,可以推迟。

我见过一个模拟项目X,某开发者为了方便调试,在中断处理里用一个互斥锁保护共享队列。平时一切正常,但某个低概率路径触发了锁上的睡眠,系统随机卡死,控制台里出现调度错误提示。后来改成自旋锁,并把可能竞争的数据挪到下半部去处理,问题才彻底消失。这类问题一旦出现就很难复现,所以写代码时把这些规则刻在脑子里,比事后排查省太多时间。

3. 上半部与下半部:把“紧急”和“重要”分开

3.1 为什么必须拆成两截

Linux内核并不是一开始就有完善的上下半部机制。早期设计里,中断处理函数经常被用来做完整的数据处理,结果就是中断关闭时间过长,丢中断、系统交互卡顿、音频爆音频频出现。后来社区逐渐形成一个共识:硬中断处理函数必须短到“把设备本身的状态处理干净”就返回,其余工作延后执行。

这种拆法说起来简单,但实际上是个系统工程。上半部要足够快,下半部要足够灵活,不同下半部机制有各自不同的运行上下文和限制。驱动作者必须根据自己设备的特点,选择最合适的下半部方案。

3.2 三种下半部工具怎么选

内核提供的主要下半部机制可以简单分成三类:

机制运行上下文能否睡眠典型场景
softirq中断返回路径或专用内核线程否网络收发、块设备、定时器、调度器相关
tasklet基于softirq构建否普通驱动的下半部处理
workqueue内核工作线程(进程上下文)是慢速设备、大量数据搬运、需要等待锁的操作

我在实际选择时的判断顺序是:先问下半部会不会睡眠,会睡眠就直接工作队列;再问处理频率是不是极高,极高就考虑softirq;如果只是普通设备的中断后续处理,tasklet往往最省事。tasklet像是封装好的半成品,注册简单,适合绝大多数场景。但要注意,tasklet里同样不能睡眠,不能拿互斥锁。

3.3 以网络收包为例看整个拆分

网卡收到一帧数据后,硬件触发中断。上半部里做的事情也就那么几件:读取状态寄存器确认中断类型,写清除寄存器表示“我知道了”,把数据包描述符链到收包队列里,然后返回。真正把包从队列里取出来,做校验、剥协议头、送协议栈,全部在下半部的接收路径中完成。

现代网卡还有更激进的做法,比如NAPI机制。它允许驱动在收到第一个包后关闭中断,转用轮询方式连续收取后续一批包,攒到一定数量再重新打开中断。这样做的好处是大幅减少中断次数,因为高负载下每来一个包就触发一次中断太浪费CPU。高PPS场景里,一次中断几十微秒的开销会被放大成吞吐瓶颈,NAPI就是在“中断”和“轮询”之间找平衡。

3.4 从设计角度谈“别滥用”

看到这里你可能会想,既然下半部能干活,那把所有活都扔进去不就行了。实际不是这样。workqueue里能睡眠,但任务调度本身的延迟是毫秒级的,不适合对延迟极度敏感的业务;tasklet虽然延迟低,但执行期间不允许睡眠,而且它本质上还是占用CPU。最合理的设计是:上半部用最小代价把设备救活,能延后的都延后,但延后程度要根据设备的实时要求来确定。

比如USB存储设备的读写,数据量很大,搬运过程中还可能要等DMA完成,明显适合工作队列;而网卡需要低延迟高吞吐,通常用softirq或NAPI。别把业务逻辑一股脑塞进下半部,也别为了省事把所有事情都放在硬中断里,这两条路都有人踩过坑。

4. 共享IRQ、触发模式与中断丢失:三个绕不开的坑

4.1 共享IRQ上的“分机接线”

当多个设备共享同一条中断请求线时,CPU收到中断后,内核需要沿着这条中断线对应的处理函数链表逐个执行,由每个驱动的处理函数自己去判断“这次中断是不是我的”。判断方法一般是读取设备自己的状态寄存器,如果发现没有任何中断待处理,就快速返回一个“不归我管”的标志。

这带来两个很实在的要求。第一,申请中断时登记的设备标识要尽量具体,最好传设备对象指针,而不是空指针,否则内核在释放时可能找不到对应项。第二,处理函数里要优先做快速自检,如果检测到不是自己的中断,要马上退出,别在这根线上拖时间。我曾见过两个设备共享IRQ,其中一个驱动在处理函数开头做了比较耗时的寄存器交叉验证,结果另一个设备的吞吐直接被拖垮。

4.2 电平触发和边沿触发的行为差异

中断触发模式直接影响是否丢中断。电平触发模式下,中断线会保持有效电平,直到设备清除请求。处理函数如果只清了中断标志但没处理完实际事件,或干脆没清干净,中断线继续有效,CPU会立刻再次进入处理函数,严重时形成中断风暴。边沿触发模式则只在电平跳变的那一瞬间触发一次,CPU关中断期间如果跳变发生,这个中断可能直接丢失。

电平触发适合多个设备共享一条中断线,因为只要线上还有设备没有清除请求,中断就不会丢失,CPU会一直收到请求直到所有设备都处理完。边沿触发更适合中断源非常明确、而且不能容忍重复触发的场景。关键是一旦确定触发模式,硬件初始化、设备寄存器配置、驱动处理逻辑必须高度一致。某模拟项目X里,一个按键驱动用了边沿触发却没有任何消抖,实际使用时抖动信号导致触发多次和丢失并存,最后改成电平触发后配合定时器采样才稳定。

4.3 中断屏蔽、关闭和丢失的连锁反应

有些驱动为了临界区安全,会直接关闭本地CPU的中断。问题在于,关闭时间一旦过长,设备中断被挂起的同时,硬件接收缓冲区可能还在写入数据。缓冲区满了之后,新数据要么丢弃,要么覆盖旧数据,表现出来就是设备偶发失联、数据包丢失。内核不是禁止关中断,而是要求临界区足够短,短到硬件缓冲区不会溢出。

多核系统上还有一个细节:关中断只能关闭当前CPU,如果数据会被其他核访问,还需要配合正确的同步手段,单纯关中断不够。实际上,内核里大多数共享数据保护已经由锁和原子操作承担,驱动里手动关中断的场合很少。如果你发现一个驱动里频繁出现关闭中断的调用,并且临界区里有循环、有耗时的寄存器操作,那极可能有性能隐患。某跨平台系统就栽过这个跟头:一个驱动在关中断状态下做了大量调试统计收集,结果整机交互卡顿,把统计移出临界区后立刻恢复。

5. 真实排查链路:从 /proc/interrupts 到延迟定位

5.1 先看 /proc/interrupts 的计数器

排查中断相关问题,第一个落脚点通常是 /proc/interrupts。文件里每一行对应一个中断号,各CPU列显示的是该CPU处理这个中断的次数,最后一列是设备名称。正常系统里,各CPU之间的计数应该相对均衡,设备名清晰可读。

如果看到某个IRQ的计数只在某一个CPU上疯涨,同时业务实际吞吐很低,说明中断分布极不均衡,或者存在中断风暴。如果设备名上出现和错误相关的描述,比如PCI错误、超时事件,那驱动需要优先检查硬件状态。这个文件的妙处在于它不需要额外工具,任何系统上都存在,几行数据就能告诉你大部分问题的方向。

5.2 /proc/softirqs:看下半部的实际压力

光看硬中断还不完整,因为下半部的处理压力才是CPU真正的负担。/proc/softirqs 按CPU列出了各类软中断的处理次数,重点看网络接收、定时器这几类。如果某个CPU的软中断计数明显高于其他CPU,硬中断也集中在同一CPU,那么这个核很可能已经成了热点,需要人为迁移或绑核。

我在排查性能抖动时,习惯把 /proc/interrupts 和 /proc/softirqs 放在一起看。硬中断分布不均衡会导致软中断跟着不平衡,而软中断的计算量往往比硬中断大得多,真正压垮CPU的多半是下半部。只优化硬中断而不关心软中断,等于只修了水龙头却不管水管。

5.3 用内核tracepoint测量单次处理耗时

计数只能反映频率,不能反映单次处理耗时。要测量耗时,最直接的办法是启用内核里现成的中断跟踪点。操作步骤大致是这样:

cd /sys/kernel/debug/tracing echo 0 > tracing_on echo 'irq_handler_entry' > set_event echo 'irq_handler_exit' >> set_event echo 1 > tracing_on sleep 5 cat trace | grep irq_handler

这条命令会把每个中断进入和退出的事件都记录下来,用时间戳相减就能算出每个处理函数的耗时。如果发现某个IRQ的单次耗时忽高忽低,甚至常年居高不下,就可以把矛头指向对应驱动。也可以借助perf简化:

perf record -e irq:irq_handler_entry -e irq:irq_handler_exit sleep 5 perf script

工具能给你数据,但判断还得靠自己:到底哪个设备在捣乱,这个设备的驱动在中断里到底做了什么。

5.4 一次完整排查链路演绎

拿一个我印象很深的案例来还原思路。某模拟项目X在高网络负载下出现周期性延迟尖峰,现象是平均延迟正常,但每过几秒就跳一次上百毫秒的尖峰。第一步看 /proc/interrupts,发现网卡对应的IRQ计数集中在CPU2上,单核压力明显。第二步看 /proc/softirqs,网络接收软中断在CPU2上同样爆表,显然硬中断和下半部都堆在同一核。第三步开tracepoint测耗时,发现该网卡驱动的中断处理函数单次执行时间比同类驱动高出一个数量级,而且偶尔出现接近毫秒的异常值。最后回到代码里,发现驱动在硬中断路径里对每个数据包都做了额外的调试计数和时间戳记录,把这段统计逻辑挪到工作队列之后,延迟尖峰消失。

这条排查链路本身是可以复用的:先看计数判断分布,再看软中断判断压力来源,然后用跟踪点定位单次耗时,最后回到代码确认罪魁祸首。顺序反了就容易大海捞针。

6. 中断亲和性与多队列:让处理能力追上设备速度

6.1 为什么默认分配未必理想

系统启动时,内核会尝试把各个IRQ均匀分布到不同CPU上,但实际运行中,设备热插拔、驱动加载顺序、电源管理事件都会打乱这种均匀性。更重要的是,硬中断和对应的软中断往往落在同一个CPU上,这意味着一个设备的中断处理负载并没有真正分散到多个核,只是硬中断和软中断在工作日里“同居”在一起。

当单个CPU的中断负载过高时,用户态进程得不到CPU时间,系统交互变慢,网络延迟变高。这时候手动设置中断亲和性,把不同设备的中断绑定到不同CPU,是最直接有效的办法。

6.2 手动设置亲和性的具体操作

每个IRQ都在 /proc/irq/ 目录下有对应子目录,里面有两个关键文件:一个是 smp_affinity,用十六进制位掩码表示CPU集合;另一个是 smp_affinity_list,直接用CPU编号列表,比位掩码直观得多。举例来说,把中断2绑定到CPU3:

echo 3 > /proc/irq/2/smp_affinity_list

或者用位掩码:

echo 0x8 > /proc/irq/2/smp_affinity

需要根据十六进制掩码和CPU编号的转换做好准备。CPU0对应0x1,CPU1对应0x2,CPU2对应0x4,CPU3对应0x8。如果你有两颗物理CPU共32个逻辑核,想指定0到7号核,掩码就是0xff。设置后立即生效,但重启会丢失,需要写进启动脚本。

一个常见的坑是:某些中断描述符被设置为不可迁移,或者设备驱动禁止了亲和性调整,这时候写文件会报错。遇到这种情况,先确认设备是否使用MSI/MSI-X模式、驱动是否支持中断迁移,不要硬来。

6.3 多队列设备:尤其是网卡

现代网卡基本都支持多队列,也就是把收到的数据流按哈希分散到多个收包队列,每个队列拥有独立的IRQ向量。从 /proc/interrupts 里能看到类似 eth0-TxRx-0、eth0-TxRx-1 这样的条目,分别对应不同队列。

设置多队列之前,先用网卡工具查看当前队列数:

ethtool -l eth0

然后按需调整:

ethtool -L eth0 combined 4

这并不会自动保证队列的IRQ分散到不同CPU,还要进入 /proc/irq/ 对每个队列的IRQ分别设置亲和性。配置原则是把承载对应业务流量的队列绑到合适的CPU上,特别注意别把所有队列都压到CPU0。有些驱动会忽略亲和性设置,需要额外确认驱动是否支持这个功能。

6.4 实际操作中积累的经验

先说结论:低延迟且负载可预测的场景,用固定亲和性;负载波动大且多核充裕的场景,可以维持自动分布。irqbalance在某些机器上会不断迁移中断,每次迁移都带来缓存失效,反而造成额外延迟抖动,因此延迟敏感的服务器上我一般直接禁用,手动分配。

还有一点,有超线程的CPU上,不要光看逻辑核数量就随便绑定。两个逻辑核共享同一条物理流水线资源,把高负载的中断绑到同一个物理核心的两个超线程上,并不会比只用一个核好多少,甚至可能互相干扰。实际看核间延迟和缓存拓扑比看逻辑数量更可靠。

我从实践中养成的习惯是这样的:先把 /proc/interrupts 的当前分布存成基线档案,再浏览 /proc/irq/ 下每个中断的当前亲和性,然后针对最繁忙的设备逐一手动绑定,改完立刻用压测工具观察计数分布和延迟变化。每台机器的负载模式不同,别指望一套配置走天下。

6.5 验证配置是否真的生效

改完之后,不要看一眼计数分布就收工。持续压测,同时观察 /proc/interrupts 里各队列计数是否均衡,/proc/softirqs 里软中断是否跟着分散。可以用类似 htop 的工具看哪个CPU的用户态使用率被挤压,或者用性能采样工具看看中断热点集中在哪些函数。

如果设备在多队列模式下表现不佳,可以适当减少队列数,让每个队列的任务更集中。队列不是越多越好,超过物理核数或设备能力后,额外开销反而会让吞吐下降。配置是个反复试错的过程,基线、修改、观察、再调整,这四个步骤是标准动作。

整个中断处理链路梳理下来,印象最深的一点是:从硬件引脚到CPU入口,从上半部到下半部,从共享IRQ到亲和性配置,每一层都有它存在的理由。你在现场遇到一个延迟问题,很可能不是某一个环节单独出错,而是链路里多个因素叠加的结果。把每个环节的职责、限制和排查方法绑定在一起理解,才算真正把“中断处理流程”拿捏住了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进