4. 按键输入与中断:数字输入原理、按键消抖、外部中断配置、中断服务函数编写
按键,是嵌入式系统里最基础、也最容易被忽视的输入方式。你想想看,一个设备怎么知道用户想让它干什么?大部分时候,就是靠按键按下这个简单的动作。但就是这个“按下”,背后藏着不少门道。
我个人习惯把按键处理分成三个层次:读对电平、消掉抖动、用中断还是轮询。这一章,咱们就把这三件事彻底讲透。
4.1 数字输入原理:GPIO怎么知道按键被按下了?
ESP32的GPIO引脚,在输入模式下,就是一个数字电平检测器。它只能识别两种状态:高电平(3.3V)和低电平(0V)。
按键的典型接法有两种:
- 上拉输入:按键一端接GND,另一端接GPIO,GPIO外部或内部上拉到VCC。按键按下 → GPIO读到低电平。
- 下拉输入:按键一端接VCC,另一端接GPIO,GPIO外部或内部下拉到GND。按键按下 → GPIO读到高电平。
ESP32内部有可配置的上拉/下拉电阻,省掉了外部电阻。但我建议:对长线连接的按键,还是加外部10kΩ上拉电阻更可靠。我在一个工业项目里遇到过,只用内部上拉,结果线缆一长,干扰信号让按键频繁误触发。
| 配置方式 | 常态电平 | 按下电平 | 内部电阻可用性 |
|---|---|---|---|
| 上拉输入 | 高(3.3V) | 低(0V) | 可用,约45kΩ |
| 下拉输入 | 低(0V) | 高(3.3V) | 可用,约45kΩ |
4.2 按键消抖:为什么不能直接读电平?
你按下按键的瞬间,金属触点不是“啪”一下就稳定接触的。它会弹跳几次,在几毫秒到几十毫秒内,产生一连串的脉冲。直接读这个信号,程序会认为你按了好多次。
消抖的本质就是:等信号稳定了再读取。
常用的消抖方法有两种:
- 硬件消抖:在按键两端并联一个0.1μF电容,利用电容的充放电特性滤除毛刺。简单粗暴,但增加BOM成本。
- 软件消抖:检测到电平变化后,延时10~20ms,再读一次。如果电平一致,才确认按键状态。
我几乎只用软件消抖。原因很简单:改延时比改电路容易多了。
一个经典的软件消抖代码片段:
// 非阻塞消抖函数
int debounce_read(gpio_num_t pin) {
if (gpio_get_level(pin) == 0) { // 第一次读到低电平
vTaskDelay(pdMS_TO_TICKS(20)); // 等待20ms
if (gpio_get_level(pin) == 0) { // 再次确认
return 1; // 确认按下
}
}
return 0; // 未按下或抖动
}
delay(20) 这种阻塞延时做消抖。在单任务系统里没问题,但在FreeRTOS下,这会让整个系统卡住20ms。一定要用 vTaskDelay 或定时器轮询。
4.3 外部中断配置:让CPU别傻等
轮询方式读取按键,CPU得不停地去查电平。浪费电,也浪费算力。中断的思路是:让按键主动通知CPU。
ESP32的每个GPIO都可以配置为外部中断源。中断触发模式有四种:
- 上升沿触发:电平从低变高时触发。
- 下降沿触发:电平从高变低时触发。
- 任意沿触发:电平变化就触发。
- 低电平触发:电平为低时持续触发(ESP32部分引脚支持)。
配置外部中断的步骤,我总结为三步:
- 配置GPIO为输入模式:设置上拉/下拉。
- 注册中断服务:告诉ESP32,这个引脚的中断由哪个函数处理。
- 设置触发条件:选择上升沿还是下降沿。
代码示例:
// 按键中断初始化
void button_interrupt_init(gpio_num_t pin) {
// 1. 配置GPIO
gpio_config_t io_conf = {};
io_conf.intr_type = GPIO_INTR_NEGEDGE; // 下降沿触发
io_conf.pin_bit_mask = (1ULL << pin);
io_conf.mode = GPIO_MODE_INPUT;
io_conf.pull_up_en = GPIO_PULLUP_ENABLE; // 内部上拉
gpio_config(&io_conf);
// 2. 安装GPIO中断服务
gpio_install_isr_service(0);
// 3. 注册中断处理函数
gpio_isr_handler_add(pin, isr_handler, (void*)pin);
}
gpio_install_isr_service 在整个程序中只需要调用一次。重复调用会导致错误。
4.4 中断服务函数编写:短、平、快
中断服务函数(ISR)是运行在中断上下文里的。它有几个铁律:
- 不能调用阻塞函数:比如
vTaskDelay、printf、malloc。 - 不能做复杂运算:浮点运算、大循环都不行。
- 执行时间要短:最好在微秒级完成。
那ISR里到底该干什么?我的做法是:只做一件事——发信号。
在FreeRTOS下,ISR里最常用的机制是:
- 二值信号量:通知任务按键发生了。
- 队列:把按键值发给任务处理。
- 任务通知:轻量级信号量,效率更高。
一个典型的ISR写法:
// 中断服务函数
static void IRAM_ATTR isr_handler(void* arg) {
uint32_t gpio_num = (uint32_t)arg;
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// 发送信号量给处理任务
xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
// 如果需要上下文切换
if (xHigherPriorityTaskWoken) {
portYIELD_FROM_ISR();
}
}
// 任务中等待信号量
void button_task(void* pvParameters) {
while (1) {
if (xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
// 在这里做消抖和按键处理
vTaskDelay(pdMS_TO_TICKS(20));
if (gpio_get_level(BUTTON_PIN) == 0) {
// 执行按键动作
printf("Button pressed!\n");
}
}
}
}
IRAM_ATTR 属性,把代码放到IRAM中运行。否则,如果Flash缓存未命中,会导致崩溃。
4.5 本章知识体系
下面这张图,把按键输入与中断的完整流程串起来了。从硬件电平到ISR,再到任务处理,每一步都有对应的技术点。
从这张图可以看得很清楚:硬件是基础,消抖是保障,中断是效率,任务是归宿。每一层都有明确的职责,不能跳过,也不能混淆。
嗯,这一章的内容就到这里。按键看似简单,但处理不好,会让整个系统显得很不专业。把消抖和中断的细节做到位,你的设备用起来才会有“高级感”。