0%

STM32——红灯持续闪烁,绿灯用外部中断翻转状态

背景:为什么需要中断?

在前一篇按键输入博客中,KEY2 的”按一下翻转”功能用了轮询方式:

1
2
3
4
5
6
// 轮询:CPU 主动、反复地问"按键被按了吗?"
while (1)
{
if (按键被按下) { 处理; }
// 还要做其他事...
}

轮询的缺点很明显——如果 CPU 正在执行某个耗时操作,按键按下后要等到下一个循环才能被检测到,响应不及时。

中断的思路完全不同:

1
2
轮询:   CPU → "按了吗?" → "没按" → "按了吗?" → "没按" → "按了吗?" → "按了!" → 处理
中断: CPU → 干正事 → 干正事 → 干正事 → (按键按下,打断!)→ 处理按键 → 继续干正事

打个比方:

  • 轮询 = 你在写作业,每 5 秒抬头看一眼有没有人敲门
  • 中断 = 你在写作业,门铃响了(中断信号),你去开门,回来继续写

硬件原理

硬件与之前博客完全一样——KEY1(PB12,外部上拉)控制中断触发,红灯(PB0) 和 绿灯(PA7) 作为输出,高电平点亮。

image-20260728110508961

image-20260728110535256


CubeMX 配置

新建项目

芯片选择 STM32F103C8T6,步骤与之前一样,不重复。

配置引脚

引脚 功能 User Label 详细参数
PB0 GPIO_Output LED_RED 推挽输出、无上下拉、低速
PA7 GPIO_Output LED_GREEN 推挽输出、无上下拉、低速
PB12 GPIO_EXTI12 KEY1 下降沿触发中断、无上下拉(外部已有上拉)

关键步骤:配置 PB12 的中断模式

在芯片图上找到 PB12 → 点击 → 选择 GPIO_EXTI12,顺便把user label设置成key1

image-20260728105816402

然后到左侧 GPIO 设置中,把 PB12 的具体参数配好:

image-20260728105913502

注意这里选择的是 External Interrupt Mode with Falling edge trigger detection(外部中断模式,下降沿触发)——按键按下时电平从高变低,正好产生下降沿,与前面的硬件原理匹配.

配置 NVIC(中断控制器)

GPIO 配好后,还需要告诉 NVIC(嵌套向量中断控制器)”我要使用这个中断”。

切换到 System Core → NVIC,找到 EXTI line[15:10] interrupts,在旁边那个小方框里打上勾:

image-20260728110232947

这里有两个参数需要关注:

参数 值 说明
Preemption Priority 15 抢占优先级(数字越小优先级越高)
Sub Priority 0 子优先级

关于优先级的具体原理,后面会单独说明。

配调试接口 & 生成代码

老规矩:System Core → SYS → Debug → Serial Wire,防止烧录一次后 SWD 被锁。

Project Manager 设置:

设置项 值
Project Name Interrupt
Toolchain / IDE CMake

点击 Generate Code,用 VS Code 打开。


代码实现

引脚宏定义(main.h)

CubeMX 根据 User Label 生成了以下宏:

1
2
3
4
5
6
7
8
9
10
11
12
// 绿色 LED — PA7
#define LED_GREEN_Pin GPIO_PIN_7
#define LED_GREEN_GPIO_Port GPIOA

// 红色 LED — PB0
#define LED_RED_Pin GPIO_PIN_0
#define LED_RED_GPIO_Port GPIOB

// 按键 KEY1 — PB12
#define KEY1_Pin GPIO_PIN_12
#define KEY1_GPIO_Port GPIOB
#define KEY1_EXTI_IRQn EXTI15_10_IRQn // 中断号:EXTI 线 15~10

main.c —— 红灯持续闪烁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init(); // 这里会初始化 GPIO 和 NVIC 中断

/* USER CODE BEGIN 2 */

/* USER CODE END 2 */

while (1)
{
/* USER CODE BEGIN WHILE */
// 红灯亮
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_SET);
HAL_Delay(2000); // 保持 2 秒

// 红灯灭
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_RESET);
HAL_Delay(2000); // 熄灭 2 秒
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
}

逻辑很简单:PB0 每 2 秒翻转一次电平,红灯持续闪烁。

主循环里无需任何与按键相关的代码——按键检测完全由中断处理。

stm32f1xx_it.c —— 外部中断服务函数

当 KEY1 按下时,EXTI15_10 中断被触发,CPU 跳转到 EXTI15_10_IRQHandler 执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @brief This function handles EXTI line[15:10] interrupts.
*/
void EXTI15_10_IRQHandler(void)
{
/* USER CODE BEGIN EXTI15_10_IRQn 0 */

HAL_Delay(10); // ① 消抖:等待电平稳定

// ② 确认按键确实被按下(低电平)
if (HAL_GPIO_ReadPin(KEY1_GPIO_Port, KEY1_Pin) == GPIO_PIN_RESET)
{
// ③ 翻转绿灯状态
HAL_GPIO_TogglePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin);
}

/* USER CODE END EXTI15_10_IRQn 0 */

HAL_GPIO_EXTI_IRQHandler(KEY1_Pin); // ④ 清除中断标志位

/* USER CODE BEGIN EXTI15_10_IRQn 1 */

/* USER CODE END EXTI15_10_IRQn 1 */
}

逐行解释:

行号 代码 作用
① HAL_Delay(10) 机械按键按下瞬间会有抖动,电平在几毫秒内反复跳变。等 10ms 让电平稳定下来再读
② HAL_GPIO_ReadPin(...) 再次读取 KEY1 的电平,确认是真的按下了(低电平)。如果只是噪声干扰,不会误触发
③ HAL_GPIO_TogglePin(...) 翻转绿灯 PA7 的电平——原来亮就灭,原来灭就亮
④ HAL_GPIO_EXTI_IRQHandler(KEY1_Pin) 非常重要 清除 EXTI 的中断挂起标志位。如果不清除,中断会反复触发,程序卡死。函数里本身自带,不能删除。

⚠️ HAL_GPIO_EXTI_IRQHandler 的两种写法

上面这个函数内部其实干了三件事:

1
2
3
4
5
6
7
8
void HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin)
{
if (__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) != RESET) // ① 检查中断标志
{
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin); // ② 清除中断标志
HAL_GPIO_EXTI_Callback(GPIO_Pin); // ③ 调用回调函数
}
}

也就是说,你也可以不在 ISR 里直接写逻辑,而是把逻辑写在 HAL_GPIO_EXTI_Callback 里,CubeMX 会在 main.c 中留出 USER CODE BEGIN 4 给你写回调:

1
2
3
4
5
6
7
8
9
/* USER CODE BEGIN 4 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == KEY1_Pin)
{
HAL_GPIO_TogglePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin);
}
}
/* USER CODE END 4 */

MX_GPIO_Init —— GPIO 与 NVIC 初始化

MX_GPIO_Init 函数中,CubeMX 不仅配了 GPIO,还配了 NVIC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};

/* GPIO Ports Clock Enable */
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_GPIOB_CLK_ENABLE();

/* 初始化输出电平 */
HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_RESET);

/* 配置 LED_GREEN (PA7) —— 推挽输出 */
GPIO_InitStruct.Pin = LED_GREEN_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(LED_GREEN_GPIO_Port, &GPIO_InitStruct);

/* 配置 LED_RED (PB0) —— 推挽输出 */
GPIO_InitStruct.Pin = LED_RED_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(LED_RED_GPIO_Port, &GPIO_InitStruct);

/* 配置 KEY1 (PB12) —— 下降沿触发外部中断 */
GPIO_InitStruct.Pin = KEY1_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // ← 下降沿触发中断
GPIO_InitStruct.Pull = GPIO_NOPULL; // ← 外部已有上拉,内部不配
HAL_GPIO_Init(KEY1_GPIO_Port, &GPIO_InitStruct);

/* EXTI 中断初始化——使能 NVIC */
HAL_NVIC_SetPriority(EXTI15_10_IRQn, 15, 0);
HAL_NVIC_EnableIRQ(EXTI15_10_IRQn); // ← 使能 NVIC 中断!
}

关键点:

  1. GPIO_MODE_IT_FALLING —— 配置 PB12 为外部中断模式,下降沿触发
  2. GPIO_NOPULL —— 因为板子已经有外部上拉电阻,不需要内部上拉
  3. HAL_NVIC_EnableIRQ(EXTI15_10_IRQn) —— 使能 NVIC 中的 EXTI15_10 中断通道。这步非常重要,漏了中断不会触发

关于中断优先级

CubeMX 中配置的优先级和实际生效的值之间有一个”编码”过程,理解了这个才能搞清中断嵌套行为。

优先级分组

STM32 的 NVIC 支持优先级分组,决定了抢占优先级和子优先级各占几位:

分组宏 抢占优先级 子优先级 适用场景
NVIC_PRIORITYGROUP_0 0 bit 4 bits 所有中断同级,不能嵌套
NVIC_PRIORITYGROUP_1 1 bit 3 bits 最多 2 级嵌套
NVIC_PRIORITYGROUP_2 2 bits 2 bits 最多 4 级嵌套
NVIC_PRIORITYGROUP_3 3 bits 1 bit 最多 8 级嵌套
NVIC_PRIORITYGROUP_4 4 bits 0 bits 最多 16 级嵌套(最常用)

CubeMX 默认使用 NVIC_PRIORITYGROUP_4——4 位全是抢占优先级,没有子优先级。

本次配置的优先级

中断 PreemptPriority 编码值 实际优先级
SysTick 14 0x00 0(最高)
EXTI15_10 15 0x80 8

数值越小优先级越高。SysTick 的优先级高于 EXTI,所以在 EXTI 中断服务函数中调用 HAL_Delay() 是安全的——SysTick 可以抢占 EXTI 来更新 uwTick 变量,不会死锁。


编译 & 烧录

  1. VS Code 底部工具栏点击齿轮图标 → 生成
  2. 连接 ST-Link
  3. 点击 运行和调试 → 选择 STM32Cube:STM32 Launch STLink GDB Server
  4. 点击继续按钮,程序开始运行

如果遇到烧录问题,参考上一篇或波特律动 FAQ。


效果展示

烧录成功后:

  • 🔴 红灯持续闪烁——每 2 秒亮灭一次
  • 按下 KEY1(PB12) → 🟢 绿灯翻转状态(亮↔灭)
  • 每次按键,绿灯变化一次,红灯不受影响,继续闪烁
  • 如果绿灯和红灯一起亮起,则颜色变化成黄色。

效果展示视频:https://www.bilibili.com/video/BV11e3e6TEGF/


中断 vs 轮询的对比

这个 demo 正好可以和上一篇按键博客做一个对比:

对比项 轮询(上一篇 KEY2) 中断(本篇)
CPU 占用 高(一直在检测) 低(平时干正事,有事件才处理)
响应速度 取决于循环周期 立即响应
代码位置 main 循环里 中断服务函数中
主循环复杂度 按键逻辑和主逻辑混在一起 主循环只关心本职工作
适用场景 低频检测、简单系统 实时性要求高的场景

当然,中断也有代价——每次进入和退出中断都有上下文切换开销(保存现场、恢复现场),并且中断函数要尽量简短,否则会影响其他实时任务的响应。


进阶思路

1. 用回调函数分离逻辑

前面提到过,可以把中断处理逻辑写到 HAL_GPIO_EXTI_Callback 里,中断服务函数保持整洁。当项目中多个引脚共用一个 EXTI 中断线时(比如 EXTI15_10 同时服务 PB12 和 PB13),回调函数里用 GPIO_Pin 区分来源,代码结构更清晰。

2. 中断中只设标志位,主循环处理

标准的嵌入式写法——中断里只设一个 volatile 标志位:

1
2
3
4
5
6
7
8
9
10
volatile uint8_t key1_flag = 0;

void EXTI15_10_IRQHandler(void)
{
if (__HAL_GPIO_EXTI_GET_IT(KEY1_Pin) != RESET)
{
__HAL_GPIO_EXTI_CLEAR_IT(KEY1_Pin);
key1_flag = 1; // 只设标志,不做复杂操作
}
}

主循环检测到标志位置位后,再执行消抖、翻转等操作。这样 ISR 的执行时间最短,对系统实时性的影响最小。

3. 上升沿 + 下降沿双触发

按键按下和松开都产生中断,可以用来检测”长按”和”短按”,或者做更精细的状态控制。

4. 优先级嵌套测试

可以给不同的外设中断配置不同的抢占优先级,观察高优先级中断打断低优先级中断的行为,加深对中断嵌套的理解。


总结

这次的收获:

  1. 外部中断(EXTI)的基本配置方法——CubeMX 里配引脚模式 + NVIC 设置
  2. 中断服务函数的写法——消抖 → 读引脚确认 → 执行逻辑 → 清标志位
  3. 中断优先级——优先级分组、优先级编码、抢占与嵌套的基本概念
  4. CMake 生成器的注意事项——NVIC 配置可能不会自动生成,需要手动检查

中断是嵌入式开发中非常重要的概念,后面学定时器、UART、I2C、SPI 等外设时都会用到中断机制。理解了这个 demo,后面的路会顺畅很多。


参考资源