3. 按键输入与中断:数字输入读取,软件消抖,外部中断配置,中断服务函数编写注意事项
按键,是嵌入式系统里最基础、也最容易出问题的输入方式。很多初学者觉得按键不就是读个电平吗?其实不然。我在项目里见过太多因为按键处理不当导致的诡异bug——按一下触发两次、长按变短按、甚至系统死机。今天我们就来彻底搞定它。
3.1 数字输入读取基础
ESP32的GPIO作为数字输入时,核心就是读电平高低。但这里有个坑——引脚悬空时电平是不确定的。你想想看,一个没接任何东西的引脚,读到的值可能是0也可能是1,完全随机。
解决办法很简单:上拉或下拉电阻。ESP32内部有可编程的上拉/下拉电阻,我们可以在初始化时配置。
核心原则:按键按下时,电平要明确变化。通常有两种接法:
- 按下为低电平:按键一端接GND,另一端接GPIO,GPIO内部上拉
- 按下为高电平:按键一端接VCC(3.3V),另一端接GPIO,GPIO内部下拉
我个人习惯用「按下为低」的接法。为什么?因为ESP32内部上拉电阻默认就是使能的,省事。而且大多数开发板按键就是这么设计的。
3.1.1 GPIO输入配置代码
#include "driver/gpio.h"
#define BUTTON_GPIO 0 // 开发板上的BOOT按键,默认上拉
void button_init(void) {
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_DISABLE // 先不配中断
};
gpio_config(&io_conf);
}
// 读取按键状态
int button_read(void) {
return gpio_get_level(BUTTON_GPIO); // 返回0表示按下,1表示松开
}
小技巧:GPIO0在ESP32上默认是BOOT按键,按下时拉低。很多开发板都把它引出来了,调试时特别方便。我经常用它做测试按键。
3.2 软件消抖——为什么必须做?
机械按键有个物理特性:按下和松开的瞬间,触点会弹跳。这个弹跳时间通常持续5-20ms。如果不做处理,一次按键会被误读成多次。
我曾经在一个产品里遇到过:用户按一次,设备切换了三个模式。查了半天,就是没做消抖。用户骂我们产品有鬼,其实只是机械弹跳在捣乱。
3.2.1 消抖原理
说白了就是「等一等,再确认」。检测到电平变化后,不立即响应,而是等一段时间再读一次。如果两次结果一致,才认为是有效按键。
| 消抖方法 | 原理 | 适用场景 | 缺点 |
|---|---|---|---|
| 延时消抖 | 检测到变化后delay(20ms)再读 | 简单项目,非实时 | 阻塞CPU,浪费资源 |
| 定时轮询 | 定时器每10ms读一次,连续N次一致才确认 | 大多数嵌入式项目 | 需要定时器资源 |
| 状态机消抖 | 用状态机记录按键状态变迁 | 复杂按键逻辑(长按、双击) | 代码量稍大 |
3.2.2 实战:定时轮询消抖
我个人最推荐定时轮询法。它不阻塞CPU,而且容易扩展成长按、双击检测。下面是一个经典实现:
#define DEBOUNCE_TIME_MS 20 // 消抖时间
#define SAMPLE_INTERVAL_MS 5 // 采样间隔
static uint8_t last_stable_level = 1; // 上次稳定电平
static uint8_t current_level = 1; // 当前电平
static uint32_t last_change_time = 0;
// 在定时器中断或主循环中每5ms调用一次
uint8_t button_debounce(void) {
uint8_t level = gpio_get_level(BUTTON_GPIO);
uint32_t now = esp_timer_get_time() / 1000; // 转成ms
if (level != current_level) {
current_level = level;
last_change_time = now;
}
// 电平稳定超过消抖时间
if ((now - last_change_time) >= DEBOUNCE_TIME_MS) {
if (last_stable_level != current_level) {
last_stable_level = current_level;
return current_level; // 返回有效电平变化
}
}
return 0xFF; // 无有效变化
}
注意:不要在中断服务函数里做延时消抖!中断服务要快进快出。延时消抖应该在任务或主循环里做。
3.3 外部中断配置
轮询虽然简单,但CPU要一直跑着去查按键状态,浪费电。对于电池供电的设备,我们更希望按键按下时CPU才被唤醒——这就是外部中断的用武之地。
ESP32的每个GPIO都可以配置为外部中断源。支持以下几种触发方式:
| 中断触发方式 | 宏定义 | 说明 |
|---|---|---|
| 下降沿触发 | GPIO_INTR_NEGEDGE | 电平从高变低时触发(按键按下) |
| 上升沿触发 | GPIO_INTR_POSEDGE | 电平从低变高时触发(按键松开) |
| 任意沿触发 | GPIO_INTR_ANYEDGE | 电平变化就触发 |
| 低电平触发 | GPIO_INTR_LOW_LEVEL | 低电平时持续触发 |
| 高电平触发 | GPIO_INTR_HIGH_LEVEL | 高电平时持续触发 |
3.3.1 配置外部中断
#include "driver/gpio.h"
#define BUTTON_GPIO 0
// 中断服务函数声明
static void IRAM_ATTR button_isr_handler(void* arg);
void button_interrupt_init(void) {
// 1. 配置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);
// 2. 安装GPIO中断服务
gpio_install_isr_service(ESP_INTR_FLAG_DEFAULT);
// 3. 注册中断处理函数
gpio_isr_handler_add(BUTTON_GPIO, button_isr_handler, (void*) BUTTON_GPIO);
}
// 中断服务函数
static void IRAM_ATTR button_isr_handler(void* arg) {
// 这里只做标记,不做耗时操作
uint32_t gpio_num = (uint32_t) arg;
// 设置一个标志位,通知主循环处理
g_button_pressed = true;
}
关键点:
IRAM_ATTR属性必须加!它把函数放在IRAM中,保证中断能快速响应- 中断服务函数里不要调用
printf、delay、malloc等阻塞或耗时函数 - 中断里只做标记,实际处理放到主循环或任务里
3.4 中断服务函数编写注意事项
这部分是重点,也是很多工程师容易翻车的地方。我见过最惨的一次:同事在中断里调了 vTaskDelay(),结果系统直接卡死,连看门狗都救不回来。
3.4.1 中断服务函数的「三要三不要」
| 原则 | 说明 | 为什么 |
|---|---|---|
| 要快进快出 | 执行时间控制在微秒级 | 中断会阻塞其他中断和任务 |
| 要使用IRAM_ATTR | 函数放在内部RAM | Flash访问慢,且中断中可能被其他操作阻塞 |
| 要使用volatile标志 | 共享变量加volatile | 防止编译器优化掉变量读取 |
| 不要调用FreeRTOS API | 如vTaskDelay、xQueueSend等 | 大部分FreeRTOS API不能在中断中调用 |
| 不要做I2C/SPI通信 | 不要在中断里读写传感器 | 通信耗时且可能触发其他中断 |
| 不要使用printf | 打印输出太慢 | printf内部可能使用互斥锁,导致死锁 |
3.4.2 中断与任务的同步
中断里标记,任务里处理。这是嵌入式开发的黄金法则。具体怎么做?
// 全局标志,volatile必须加!
static volatile bool g_button_pressed = false;
// 中断服务函数
static void IRAM_ATTR button_isr_handler(void* arg) {
g_button_pressed = true; // 只做标记
}
// 主循环或任务
void app_main(void) {
button_interrupt_init();
while (1) {
if (g_button_pressed) {
g_button_pressed = false; // 清除标志
// 这里做消抖和实际处理
// 注意:消抖不能在中断里做!
vTaskDelay(pdMS_TO_TICKS(20));
if (gpio_get_level(BUTTON_GPIO) == 0) {
// 确认按键按下,执行操作
handle_button_press();
}
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
我曾经踩过的坑:在中断里直接调用了 gpio_get_level() 去读另一个引脚,结果那个引脚正好配置了中断,导致递归中断,栈溢出复位。记住:中断里只做最必要的事。
3.5 本章知识体系
下面这张图总结了按键输入与中断的完整处理流程,从硬件连接到软件处理,每一步都环环相扣:
3.6 总结与避坑清单
按键看似简单,但要做好其实不容易。我把这些年踩过的坑整理成清单,你写代码时对照着检查:
避坑清单:
- ✅ GPIO配置时一定要使能上拉或下拉,防止悬空
- ✅ 软件消抖时间建议15-30ms,太短消不干净,太长影响手感
- ✅ 中断服务函数必须加
IRAM_ATTR属性 - ✅ 中断和主循环共享的变量必须加
volatile - ❌ 不要在ISR里调用
printf、delay、malloc - ❌ 不要在ISR里做I2C/SPI通信
- ❌ 不要在ISR里调用FreeRTOS的阻塞API
嗯,这一章的内容就到这里。按键处理是嵌入式开发的入门必修课,也是后续学习中断驱动编程的基础。把这些基础打牢了,后面写传感器驱动、通信协议时才能游刃有余。