如果你正对着一个轮询循环发愁——CPU 转了一圈又一圈,按键响应慢了半拍,串口数据丢了一帧,好不容易把事件都检查完了,主任务却饿得不行——那你大概率需要认真把“中断”这个概念重新捋一遍。我从裸机底层一路做到现在的项目,中断和中断优先级判断这块起初也吃了不少亏,后来才逐渐摸清楚背后的原理和套路。这篇文章不搞名词概念背诵,我会从 CPU 的视角把中断讲透,再重点拆解中断优先级判断的核心逻辑,最后给出的都是可以直接落到项目里的经验。不管是刚入门的学生,还是被中断嵌套折腾过的开发者,都值得把这块吃透。1. 中断的本质:没有它,CPU就是一台低效的“轮询机器”1.1 轮询模型的三大痛想象一下,你一边写代码,一边每隔几秒就伸手摸一下手机,看有没有新消息。再想想你的程序如果也这么做——CPU 在主程序里反复读取按键寄存器、查询串口接收标志、检查定时器计数值——这就是轮询。轮询不是不能用,但它在实际项目里会暴露三个非常疼的问题。第一是事件来不及响应。CPU 在主循环里执行一串指令,比如在做滤波算法或者文件存储,这个循环可能要几百微秒甚至几毫秒。就在这段窗口里,外部来了一个脉冲,或者串口收到一个字节。如果主循环还没跑回“检查事件”的那句代码,这个事件就永远错过了。对按键来说丢一次可能无所谓,但对编码器测速、脉冲计数这类场景,丢一次就意味着一整套数据出错。第二是资源被白白浪费。为了不丢事件,你只能把轮询频率提得非常高,结果 CPU 大部分时间都在做“检查有没有事”这件事本身。真正的主任务反而挤不到算力。这就好比你每隔几秒就打电话问客户有没有回复,什么正经工作都干不了。第三是低功耗设计做不了。低功耗模式下 CPU 要睡过去,只留部分外设在工作。如果用轮询,CPU 必须保持醒来状态去“看”有没有事件,等于把省电的底牌扔了。中断的意义恰恰是把“主动找事件”变为“被动被事件叫醒”。1.2 中断机制到底省了什么中断的本质,是由硬件替代软件完成“事件监测”这件事。外设或内部核心在需要 CPU 处理时会主动发出一个信号,CPU 在完成当前指令后停下当前任务,跳转到一个专门的处理函数执行事件处理,处理完再自动回到刚才打断的位置继续干活。用生活里的场景类比特别贴切:你坐在工位上写报告,手边的电话响了才接,而不是每隔十秒把电话拿起来听一听有没有声音。中断让 CPU 可以把全部精力放在主任务上,只在真正有意义的事件发生时才会被“叫走”。这个机制省下的是三样东西:CPU 的连续工作时间、事件响应的延迟、还有整个系统的功耗。中断到来后,CPU 必须回答三个问题:第一,这件事值不值得现在处理;第二,如果需要现在处理,当前手里做到一半的工作怎么保存;第三,处理完之后怎么安全地回到原来的位置。这三个问题的答案,就是中断系统的骨架——优先级判断、现场保存、中断返回。1.3 一次中断响应的最小流程一次完整的中断流程可以压缩成四个动作:请求、响应、服务、返回。请求:事件触发,外设或内部模块把对应的中断请求标志置位,同时向 CPU 送出中断信号。响应:CPU 在当前指令执行完之后,根据当前中断是否被允许、优先级够不够高等条件决定是否打断当前任务。服务:CPU 从中断向量表中找到对应的处理函数地址,跳转进去,执行中断服务程序(ISR)。返回:执行完 ISR 后,CPU 恢复之前打断时的现场,回到原来的下一条指令继续执行。这里有一个容易被忽视的关键点:必须是“当前指令执行完之后”才响应中断。绝大多数处理器都遵循这个时序,这保证了指令的原子性。如果一条指令执行到一半被硬生生打断,寄存器状态就会处在一个毫无意义的中间状态,现场就没法精确恢复。理解这一点之后,后续再看嵌套、抢占、临界区这些概念都会更顺。2. 先给中断分个类:不是所有“插队”都一样2.1 内部中断与外部中断按中断的来源,可以先把中断分成内部中断和外部中断两大类。内部中断是由 CPU 核心本身在执行指令过程中产生的,比如除法异常、未定义指令、内存访问越界、调试用断点等。这类事件通常意味着程序运行状态有问题,因此它们的优先级往往都很高。外部中断则来自 CPU 之外的电路或内部但非核心的模块:最典型的是 GPIO 引脚电平变化、定时器溢出、串口接收完成、DMA 传输完成等。平时项目中真正需要频繁打交道的大多是这一类。它们的优先级不像内部异常那样“半天塌下来”那么紧急,而是由开发者在可配置范围内自行决定优先级高低。我见过很多新手把“内部中断”和“外部中断”想成单纯是芯片引脚内外的区别,这不太准确。比如定时器是芯片内部集成的一个外设模块,它产生的中断在信号路径上不属于 CPU 核心,所以归为外部中断这一类,但它和你按键引脚上发生的中断都属于“外部”这个大类——本质上是 CPU 核心之外的事件源。2.2 可屏蔽中断与不可屏蔽中断按 CPU 是否能够选择忽略,又可以分为可屏蔽中断与不可屏蔽中断(NMI)。可屏蔽中断是绝大多数设备中断,CPU 内部有一个总开关(x86 上是 IF 标志位,ARM Cortex-M 上是 PRIMASK 等)可以把它们整体关掉。开发者在进入一段不希望被打断的临界区时,就会操作这个开关。不可屏蔽中断则是无论如何都关不掉的中断。它的典型应用场景包括看门狗超时、供电跌落检测、硬件异常报警等。这类事件一旦发生,就意味着系统要么已经出问题,要么马上要出大问题,必须立刻处理,不允许被任何机制耽误。在系统设计里,我会习惯把最紧急、最不能丢的事件挂到 NMI 上,哪怕它不是日常运行时最频繁的事件。刚做项目的人容易犯一个错误:为了临界区安全,把中断全屏蔽了,结果 NMI 也被一起屏蔽——某些硬件设计上如果 NMI 也能被“屏蔽”,只能说明它在芯片设计时并没有真正走 NMI 路径。选型核对的时候要特别确认这一点。2.3 软件中断:被低估的一支还有一类容易忽略的是软件中断。它不是外部事件,而是由程序主动执行一条指令触发的,x86 里的int n、ARM 里的SVC指令都属于这一类。现代操作系统里,用户态程序进入内核态做特权操作,很多就是通过软件中断实现的——这就是系统调用的底层入口之一。软件中断的重要性在于它是“主动触发”的。你可以利用软件中断在代码里模拟一个异步事件,专门用来测试中断响应的流程,或者用来实现任务间的上下文切换。我当年调试一套调度器时,就是靠软件中断来验证嵌套和现场恢复是否正确的,比接外部信号发生器方便得多。2.4 中断与异常:一个家族里的两种叫法严格来说,在 ARM Cortex-M 体系里,“异常”是比“中断”更大的概念,中断只是异常的一个子集。文档里会看到 0 号异常是栈顶地址,1 号异常是复位,各种内部错误排在前面,外部事件异常排在后面。x86 体系同样把错误、陷阱、中断统一收录在中断向量表里。搞清楚这个关系,你在查芯片手册时才不会被混乱的术语绕晕。3. 一次中断的完整旅程:从触发标志到恢复现场3.1 请求、响应、执行、返回四个阶段的细节前面说的四阶段是宏观框架,真实项目里每一阶段都有需要你关注的硬件细节。请求阶段,最核心的是中断挂起标志(Pending)。外设检测到事件后,先把标志位置位,表示“这一笔账还没结”。CPU 没有及时处理时,这个标志会一直等着,直到 CPU 响应。这里的坑是:很多外设的挂起标志需要你在 ISR 里手动清零。如果你只顾着写处理逻辑而忘了清标志,退出中断后同一个中断会立刻再次触发,表现为“ISR 被无限反复调用”,CPU 直接卡死在中断里。这是我调试时见过最多的低级却致命的问题。响应阶段,CPU 会检查全局中断开关是否打开、当前中断号是否被挂起、以及它的优先级是否比当前正在运行的事更高。如果条件都满足,CPU 需要先把当前工作状态原封不动地存起来。如果不允许响应,挂起标志大多会保持,等到条件允许时再继续。执行和返回阶段,主要看 ISR 是不是够短。中断处理讲究“快进快出”,不要在 ISR 里做耗时操作,这个我在后面还会展开讲。3.2 现场到底保存了哪些东西所谓“现场”,就是 CPU 被打断那一刻的全部工作状态。核心有两大块:一是当前执行到哪条指令,一般由程序计数器(PC)记录;二是当前计算用的中间结果,也就是各种通用寄存器和状态标志位。不同架构保存现场的方式不同。x86 上,CPU 硬件会把返回地址、状态标志等压入栈,其余寄存器由软件在 ISR 入口手工保存。Cortex-M 则只压入一部分,剩下的 R4-R11 等由软件决定是否保存。比如你在 Cortex-M 上进入 ISR 时,硬件自动压栈 PC、xPSR、LR、R0-R3、R12 这 8 个元素,汇编里通常还能看到对 R4-R11 的额外保存。理解了现场保存,你就明白一个很深的坑:如果在 ISR 里修改了编译器认为没有必要的寄存器,但没有保存恢复,主流程的变量就会被悄悄篡改。高优化等级下编译器可能做各种假设,所以不要在 ISR 与主流程之间共享复杂状态,除非你知道自己在做什么。最简单的做法是,ISR 里只置一个 volatile 标志,具体处理丢回主循环完成。3.3 向量表:中断的地图册CPU 怎么知道中断来了该跳去哪儿?靠的是中断向量表。这是一张存在固定地址区域里的表格,每个中断源对应表里一个条目,条目里存放的是对应 ISR 的入口地址。CPU 响应中断时,根据中断号去查表,拿到地址后跳转过去。x86 里的中断向量表叫 IDT,ARM 里叫中断向量表,通常放在存储器的起始区域。要注意,处理函数是 CPU 跳进去执行的,表里存的是“地址”。Cortex-M 的 0 号向量存的是初始栈指针,不是代码地址,很多人初始化流程看错了半天。因为表里存的是地址,程序里你就需要把编写好的 ISR 函数名关联到对应的向量条目上。芯片厂商的启动文件里通常已经用弱符号把 ISR 占位了,你只要写了同名函数就能自动覆盖。这类机制在不同芯片上表现略不同,但思路都是“地址查表”。3.4 嵌套与抢占是怎么叠起来的当多个中断在时间上重叠时,就会涉及嵌套。一个高优先级中断可以打断正在执行的低优先级 ISR,低优先级中断不能反过来打断高优先级。被打断的 ISR 现场同样会被保存,等更高优先级的 ISR 执行完,再恢复继续执行之前的低优先级 ISR。这个过程可以一层层叠下去,就像一个栈里堆了一摞函数调用。Cortex-M 还有一个非常实用的优化叫尾链(Tail-Chaining)。如果两个中断几乎同时到达,CPU 处理完一个 ISR 后,不再恢复现场再重新压栈,而是直接链式进入下一个 ISR。这个优化省了一大笔现场恢复和保存的开销。你在评测中断响应性能时,如果测出来的数据比理论值快不少,很可能就是尾链在起作用。4. 为什么必须讨论优先级:真实场景里的“撞车”4.1 同时到达:总得有个先后顺序先考虑最简单的场景:两个中断在同一时刻到来,CPU 不能分身同时处理两个。这时候必须有一个裁决机制决定先响应谁。这就是中断优先级判断的第一个作用——同刻仲裁。假设你系统里有一个电机编码器中断和一个串口接收中断。它们同时触发,如果编码器的计数丢了,电机的速度环就会出现大的跳变;串口数据丢了一帧,顶多要求对端重发。显然,编码器中断应该先执行。如果硬件只支持固定优先级,编码器接口恰好排在低优先级位,你就只能换引脚、换中断通道,或者通过软件策略弥补。实际项目里,重要的不是“谁功能多”,而是“谁丢了后果更严重”。这个损失排序,就是你给每个中断分配优先级的核心依据。4.2 处理过程中到达:能不能打断是关键还有一种更常见的情况:CPU 正在处理一个不紧急但耗时的中断,突然来了一个紧急事件。如果不允许打断,那个紧急事件就只能等当前 ISR 跑完才能处理,延迟可能高到不可接受。如果允许打断,那就意味着中断嵌套。中断优先级判断在这一刻要回答的问题是:新来的中断优先级是否比当前正在执行的更高。如果更高,它可以抢占当前 ISR;如果更低或相同,必须排队等待。注意,很多处理器在响应某个中断后,会自动屏蔽同优先级或更低优先级的中断,只有更高优先级才能触发嵌套。这也就是为什么我前面提到“同时到达的仲裁”和“处理中的抢占”是两种不同的优先级作用维度。4.3 优先级不是只有一种,别和RTOS优先级搞混这是新手最容易混乱的点。中断优先级是硬件中断控制器管的,比如 ARM Cortex-M 的 NVIC,或其它平台的 GIC、APIC。它决定的是“一个外部/内部硬件事件能否打断 CPU 正在执行的代码”。而实时操作系统里的任务优先级,是软件调度器管的,它决定的是“就绪队列里哪个任务先获得 CPU 使用权”。你可以让一个任务长期处于低优先级,但它仍然可能被一个高优先级中断打断,即使这个中断对应的功能只是把某个标志置个位。用一个表格把三种“优先级”分开看会更清楚:优先级的类型管辖方决定的事情中断优先级中断控制器 / CPU 硬件硬件事件之间谁先响应、能否抢占 ISRRTOS 任务优先级软件调度器就绪任务之间谁先运行进程调度优先级操作系统内核进程之间谁先被别人切换千万不要在写 ISR 时依赖 RTOS 的任务优先级来做实时保证,中断优先级才是硬实时的保障。反过来也一样,不要觉得“某个任务的优先级最高”就等于实时性有保证。5. 中断优先级判断的四种典型实现5.1 硬件优先级编码:把固定规则做成电路一些早期和简单的处理器,中断优先级是由硬件固定死的,不可随意更改。这种方案在芯片内部实现为优先级编码器——多路中断请求线进来,编码器输出优先级最高的那个中断号,低优先级请求只能在较高优先级处理完后再被响应。这种方式最大的优势是响应快、逻辑简单,因为它不需要额外的配置寄存器,一切都是纯电路行为。代价是完全不够灵活。如果系统里最紧急的设备和固定优先级排位不一致,你只能在外围想办法,比如把该设备的中断脚重新映射到高优先级通道。这种设计至今仍然存在于不少 MCU 的部分固定异常或某几路快速中断上。5.2 可编程中断控制器:优先级变成寄存器里的数字现代处理器几乎都采用可编程中断控制器。以 ARM Cortex-M 的 NVIC 为例,每个可配置中断通道都有一个对应的优先级寄存器,你可以在里面写入优先级数值,数值越小优先级越高(具体要看芯片手册,有些是反的)。设置优先级只需要调用 CMSIS 提供的接口,例如:// NVIC_SetPriority函数的第一个参数是中断号,第二个是优先级数值 NVIC_SetPriority(USART1_IRQn, 2); NVIC_SetPriority(TIM2_IRQn, 0); // 定时器优先级更高这种可编程机制的意义在于,你可以在系统启动阶段根据当前项目的实际需求动态分配优先级,而不必改冲硬件。好的中断控制器还支持使能、失能、挂起、清除挂起等独立控制位,以及软件触发中断。这些都是调试和唤醒设计的利器。5.3 软件查表:没有硬件仲裁时的土办法并不是所有平台都给了你完整的中断嵌套能力。某些简单的 8 位 MCU,同一时刻只响应一个中断请求,也没有复杂的中断控制器帮你仲裁。这时候优先级判断就要靠软件来做。一个典型的软件查表实现是:在 ISR 入口统一读取所有可能的中断标志位,然后按一张提前定义好的优先级表依次判断。比如先用if判断最高优先级的标志,如果有就处理它,然后返回;没有就查次高优先级。这种方式虽然能在有限硬件能力下模仿“优先级”效果,但有明显的实时性缺陷——中断响应延迟取决于标志位的个数和判断分支的长度,你不可能做到严格的可抢占嵌套。在有条件选型的情况下,我还是建议优先使用带硬件 NVIC 的芯片。软件查表适合资源受限、实时性要求不高的小项目,确实被逼到用软件仲裁时,要记得把所有标志检查代码写得极短,并避免在判断过程里被其它东西干扰。5.4 抢占优先级与子优先级的分组设计回到 Cortex-M 的 NVIC,优先级寄存器并没有完全被当成一个简单的数字。它被划分成两个部分:抢占优先级和子优先级。抢占优先级决定能否嵌套打断当前 ISR;子优先级只用于解决“抢占优先级相同的中断同时到来时谁先执行”,它本身不能触发嵌套。这两部分的划分比例是可以配置的,具体由AIRCR寄存器里的PRIGROUP字段决定。不同配置下,可用的抢占优先级级数和子优先级级数各不相同。例如某个芯片用 4 位优先级寄存器:PRIGROUP 配置抢占优先级位数子优先级位数可表达抢占级数分组 0132分组 1224分组 2318分组 34016分组 3 意味着所有优先级位都用来做抢占优先级,没有子优先级,此时同优先级中断同时到达时,按中断号小的先响应。分组 0 则相反,抢占级很少,但每一组里可以区分多个子优先级顺序。实际项目里我通常会把分组配置成“抢占优先级尽量多”的模式,比如 STM32 上常用的NVIC_PriorityGroup_2或Group_3。因为嵌套抢占能力对实时系统更重要,子优先级只在“同一抢占级的一堆中断同时来”时有一点点排序意义,很多时候你用不到那么多子优先级。6. 实际项目中设置中断优先级的经验与教训6.1 优先级设置的通用原则讲完原理,回到项目落地的层面。给中断分配优先级时,我一般按以下顺序思考:先把绝对不允许延迟的事件排出来,这些事件对应最高的抢占优先级,比如紧急停机、掉电保护、编码器位置锁存。再按“允许延迟多久”把其余事件分档。延迟敏感但不致命的,比如通信超时、传感器读取,放中高优先级。最后是那些可以容忍较久延迟的事件,比如状态指示灯、按键扫描、普通日志输出,放低优先级。原则很简单:谁的损失最大,谁排前面。而不是谁的数据量大谁排前面。很多人把“高频”当成“高优先级”,其实高频中断如果每个处理都很短,完全可以用低优先级加上尾链优化来处理;低频但紧急的掉电中断反而必须放在最高位。6.2 中断服务程序里千万别做的几件事ISR 的黄金法则是短、快、无阻塞。我在代码评审里反复看到这些反面教材:在 ISR 里调用耗时的库函数,比如浮点字符串格式化、复杂计算。在 ISR 里等待另一个事件,比如自旋等待某个标志被置位。在 ISR 里直接操作非线程安全的数据结构,比如链表、队列的增删节点,却不做临界区保护。在 ISR 里调用可能触发调度的函数,比如某些 RTOS 的延时会发生在任务上下文,放进 ISR 就成了非法操作。正确的做法是,ISR 只负责做最小必要动作:清除标志、读取或搬运必要的数据到缓冲区,然后置一个主循环能看见的标志,或者通过 RTOS 的消息队列/信号量通知任务。其余复杂处理全部延后到主循环或任务中去执行。6.3 临界区、屏蔽中断与不可屏蔽中断的边界当主流程图和 ISR 同时访问同一个变量时,就可能出现数据竞争。处理办法是在主流程里进入临界区——临时屏蔽可屏蔽中断,完成关键读写后再恢复。Cortex-M 上的典型写法:uint32_t primask = __get_PRIMASK(); __set_PRIMASK(1); // 关中断,进入临界区 // 此处对共享变量的访问不会被 ISR 打断 __set_PRIMASK(primask); // 恢复之前的中断状态这里特别强调一下“之前的中断状态”,是因为有些代码会直接调用__set_PRIMASK(1)关闭后,退出时又把 PRIMASK 设成 0,也就是无条件开中断。如果函数在被调用前本来是关中断状态,这样子就会破坏调用者的上下文。正确做法是先保存再恢复,比如上面的写法。还有一条边界:在临界区里不能停留太久。关中断时间过长,所有可屏蔽中断都会排队,中断延迟会显著变差。如果临界区里只是几条赋值指令,这是完全可接受的;如果涉及大块数据拷贝,就要考虑换用锁或双缓冲方案,而不是长时间关中断。同时记住,临界区能屏蔽的是可屏蔽中断,NMI 依然会打进来。所以临界区内的代码要尽量做到“即使被 NMI 打断,再返回也能继续执行”,不要在临界区里维护临时状态,否则 NMI 反而会变成数据破坏的入口。6.4 用GPIO翻转运自己的中断响应时延最后分享一个我实用的调试手段:用 GPIO 翻转来测量中断的真实响应时延。在 ISR 的第一行把某个测试引脚拉高,在 ISR 的最后一行把它拉低,然后用示波器或逻辑分析仪观察这个引脚的脉冲宽度。你可以得到:从中断请求产生到 ISR 第一行执行之间的延迟,用事件产生时刻和 GPIO 拉高时刻对比。ISR 自身执行了多长时间,GPIO 高电平的宽度。多个中断之间的嵌套关系,通过多个 GPIO 引脚的波形叠加看清谁插队了谁。这个方法看起来土,却是排查中断响应的最快方式。我曾经用这种方式定位到一个看似随机的问题:某个高优先级 ISR 里调用了一个非重入的函数,导致偶发的现场被破坏。从 CPU 寄存器上排查半天没头绪,换成 GPIO 翻转之后,波形直接告诉我那个 ISR 执行时间异常长,顺藤摸瓜才找到根因。自己也养成了一个习惯:写中断相关代码时,先把测试 GPIO 初始化好,ISR 骨架搭好之后直接测一轮响应时延,再继续往下写业务逻辑。硬件是诚实可靠的,波形不会骗人,它比人眼盯着逻辑分析仪打印日志可靠得多。毕竟中断这种东西,一旦进了死循环或者被错误抢占,日志本身都可能没法正常输出,而示波器始终在那里老老实实地帮你记录每一笔变化。
如果你正对着一个轮询循环发愁——CPU 转了一圈又一圈,按键响应慢了半拍,串口数据丢了一帧,好不容易把事件都检查完了,主任务却饿得不行——那你大概率需要认真把“中断”这个概念重新捋一遍。我从裸机底层一路做到现在的项目,中断和中断优先级判断这块起初也吃了不少亏,后来才逐渐摸清楚背后的原理和套路。这篇文章不搞名词概念背诵,我会从 CPU 的视角把中断讲透,再重点拆解中断优先级判断的核心逻辑,最后给出的都是可以直接落到项目里的经验。不管是刚入门的学生,还是被中断嵌套折腾过的开发者,都值得把这块吃透。1. 中断的本质:没有它,CPU就是一台低效的“轮询机器”1.1 轮询模型的三大痛想象一下,你一边写代码,一边每隔几秒就伸手摸一下手机,看有没有新消息。再想想你的程序如果也这么做——CPU 在主程序里反复读取按键寄存器、查询串口接收标志、检查定时器计数值——这就是轮询。轮询不是不能用,但它在实际项目里会暴露三个非常疼的问题。第一是事件来不及响应。CPU 在主循环里执行一串指令,比如在做滤波算法或者文件存储,这个循环可能要几百微秒甚至几毫秒。就在这段窗口里,外部来了一个脉冲,或者串口收到一个字节。如果主循环还没跑回“检查事件”的那句代码,这个事件就永远错过了。对按键来说丢一次可能无所谓,但对编码器测速、脉冲计数这类场景,丢一次就意味着一整套数据出错。第二是资源被白白浪费。为了不丢事件,你只能把轮询频率提得非常高,结果 CPU 大部分时间都在做“检查有没有事”这件事本身。真正的主任务反而挤不到算力。这就好比你每隔几秒就打电话问客户有没有回复,什么正经工作都干不了。第三是低功耗设计做不了。低功耗模式下 CPU 要睡过去,只留部分外设在工作。如果用轮询,CPU 必须保持醒来状态去“看”有没有事件,等于把省电的底牌扔了。中断的意义恰恰是把“主动找事件”变为“被动被事件叫醒”。1.2 中断机制到底省了什么中断的本质,是由硬件替代软件完成“事件监测”这件事。外设或内部核心在需要 CPU 处理时会主动发出一个信号,CPU 在完成当前指令后停下当前任务,跳转到一个专门的处理函数执行事件处理,处理完再自动回到刚才打断的位置继续干活。用生活里的场景类比特别贴切:你坐在工位上写报告,手边的电话响了才接,而不是每隔十秒把电话拿起来听一听有没有声音。中断让 CPU 可以把全部精力放在主任务上,只在真正有意义的事件发生时才会被“叫走”。这个机制省下的是三样东西:CPU 的连续工作时间、事件响应的延迟、还有整个系统的功耗。中断到来后,CPU 必须回答三个问题:第一,这件事值不值得现在处理;第二,如果需要现在处理,当前手里做到一半的工作怎么保存;第三,处理完之后怎么安全地回到原来的位置。这三个问题的答案,就是中断系统的骨架——优先级判断、现场保存、中断返回。1.3 一次中断响应的最小流程一次完整的中断流程可以压缩成四个动作:请求、响应、服务、返回。请求:事件触发,外设或内部模块把对应的中断请求标志置位,同时向 CPU 送出中断信号。响应:CPU 在当前指令执行完之后,根据当前中断是否被允许、优先级够不够高等条件决定是否打断当前任务。服务:CPU 从中断向量表中找到对应的处理函数地址,跳转进去,执行中断服务程序(ISR)。返回:执行完 ISR 后,CPU 恢复之前打断时的现场,回到原来的下一条指令继续执行。这里有一个容易被忽视的关键点:必须是“当前指令执行完之后”才响应中断。绝大多数处理器都遵循这个时序,这保证了指令的原子性。如果一条指令执行到一半被硬生生打断,寄存器状态就会处在一个毫无意义的中间状态,现场就没法精确恢复。理解这一点之后,后续再看嵌套、抢占、临界区这些概念都会更顺。2. 先给中断分个类:不是所有“插队”都一样2.1 内部中断与外部中断按中断的来源,可以先把中断分成内部中断和外部中断两大类。内部中断是由 CPU 核心本身在执行指令过程中产生的,比如除法异常、未定义指令、内存访问越界、调试用断点等。这类事件通常意味着程序运行状态有问题,因此它们的优先级往往都很高。外部中断则来自 CPU 之外的电路或内部但非核心的模块:最典型的是 GPIO 引脚电平变化、定时器溢出、串口接收完成、DMA 传输完成等。平时项目中真正需要频繁打交道的大多是这一类。它们的优先级不像内部异常那样“半天塌下来”那么紧急,而是由开发者在可配置范围内自行决定优先级高低。我见过很多新手把“内部中断”和“外部中断”想成单纯是芯片引脚内外的区别,这不太准确。比如定时器是芯片内部集成的一个外设模块,它产生的中断在信号路径上不属于 CPU 核心,所以归为外部中断这一类,但它和你按键引脚上发生的中断都属于“外部”这个大类——本质上是 CPU 核心之外的事件源。2.2 可屏蔽中断与不可屏蔽中断按 CPU 是否能够选择忽略,又可以分为可屏蔽中断与不可屏蔽中断(NMI)。可屏蔽中断是绝大多数设备中断,CPU 内部有一个总开关(x86 上是 IF 标志位,ARM Cortex-M 上是 PRIMASK 等)可以把它们整体关掉。开发者在进入一段不希望被打断的临界区时,就会操作这个开关。不可屏蔽中断则是无论如何都关不掉的中断。它的典型应用场景包括看门狗超时、供电跌落检测、硬件异常报警等。这类事件一旦发生,就意味着系统要么已经出问题,要么马上要出大问题,必须立刻处理,不允许被任何机制耽误。在系统设计里,我会习惯把最紧急、最不能丢的事件挂到 NMI 上,哪怕它不是日常运行时最频繁的事件。刚做项目的人容易犯一个错误:为了临界区安全,把中断全屏蔽了,结果 NMI 也被一起屏蔽——某些硬件设计上如果 NMI 也能被“屏蔽”,只能说明它在芯片设计时并没有真正走 NMI 路径。选型核对的时候要特别确认这一点。2.3 软件中断:被低估的一支还有一类容易忽略的是软件中断。它不是外部事件,而是由程序主动执行一条指令触发的,x86 里的int n、ARM 里的SVC指令都属于这一类。现代操作系统里,用户态程序进入内核态做特权操作,很多就是通过软件中断实现的——这就是系统调用的底层入口之一。软件中断的重要性在于它是“主动触发”的。你可以利用软件中断在代码里模拟一个异步事件,专门用来测试中断响应的流程,或者用来实现任务间的上下文切换。我当年调试一套调度器时,就是靠软件中断来验证嵌套和现场恢复是否正确的,比接外部信号发生器方便得多。2.4 中断与异常:一个家族里的两种叫法严格来说,在 ARM Cortex-M 体系里,“异常”是比“中断”更大的概念,中断只是异常的一个子集。文档里会看到 0 号异常是栈顶地址,1 号异常是复位,各种内部错误排在前面,外部事件异常排在后面。x86 体系同样把错误、陷阱、中断统一收录在中断向量表里。搞清楚这个关系,你在查芯片手册时才不会被混乱的术语绕晕。3. 一次中断的完整旅程:从触发标志到恢复现场3.1 请求、响应、执行、返回四个阶段的细节前面说的四阶段是宏观框架,真实项目里每一阶段都有需要你关注的硬件细节。请求阶段,最核心的是中断挂起标志(Pending)。外设检测到事件后,先把标志位置位,表示“这一笔账还没结”。CPU 没有及时处理时,这个标志会一直等着,直到 CPU 响应。这里的坑是:很多外设的挂起标志需要你在 ISR 里手动清零。如果你只顾着写处理逻辑而忘了清标志,退出中断后同一个中断会立刻再次触发,表现为“ISR 被无限反复调用”,CPU 直接卡死在中断里。这是我调试时见过最多的低级却致命的问题。响应阶段,CPU 会检查全局中断开关是否打开、当前中断号是否被挂起、以及它的优先级是否比当前正在运行的事更高。如果条件都满足,CPU 需要先把当前工作状态原封不动地存起来。如果不允许响应,挂起标志大多会保持,等到条件允许时再继续。执行和返回阶段,主要看 ISR 是不是够短。中断处理讲究“快进快出”,不要在 ISR 里做耗时操作,这个我在后面还会展开讲。3.2 现场到底保存了哪些东西所谓“现场”,就是 CPU 被打断那一刻的全部工作状态。核心有两大块:一是当前执行到哪条指令,一般由程序计数器(PC)记录;二是当前计算用的中间结果,也就是各种通用寄存器和状态标志位。不同架构保存现场的方式不同。x86 上,CPU 硬件会把返回地址、状态标志等压入栈,其余寄存器由软件在 ISR 入口手工保存。Cortex-M 则只压入一部分,剩下的 R4-R11 等由软件决定是否保存。比如你在 Cortex-M 上进入 ISR 时,硬件自动压栈 PC、xPSR、LR、R0-R3、R12 这 8 个元素,汇编里通常还能看到对 R4-R11 的额外保存。理解了现场保存,你就明白一个很深的坑:如果在 ISR 里修改了编译器认为没有必要的寄存器,但没有保存恢复,主流程的变量就会被悄悄篡改。高优化等级下编译器可能做各种假设,所以不要在 ISR 与主流程之间共享复杂状态,除非你知道自己在做什么。最简单的做法是,ISR 里只置一个 volatile 标志,具体处理丢回主循环完成。3.3 向量表:中断的地图册CPU 怎么知道中断来了该跳去哪儿?靠的是中断向量表。这是一张存在固定地址区域里的表格,每个中断源对应表里一个条目,条目里存放的是对应 ISR 的入口地址。CPU 响应中断时,根据中断号去查表,拿到地址后跳转过去。x86 里的中断向量表叫 IDT,ARM 里叫中断向量表,通常放在存储器的起始区域。要注意,处理函数是 CPU 跳进去执行的,表里存的是“地址”。Cortex-M 的 0 号向量存的是初始栈指针,不是代码地址,很多人初始化流程看错了半天。因为表里存的是地址,程序里你就需要把编写好的 ISR 函数名关联到对应的向量条目上。芯片厂商的启动文件里通常已经用弱符号把 ISR 占位了,你只要写了同名函数就能自动覆盖。这类机制在不同芯片上表现略不同,但思路都是“地址查表”。3.4 嵌套与抢占是怎么叠起来的当多个中断在时间上重叠时,就会涉及嵌套。一个高优先级中断可以打断正在执行的低优先级 ISR,低优先级中断不能反过来打断高优先级。被打断的 ISR 现场同样会被保存,等更高优先级的 ISR 执行完,再恢复继续执行之前的低优先级 ISR。这个过程可以一层层叠下去,就像一个栈里堆了一摞函数调用。Cortex-M 还有一个非常实用的优化叫尾链(Tail-Chaining)。如果两个中断几乎同时到达,CPU 处理完一个 ISR 后,不再恢复现场再重新压栈,而是直接链式进入下一个 ISR。这个优化省了一大笔现场恢复和保存的开销。你在评测中断响应性能时,如果测出来的数据比理论值快不少,很可能就是尾链在起作用。4. 为什么必须讨论优先级:真实场景里的“撞车”4.1 同时到达:总得有个先后顺序先考虑最简单的场景:两个中断在同一时刻到来,CPU 不能分身同时处理两个。这时候必须有一个裁决机制决定先响应谁。这就是中断优先级判断的第一个作用——同刻仲裁。假设你系统里有一个电机编码器中断和一个串口接收中断。它们同时触发,如果编码器的计数丢了,电机的速度环就会出现大的跳变;串口数据丢了一帧,顶多要求对端重发。显然,编码器中断应该先执行。如果硬件只支持固定优先级,编码器接口恰好排在低优先级位,你就只能换引脚、换中断通道,或者通过软件策略弥补。实际项目里,重要的不是“谁功能多”,而是“谁丢了后果更严重”。这个损失排序,就是你给每个中断分配优先级的核心依据。4.2 处理过程中到达:能不能打断是关键还有一种更常见的情况:CPU 正在处理一个不紧急但耗时的中断,突然来了一个紧急事件。如果不允许打断,那个紧急事件就只能等当前 ISR 跑完才能处理,延迟可能高到不可接受。如果允许打断,那就意味着中断嵌套。中断优先级判断在这一刻要回答的问题是:新来的中断优先级是否比当前正在执行的更高。如果更高,它可以抢占当前 ISR;如果更低或相同,必须排队等待。注意,很多处理器在响应某个中断后,会自动屏蔽同优先级或更低优先级的中断,只有更高优先级才能触发嵌套。这也就是为什么我前面提到“同时到达的仲裁”和“处理中的抢占”是两种不同的优先级作用维度。4.3 优先级不是只有一种,别和RTOS优先级搞混这是新手最容易混乱的点。中断优先级是硬件中断控制器管的,比如 ARM Cortex-M 的 NVIC,或其它平台的 GIC、APIC。它决定的是“一个外部/内部硬件事件能否打断 CPU 正在执行的代码”。而实时操作系统里的任务优先级,是软件调度器管的,它决定的是“就绪队列里哪个任务先获得 CPU 使用权”。你可以让一个任务长期处于低优先级,但它仍然可能被一个高优先级中断打断,即使这个中断对应的功能只是把某个标志置个位。用一个表格把三种“优先级”分开看会更清楚:优先级的类型管辖方决定的事情中断优先级中断控制器 / CPU 硬件硬件事件之间谁先响应、能否抢占 ISRRTOS 任务优先级软件调度器就绪任务之间谁先运行进程调度优先级操作系统内核进程之间谁先被别人切换千万不要在写 ISR 时依赖 RTOS 的任务优先级来做实时保证,中断优先级才是硬实时的保障。反过来也一样,不要觉得“某个任务的优先级最高”就等于实时性有保证。5. 中断优先级判断的四种典型实现5.1 硬件优先级编码:把固定规则做成电路一些早期和简单的处理器,中断优先级是由硬件固定死的,不可随意更改。这种方案在芯片内部实现为优先级编码器——多路中断请求线进来,编码器输出优先级最高的那个中断号,低优先级请求只能在较高优先级处理完后再被响应。这种方式最大的优势是响应快、逻辑简单,因为它不需要额外的配置寄存器,一切都是纯电路行为。代价是完全不够灵活。如果系统里最紧急的设备和固定优先级排位不一致,你只能在外围想办法,比如把该设备的中断脚重新映射到高优先级通道。这种设计至今仍然存在于不少 MCU 的部分固定异常或某几路快速中断上。5.2 可编程中断控制器:优先级变成寄存器里的数字现代处理器几乎都采用可编程中断控制器。以 ARM Cortex-M 的 NVIC 为例,每个可配置中断通道都有一个对应的优先级寄存器,你可以在里面写入优先级数值,数值越小优先级越高(具体要看芯片手册,有些是反的)。设置优先级只需要调用 CMSIS 提供的接口,例如:// NVIC_SetPriority函数的第一个参数是中断号,第二个是优先级数值 NVIC_SetPriority(USART1_IRQn, 2); NVIC_SetPriority(TIM2_IRQn, 0); // 定时器优先级更高这种可编程机制的意义在于,你可以在系统启动阶段根据当前项目的实际需求动态分配优先级,而不必改冲硬件。好的中断控制器还支持使能、失能、挂起、清除挂起等独立控制位,以及软件触发中断。这些都是调试和唤醒设计的利器。5.3 软件查表:没有硬件仲裁时的土办法并不是所有平台都给了你完整的中断嵌套能力。某些简单的 8 位 MCU,同一时刻只响应一个中断请求,也没有复杂的中断控制器帮你仲裁。这时候优先级判断就要靠软件来做。一个典型的软件查表实现是:在 ISR 入口统一读取所有可能的中断标志位,然后按一张提前定义好的优先级表依次判断。比如先用if判断最高优先级的标志,如果有就处理它,然后返回;没有就查次高优先级。这种方式虽然能在有限硬件能力下模仿“优先级”效果,但有明显的实时性缺陷——中断响应延迟取决于标志位的个数和判断分支的长度,你不可能做到严格的可抢占嵌套。在有条件选型的情况下,我还是建议优先使用带硬件 NVIC 的芯片。软件查表适合资源受限、实时性要求不高的小项目,确实被逼到用软件仲裁时,要记得把所有标志检查代码写得极短,并避免在判断过程里被其它东西干扰。5.4 抢占优先级与子优先级的分组设计回到 Cortex-M 的 NVIC,优先级寄存器并没有完全被当成一个简单的数字。它被划分成两个部分:抢占优先级和子优先级。抢占优先级决定能否嵌套打断当前 ISR;子优先级只用于解决“抢占优先级相同的中断同时到来时谁先执行”,它本身不能触发嵌套。这两部分的划分比例是可以配置的,具体由AIRCR寄存器里的PRIGROUP字段决定。不同配置下,可用的抢占优先级级数和子优先级级数各不相同。例如某个芯片用 4 位优先级寄存器:PRIGROUP 配置抢占优先级位数子优先级位数可表达抢占级数分组 0132分组 1224分组 2318分组 34016分组 3 意味着所有优先级位都用来做抢占优先级,没有子优先级,此时同优先级中断同时到达时,按中断号小的先响应。分组 0 则相反,抢占级很少,但每一组里可以区分多个子优先级顺序。实际项目里我通常会把分组配置成“抢占优先级尽量多”的模式,比如 STM32 上常用的NVIC_PriorityGroup_2或Group_3。因为嵌套抢占能力对实时系统更重要,子优先级只在“同一抢占级的一堆中断同时来”时有一点点排序意义,很多时候你用不到那么多子优先级。6. 实际项目中设置中断优先级的经验与教训6.1 优先级设置的通用原则讲完原理,回到项目落地的层面。给中断分配优先级时,我一般按以下顺序思考:先把绝对不允许延迟的事件排出来,这些事件对应最高的抢占优先级,比如紧急停机、掉电保护、编码器位置锁存。再按“允许延迟多久”把其余事件分档。延迟敏感但不致命的,比如通信超时、传感器读取,放中高优先级。最后是那些可以容忍较久延迟的事件,比如状态指示灯、按键扫描、普通日志输出,放低优先级。原则很简单:谁的损失最大,谁排前面。而不是谁的数据量大谁排前面。很多人把“高频”当成“高优先级”,其实高频中断如果每个处理都很短,完全可以用低优先级加上尾链优化来处理;低频但紧急的掉电中断反而必须放在最高位。6.2 中断服务程序里千万别做的几件事ISR 的黄金法则是短、快、无阻塞。我在代码评审里反复看到这些反面教材:在 ISR 里调用耗时的库函数,比如浮点字符串格式化、复杂计算。在 ISR 里等待另一个事件,比如自旋等待某个标志被置位。在 ISR 里直接操作非线程安全的数据结构,比如链表、队列的增删节点,却不做临界区保护。在 ISR 里调用可能触发调度的函数,比如某些 RTOS 的延时会发生在任务上下文,放进 ISR 就成了非法操作。正确的做法是,ISR 只负责做最小必要动作:清除标志、读取或搬运必要的数据到缓冲区,然后置一个主循环能看见的标志,或者通过 RTOS 的消息队列/信号量通知任务。其余复杂处理全部延后到主循环或任务中去执行。6.3 临界区、屏蔽中断与不可屏蔽中断的边界当主流程图和 ISR 同时访问同一个变量时,就可能出现数据竞争。处理办法是在主流程里进入临界区——临时屏蔽可屏蔽中断,完成关键读写后再恢复。Cortex-M 上的典型写法:uint32_t primask = __get_PRIMASK(); __set_PRIMASK(1); // 关中断,进入临界区 // 此处对共享变量的访问不会被 ISR 打断 __set_PRIMASK(primask); // 恢复之前的中断状态这里特别强调一下“之前的中断状态”,是因为有些代码会直接调用__set_PRIMASK(1)关闭后,退出时又把 PRIMASK 设成 0,也就是无条件开中断。如果函数在被调用前本来是关中断状态,这样子就会破坏调用者的上下文。正确做法是先保存再恢复,比如上面的写法。还有一条边界:在临界区里不能停留太久。关中断时间过长,所有可屏蔽中断都会排队,中断延迟会显著变差。如果临界区里只是几条赋值指令,这是完全可接受的;如果涉及大块数据拷贝,就要考虑换用锁或双缓冲方案,而不是长时间关中断。同时记住,临界区能屏蔽的是可屏蔽中断,NMI 依然会打进来。所以临界区内的代码要尽量做到“即使被 NMI 打断,再返回也能继续执行”,不要在临界区里维护临时状态,否则 NMI 反而会变成数据破坏的入口。6.4 用GPIO翻转运自己的中断响应时延最后分享一个我实用的调试手段:用 GPIO 翻转来测量中断的真实响应时延。在 ISR 的第一行把某个测试引脚拉高,在 ISR 的最后一行把它拉低,然后用示波器或逻辑分析仪观察这个引脚的脉冲宽度。你可以得到:从中断请求产生到 ISR 第一行执行之间的延迟,用事件产生时刻和 GPIO 拉高时刻对比。ISR 自身执行了多长时间,GPIO 高电平的宽度。多个中断之间的嵌套关系,通过多个 GPIO 引脚的波形叠加看清谁插队了谁。这个方法看起来土,却是排查中断响应的最快方式。我曾经用这种方式定位到一个看似随机的问题:某个高优先级 ISR 里调用了一个非重入的函数,导致偶发的现场被破坏。从 CPU 寄存器上排查半天没头绪,换成 GPIO 翻转之后,波形直接告诉我那个 ISR 执行时间异常长,顺藤摸瓜才找到根因。自己也养成了一个习惯:写中断相关代码时,先把测试 GPIO 初始化好,ISR 骨架搭好之后直接测一轮响应时延,再继续往下写业务逻辑。硬件是诚实可靠的,波形不会骗人,它比人眼盯着逻辑分析仪打印日志可靠得多。毕竟中断这种东西,一旦进了死循环或者被错误抢占,日志本身都可能没法正常输出,而示波器始终在那里老老实实地帮你记录每一笔变化。