3. 按键输入与中断:数字输入原理、按键消抖、外部中断配置、中断服务函数编写
按键,是嵌入式系统里最基础、也最容易出问题的输入设备。你想想看,一个简单的开关动作,到了单片机眼里却是一连串的抖动信号。我刚开始做项目时,就被这个“抖动”坑过——按一下键,灯闪了三次。嗯,今天我们就彻底把按键这件事讲透。
3.1 数字输入原理
ESP32 的 GPIO 作为数字输入时,其实就是在检测引脚上的电平:高电平(3.3V)读作 1,低电平(0V)读作 0。但实际电路里,按键按下和释放的瞬间,电平并不是干净跳变的。
核心概念:数字输入的本质是电压比较。ESP32 的 Schmitt Trigger 输入缓冲器带有迟滞特性,能滤除一部分噪声,但对机械抖动无能为力。
我个人习惯在按键电路上做两件事:
- 上拉/下拉电阻:按键未按下时,引脚电平必须确定。常用 10kΩ 电阻上拉到 VCC 或下拉到 GND。
- 外部 RC 滤波:在按键两端并联 0.1μF 电容,能硬件上减少抖动幅度。
| 按键接法 | 未按下电平 | 按下电平 | 适用场景 |
|---|---|---|---|
| 上拉输入 | 高 (1) | 低 (0) | 常用,默认高电平省电 |
| 下拉输入 | 低 (0) | 高 (1) | 特殊逻辑需求 |
| 内部上拉 | 高 (1) | 低 (0) | 节省外部元件 |
我的经验:ESP32 内部上拉电阻约 45kΩ,比外部 10kΩ 弱很多。在强电磁干扰环境中,我建议用外部上拉,更可靠。
3.2 按键消抖
机械按键从按下到稳定,通常有 5-20ms 的抖动期。这期间电平会快速跳变多次。如果不处理,一次按键会被误判成多次。
消抖方法分两种:
3.2.1 硬件消抖
在按键两端并联电容(0.1μF~1μF),利用电容的充放电特性平滑抖动。优点是零 CPU 开销,缺点是增加 BOM 成本。
3.2.2 软件消抖
这是最常用的方式。核心思路:检测到电平变化后,延时 10-20ms 再读一次,确认电平稳定。
// 简单的软件消抖函数
int debounce_read(int pin) {
if (gpio_get_level(pin) == 0) { // 第一次检测到按下
vTaskDelay(pdMS_TO_TICKS(20)); // 延时 20ms 避开抖动
if (gpio_get_level(pin) == 0) { // 再次确认
return 1; // 确认按下
}
}
return 0; // 抖动或未按下
}
我曾经踩过的坑:在 FreeRTOS 任务里用 vTaskDelay 做消抖,如果任务优先级太低,20ms 延时可能被切走,导致消抖失效。建议把按键扫描放在中等优先级任务中,或者用硬件定时器触发。
更高效的消抖方式是状态机法,不阻塞 CPU:
typedef enum {
KEY_IDLE,
KEY_DEBOUNCE,
KEY_PRESSED
} key_state_t;
key_state_t key_state = KEY_IDLE;
uint32_t last_tick = 0;
void key_scan(int pin) {
uint8_t level = gpio_get_level(pin);
switch (key_state) {
case KEY_IDLE:
if (level == 0) {
key_state = KEY_DEBOUNCE;
last_tick = xTaskGetTickCount();
}
break;
case KEY_DEBOUNCE:
if ((xTaskGetTickCount() - last_tick) > pdMS_TO_TICKS(20)) {
if (gpio_get_level(pin) == 0) {
key_state = KEY_PRESSED;
// 触发按键事件
key_event_callback();
} else {
key_state = KEY_IDLE; // 抖动恢复
}
}
break;
case KEY_PRESSED:
if (level == 1) {
key_state = KEY_IDLE; // 等待释放
}
break;
}
}
3.3 外部中断配置
轮询方式浪费 CPU,尤其当系统需要同时处理多个传感器时。外部中断才是正经做法——按键按下时,CPU 暂停当前任务,跳转到中断服务函数处理。
ESP32 的 GPIO 中断支持四种触发方式:
| 触发模式 | 宏定义 | 典型用途 |
|---|---|---|
| 上升沿触发 | GPIO_INTR_POSEDGE | 按键释放检测 |
| 下降沿触发 | GPIO_INTR_NEGEDGE | 按键按下检测 |
| 任意沿触发 | GPIO_INTR_ANYEDGE | 电平变化检测 |
| 低电平触发 | GPIO_INTR_LOW_LEVEL | 唤醒睡眠模式 |
配置步骤其实很固定,我一般写成这样:
// 按键中断初始化
void key_interrupt_init(int pin) {
// 1. 配置 GPIO 为输入模式
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << pin),
.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(pin, key_isr_handler, (void*) &pin);
}
注意:gpio_install_isr_service 只需调用一次,多个按键共享同一个中断服务。我习惯在系统初始化时统一安装。
3.4 中断服务函数编写
中断服务函数(ISR)有严格限制:快进快出。不能在 ISR 里做延时、打印、动态内存分配等阻塞操作。
正确的做法是:ISR 里只做标记,具体处理交给任务。
// 中断服务函数——只做标记
static void IRAM_ATTR key_isr_handler(void* arg) {
int pin = *(int*)arg;
// 记录按键事件到队列(非阻塞)
key_event_t evt = {
.pin = pin,
.timestamp = xTaskGetTickCountFromISR()
};
xQueueSendFromISR(key_evt_queue, &evt, NULL);
}
// 任务函数——处理按键事件
void key_process_task(void* pvParameters) {
key_event_t evt;
while (1) {
if (xQueueReceive(key_evt_queue, &evt, portMAX_DELAY)) {
// 在这里做消抖和业务逻辑
if (debounce_read(evt.pin)) {
printf("Key %d pressed at %lu ms\n", evt.pin, evt.timestamp);
// 执行按键动作...
}
}
}
}
IRAM_ATTR 是什么?这个属性告诉编译器把函数放在 IRAM(指令 RAM)中,而不是 Flash。因为 Flash 访问有延迟,中断响应必须快。我见过有人忘了加这个,导致中断响应时间不稳定。
为什么用队列而不是全局变量?因为多个按键可能同时触发中断,队列能保证事件不丢失。全局变量会被覆盖。
3.5 知识体系总览
下面这张图总结了按键输入与中断的完整流程:
3.6 完整示例:长按与短按识别
最后,我分享一个实际项目中用到的长按短按识别代码。这个逻辑在智能家居面板上跑了一年多,没出过问题。
#define KEY_PIN GPIO_NUM_0
#define LONG_PRESS_MS 1000
typedef enum {
KEY_ACTION_NONE,
KEY_ACTION_SHORT,
KEY_ACTION_LONG
} key_action_t;
static QueueHandle_t key_evt_queue;
static uint32_t press_start_tick = 0;
static void IRAM_ATTR key_isr_handler(void* arg) {
int pin = (int)arg;
uint32_t tick = xTaskGetTickCountFromISR();
xQueueSendFromISR(key_evt_queue, &tick, NULL);
}
void key_task(void* pvParameters) {
uint32_t tick;
key_action_t action = KEY_ACTION_NONE;
int state = 0; // 0:等待按下, 1:等待释放
while (1) {
if (xQueueReceive(key_evt_queue, &tick, portMAX_DELAY)) {
if (state == 0) {
// 按下事件
press_start_tick = tick;
state = 1;
} else {
// 释放事件
uint32_t elapsed = (tick - press_start_tick) * portTICK_PERIOD_MS;
if (elapsed >= LONG_PRESS_MS) {
action = KEY_ACTION_LONG;
} else {
action = KEY_ACTION_SHORT;
}
state = 0;
// 分发按键动作
switch (action) {
case KEY_ACTION_SHORT:
printf("短按\n");
// 执行短按功能
break;
case KEY_ACTION_LONG:
printf("长按 %lu ms\n", elapsed);
// 执行长按功能
break;
default:
break;
}
}
}
}
}
void app_main(void) {
key_evt_queue = xQueueCreate(10, sizeof(uint32_t));
key_interrupt_init(KEY_PIN);
xTaskCreate(key_task, "key_task", 2048, NULL, 10, NULL);
}
关于长按阈值:1000ms 是我常用的值。但不同用户手感不同,我建议把这个值做成可配置参数,甚至允许用户通过上位机调整。
嗯,按键看似简单,但要做好真不容易。从硬件电路到软件消抖,再到中断响应和事件分发,每一步都有坑。记住一个原则:硬件能解决的不要丢给软件,中断里能少做的事绝不多做。这样写出来的代码,才经得起产品级的考验。