前言

上一篇我们用定时器实现了"定时器主动通知 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 为外部中断

  1. 点击芯片图上的 PA0 引脚

  2. 选择 GPIO_EXTI0

  3. 左侧 GPIO 设置:

    • GPIO modeExternal Interrupt Mode with Falling edge trigger detection(下降沿触发)

    • GPIO Pull-up/Pull-downPull-up(内部上拉,省外部电阻)

    • User LabelKEY

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 优先级:合理安排中断优先级,避免低优先级任务被打断重要响应

练习:

  1. 增加第二个按键,配置到 EXTI1(比如 PD1),一个按键切换模式,一个按键控制 LED 开关

  2. 做一个长按检测——按下超过 1 秒执行特殊功能(如快速关灯)

下一篇我们来学习 ADC,让 STM32 能够读取模拟信号——这是示波器项目的核心基础。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐