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

所有评论(0)