3、按键输入与中断:GPIO输入模式、按键消抖(硬件与软件)、外部中断配置、中断服务函数编写

好,咱们进入第三章。这一章讲的是按键,看似简单,但坑不少。我刚开始做嵌入式那会儿,觉得按键不就是读个电平嘛,结果被现实狠狠教育了一顿——按键按下弹起那几下抖动,能把你的逻辑搞得一团糟。所以这一章,咱们把按键这件事彻底讲透。

3.1 GPIO输入模式

ESP32的GPIO作为输入,其实就两个关键点:上拉/下拉电平读取

你想想看,按键一端接GND,另一端接GPIO。按键没按下时,GPIO悬空,电平不确定。这时候就需要内部上拉电阻,把电平拉到高。按下时,GPIO被拉到GND,读到低电平。

配置代码很简单:

gpio_config_t io_conf = {
    .intr_type = GPIO_INTR_DISABLE,    // 先不用中断
    .mode = GPIO_MODE_INPUT,           // 输入模式
    .pin_bit_mask = (1ULL < GPIO_NUM_0), // 选择GPIO0
    .pull_up_en = 1,                   // 使能上拉
    .pull_down_en = 0                  // 下拉关闭
};
gpio_config(&io_conf);

嗯,这里要注意:GPIO0、GPIO2、GPIO12这几个引脚在ESP32上有特殊功能。GPIO0是BOOT模式选择引脚,GPIO12是电压选择引脚。我建议你尽量避开它们做按键,否则调试时容易出幺蛾子。

3.2 按键消抖——硬件方案

为什么需要消抖?机械按键内部是金属弹片,按下时不会瞬间稳定接触,而是会弹跳几次。这个过程大概持续5-20ms。如果不处理,一次按下可能被识别成多次。

硬件消抖,说白了就是加个RC低通滤波器。一个电阻串联,一个电容对地并联。时间常数τ = R × C,一般取10ms左右。

举个例子:R=10kΩ,C=1μF,τ=10ms。这个组合能把抖动的高频分量滤掉,让电平变化变得平滑。

我个人习惯是:如果板子空间允许,优先用硬件消抖。因为硬件消抖不占用CPU,而且可靠性高。我在一个工业控制项目里,所有按键都加了RC滤波,三年没出过一次误触发。

小技巧: 硬件消抖的电阻电容尽量靠近按键引脚放置。走线长了,反而容易引入噪声。

3.3 按键消抖——软件方案

但很多时候,硬件上没法加电容(比如空间受限、成本敏感)。这时候就得靠软件了。

软件消抖的核心思想就一句话:延迟判断,确认稳定

最常用的方法是:检测到电平变化后,延时10-20ms,再读一次。如果两次一致,才认为是有效按键。

uint8_t key_scan(void) {
    static uint8_t last_state = 1;  // 上次状态,默认高电平
    uint8_t current_state = gpio_get_level(KEY_GPIO);
    
    if (current_state != last_state) {
        // 状态变化,启动延时消抖
        vTaskDelay(pdMS_TO_TICKS(20));
        current_state = gpio_get_level(KEY_GPIO);
        
        if (current_state == last_state) {
            // 抖动,忽略
            return 0;
        }
        
        last_state = current_state;
        
        if (current_state == 0) {
            // 按键按下(低电平有效)
            return 1;
        }
    }
    return 0;
}

我曾经犯过一个错误:在中断里直接调用vTaskDelay。结果系统直接崩溃。记住,中断服务函数里不能调用阻塞型函数。软件消抖最好放在任务循环里做。

注意: 延时消抖期间,CPU被阻塞了。如果系统有多个按键,建议用状态机或者定时器轮询,而不是傻等。

3.4 外部中断配置

轮询方式虽然简单,但浪费CPU。更好的做法是用中断——按键按下时,CPU自动跳转到中断服务函数处理。

ESP32的GPIO中断支持四种触发方式:

触发方式 宏定义 说明
上升沿触发 GPIO_INTR_POSEDGE 电平从低变高时触发
下降沿触发 GPIO_INTR_NEGEDGE 电平从高变低时触发
任意沿触发 GPIO_INTR_ANYEDGE 电平变化就触发
低电平触发 GPIO_INTR_LOW_LEVEL 低电平持续触发

按键一般用下降沿触发(按下瞬间触发一次)或者低电平触发(按住时持续触发)。我个人习惯用下降沿,因为一次按下只触发一次,逻辑更清晰。

配置中断的代码:

// 配置GPIO为输入,并使能中断
gpio_config_t io_conf = {
    .intr_type = GPIO_INTR_NEGEDGE,    // 下降沿触发
    .mode = GPIO_MODE_INPUT,
    .pin_bit_mask = (1ULL < GPIO_NUM_4),
    .pull_up_en = 1,
    .pull_down_en = 0
};
gpio_config(&io_conf);

// 安装GPIO中断服务
gpio_install_isr_service(0);

// 注册中断回调函数
gpio_isr_handler_add(KEY_GPIO, key_isr_handler, (void*) KEY_GPIO);
核心要点: 中断服务函数要短小精悍。不要在ISR里做复杂运算、打印、延时。正确的做法是:ISR里只设置一个标志位,真正的处理放在主循环或任务里。

3.5 中断服务函数编写

中断服务函数(ISR)有严格的限制。我总结了几条铁律:

  • 不能调用printf——printf内部用了互斥锁,中断里用会死锁
  • 不能调用vTaskDelay——阻塞函数会挂起整个系统
  • 不能申请动态内存——malloc不是线程安全的
  • 执行时间尽量短——超过100μs就可能影响其他中断

那ISR里能做什么?说白了就两件事:读寄存器、写标志位

// 中断服务函数
static void IRAM_ATTR key_isr_handler(void* arg) {
    uint32_t gpio_num = (uint32_t) arg;
    
    // 清除中断标志位
    gpio_intr_clear(gpio_num);
    
    // 设置标志位,通知主循环处理
    key_pressed_flag = 1;
    key_pressed_gpio = gpio_num;
}

// 主循环中处理
void app_main() {
    // ... 初始化代码 ...
    
    while (1) {
        if (key_pressed_flag) {
            key_pressed_flag = 0;
            
            // 这里做消抖处理(延时再读一次)
            vTaskDelay(pdMS_TO_TICKS(20));
            if (gpio_get_level(key_pressed_gpio) == 0) {
                // 确认按键按下,执行动作
                handle_key_press(key_pressed_gpio);
            }
        }
        // 其他任务...
    }
}

你可能会问:为什么不在ISR里直接消抖?因为ISR里不能延时。那怎么办?我常用的做法是:ISR只负责通知,消抖逻辑放在任务里。这样既利用了中断的实时性,又避免了ISR的限制。

避坑指南: 我曾经在ISR里用了一个全局变量做计数器,结果发现有时候计数不准。排查了半天,原来是编译器优化把变量放到寄存器里了,中断和主循环看到的值不一致。解决方案:给共享变量加 volatile 关键字。

3.6 综合示例:一个完整的按键中断程序

把上面所有知识点串起来,一个完整的按键中断程序大概长这样:

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"

#define KEY_GPIO    4
#define LED_GPIO    2

static volatile int key_pressed_flag = 0;

static void IRAM_ATTR key_isr_handler(void* arg) {
    gpio_intr_clear(KEY_GPIO);
    key_pressed_flag = 1;
}

void app_main() {
    // 配置LED
    gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT);
    
    // 配置按键
    gpio_config_t io_conf = {
        .intr_type = GPIO_INTR_NEGEDGE,
        .mode = GPIO_MODE_INPUT,
        .pin_bit_mask = (1ULL < KEY_GPIO),
        .pull_up_en = 1,
        .pull_down_en = 0
    };
    gpio_config(&io_conf);
    
    // 安装中断服务
    gpio_install_isr_service(0);
    gpio_isr_handler_add(KEY_GPIO, key_isr_handler, (void*) KEY_GPIO);
    
    int led_state = 0;
    
    while (1) {
        if (key_pressed_flag) {
            key_pressed_flag = 0;
            
            // 软件消抖
            vTaskDelay(pdMS_TO_TICKS(20));
            if (gpio_get_level(KEY_GPIO) == 0) {
                // 按键有效,翻转LED
                led_state = !led_state;
                gpio_set_level(LED_GPIO, led_state);
                printf("按键按下,LED状态:%d\n", led_state);
            }
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

这个程序实现了:按下按键,LED翻转,串口打印状态。代码里包含了中断配置、ISR编写、软件消抖三个核心知识点。

嗯,到这里,按键输入与中断的内容就差不多了。总结一下:硬件消抖保底,软件消抖兜底,中断通知,任务处理。这套组合拳打好了,按键这块基本不会出问题。

下一章咱们开始讲PWM和LED呼吸灯效果,到时候你会看到中断和定时器是怎么配合的。