3. 按键输入与中断:数字输入、内部上拉/下拉电阻、外部中断、消抖处理
按键,是嵌入式系统里最基础、也最容易被轻视的输入方式。很多新手觉得按键不就是读个高低电平吗?嗯,理论上是这样。但实际项目中,按键带来的坑可不少——抖动、误触发、引脚电平不确定……我最早做产品时就在这上面吃过亏。
这一章,咱们把按键这件事彻底讲透。从硬件原理到软件消抖,从轮询读到中断触发,一步到位。
3.1 数字输入的本质
ESP32 的 GPIO 做数字输入时,其实就是在读一个寄存器的值——引脚电压高于某个阈值(约 0.75×VDD),读到 1;低于阈值(约 0.25×VDD),读到 0。说白了,就是个 1 或 0 的判断。
但这里有个关键问题:引脚悬空时,电平是不确定的。你想想看,一个没接任何东西的引脚,外界电磁干扰就能让它乱跳。这就是为什么我们需要上拉或下拉电阻。
| 状态 | 引脚电平 | 典型场景 |
|---|---|---|
| 高电平(1) | VIH ≥ 2.475V | 按键未按下(上拉) |
| 低电平(0) | VIL ≤ 0.825V | 按键按下(上拉) |
| 悬空 | 不确定 | 引脚未连接任何电路 |
3.2 内部上拉/下拉电阻
ESP32 的 GPIO 内部集成了可编程的上拉和下拉电阻。我个人习惯是:能用内部电阻,绝不外加。省一颗电阻事小,少一个焊点就少一个故障点。
内部上拉电阻的典型值在 45kΩ 左右,下拉约 45kΩ。这个阻值足够让按键在未按下时保持确定电平,同时按下时电流也很小——微安级别,不用担心功耗。
关键配置函数:
gpio_set_pull_mode(GPIO_NUM_X, GPIO_PULLUP_ONLY)— 仅上拉gpio_set_pull_mode(GPIO_NUM_X, GPIO_PULLDOWN_ONLY)— 仅下拉gpio_set_pull_mode(GPIO_NUM_X, GPIO_FLOATING)— 浮空(不推荐)
我曾经在一个低功耗项目里,为了省电把按键引脚设成了浮空。结果产品在实验室跑得好好的,一到客户现场就频繁误触发。查了两天才发现是工频干扰导致的。从那以后,我再也不敢让按键引脚浮空了。
3.3 外部中断:让 CPU 别再傻等
轮询读按键,CPU 得一直跑循环检查电平变化。这在简单程序里没问题,但一旦系统复杂了——比如同时要处理 Wi-Fi、传感器、显示——轮询就成了性能杀手。
中断的思路是:让 CPU 该干嘛干嘛,按键按下时主动通知它。ESP32 的 GPIO 支持四种中断触发方式:
| 触发方式 | 宏定义 | 说明 |
|---|---|---|
| 上升沿触发 | GPIO_INTR_POSEDGE | 电平从低变高时触发 |
| 下降沿触发 | GPIO_INTR_NEGEDGE | 电平从高变低时触发 |
| 任意沿触发 | GPIO_INTR_ANYEDGE | 电平变化就触发 |
| 低电平触发 | GPIO_INTR_LOW_LEVEL | 低电平持续时触发 |
实际项目中,按键最常用的是下降沿触发——按键按下时电平从高变低,正好触发中断。但注意,中断服务函数(ISR)里不能做耗时操作,比如打印、延时。我一般只在 ISR 里设一个标志位,具体处理放到主循环里做。
我的习惯做法:
ISR 里只做两件事——清除中断标志、置位一个 volatile 变量。主循环检测到这个变量变化后,再执行消抖和业务逻辑。这样既保证了响应速度,又不会阻塞系统。
3.4 消抖处理:机械按键的必修课
机械按键按下和释放的瞬间,触点会弹跳几次,持续时间通常 5~20ms。如果不做消抖,一次按键可能被误判成多次。我见过最夸张的一次,一个劣质按键弹跳了 30ms,程序里没消抖,按一次触发了 7 次中断。
消抖方法主要有三种:
- 硬件消抖——在按键两端并联一个 0.1μF 电容。简单粗暴,但增加 BOM 成本。
- 软件延时消抖——检测到电平变化后,延时 10~20ms 再读一次。代码简单,但会阻塞。
- 状态机消抖——用状态机记录按键状态,不阻塞、不延时。我强烈推荐这种方式。
注意:在 ISR 里做延时消抖是禁止的!中断服务函数必须快速返回。延时消抖请放在主循环或任务中处理。
下面是我常用的状态机消抖代码,你可以直接拿去用:
typedef enum {
KEY_STATE_IDLE,
KEY_STATE_PRESS_DEBOUNCE,
KEY_STATE_PRESSED,
KEY_STATE_RELEASE_DEBOUNCE
} key_state_t;
key_state_t key_state = KEY_STATE_IDLE;
uint32_t last_tick = 0;
const uint32_t DEBOUNCE_MS = 20;
void key_task(void *arg) {
while (1) {
int level = gpio_get_level(KEY_GPIO);
uint32_t now = xTaskGetTickCount();
switch (key_state) {
case KEY_STATE_IDLE:
if (level == 0) { // 检测到按下
key_state = KEY_STATE_PRESS_DEBOUNCE;
last_tick = now;
}
break;
case KEY_STATE_PRESS_DEBOUNCE:
if ((now - last_tick) >= pdMS_TO_TICKS(DEBOUNCE_MS)) {
if (gpio_get_level(KEY_GPIO) == 0) {
key_state = KEY_STATE_PRESSED;
// 按键有效按下,处理业务
printf("Key pressed!\n");
} else {
key_state = KEY_STATE_IDLE; // 抖动误判
}
}
break;
case KEY_STATE_PRESSED:
if (level == 1) { // 检测到释放
key_state = KEY_STATE_RELEASE_DEBOUNCE;
last_tick = now;
}
break;
case KEY_STATE_RELEASE_DEBOUNCE:
if ((now - last_tick) >= pdMS_TO_TICKS(DEBOUNCE_MS)) {
if (gpio_get_level(KEY_GPIO) == 1) {
key_state = KEY_STATE_IDLE;
printf("Key released!\n");
} else {
key_state = KEY_STATE_PRESSED; // 抖动误判
}
}
break;
}
vTaskDelay(pdMS_TO_TICKS(5)); // 每5ms检查一次
}
}
这段代码的核心思路是:不信任单次采样,用时间窗口过滤抖动。20ms 的消抖时间是我试过很多按键后觉得最稳妥的值。如果你用的按键质量特别好,可以缩短到 10ms;如果是那种几毛钱的轻触开关,建议留到 30ms。
3.5 知识体系总览
下面这张图把按键输入与中断的完整链路画出来了。从硬件引脚配置,到中断触发,再到消抖处理,最后到业务响应——每一步都有坑,每一步也都有标准解法。
3.6 完整配置示例
最后,给一个完整的按键中断配置代码。你可以把它当成模板,改改引脚号就能用:
#include "driver/gpio.h"
#define KEY_GPIO GPIO_NUM_0
#define KEY_PRESSED 0 // 上拉,按下为低
static volatile bool key_pressed_flag = false;
static void IRAM_ATTR key_isr_handler(void *arg) {
key_pressed_flag = true;
}
void key_init(void) {
// 配置GPIO
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << KEY_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中断服务
gpio_install_isr_service(0);
gpio_isr_handler_add(KEY_GPIO, key_isr_handler, NULL);
}
void app_main(void) {
key_init();
while (1) {
if (key_pressed_flag) {
key_pressed_flag = false;
// 这里放消抖和业务处理
vTaskDelay(pdMS_TO_TICKS(20));
if (gpio_get_level(KEY_GPIO) == KEY_PRESSED) {
printf("Key event detected!\n");
}
}
// 其他任务...
vTaskDelay(pdMS_TO_TICKS(10));
}
}
这段代码里,ISR 只做了一件事——置标志位。主循环检测到标志位后,先延时 20ms 消抖,再确认电平是否真的变了。这种做法我在五六个项目里验证过,稳定可靠。
小技巧:如果你的按键需要区分短按和长按,可以在状态机里加一个计时器。按下后开始计时,超过 1 秒算长按,否则算短按。这个扩展留给你自己实现,不难。