中断系统(NVIC 与 EXTI)¶
中断是嵌入式系统的灵魂。没有中断,CPU 只能不停地轮询检查事件是否发生,效率极低。有了中断,CPU 可以专心做别的事,当事件发生时硬件自动通知 CPU 去处理。
一、什么是中断?¶
生活中的类比¶
想象你在写作业(主程序),突然手机响了(中断请求):
- 你放下笔,记住写到哪了(保存现场 / 压栈)
- 接电话处理事情(执行中断服务函数)
- 挂电话,继续写作业(恢复现场 / 出栈)
这就是中断的完整过程。
中断的基本流程¶
graph LR
A[主程序运行] --> B{中断请求?}
B -- 否 --> A
B -- 是 --> C[保存现场 压栈]
C --> D[执行中断服务函数 ISR]
D --> E[恢复现场 出栈]
E --> A
中断源分类¶
STM32 的中断源可以分为两大类:
| 分类 | 来源 | 示例 |
|---|---|---|
| 内核中断(异常) | Cortex-M3 内核 | Reset、NMI、HardFault、SysTick |
| 外部中断 | 片上外设 | EXTI、TIM、USART、ADC、DMA、SPI、I2C |
二、NVIC(嵌套向量中断控制器)¶
NVIC 是什么?¶
NVIC(Nested Vectored Interrupt Controller)是 Cortex-M 内核的一部分,负责管理所有中断:
- 决定哪个中断可以打断当前程序
- 决定多个中断同时发生时谁先执行
- 管理中断的使能和禁止
中断优先级¶
STM32 使用 4 位优先级(0~15),分为两个维度:
| 维度 | 说明 | 特点 |
|---|---|---|
| 抢占优先级(Preemption) | 高抢占优先级可以打断低抢占优先级的中断 | 可以嵌套 |
| 子优先级(Sub-priority) | 抢占优先级相同时,决定谁先响应 | 不能嵌套,只影响排队顺序 |
数值越小 = 优先级越高
优先级 0 比优先级 15 更高!这是初学者最容易搞混的地方。
优先级分组¶
4 位优先级如何分配给抢占和子优先级?通过 优先级分组 来决定:
| 分组 | 抢占优先级位数 | 子优先级位数 | 抢占级范围 | 子优先级范围 |
|---|---|---|---|---|
| 0 | 0 位 | 4 位 | 0 | 0~15 |
| 1 | 1 位 | 3 位 | 0~1 | 0~7 |
| 2 | 2 位 | 2 位 | 0~3 | 0~3 |
| 3 | 3 位 | 1 位 | 0~7 | 0~1 |
| 4 | 4 位 | 0 位 | 0~15 | 0 |
推荐使用分组 2
分组 2(2 位抢占 + 2 位子优先级)是最常用的配置,提供 4 级抢占和 4 级子优先级,够用且灵活。
中断响应规则¶
当多个中断同时发生时,按以下规则处理:
- 抢占优先级不同 → 高抢占优先级先执行(可以打断低优先级的中断)
- 抢占优先级相同,子优先级不同 → 子优先级高的先执行(但不能打断)
- 两者都相同 → 按中断号(硬件编号)排序,编号小的先执行
举例
假设使用分组 2,有三个中断:
- 中断 A:抢占 = 1,子 = 0
- 中断 B:抢占 = 0,子 = 3
- 中断 C:抢占 = 1,子 = 2
响应顺序:B 优先(抢占最高=0),A 和 C 不能互相打断但 A 先响应(子优先级更高=0)。
若 A 正在执行,B 可以打断 A(抢占优先级更高),但 C 不能打断 A(抢占相同)。
三、EXTI(外部中断/事件控制器)¶
EXTI 是什么?¶
EXTI(External Interrupt/Event Controller)用于检测 GPIO 引脚上的电平变化,触发中断或事件。
典型应用:按键按下检测、传感器信号触发、外部脉冲计数。
EXTI 与 GPIO 的映射关系¶
STM32 有 16 条 EXTI 线(EXTI0 ~ EXTI15),每条线对应一个引脚编号:
EXTI0 ← PA0 / PB0 / PC0 / PD0 ...(同一时刻只能选一个)
EXTI1 ← PA1 / PB1 / PC1 / PD1 ...
EXTI2 ← PA2 / PB2 / PC2 / PD2 ...
...
EXTI15 ← PA15 / PB15 / PC15 ...
重要限制
同一编号的引脚只能有一个连接到 EXTI。比如 PA0 和 PB0 不能同时使用外部中断,因为它们共用 EXTI0。
触发方式¶
| 触发方式 | 含义 | 使用场景 |
|---|---|---|
| 上升沿触发 | 电平从低变高时触发 | 按键释放、脉冲上升边 |
| 下降沿触发 | 电平从高变低时触发 | 按键按下(低电平有效) |
| 双边沿触发 | 上升沿和下降沿都触发 | 编码器信号检测 |
EXTI 的中断服务函数¶
EXTI 线共用部分 IRQ(中断请求通道):
| EXTI 线 | 对应的 IRQ Handler | 说明 |
|---|---|---|
| EXTI0 | EXTI0_IRQHandler |
独占 |
| EXTI1 | EXTI1_IRQHandler |
独占 |
| EXTI2 | EXTI2_IRQHandler |
独占 |
| EXTI3 | EXTI3_IRQHandler |
独占 |
| EXTI4 | EXTI4_IRQHandler |
独占 |
| EXTI5~9 | EXTI9_5_IRQHandler |
5 条共用,需在函数内判断是哪条 |
| EXTI10~15 | EXTI15_10_IRQHandler |
6 条共用,需在函数内判断是哪条 |
四、EXTI 编程实战(CubeMX + HAL)¶
外部中断检测按键¶
配置 PA0 的下降沿触发中断,按下按键翻转 PC13 LED:
CubeMX 配置步骤:
- PC13 →
GPIO_Output(LED,推挽输出) - PA0 →
GPIO_EXTI0(外部中断模式) - PA0 的 GPIO 配置:
- GPIO mode: External Interrupt Mode with Falling edge trigger detection(下降沿触发)
- GPIO Pull-up/Pull-down: Pull-up(上拉,默认高电平)
- 在 NVIC 标签页中勾选 EXTI line0 interrupt 并设置优先级
- 在 System Core → NVIC 中设置优先级分组为 2 bits for pre-emption
- 生成代码
CubeMX 会自动生成初始化代码(gpio.c 和 NVIC 配置)。你只需要实现 回调函数:
/* 在 main.c 中(USER CODE 区域)编写回调函数 */
/* USER CODE BEGIN 4 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_0) // 确认是 PA0 触发的
{
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); // 翻转 LED
}
}
/* USER CODE END 4 */
HAL 中断处理流程
HAL 库的中断处理分为两层:
- IRQ Handler(
stm32f1xx_it.c中,CubeMX 自动生成):调用HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0) - HAL 内部处理:自动判断中断标志、清除标志位、调用回调函数
- 回调函数(你来实现):
HAL_GPIO_EXTI_Callback()
你只需要写第 3 步,标志位的判断和清除 HAL 已经帮你处理了!
graph LR
A[中断发生] --> B["EXTI0_IRQHandler()<br>(stm32f1xx_it.c)"]
B --> C["HAL_GPIO_EXTI_IRQHandler()<br>(HAL 内部)"]
C --> D[检查 & 清除中断标志]
D --> E["HAL_GPIO_EXTI_Callback()<br>(你来实现)"]
CubeMX 生成的中断相关代码¶
stm32f1xx_it.c 中(自动生成,不需修改):
gpio.c 中的初始化(自动生成):
void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();
/* LED */
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); // 初始灭
GPIO_InitStruct.Pin = GPIO_PIN_13;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
/* 按键 - 外部中断 */
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // 下降沿触发中断
GPIO_InitStruct.Pull = GPIO_PULLUP; // 上拉
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
/* NVIC */
HAL_NVIC_SetPriority(EXTI0_IRQn, 1, 1); // 抢占1,子优先级1
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}
不需要手动开启 AFIO 时钟
在 HAL 库中,HAL_GPIO_Init() 设置 EXTI 模式时会自动处理 AFIO 相关的配置,你不需要手动操作。
五、中断编程三要素(HAL 版)¶
总结一下,使用 CubeMX + HAL 配置中断的流程:
graph LR
A["1. CubeMX 配置外设<br>使能中断"] --> B["2. CubeMX 配置 NVIC<br>设置优先级"]
B --> C["3. 实现回调函数<br>处理逻辑"]
要素一:CubeMX 配置外设并使能中断¶
在对应外设的配置页面中勾选中断使能:
| 外设 | CubeMX 操作 |
|---|---|
| EXTI | 引脚设置为 GPIO_EXTIx 模式 |
| TIM | Parameter Settings → 使能,NVIC 标签页勾选中断 |
| USART | NVIC Settings 标签页中勾选中断 |
| ADC | 启用 DMA 或在 NVIC 中勾选中断 |
要素二:配置 NVIC 优先级¶
在 System Core → NVIC 中设置:
- 优先级分组(全局只设置一次)
- 各中断的抢占优先级和子优先级
CubeMX 生成的代码中会调用:
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2); // 分组(main.c 中)
HAL_NVIC_SetPriority(EXTI0_IRQn, 1, 1); // 设置优先级
HAL_NVIC_EnableIRQ(EXTI0_IRQn); // 使能中断
要素三:实现回调函数¶
HAL 库为每种外设提供了对应的回调函数(__weak 弱定义),你只需要重写它:
| 外设 | 回调函数 |
|---|---|
| EXTI | HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) |
| TIM | HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) |
| UART | HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) |
| ADC | HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) |
HAL 回调机制的优势
- 不需要手动判断和清除中断标志位(HAL 内部已处理)
- 回调函数名统一、规范,不容易写错
- 多个相同外设可以在回调函数中通过 handle 指针区分
回调函数中仍然要快进快出
虽然 HAL 简化了中断处理,但回调函数本质上还是在中断上下文中执行。应避免在回调中做耗时操作,推荐用标志位通知主循环处理。
六、常见问题¶
中断回调函数写在哪个文件里?
推荐写在 main.c 的 /* USER CODE BEGIN 4 */ 区域,或者单独创建一个 callback.c 文件。不要写在 stm32f1xx_it.c 中(会被 CubeMX 覆盖)。
中断中可以调用 HAL_Delay 吗?
不可以。HAL_Delay() 依赖 SysTick 中断,如果你的中断优先级高于 SysTick(默认最低优先级 15),会导致 SysTick 无法更新计数器,HAL_Delay() 卡死。如需延时操作,应在中断中设置标志位,在主循环中处理。
中断 vs 轮询,怎么选?
- 中断:适合事件不频繁、实时性要求高的场景(按键、通信接收)
- 轮询:适合事件非常频繁、处理简单的场景(高速 ADC 连续采集)
- 一般原则:能用中断就用中断,CPU 效率更高