3. 按键输入与中断:GPIO输入模式、轮询与中断方式、按键消抖处理
按键,可以说是嵌入式开发里最基础、也最容易被忽视的输入设备了。我刚开始做项目那会儿,总觉得按键嘛,读个电平就行了,能有多复杂?结果第一次做产品样机,十个按键有八个按下去乱跳,被硬件同事笑了好几天。嗯,从那以后我再也不敢小看按键处理了。
今天咱们就把按键这件事彻底聊透。从GPIO输入模式怎么配,到轮询和中断两种方式的取舍,再到那个让人头疼的消抖问题,我一个一个说清楚。
3.1 GPIO输入模式:你得先让引脚会“听”
在ESP32上,要把一个GPIO设成输入模式,其实就一行代码的事。但这里有个坑——你想想看,如果引脚悬空,电平是不确定的。所以内部上拉或下拉电阻就派上用场了。
// 配置GPIO0为输入模式,并启用内部上拉
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << GPIO_NUM_0),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE
};
gpio_config(&io_conf);
我个人习惯,按键电路一般用外部上拉电阻(10kΩ左右),内部上拉只在实验板上临时用用。为什么?因为内部上拉的阻值偏大(约45kΩ),抗干扰能力弱一些。我在一个工控项目里吃过这个亏——现场电机一启动,按键电平就乱跳,后来换成外部上拉才稳住。
- 输入模式下,引脚是高阻态,必须要有确定的电平
- 内部上拉/下拉可以通过软件配置,但阻值较大
- 外部上拉电阻更可靠,推荐10kΩ
3.2 轮询方式:简单但费CPU
轮询,说白了就是主循环里不停地读引脚电平。代码写起来最简单,但代价是CPU被占着啥也干不了。
void app_main(void)
{
gpio_config_input(GPIO_NUM_0);
while (1) {
int level = gpio_get_level(GPIO_NUM_0);
if (level == 0) { // 假设按键按下为低电平
printf("按键按下\n");
vTaskDelay(pdMS_TO_TICKS(200)); // 简单延时防抖
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
你看,这个写法有个明显的问题:vTaskDelay(10) 意味着每10ms才检测一次。如果按键按下去只有5ms,那就漏掉了。反过来,如果延时设得太短,CPU又忙不过来。
轮询适合什么场景?我个人觉得,只有一种情况适合——系统里就一个按键,而且没有其他实时任务。但凡任务多一点,轮询就会变成噩梦。我曾经在一个四按键的遥控器项目里用轮询,结果按键响应时快时慢,用户体验极差。
3.3 中断方式:让硬件来通知你
中断的思路正好反过来——平时CPU该干嘛干嘛,按键按下时硬件主动通知你。这样既省CPU,响应也快。
// 中断服务函数
static void IRAM_ATTR gpio_isr_handler(void* arg)
{
uint32_t gpio_num = (uint32_t) arg;
// 注意:ISR里不能调用printf等阻塞函数
// 通常的做法是发一个信号量给任务
xSemaphoreGiveFromISR(sem_button, NULL);
}
// 配置中断
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << GPIO_NUM_0),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.intr_type = GPIO_INTR_NEGEDGE // 下降沿触发
};
gpio_config(&io_conf);
// 安装中断服务
gpio_install_isr_service(0);
gpio_isr_handler_add(GPIO_NUM_0, gpio_isr_handler, (void*)GPIO_NUM_0);
这里有个关键点:中断服务函数里不要做复杂操作。我见过有人直接在ISR里写延时、打印、甚至调用WiFi API,结果系统直接崩溃。正确的做法是——ISR里只做最轻量的事,比如发信号量、置标志位,真正的处理交给任务。
3.4 按键消抖:硬件和软件两手抓
按键消抖,说白了就是解决按键按下和松开瞬间的电平抖动。你想想看,机械触点在接触的瞬间会弹跳几次,时间大概5-20ms。如果不处理,一次按键可能被识别成多次。
硬件消抖最简单——加个RC低通滤波器。一个10kΩ电阻加一个0.1μF电容,时间常数约1ms,效果不错。但硬件方案会增加BOM成本,所以大多数时候我们用软件消抖。
软件消抖我常用的有两种方法:
方法一:延时消抖(最简单)
if (gpio_get_level(BUTTON_PIN) == 0) {
vTaskDelay(pdMS_TO_TICKS(20)); // 等20ms
if (gpio_get_level(BUTTON_PIN) == 0) {
// 确认按键按下
}
}
这个方法虽然简单,但有个问题——延时期间CPU被阻塞了。如果系统里还有其他任务,这个延时就会拖慢整体响应。
方法二:状态机消抖(推荐)
typedef enum {
STATE_IDLE,
STATE_DEBOUNCE,
STATE_PRESSED
} button_state_t;
button_state_t state = STATE_IDLE;
uint32_t last_tick = 0;
void button_task(void *arg)
{
while (1) {
int level = gpio_get_level(BUTTON_PIN);
uint32_t now = xTaskGetTickCount();
switch (state) {
case STATE_IDLE:
if (level == 0) {
state = STATE_DEBOUNCE;
last_tick = now;
}
break;
case STATE_DEBOUNCE:
if ((now - last_tick) > pdMS_TO_TICKS(20)) {
if (level == 0) {
state = STATE_PRESSED;
printf("按键按下\n");
} else {
state = STATE_IDLE; // 抖动,回到空闲
}
}
break;
case STATE_PRESSED:
if (level == 1) {
state = STATE_IDLE; // 松开
}
break;
}
vTaskDelay(pdMS_TO_TICKS(5));
}
}
状态机的好处是不阻塞,而且能准确区分按下和松开。我在一个需要长按/短按识别的项目里就用这个方案,效果很好。
- 延时20ms是通用值,但不同按键可能不同,最好实测调整
- 状态机消抖比延时消抖更优雅,适合多任务系统
- 如果按键数量多,可以考虑用定时器统一扫描
- 硬件消抖+软件消抖双保险,但一般软件就够了
3.5 实战建议:按键处理的最佳实践
说了这么多,最后给几个我实际项目里总结出来的经验:
- 中断+任务处理:中断里只发信号量,真正的按键逻辑放在一个独立任务里。这样既响应快,又不会阻塞其他任务。
- 按键事件队列:如果系统里有多个按键,建议用队列把按键事件串起来。任务从队列里取事件,按顺序处理。
- 长按和短按识别:在状态机里加一个计时器,按下时间超过某个阈值就判定为长按。这个在菜单系统里特别实用。
- 调试时先看波形:用示波器抓一下按键引脚的电平,看看抖动时间到底多长。我每次换新按键型号都会先测一下,省得后面出问题。
嗯,按键这部分就聊到这儿。说白了,按键处理的核心就三件事:电平读取、消抖处理、事件分发。把这三件事理清楚了,不管多少个按键你都能搞定。下一章咱们聊聊更高级的输入设备——矩阵键盘和触摸按键,到时候再细说。