LV002-异常与中断基本概念
在LV001-STM32中断简介里我们快速过了一遍 STM32 的中断体系,但初学时最容易混淆的两个词——异常(Exception)和中断(Interrupt)——还没有展开讲清楚。这篇笔记就把这两个基本概念掰开揉碎:它们分别是什么、从哪来、什么区别、CPU 又是如何响应它们的。理解了这两个词,后面看 NVIC、向量表、HardFault 排查等内容都会顺畅很多。
一、 为什么需要中断
假设没有中断,单片机想知道"串口有没有收到数据",只有一种办法——轮询(Polling):
/* 轮询方式:CPU 死等标志位 */
while (1)
{
if (USART1->SR & USART_SR_RXNE) /* 反复读状态寄存器 */
{
data = USART1->DR; /* 收到才处理 */
process(data);
}
/* 别的事一件都干不了 */
}轮询的缺点很明显:
- CPU 空转:大部分时间标志位都是 0,白白浪费在"反复查看"上;
- 实时性差:如果还要轮询另一个外设,就得轮流检查,事件可能被耽误;
- 功耗高:CPU 一直在全速跑。
于是硬件设计师换了一种思路:与其让 CPU 反复去问"有事吗",不如让事件主动通知 CPU。平时 CPU 安心执行主程序,事件发生时硬件强制 CPU 暂停手头工作,先去处理紧急事件,处理完再回来继续。这个"强制暂停并跳转"的机制,就是本篇的主角——中断/异常机制。
一句话:中断的本质是把 CPU 从"查询等待"中解放出来,变轮询驱动为事件驱动。
二、 基本概念详解
1. 异常是什么
1.1 异常的定义
在 ARM 的术语体系里,异常(Exception)是所有需要打断处理器正常执行流的事件的统称。也就是说:只要发生了"CPU 不能按原顺序继续执行下去、必须先处理某件事"的情况,这件事就是一个异常。
这个过程和生活中"看电视剧时有人敲门"是一样的:电视剧暂停(当前程序暂停)→ 去开门(处理紧急事件)→ 回来继续看(返回原程序继续执行)。区别只在于,"敲门这件事"到底是 CPU 自己惹出来的,还是外面送进来的:
- CPU 内部产生的(执行指令本身引发的):比如除零、取指访问了非法地址、执行了未定义指令——这类事件称为异常(狭义);
- CPU 外部送来的(片上外设或引脚产生的):比如串口收到数据、定时器溢出、GPIO 引脚电平变化——这类事件称为中断(狭义)。
两者的处理流程几乎完全一样:打断 → 保存现场 → 跳到服务函数 → 恢复现场返回。所以在 ARM 手册里,中断被看作异常的一个子集(详见本节第 3 小节)。
把主程序执行流画出来,两类事件的"打断方式"一目了然:
图中可见两类事件唯一的本质区别是到达方式:同步异常由某条指令引发(重跑必在同一位置复现),异步中断与指令无关、随时可能到来;而打断、保存现场、跳转、恢复返回的响应机制完全一致。
1.2 异常的分类
按不同维度,异常可以这样划分:
(1)按来源分
| 分类 | 来源 | 例子 |
|---|---|---|
| 内部异常(狭义异常) | CPU 执行指令引发 | 除零、未定义指令、地址越界、SVC 系统调用 |
| 外部异常(即中断) | 片上外设/引脚信号 | USART 接收、TIM 更新、EXTI 电平变化 |
(2)按与指令流的同步关系分
| 分类 | 含义 | 例子 |
|---|---|---|
| 同步异常 | 由某条指令的执行直接引起,重跑同一程序必然在同一位置复现 | 未定义指令、BusFault、SVC |
| 异步异常 | 与当前指令无关,随时可能到来 | 外设中断、NMI |
这个分类在排查死机时非常有用:同步异常意味着程序本身有 bug(如野指针),必然可复现;异步异常则要看是哪个外设的事件,方向完全不同(可参考LV001-死机原因与故障定位)。
1.3 Cortex-M 中的系统异常
Cortex-M3 内核定义了 16 个编号(1~15,没有 0 号)的系统异常,它们不是外设事件,而是内核级事件。完整清单如下(也是启动文件里最前面那几个 *_Handler 的来历):
| 异常编号 | 名称 | 优先级 | 说明 |
|---|---|---|---|
| - | (0 号不存在) | - | 向量表第 0 项存的是 MSP 初始值 |
| 1 | Reset | -3(固定,最高) | 复位,程序从这里开始执行 |
| 2 | NMI | -2(固定) | 不可屏蔽中断,如时钟安全系统 CSS 触发 |
| 3 | HardFault | -1(固定) | 硬件异常,所有无法处理的错误的"兜底" |
| 4 | MemManage | 可编程 | 存储器管理异常(MPU 违规等) |
| 5 | BusFault | 可编程 | 总线错误(访问非法地址等) |
| 6 | UsageFault | 可编程 | 用法错误(未定义指令、除零等) |
| 7~10 | Reserved | - | 保留 |
| 11 | SVCall | 可编程 | 执行 SVC 指令触发(系统服务调用) |
| 12 | Debug Monitor | 可编程 | 调试监视器(断点、观察点) |
| 13 | Reserved | - | 保留 |
| 14 | PendSV | 可编程 | 可挂起系统服务,RTOS 常用它做任务切换 |
| 15 | SysTick | 可编程 | 系统滴答定时器,RTOS 的心跳 |
关于这张表的优先级规则、256 个异常编号的完整划分,在LV040-CortexM3异常中已有详细展开,这里只需要建立一个印象:编号 1~15 是内核自己的事(系统异常),编号 16 及以后才是外设的事(中断)。
1.4 常见 Fault 异常的触发场景
实际开发中最常遇到的异常就是各种 Fault(有的资料翻译为"失效/错误"),先混个眼熟:
| Fault | 典型触发场景 |
|---|---|
| HardFault | 野指针/未初始化指针跳转、栈溢出破坏现场、除零(未开启 DIV_0_TRP 时也会升级为 HardFault)、其它 Fault 未使能时"升级"上报 |
| BusFault | 访问不存在的地址(如把外设地址写错)、预取指失败 |
| UsageFault | 执行未定义指令、非对齐访问、除零(使能 DIV_0_TRP 后) |
| MemManage | MPU 规则违反(未配置 MPU 的 F103 一般遇不到) |
遇到 HardFault 不要慌,本质就是"CPU 执行不下去了,跳进了 HardFault_Handler 死循环",把 PC/LR 等现场打印出来即可定位,具体方法见《80-异常与死机排查》系列。
2. 中断是什么
2.1 中断的定义
中断(Interrupt)是由 CPU 外部的事件引起的、请求 CPU 暂停当前程序去处理的服务请求。注意三点:
- 来源在外部:由片上外设(USART、TIM、ADC、DMA……)或外部引脚(EXTI)产生,通过 NVIC 送入内核;
- 异步:什么时候来与 CPU 正在执行什么指令无关;
- 需要处理的服务:来的是"事",CPU 要去执行对应的中断服务函数(ISR)。
在 Cortex-M 的编号体系里,中断 = 编号 ≥ 16 的异常,每个中断对应一个外设事件线,这个编号就是后面 HAL 库里的 IRQn_Type(如 USART1_IRQn = 37)。NVIC 最多支持 240 个中断输入,具体数目由芯片厂商裁剪决定,STM32F103 只用了 60 个。
2.2 中断的分类
(1)按请求源分
| 分类 | 说明 | 例子 |
|---|---|---|
| 片上外设中断 | 芯片内部外设的事件,通过 NVIC 统一管理 | USART1_IRQn、TIM2_IRQn、DMA1_Channel1_IRQn |
| 外部引脚中断(EXTI) | 芯片引脚电平变化,经 EXTI 送入 NVIC | 按键按下(EXTI0_IRQn) |
注意"外部中断(EXTI)"这个词指的是引脚来的中断,是"片上外设中断"的子集,不要和"中断=外部事件"这种广义说法混淆。
(2)按能否屏蔽分
| 分类 | 说明 |
|---|---|
| 可屏蔽中断(IRQ) | 绝大多数中断,可以通过 PRIMASK/BASEPRI/NVIC 使能位屏蔽 |
| 不可屏蔽中断(NMI) | 优先级固定为 -2,无法屏蔽,来了必须响应,如 F103 的时钟安全系统 CSS |
(3)按触发方式分
| 触发方式 | 说明 | 适用场景 |
|---|---|---|
| 边沿触发 | 信号出现上升沿/下降沿时置位请求 | 按键、脉冲捕获 |
| 电平触发 | 信号保持有效电平期间持续请求 | 需要电平同步的场合 |
Cortex-M 的 NVIC 中断输入是电平敏感的(悬起寄存器把请求保存下来),STM32 的 EXTI 则可配置为上升沿/下降沿/双边沿触发。
2.3 中断相关术语详解
这一小节是重点。中断系统术语多,很多资料一带而过,这里逐个说透:
| 术语 | 英文/寄存器 | 含义 |
|---|---|---|
| 中断源 | - | 产生中断事件的源头(某个外设、某根引脚) |
| 中断请求 | IRQ(Interrupt Request) | 中断源向 CPU 发出的"请处理我"的信号 |
| 中断号(IRQn) | IRQn_Type | NVIC 给每个中断的编号,从 0 开始;异常号 = 中断号 + 16 |
| 异常号 | IPSR 寄存器可读 | 向量表中的位置编号,系统异常 1~15,中断从 16 起 |
| 中断标志位 | 外设状态寄存器(如 SR) | 外设自己记录"事件发生了",与 NVIC 无关,需软件清除 |
| 悬起(挂起) | Pending(ISPR/ICPR) | 请求已送达 NVIC 但尚未被响应的"排队"状态,即使源信号消失也不会丢 |
| 中断使能 | SETENA/CLRENA | NVIC 中控制某中断是否被允许响应的开关,两级:全局(PRIMASK)+ 单个中断 |
| 中断向量 | Vector | 该中断服务函数的入口地址 |
| 中断向量表 | VTOR 指向的表 | 按异常号顺序排列的所有向量的表格,启动文件 __Vectors 定义 |
| 中断服务函数 | ISR(Interrupt Service Routine) | 响应中断后 CPU 去执行的函数,如 USART1_IRQHandler |
| 现场保护/恢复 | 自动压栈/出栈 | 保存被打断时的寄存器状态,以便处理完后原样恢复 |
| 中断嵌套 | Nesting | 高抢占优先级中断可以打断正在执行的低优先级中断 |
| 临界区 | Critical Section | 主程序与 ISR 都会访问的共享数据区域,需要关中断等手段保护 |
其中三个最容易混:中断标志位(外设记录事件,要手动清)、悬起状态(NVIC 记录"排队中",响应后自动清,也可软件清)、使能位(NVIC 的响应开关)。一次完整的中断传递是:
外设事件发生 → 外设状态寄存器置标志位 → 中断输出线送 NVIC(若外设中断使能)
→ NVIC 置悬起位 → 优先级允许则响应(悬起位自动清除)→ 执行 ISR → ISR 内清外设标志位漏了最后一步"清外设标志位",退出 ISR 后会立刻再次进入,程序表现为"死在中断里"。
3. 异常与中断的关系和区别
3.1 包含关系
用一张图表示 Cortex-M 中两者的关系:
- 广义上:异常 = 系统异常 + 外部中断,中断是异常的子集;
- 狭义上(日常口语区分):异常指内核内部事件,中断指外设请求事件。
3.2 对比表
| 对比项 | 异常(狭义) | 中断(狭义) |
|---|---|---|
| 产生者 | CPU 内部(指令执行引发) | CPU 外部(外设/引脚) |
| 同步性 | 同步为主,与指令绑定 | 异步,与指令无关 |
| 编号 | 1~15 | ≥16(IRQn = 异常号 - 16) |
| 可屏蔽性 | NMI/HardFault 等不可屏蔽 | 一般均可屏蔽 |
| 服务函数名 | HardFault_Handler、SVC_Handler 等 | USART1_IRQHandler、EXTI0_IRQHandler 等(带 _IRQ) |
| 典型例子 | 除零、非法地址、SVC 调用 | 串口接收、定时器溢出、按键 |
| 谁的责任 | 多为软件 bug,可复现排查 | 多为正常业务事件,需设计处理 |
3.3 为什么经常统称"中断"
因为两者的响应机制完全一致(保存现场 → 查向量表 → 执行服务函数 → 恢复现场),CPU 硬件并不区分处理,日常交流说"进中断了"往往把 HardFault 也包含在内。但在读 ARM 官方手册时必须严谨:手册中的 Interrupt 特指 IRQ,Exception 才是统称。
3.4 常见疑问澄清
HardFault_Handler是中断服务函数吗? 习惯上叫,但严格说它是异常服务函数——看名字没有_IRQ后缀;- SysTick 是中断还是异常? 它是 15 号系统异常,但因为行为和中断一样(周期性打断 CPU),习惯都叫"SysTick 中断";
- 为什么启动文件里中断函数名是固定的? 因为向量表里存的就是这些名字对应的地址,名字写错向量就指向了默认的死循环(
Default_Handler),表现为"中断开了但没反应"。
3.5 扩展:其它体系结构的叫法
如果以后接触 x86 或其它内核,会看到 Trap(陷阱)、Fault(故障)、Abort(中止)等词,它们本质上都是"异常的细分类型"。Cortex-M 简化了这套模型,只保留 Fault + IRQ + 少量系统异常,理解了 Cortex-M 的分类,再看其它架构就是换个名字而已。
三、 中断响应机制
先看全景:一次中断从外设到达 CPU,要经过"中断源 → NVIC → 内核 → 向量表"这条硬件链路:
1. 中断被响应的条件
一个中断要被响应,需要同时满足:
(1)全局中断使能:PRIMASK 未关中断(__disable_irq() 就是置位 PRIMASK);
(2)该中断在 NVIC 中已使能:SETENA 对应位为 1(HAL_NVIC_EnableIRQ() 干的事);
(3)优先级允许:没有同级或更高优先级的异常正在执行;
(4)指令边界:当前指令执行完毕(中断不能打断一条执行到一半的指令)。
四者缺一,中断就进入悬起状态排队等待。
2. 响应的五个阶段
(1)事件检出:NVIC 收到请求,置悬起位;
(2)优先级裁决:若同时有多个悬起,先响应优先级最高的;
(3)现场保护(自动压栈):硬件自动把 8 个寄存器压入当前栈(主栈 MSP 或进程栈 PSP):{PC, xPSR, R0-R3, R12, LR}——R4~R11 不自动保存,所以 ISR 中若用到,由编译器插入的代码负责保存;
(4)取向量:从向量表中取出该异常号对应的服务函数入口地址(NVIC 的"V"就是 Vector,硬件自动查表,无需软件判断来源,这也是"向量化中断"相比"非向量化中断"快的原因);
(5)跳转执行 ISR:PC 被装载为服务函数地址,开始执行我们写的代码。
把这条链路画成流程图,配合上面的架构图对照着看:
3. 异常返回
ISR 执行完最后一条 BX LR 时,由于进入异常时 LR 被硬件改写为特殊的 EXC_RETURN 值(0xFFFFFFF1/F9/FD 之一),CPU 识别到它就触发异常返回序列:自动出栈恢复 8 个寄存器,继续执行被打断的代码。所以 ISR 在 C 语言里就是一个普通函数,不需要写任何特殊返回语句。
4. 两个性能优化概念
Cortex-M3 为了缩短中断响应还设计了两个机制,这里只留个印象(详见《CM3权威指南》):
- 晚到中断:压栈尚未完成时来了更高优先级中断,先响应后来者,现场无需保存两遍;
- 咬尾中断(Tail-Chaining):一个 ISR 刚结束又有一个悬起中断要处理时,不再出栈再压栈,直接"咬"着处理下一个,节省约 12 个周期。
5. 优先级与中断嵌套
多个中断"抢"CPU 时靠优先级裁决,规则只有四条:
(1)数值越小,优先级越高;复位(-3)> NMI(-2)> HardFault(-1)> 其它(0 起可编程);
(2)优先级分为抢占优先级和子优先级两段(分组决定各占几位);
(3)只有抢占优先级高才能打断正在执行的中断(中断嵌套);子优先级只在"多个中断同时悬起排队"时决定先响应谁,不能引发嵌套;
(4)抢占和子优先级都相同时,异常编号小的先响应。
STM32F103 只用了 4 位表达优先级(16 级),分组配置与 HAL 库函数的用法在LV001-STM32中断简介第 4.3、4.4 节有完整实例;NVIC 各控制寄存器的细节见LV045-CortexM3的NVIC中断控制。
四、 实践与总结
1. 使用中断的注意事项
概念清楚了,写代码时还要遵守几条实践原则:
(1)ISR 尽量短小。中断里只做"取数据、置标志",耗时的解析、打印、延时放到主循环处理。ISR 拖得太长会阻塞低优先级中断,实时性反而变差。
(2)主程序与 ISR 共享的变量必须加 volatile,否则编译器优化(比如把变量缓存在寄存器里)会让主循环"看不见"ISR 的修改。
volatile uint8_t rx_done = 0; /* 少了 volatile,主循环可能永远读不到 1 */
void USART1_IRQHandler(void)
{
/* ... */
rx_done = 1;
}
int main(void)
{
/* ... */
while (1)
{
if (rx_done) { /* 处理 */ rx_done = 0; }
}
}(3)访问共享数据要进临界区。主程序修改多字节共享数据前关中断,改完再开,防止改到一半被 ISR 打断:
__disable_irq();
shared_var = new_value; /* 原子地完成修改 */
__enable_irq();(4)及时、正确地清除标志位。清标志的顺序也有讲究:一般先清标志再做后续处理,且要按参考手册要求的方式清(写 0 清、写 1 清、读 SR 后写 DR 清各有不同),漏清会反复进中断,清错会丢中断。
(5)注意栈大小。每层中断嵌套都要在栈上压入至少 32 字节现场,加上 ISR 局部变量,启用嵌套多的工程要给栈留足余量(MSP 初始值在启动文件开头定义)。
(6)HAL 库的中断流程要认全。以串口为例,中断入口是向量表指定的 USART1_IRQHandler,真正的处理在 HAL 库内分两层:
void USART1_IRQHandler(void) /* ① 向量表指定的入口 */
{
HAL_UART_IRQHandler(&huart1); /* ② HAL 统一处理:判断标志、清标志 */
} /* ③ 再回调用户代码,如 HAL_UART_RxCpltCallback() */入口函数名必须与启动文件一致,业务逻辑写在回调里,不要直接在 IRQHandler 里既清标志又写业务,容易和 HAL 的清标志动作打架。
2. 要点小结
- 异常是统称:一切打断 CPU 正常执行流的事件;中断是子集:编号 ≥16、来自外设的那部分;
- 狭义区分看来源:内部指令引发的叫异常(Fault、SVC……),外部事件请求的叫中断(USART、EXTI……);
- 一次完整传递:外设置标志 → NVIC 悬起 → 优先级裁决 → 自动压栈 → 查向量表 → 执行 ISR → 清标志 → EXC_RETURN 返回;
- 记住三个"记录员":外设标志位(软件清)、NVIC 悬起位(响应后自动清)、使能位(响应开关);
- 实践口诀:ISR 短小、共享加
volatile、临界区保护、标志位及时清。
相关阅读:
- LV001-STM32中断简介:STM32F103 中断体系、优先级分组与 HAL 库配置;
- LV040-CortexM3异常:CM3 异常编号、优先级位段与向量表详解;
- LV045-CortexM3的NVIC中断控制:NVIC 使能/悬起/优先级寄存器详解;
- LV001-死机原因与故障定位:从异常现场排查死机问题。