记得在十几年前学习MCU时,教程里在中断的章节里提到,中断机制就像“你正在切菜,电话突然响了,你转去接电话,挂断之后再回来继续切菜……”,这个类比在当时确实直观地建立起了对中断的第一印象。更深入一点会说__中断是一种由硬件或软件触发的异步事件机制,它迫使处理器暂停当前正在执行的指令流,保存当前的执行上下文(Context),并强制跳转到特定的处理程序(Interrupt Service Routine, ISR)去响应这一事件__。然而,这样的表述还是太单薄了,在一些的工程实践中,稍微深入到 ARM 架构的底层,去分析 Cortex-M 的 NVIC或是其他很多相关组件和机制时,就会发现中断不单单是新手教程里这个简单类比,更是整个计算机体系结构中实现异步事件处理、硬件抽象以及复杂调度的核心物理基石,这远比想象中的要更加宏大和复杂。
Cortex-M33为例,它在中断发生时,硬件层面会自动执行一系列复杂的响应,大约包括以下几步:
当中断触发时,处理器首先会将当前的关键寄存器自动压入堆栈中,以保存现场。这些寄存器通常包括 xPSR(程序状态寄存器)、PC(程序计数器)、LR(链接寄存器)、R12 以及 R0-R3。如果系统启用了浮点单元且处于活动状态,浮点相关的寄存器也会被自动保存到堆栈帧中。
这是 Cortex-M33 区别于其他内核的重要环节。中断发生时,硬件会根据 NVIC 中的配置判断该中断是目标为“安全世界(Secure)”还是“非安全世界(Non-Secure)”,分为两种情况:
处理器会根据中断的类型和安全属性,去对应的中断向量表(VTOR_S 或 VTOR_NS)中查找该中断服务程序(ISR)的入口地址
在跳转执行 ISR 之前,处理器会更新一系列内部寄存器:
完成上述所有准备后,CPU 正式进入 Handler 模式,开始执行对应的中断服务例程。在此期间,如果有更高优先级的中断到来,还会触发中断嵌套机制,重复上述流程。
这里有一个疑问,为什么在第一步,硬件只压栈R0~R3和R12,不压全部?
Cortex-M33一共有13个通用寄存器,但是在AAPCS(Arm C/C++ 过程调用标准)当中,分成了两组,R0~ R3和R12是__“调用者保存寄存器”__,R4~R11则是“被调用者保存寄存器”,这意味着,如果一个函数在执行过程中打算使用 R4-R11,它必须在函数的开头手动将这些寄存器的值压栈保护,并在返回前恢复。因此,硬件不需要自动保存它们。
这自然是一个约定,如果编译器在生成代码时不遵循 AAPCS,或者用户编写汇编代码时破坏了这些约定,那么硬件机制和软件之间就会产生严重的“脱节”,进而导致一系列从轻微异常到系统崩溃的后果。当然也有好处,硬件少压栈几个,就能更迅速的进入中断和恢复现场,对性能的提升是有益的。
这里有一个反直觉的点,就是通用寄存器看起来不那么通用,仅看中断机制的话,R0和R4显然不对等,因为R4是硬件撒手不管的孩子。如果再往前倒腾几十年,古老的ARM7架构中它们其实是对等的,因为硬件根本不会将通用寄存器自动压栈。那时候很多教人编写RTOS的教程里都会提到,进入ISR第一件事就是通过汇编代码压R0~R12入栈,属于软件上开发者需要手动处理的过程,这在当时正确,而对于Cortex-M来讲已经过时了。一个重要原因是,受限于当时的处理器性能,自动压栈带来的延迟是不能忽略的,硬件上撒手不管,反过来也提供了强大的灵活性,工程师能够精确控制每一个寄存器和时钟周期,进而提升性能。
当然这里也有相应的代价,是在经典 ARM 架构中,发生中断后,CPU 需要先跳转到向量表,然后执行一堆汇编指令来保存现场,这通常需要十几个甚至几十个时钟周期,而且不固定。对于要求极高实时性的用,比如电机控制中的过流保护,这种不可控的软件开销是致命的。而引入了硬件自动压栈机制,将基础上下文的保存时间硬生生压缩到了固定的 12 个时钟周期以内(ARMv8-M)。这种确定性极高的极低延迟,是 Cortex-M 能够在工业控制和汽车电子领域大杀四方的核心武器之一。
在经典的ARM架构中,发生中断时,硬件会产生中断号,同时在内存空间中的某个固定地方,事先存着一个向量表,中断号和向量表中的每个条目都是一一对应的,可以认为,该条目的地址就是中断号,而值则为一条跳转指令(如LDR PC, [PC, #offset])。所以,CPU会响应中断,跳转到这个向量表对应的条目上,执行这一条跳转指令,然后才真正进入到ISR(即PC + #offset所在的位置)。
显然,这里产生了两次跳转。为了优化这一点,现在的向量表里已不再存放跳转指令,而是纯粹的函数指针(即内存地址值)。区别在于:
LDR PC, [PC, #offset])属于正常的程序流,必须像其他所有指令一样,经历完整的流水线步骤:取指 -> 译码 -> 执行。在“执行”阶段,ALU(算术逻辑单元)才会计算出目标地址并更新 PC 寄存器。
它的缺点很明显, 当 CPU 执行一条跳转指令时,由于之前预取(Prefetch)进流水线里的后续指令全都作废了,CPU 必须清空流水线(Pipeline Flush)。这就意味着要白白浪费几个时钟周期等待新的指令重新填满流水线。
此外,执行跳转指令会触发一次或多次的内存操作,看似微乎其微,但如果此时正好发生了外部存储器的等待状态(Wait States),或者与其他 DMA/总线主控设备发生了总线冲突,这段压栈时间就会被进一步拉长,导致极大的抖动(Jitter)。
而当向量表里直接存放函数指针时,NVIC(嵌套向量中断控制器)是一个独立于 CPU 执行流水线的硬件模块,它直接在后台读取向量表中的地址,并通过内部专用的物理连线,强行覆盖/写入 CPU 的 PC 寄存器,这个过程直接绕过了取指和译码阶段。
如果拓展一下,会发现现代 AArch64 架构(ARMv8-A 及以上,比如知名的cortex-A53)的做法更激进,变成了“内联执行代码”,彻底解决间接跳转带来的延迟问题。AArch64 对异常向量表进行了颠覆性的重构:
由于空间足够大,顶层的处理逻辑可以直接内联写在向量表内部,当异常发生时,CPU 会直接执行这个 128 字节的区域内的代码,连一次跳转都没有。同时,这128 字节的巨大空间还有另一个巧妙的好处:避免污染指令缓存(Instruction Cache)。较大的间距确保了未使用的向量条目不会跨越边界去污染典型大小的 Cache Line,从而保证了正在运行的关键中断处理代码能够更稳定、高效地驻留在高速缓存中。
还有一点是,现在也不再固定向量表的位置,而是该表的指针放在一个寄存器中(VTOR) 。这意味着,可以把向量表放在内存的任何位置,比如放在紧耦合区域(访问周期与CPU一致,速度快),这样可以极大提升中断响应速度。
NVIC 的优先级系统是二维的,分为“抢占优先级”和“子优先级”。它们的分工非常明确:
这里有需要注意的一点,中断嵌套不是免费的午餐,它会带来副作用,比如一个常见的情景:低优先级 ISR 正在执行“读-改-写回”操作,被高优先级中断打断,而高优先级 ISR 修改了同一个变量——发生典型的竞态条件,等到高优先级ISR结束返回时,该变量已然改变,最终低优先级ISR可能写回错误值。这种情况十分严重,轻则程序错乱、宕机,重则发生事故、机毁人亡。
此时可以借助多种措施避免在临界区打断,比如BASEPRI 的精细控制,他是 Cortex-M 中的一个寄存器,允许屏蔽掉优先级低于或等于某个阈值的中断,但不会屏蔽更高优先级的中断。BASEPRI 不是一刀切地关掉所有中断,它只屏蔽“不那么紧急”的中断,而优先级数值更小、逻辑优先级更高的中断,依然可以长驱直入,打断当前代码,它做了一个灵活的平衡。被 BASEPRI 屏蔽的中断如果在屏蔽期间产生事件,不会丢失,只是会排队响应。
什么是“尾链”?这绝对是NVIC最精妙的一笔,用来解决“背靠背”中断时的性能浪费。那么,什么是“背靠背”?
在正常流程中,一个中断服务程序刚执行完,已经有另一个中断已经在排队了,按标准流程 CPU 会老老实实地做两件事:
这一出一进,全是冗余操作,白白浪费 CPU 周期。对于 168MHz 的 Cortex-M4 来说,这个完整的“退出-再进入”过程至少需要 12 个时钟周期。尾链的存在,当处理器检测到当前中断即将退出,且此时已经有一个挂起的中断在等待时,硬件会自动跳过“出栈再入栈”的冗余步骤。它不会恢复寄存器,而是直接跳转到下一个中断服务程序的入口地址,开始执行:
这个“跳过”动作将两个中断之间的切换开销从传统的 12 个时钟周期压缩到了 6 个时钟周期。
这里需要说明的一点是,上述尾链机制是 NVIC 的硬件行为,但在非特权模式下,任务代码无法配置或屏蔽中断,这可能间接影响中断响应的可预测性,这是需要额外注意的差异。而所谓的特权模式和非特权模式,是 Cortex-M 处理器实现特权级和用户级两个逻辑状态的底层支持。
Cortex-M 处理器复位后,首先进入的就是特权级线程模式。此时,代码可以访问所有系统资源,包括 NVIC、系统控制块等。这也是大多数裸机程序运行的环境。而通过软件修改 CONTROL 寄存器的第 0 位,可以将线程模式从特权级切换到非特权级(用户级),在这种模式下,代码的权限会受到很大限制,比如不能访问某些系统寄存器,也不能直接开关中断。这正是 RTOS 将用户任务与内核隔离的基础。特权模式本身是一个庞大而精深的议题,涉及处理器状态、权限模型与系统安全边界,值得单开一篇文章来探究,在此按下不表。
回到最初“切菜接电话”的类比,现在看来,从直观感受来讲它的表述也算对,但从精确性上来讲差的很远:它掩盖了背后的一套复杂且精密的硬件自动机——寄存器选择性的入栈、安全世界的隔离、向量表的硬件跳转、尾链的零开销切换。中断从来不是一个简单的“暂停-恢复”,它是计算机体系结构中,硬件、编译器、操作系统三方共同签署的一份复杂契约。写到这儿,其实自己也有些心虚,中断世界的很多机制,其实至今仍在摸索,远谈不上真正理解。参考资料中的图文也尚待系统整理,谨在此做一份阶段性的笔记。中断这个议题,仍然值得在未来花费更多时间和精力去慢慢探究。
未完待续。