ESP32 GPIO 中断与防抖:按键/脉冲采集实战

先说结论

硬件一个 RC 滤波 + 软件一个防抖算法,基本能搞定 99% 的按键场景。如果做脉冲计数(霍尔传感器、编码器),得用 GPIO 中断 + 定时器配合。

按键检测看起来是入门级的问题——GPIO 配输入,读电平,完事。但实际做产品时,一个按键可能踩 5 个坑:抖动、误触发、中断嵌套、ISR 阻塞、低功耗冲突


一、GPIO 中断配置

ESP-IDF 的 GPIO 中断配置:

#include "driver/gpio.h"

#define BUTTON_GPIO  GPIO_NUM_0

// 中断回调
static void IRAM_ATTR button_isr_handler(void *arg) {
    // 这里在中断上下文中运行!
    // 不能用printf、malloc、vTaskDelay
    // 只能用xQueueSendFromISR、xEventGroupSetBitsFromISR等
    uint32_t gpio_num = (uint32_t) arg;
    xQueueSendFromISR(gpio_evt_queue, &gpio_num, NULL);
}

void button_init(void) {
    // 配置GPIO为输入
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL << BUTTON_GPIO),
        .mode = GPIO_MODE_INPUT,
        .pull_up_en = GPIO_PULLUP_ENABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_NEGEDGE,  // 下降沿触发
    };
    gpio_config(&io_conf);

    // 安装中断服务
    gpio_install_isr_service(0);
    gpio_isr_handler_add(BUTTON_GPIO, button_isr_handler,
                         (void *) BUTTON_GPIO);
}

// 中断处理任务
void button_task(void *arg) {
    uint32_t gpio_num;
    uint32_t last_time = 0;

    while (1) {
        if (xQueueReceive(gpio_evt_queue, &gpio_num, portMAX_DELAY)) {
            uint32_t now = esp_timer_get_time() / 1000;  // ms

            // 软件防抖:50ms内不处理
            if (now - last_time < 50) continue;
            last_time = now;

            printf("Button pressed (gpio %d)\n", gpio_num);
            // 在这里做实际处理
        }
    }
}

关键点:

  • IRAM_ATTR 保证中断函数在内部 RAM 运行(不从 Flash 加载)
  • xQueueSendFromISR 把事件发给任务,在中断中安全
  • 50ms 防抖过滤掉按键抖动

二、硬件防抖

RC 滤波电路:

VCC → 10kΩ → GPIO → 按键 → GND
            |
          0.1μF
            |
           GND

时间常数 τ = R × C = 10kΩ × 0.1μF = 1ms。按键按下时,电容通过 10kΩ 充电,电压缓慢上升。如果抖动持续时间 <1ms,GPIO 电平不会到达阈值。

RC 滤波的效果:

  • 上升时间约 2.2τ = 2.2ms(0→63%→稳态的 90%)
  • 抖动幅度大幅减少
  • 但响应有 1-2ms 延迟

三、软件防抖算法

算法 1:延时确认(最简单)

void button_isr_handler(void *arg) {
    uint32_t gpio_num = (uint32_t) arg;
    // 发送到队列,在任务里处理
    xQueueSendFromISR(gpio_evt_queue, &gpio_num, NULL);
}

void button_task(void *arg) {
    uint32_t gpio_num;
    while (1) {
        xQueueReceive(gpio_evt_queue, &gpio_num, portMAX_DELAY);

        vTaskDelay(pdMS_TO_TICKS(20));  // 等20ms

        // 再检查GPIO状态
        if (gpio_get_level(gpio_num) == 0) {
            printf("Button pressed\n");
        }
    }
}

算法 2:状态机(适合长按检测)

typedef enum {
    BTN_IDLE,
    BTN_PRESSED,
    BTN_DEBOUNCE,
    BTN_LONG_PRESS,
} button_state_t;

button_state_t state = BTN_IDLE;
uint32_t state_time = 0;

void button_state_machine(uint8_t pin_level, uint32_t now) {
    switch (state) {
        case BTN_IDLE:
            if (pin_level == 0) {  // 检测到下降沿
                state = BTN_DEBOUNCE;
                state_time = now;
            }
            break;

        case BTN_DEBOUNCE:
            if (now - state_time > 20) {  // 20ms后确认
                if (pin_level == 0) {
                    state = BTN_PRESSED;
                    state_time = now;
                    printf("Short press\n");
                } else {
                    state = BTN_IDLE;
                }
            }
            break;

        case BTN_PRESSED:
            if (now - state_time > 2000) {  // 2秒长按
                state = BTN_LONG_PRESS;
                printf("Long press\n");
            }
            if (pin_level == 1) {  // 松开
                state = BTN_IDLE;
            }
            break;

        case BTN_LONG_PRESS:
            if (pin_level == 1) {
                state = BTN_IDLE;
            }
            break;
    }
}

// 在定时器中调用(每5ms)
void button_timer_callback(void *arg) {
    uint8_t level = gpio_get_level(BUTTON_GPIO);
    uint32_t now = esp_timer_get_time() / 1000;
    button_state_machine(level, now);
}

算法 3:Shift Register(抗噪声强)

#define DEBOUNCE_COUNT 5  // 连续5次相同才算稳定

uint8_t shift_reg = 0xFF;  // 初始为高
uint8_t stable_level = 1;

void button_sample(void) {
    uint8_t current = gpio_get_level(BUTTON_GPIO);

    // 移位寄存器:新值进入最低位
    shift_reg = (shift_reg << 1) | current;

    // 检查是否全0或全1
    if ((shift_reg & ((1 << DEBOUNCE_COUNT) - 1)) == 0) {
        if (stable_level != 0) {
            stable_level = 0;
            printf("Button pressed\n");
        }
    } else if ((shift_reg & ((1 << DEBOUNCE_COUNT) - 1))
               == ((1 << DEBOUNCE_COUNT) - 1)) {
        if (stable_level != 1) {
            stable_level = 1;
            printf("Button released\n");
        }
    }
}

// 每5ms采样一次
esp_timer_handle_t timer;
esp_timer_create_args_t timer_cfg = {
    .callback = button_sample,
    .name = "button_timer",
};
esp_timer_create(&timer_cfg, &timer);
esp_timer_start_periodic(timer, 5000);  // 5ms

四、脉冲计数

做流量计或编码器时,GPIO 脉冲频率可能很高(10kHz+)。软件防抖扛不住,要用硬件计数器。

方案 1:PCNT 外设

ESP32 有 PCNT(Pulse Counter)硬件,8 个通道,每个通道 16 位计数器:

#include "driver/pcnt.h"

pcnt_config_t pcnt_cfg = {
    .pulse_gpio_num = GPIO_NUM_4,       // 脉冲输入
    .ctrl_gpio_num = PCNT_PIN_NOT_USED, // 不用控制引脚
    .channel = PCNT_CHANNEL_0,
    .unit = PCNT_UNIT_0,
    .pos_mode = PCNT_COUNT_INC,       // 上升沿计数
    .neg_mode = PCNT_COUNT_DIS,       // 下降沿不计数
    .lctrl_mode = PCNT_MODE_KEEP,
    .hctrl_mode = PCNT_MODE_KEEP,
    .counter_h_lim = 10000,           // 上限
    .counter_l_lim = 0,
};
pcnt_unit_config(&pcnt_cfg);

pcnt_set_filter_value(PCNT_UNIT_0, 100);  // 滤波100个APB时钟(~1.2μs)
pcnt_filter_enable(PCNT_UNIT_0);
pcnt_counter_clear(PCNT_UNIT_0);
pcnt_counter_resume(PCNT_UNIT_0);

// 读取计数值
int16_t count = 0;
pcnt_get_counter_value(PCNT_UNIT_0, &count);
printf("Pulse count: %d\n", count);

PCNT 最高计数频率约 40MHz(APB 时钟/2),但实际受滤波值限制。100 的滤波值对应最高 ~80kHz。

方案 2:外部中断 + 硬件定时器

频率不高(<1kHz)时用中断计数:

volatile uint32_t pulse_count = 0;

void IRAM_ATTR pulse_isr_handler(void *arg) {
    pulse_count++;
}

void pulse_counter_task(void *arg) {
    uint32_t last_count = 0;

    while (1) {
        vTaskDelay(pdMS_TO_TICKS(1000));
        uint32_t current = pulse_count;
        uint32_t freq = current - last_count;
        last_count = current;
        printf("Frequency: %d Hz\n", freq);
    }
}

五、低功耗场景

DeepSleep 时 GPIO 中断唤醒:

// 配置GPIO为唤醒源
esp_sleep_enable_ext0_wakeup(GPIO_NUM_0, 0);  // 下降沿唤醒

// 进入DeepSleep
esp_deep_sleep_start();

但 DeepSleep 期间计数器清零,不适合脉冲计数。用 ULP 协处理器可以在 DeepSleep 期间做简单计数。


六、实测数据

ESP32 @240MHz:

方案 最大频率 CPU占用 延迟
轮询(10ms) 50Hz <1% 10ms
轮询(1ms) 500Hz 5% 1ms
GPIO中断+防抖 100Hz 2% 20-50ms
GPIO中断无防抖 5kHz 15% 1μs
PCNT硬件计数 80kHz 0% 0μs

七、踩坑清单

  1. ISR 里不能调 printf:中断上下文没有堆栈空间给 stdio。用队列发到任务再打印
  2. IRAM_ATTR:中断函数必须放内部 RAM。不加 IRAM_ATTR,Flash cache miss 时会崩溃
  3. 嵌套中断:ESP32 中断优先级 0-7,高优先级可以打断低优先级。如果两个按键中断优先级一样,不会嵌套
  4. 上拉电阻:ESP32 内部上拉约 45kΩ,太弱。机械按键建议外接 10kΩ 上拉
  5. INPUT_PULLUP vs INPUT_PULLDOWN:按键接 GND 用上拉,接 VCC 用下拉。搞反了会一直触发
  6. ESP32-C3/C6 差异:ESP32-C3 的 GPIO 中断分配方式和 ESP32 不同,每个核心有自己的中断
  7. PCNT 溢出:16 位计数器最多 65535。超过会溢出回 0。高频脉冲要定期清零或用 PCNT 中断在溢出前读取
  8. 按键长按误触发:长按 2 秒和连续按 2 次容易混。用状态机区分:长按是"持续低电平超过阈值",双击是"两次短按间隔 <300ms"
Logo

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

更多推荐