STM32F407 从零上路 ④:外部中断与按键消抖
前言
上一篇我们用定时器实现了"定时器主动通知 CPU"的机制。这一篇反过来——让外部设备主动通知 CPU,这就是中断的另一种形式:外部中断(EXTI)。
按键是最常见的外部输入,但按键有机械抖动问题,处理不好会导致一次按下触发好几次。本文将同时解决这两个问题。
一、STM32 的中断体系
先理清几个概念:
外部事件(按键按下) ↓ EXTI(外部中断控制器) ← 检测到边沿(上升/下降沿) ↓ NVIC(嵌套向量中断控制器) ← 判断优先级,决定是否响应 ↓ CPU 暂停当前任务 → 跳转中断服务函数 → 执行完后返回
EXTI 负责检测引脚边沿。 NVIC 负责优先级管理——高优先级中断可以打断低优先级中断。
STM32F407 的 EXTI 特点
-
支持 16 个 GPIO 线(EXTI0~EXTI15)
-
每个 EXTI 线可以配置为上升沿触发、下降沿触发或双边沿触发
-
同一时间,一个 EXTI 线只能连接一个 GPIO 引脚
-
例如:EXTI0 只能接 PA0、PB0、PC0……中的一个
二、硬件:按键电路
最简单的接法:
PA0 ────┬──── 按键 ──── GND │ [10kΩ] │ VDD(3.3V)
或者更省元件的接法(用内部上拉):
PA0 ──── 按键 ──── GND (内部上拉打开)
按键按下 → PA0 接 GND → 低电平 按键松开 → PA0 被内部上拉拉高 → 高电平
所以我们配置下降沿触发(高→低跳变时产生中断)。
三、CubeMX 配置
3.1 配置 GPIO 为外部中断
-
点击芯片图上的 PA0 引脚
-
选择 GPIO_EXTI0
-
左侧 GPIO 设置:
-
GPIO mode:External Interrupt Mode with Falling edge trigger detection(下降沿触发)
-
GPIO Pull-up/Pull-down:Pull-up(内部上拉,省外部电阻)
-
User Label:
KEY
-
3.2 使能 NVIC
切换到 NVIC 标签 → 展开 EXTI line0 → 勾选 Enabled
优先级分组默认(4 bits 抢占优先级),你可以调整 Preemption Priority,越小优先级越高。 注意: NVIC_PriorityGroup_4(4-bit 抢占优先级)下没有子优先级概念——所有 4 位全部分配给抢占优先级,同级中断无法按子优先级排队。如需子优先级,请改为 NVIC_PriorityGroup_2(2 位抢占 + 2 位子优先级),CubeMX 中在 NVIC 标签页顶部 "Priority Group" 下拉框修改。
3.3 生成代码
Ctrl+S → GENERATE CODE
四、代码:最简单的按键中断
4.1 启动中断
CubeMX 自动生成了 EXTI 初始化,你只需要在 main() 中写回调(不需要调任何 Start 函数)。
4.2 添加回调函数
在 /* USER CODE BEGIN 4 */ 区添加:
/* USER CODE BEGIN 4 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == KEY_Pin) // KEY_Pin 是 CubeMX 根据 User Label 生成的宏
{
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}
}
/* USER CODE END 4 */
按一下按键,LED 翻转一次——理想中是这样的。
4.3 实际发生了什么
如果你下载程序测试,会发现按一次按键,LED 可能翻转 2~3 次甚至更多次。
原因:机械抖动。
五、按键消抖
5.1 什么是抖动?
按键内部是金属弹片,按下时不是一下子稳定接触,而是会在几毫秒内反复弹跳:
电平: ████▁▁▁▁▁███▁▁▁████▁▁▁▁▁▁▁▁▁▁▁▁████████ ↑ 弹跳区(5~20ms) 按下 释放
每次弹跳都会产生一次边沿跳变,所以中断被触发了多次。
5.2 消抖方案对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| 硬件消抖(RC 低通滤波) | 不占 CPU | 多两个元件 |
| 延时消抖(delay 后读) | 简单 | 阻塞 CPU |
| 定时器扫描 + 状态机 | 稳定、不阻塞 | 代码稍复杂 |
| 中断 + 定时器确认 | 响应快、稳定 | 需要配合定时器 |
对于入门阶段,延时消抖 + 中断标志 最容易理解:
5.3 方案一:延时消抖(简单版)
volatile uint8_t key_pressed = 0;
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == KEY_Pin)
{
// 关中断,延时跳过抖动期
// 注意:不要在中断里 HAL_Delay!
key_pressed = 1; // 只置标志位
}
}
// 在主循环中处理
while (1)
{
if (key_pressed)
{
key_pressed = 0;
HAL_Delay(20); // 等 20ms 跳过抖动
// 再次确认按键确实被按下了
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET)
{
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}
// 等按键松开
// ⚠️ 阻塞式等待,CPU 在此期间无法执行其他任务,实际项目请用状态机方案
while (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET);
}
}
但 HAL_Delay(20) 在主循环中阻塞 20ms,如果有其他实时任务就不太好。
5.4 方案二:定时器状态机扫描(推荐)
核心思路:
-
不用中断,用定时器每隔 10ms 扫描一次按键
-
连续两次读到同一状态才认为是有效变化(跳过 10ms 以内的抖动)
// 按键状态机
#define KEY_IDLE 0
#define KEY_PRESSED 1
#define KEY_CONFIRM 2
#define KEY_RELEASE 3
uint8_t key_state = KEY_IDLE;
uint8_t last_level = 1; // 上一次读到的电平(默认高,因为上拉)
// TIM2 中断回调中每 10ms 调用一次
void Key_Scan(void)
{
uint8_t level = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);
// 实际使用可以读两次取稳定的值,这里简化为一次
switch (key_state)
{
case KEY_IDLE:
if (level == 0) // 首次检测到低电平
key_state = KEY_PRESSED;
break;
case KEY_PRESSED:
if (level == 0) // 连续两次低 → 确认按下
{
key_state = KEY_CONFIRM;
// 在这里执行按键动作
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}
else // 这次读到高 → 刚才的"按下"是抖动
key_state = KEY_IDLE;
break;
case KEY_CONFIRM:
if (level == 1) // 检测到松开
key_state = KEY_RELEASE;
break;
case KEY_RELEASE:
if (level == 1) // 连续两次高 → 确认松开
key_state = KEY_IDLE;
else
key_state = KEY_CONFIRM;
break;
}
}
然后在定时器中断回调中调用:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2)
{
Key_Scan(); // 每 10ms 扫描一次按键
}
}
这种方法不阻塞 CPU,10ms 扫描一次的开销几乎可以忽略,而且稳定的 10ms 间隔天然滤除了抖动。
六、中断优先级——谁更重要?
如果系统里同时有多个中断(定时器、串口、EXTI),它们的优先级怎么安排?
在 NVIC 中可以设置:
| 中断源 | 抢占优先级 | 子优先级 | 说明 |
|---|---|---|---|
| TIM2(定时器) | 1 | 0 | 比较重要,但可以被打断 |
| USART1(串口) | 1 | 1 | 和 TIM2 同级,谁先来谁执行 |
| EXTI0(按键) | 0 | 0 | 最高,需要快速响应 |
| EXTI1(紧急停止) | 0 | 1 | 与 EXTI0 同抢占级 |
-
抢占优先级数字越小,优先级越高
-
高抢占级可以打断低抢占级
-
同级按子优先级排队,不能互相打断
在 CubeMX 的 NVIC 标签中,每个中断都可以独立调整。
七、完整示例:按键切换 LED 模式
typedef enum {
MODE_BLINK_SLOW = 0,
MODE_BLINK_FAST,
MODE_BREATHING,
MODE_ON,
MODE_OFF,
MODE_COUNT
} led_mode_t;
led_mode_t current_mode = MODE_BLINK_SLOW;
uint16_t pwm_duty = 0;
uint8_t pwm_dir = 1;
volatile uint8_t key_pressed = 0; // 按键按下标志
// 按键回调(仅置标志位,不直接执行操作)
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == KEY_Pin)
{
key_pressed = 1; // 只置标志位,消抖和逻辑在主循环处理
}
}
// 主循环根据模式执行不同行为
while (1)
{
// --- 按键消抖处理 ---
if (key_pressed)
{
key_pressed = 0;
HAL_Delay(20); // 等待 20ms 跳过机械抖动期
// 再次确认按键确实被按下(消抖核心步骤)
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET)
{
current_mode = (current_mode + 1) % MODE_COUNT;
}
}
// --- 按键处理结束 ---
switch (current_mode)
{
case MODE_BLINK_SLOW:
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
HAL_Delay(500);
break;
case MODE_BLINK_FAST:
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
HAL_Delay(100);
break;
case MODE_BREATHING:
// PWM 呼吸(假设开启了定时器 PWM)
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, pwm_duty);
if (pwm_dir) pwm_duty += 5;
else pwm_duty -= 5;
if (pwm_duty >= 1000) pwm_dir = 0;
if (pwm_duty == 0) pwm_dir = 1;
HAL_Delay(10);
break;
case MODE_ON:
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
break;
case MODE_OFF:
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET);
break;
}
}
按一下按键切换一种模式——不算复杂,但效果很好,适合在开发板上展示。
八、常见坑
| 问题 | 原因 |
|---|---|
| 按键没反应 | EXTI 线是不是和别的引脚冲突了?EXTI0 只能接一组 PA0/PB0/PC0…… |
| 一直触发中断 | 没跟上拉,引脚悬空电平不确定 |
中断里调 HAL_Delay() 死机 |
中断里不要调 HAL_Delay(改在主循环处理) |
| 按一次触发多次 | 按键机械抖动产生多个下降沿,需要消抖处理 |
HAL_GPIO_EXTI_Callback 没被调用 |
检查 NVIC 是否使能了 |
九、总结
-
EXTI 让 STM32 对外部事件做出实时响应
-
按键抖动是机械器件的物理特性,必须消抖
-
消抖方案:延时法简单粗暴,定时器状态机法更优雅
-
NVIC 优先级:合理安排中断优先级,避免低优先级任务被打断重要响应
练习:
-
增加第二个按键,配置到 EXTI1(比如 PD1),一个按键切换模式,一个按键控制 LED 开关
-
做一个长按检测——按下超过 1 秒执行特殊功能(如快速关灯)
下一篇我们来学习 ADC,让 STM32 能够读取模拟信号——这是示波器项目的核心基础。
更多推荐


所有评论(0)