4. 按键输入:按键消抖原理、轮询方式读取按键、中断方式读取按键

大家好,我是你们的嵌入式讲师。今天我们来聊聊按键输入。按键这东西,看着简单,但坑不少。我刚开始做项目时,就因为在按键处理上偷懒,结果产品在客户手里频繁误触发,被骂得狗血淋头。所以,这一节咱们把按键的底裤都扒干净。

4.1 按键消抖原理:为什么按键会“抖”?

你按下一个机械按键,是不是觉得“咔哒”一下就到位了?其实不是。在微观层面,金属触点接触时会有弹性碰撞,产生一连串的“毛刺”信号。这个过程通常持续5-20ms。如果你直接读这个信号,MCU会以为你按了十几次。

核心结论:按键抖动是物理现象,不是BUG。我们的任务就是把这5-20ms的抖动期“过滤掉”。

消抖方法主要有两种:硬件消抖和软件消抖。硬件消抖加电容、加施密特触发器,成本高。我们做嵌入式,能用软件解决的,绝不加硬件。所以,重点讲软件消抖。

软件消抖的原理很简单:延时 + 多次采样。检测到电平变化后,等10-20ms再读一次。如果电平稳定,才认为是有效按键。

我的经验:我个人习惯用10ms延时。为什么?因为大部分按键的抖动时间在5-10ms,10ms刚好能覆盖。太短了消不干净,太长了影响响应速度。

4.2 轮询方式读取按键:简单粗暴,但够用

轮询,说白了就是主循环里不停地读IO口。适合对实时性要求不高的场景,比如设置参数、切换模式。

下面是一个标准的轮询消抖代码,我用STM32 HAL库写的:

// 按键结构体
typedef struct {
    uint8_t current_state;   // 当前状态
    uint8_t last_state;      // 上一次状态
    uint8_t stable_state;    // 稳定后的状态
    uint32_t last_tick;      // 上次变化的时间戳
} Button_t;

Button_t btn;

// 初始化
void Button_Init(void) {
    btn.current_state = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);
    btn.last_state = btn.current_state;
    btn.stable_state = btn.current_state;
    btn.last_tick = HAL_GetTick();
}

// 轮询处理,放在主循环
void Button_Process(void) {
    btn.current_state = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);
    
    // 检测到电平变化
    if (btn.current_state != btn.last_state) {
        btn.last_tick = HAL_GetTick();  // 记录变化时间
    }
    
    // 如果电平稳定超过10ms,认为是有效按键
    if ((HAL_GetTick() - btn.last_tick) > 10) {
        if (btn.current_state != btn.stable_state) {
            btn.stable_state = btn.current_state;
            
            // 检测到按下(假设低电平有效)
            if (btn.stable_state == GPIO_PIN_RESET) {
                // 执行按键动作
                Key_Action();
            }
        }
    }
    
    btn.last_state = btn.current_state;
}

注意:轮询方式有个致命缺点——如果主循环里有耗时操作(比如刷屏、写Flash),按键响应会变慢。我曾经在一个项目里,主循环跑了50ms,按键按下去要等半秒才有反应,用户体验极差。

4.3 中断方式读取按键:实时响应,但要注意“坑”

如果你需要按键秒响应,比如紧急停止、唤醒系统,那就得上中断了。中断方式的好处是:按键一按,CPU立刻停下当前工作来处理。

但中断里不能做延时!为什么?因为中断服务函数(ISR)要快进快出。你在中断里delay(10ms),整个系统就卡死了。

正确的做法是:中断里只标记事件,在主循环里处理消抖和动作。这叫“中断标记 + 主循环处理”模式。

看代码:

// 全局标志
volatile uint8_t key_pressed_flag = 0;
uint32_t key_interrupt_tick = 0;

// GPIO中断回调(HAL库自动调用)
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    if (GPIO_Pin == KEY_Pin) {
        key_pressed_flag = 1;          // 标记有按键事件
        key_interrupt_tick = HAL_GetTick();  // 记录中断时间
    }
}

// 主循环中处理
void Key_Interrupt_Handler(void) {
    if (key_pressed_flag) {
        key_pressed_flag = 0;  // 清除标志
        
        // 延时10ms消抖(在主循环里做)
        while ((HAL_GetTick() - key_interrupt_tick) < 10);
        
        // 再次读取确认
        if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) {
            // 确认按键按下,执行动作
            Key_Action();
        }
    }
}

关键点:中断里只做两件事——置标志位、记录时间戳。消抖和动作执行全部放到主循环。这样既保证了实时性,又不会阻塞系统。

4.4 轮询 vs 中断:怎么选?

我给大家一个选择标准:

场景 推荐方式 原因
菜单设置、模式切换 轮询 实时性要求低,代码简单
紧急停止、唤醒 中断 需要毫秒级响应
主循环有大量耗时任务 中断 避免按键响应延迟
电池供电的低功耗设备 中断 可以唤醒MCU,轮询会浪费功耗

避坑指南:我曾经在一个产品里用了中断方式,但忘了配置GPIO的上拉电阻。结果按键没按时,引脚悬空,中断疯狂触发。嗯,从那以后,我每次配置GPIO都会检查上拉/下拉设置。

4.5 本章知识体系

下面这张图,是我画的知识结构。你看一眼,就能把按键输入的全貌记住:

按键输入知识体系 按键输入 消抖原理(5-20ms抖动) 读取方式 轮询方式 主循环中不断读取 适合低实时性场景 中断方式 中断标记+主循环处理 适合高实时性场景 关键:延时10ms + 多次采样确认

你看,整个按键输入就三个核心:消抖原理、轮询方式、中断方式。消抖是基础,轮询和中断是两种实现手段。掌握了这些,市面上99%的按键需求你都能搞定。

好了,这一节就到这里。记住:按键虽小,但处理不好会出大问题。下次你写按键代码时,想想我今天说的“10ms延时”和“中断里只标记”,能帮你省不少调试时间。


公众号:蓝海资料掘金营,微信deep3321