4、按键输入与中断:GPIO输入模式配置、软件消抖(延时消抖与状态机消抖)、外部中断(attachInterrupt)、中断服务函数(ISR)编写规范、按键控制LED开关实战
按键,是嵌入式系统里最基础、也最容易被忽视的输入设备。
我刚开始做项目时,觉得按键不就是读个电平嘛,简单。结果第一次做量产产品,用户反馈按键“按一下亮两下”、“有时候按了没反应”。排查了一整天,最后发现是抖动惹的祸。从那以后,我再也不敢小看这个小小的按键了。
今天这一章,咱们就把按键这件事彻底讲透。从GPIO输入配置,到软件消抖的两种主流方案,再到中断的玩法,最后做一个按键控制LED的完整实战。
4.1 GPIO输入模式配置
ESP32的GPIO,默认是输入模式。但直接读,会有问题。
你想想看,按键没按下时,引脚是悬空的。悬空状态下,电平是不确定的。可能读到高,也可能读到低,甚至来回跳变。这就是所谓的“浮空输入”。
解决办法很简单:上拉或下拉。
ESP32内部有上拉电阻,我们可以通过软件开启。我个人习惯用pinMode()配合INPUT_PULLUP模式。这样按键一端接GND,另一端接GPIO。平时引脚被拉到高电平,按下时被拉到低电平。
关键配置代码:
const int buttonPin = 0; // 我习惯用GPIO0,因为开发板上自带按键
void setup() {
pinMode(buttonPin, INPUT_PULLUP); // 开启内部上拉
// 或者用 pinMode(buttonPin, INPUT_PULLDOWN); // 开启内部下拉
}
小提示:如果你用外部上拉电阻,一般选10kΩ。阻值太小,功耗大;阻值太大,抗干扰能力差。10kΩ是个折中值,我在项目中一直这么用。
4.2 软件消抖:延时消抖
为什么需要消抖?
机械按键内部是金属弹片。按下时,弹片会反弹几次,才会稳定接触。这个过程大概持续5-20ms。如果不做处理,一次按下会被误判成多次。
最简单的消抖方法就是:等它稳定了再读。
延时消抖的思路:
- 检测到电平变化(比如从高变低)
- 延时10-20ms
- 再次读取电平
- 如果电平状态一致,说明是有效按下
const int buttonPin = 0;
const int ledPin = 2;
int lastButtonState = HIGH;
int buttonState;
void setup() {
pinMode(buttonPin, INPUT_PULLUP);
pinMode(ledPin, OUTPUT);
digitalWrite(ledPin, LOW);
}
void loop() {
int reading = digitalRead(buttonPin);
if (reading != lastButtonState) {
delay(20); // 延时消抖
buttonState = digitalRead(buttonPin);
if (buttonState == LOW) {
digitalWrite(ledPin, !digitalRead(ledPin)); // 翻转LED
}
lastButtonState = reading;
}
}
注意:延时消抖有个致命问题——delay()会阻塞程序。在延时期间,CPU啥也干不了。如果你的程序还要处理WiFi、传感器等任务,千万别这么写。我曾经在一个项目里用了延时消抖,结果WiFi连接频繁超时,排查了半天才发现是delay()惹的祸。
4.3 软件消抖:状态机消抖
既然delay()会阻塞,那有没有不阻塞的消抖方法?
有。用状态机。
说白了,就是用一个变量记录按键的“状态”,每隔几毫秒检查一次。状态机消抖的核心思想是:不等待,只判断。
状态机消抖的典型状态:
| 状态 | 说明 |
|---|---|
| IDLE | 空闲状态,等待按键按下 |
| PRESS_DEBOUNCE | 检测到按下,进入消抖等待 |
| PRESS_CONFIRMED | 消抖通过,确认按下 |
| RELEASE_DEBOUNCE | 检测到释放,进入消抖等待 |
const int buttonPin = 0;
const int ledPin = 2;
int buttonState = HIGH;
int lastButtonState = HIGH;
unsigned long lastDebounceTime = 0;
const unsigned long debounceDelay = 20; // 20ms消抖
enum ButtonState {
IDLE,
PRESS_DEBOUNCE,
PRESS_CONFIRMED,
RELEASE_DEBOUNCE
};
ButtonState state = IDLE;
void setup() {
pinMode(buttonPin, INPUT_PULLUP);
pinMode(ledPin, OUTPUT);
}
void loop() {
int reading = digitalRead(buttonPin);
unsigned long now = millis();
switch (state) {
case IDLE:
if (reading == LOW) {
state = PRESS_DEBOUNCE;
lastDebounceTime = now;
}
break;
case PRESS_DEBOUNCE:
if (now - lastDebounceTime >= debounceDelay) {
if (digitalRead(buttonPin) == LOW) {
state = PRESS_CONFIRMED;
} else {
state = IDLE; // 抖动干扰,回到空闲
}
}
break;
case PRESS_CONFIRMED:
digitalWrite(ledPin, !digitalRead(ledPin)); // 翻转LED
state = RELEASE_DEBOUNCE;
lastDebounceTime = now;
break;
case RELEASE_DEBOUNCE:
if (now - lastDebounceTime >= debounceDelay) {
if (digitalRead(buttonPin) == HIGH) {
state = IDLE; // 确认释放,回到空闲
}
}
break;
}
}
我的经验:状态机消抖虽然代码量多了点,但它是非阻塞的。你可以在loop()里同时处理WiFi、传感器、显示等任务。我现在的项目,只要涉及按键,一律用状态机。延时消抖?早就不用了。
4.4 外部中断:attachInterrupt
刚才的两种方法,都是在loop()里轮询按键。但轮询有个问题:如果loop()执行时间较长,可能会错过按键事件。
这时候就该中断出场了。
中断,说白了就是让硬件“通知”你,而不是你“盯着”它。按键按下时,CPU会暂停当前任务,跳转到中断服务函数(ISR)去处理。处理完再回来继续干活。
ESP32中断配置:
const int buttonPin = 0;
const int ledPin = 2;
volatile bool buttonPressed = false; // volatile关键字不能少
void IRAM_ATTR isr() {
buttonPressed = true; // ISR里只做标记,不做复杂操作
}
void setup() {
pinMode(buttonPin, INPUT_PULLUP);
pinMode(ledPin, OUTPUT);
attachInterrupt(digitalPinToInterrupt(buttonPin), isr, FALLING);
// FALLING: 下降沿触发(按下)
// RISING: 上升沿触发(释放)
// CHANGE: 电平变化触发
}
void loop() {
if (buttonPressed) {
buttonPressed = false;
digitalWrite(ledPin, !digitalRead(ledPin));
}
}
重要提醒:中断服务函数(ISR)里,千万别做这些事:
- 不要用
delay() - 不要用
Serial.print() - 不要做复杂的计算
- 不要操作I2C、SPI等总线
ISR的原则是:快进快出。只设置标志位,具体工作在loop()里处理。我曾经在ISR里写了个Serial.println(),结果程序频繁崩溃,查了好久才发现是中断里调用了阻塞函数。
4.5 中断服务函数(ISR)编写规范
ISR的编写,有几个硬性规范。我列出来,你最好背下来:
| 规范 | 说明 |
|---|---|
使用IRAM_ATTR |
将ISR放在IRAM中,避免Flash访问延迟 |
变量加volatile |
告诉编译器,这个变量可能被中断修改,不要优化 |
| ISR要短小精悍 | 只做标记,不做复杂操作 |
| 避免使用库函数 | 很多库函数不是中断安全的 |
共享变量加atomic |
如果多个中断共享变量,需要原子操作 |
标准ISR模板:
volatile bool flag = false;
void IRAM_ATTR isrHandler() {
flag = true; // 只做标记
}
void setup() {
attachInterrupt(digitalPinToInterrupt(0), isrHandler, FALLING);
}
void loop() {
if (flag) {
flag = false;
// 在这里处理实际工作
}
}
4.6 实战:按键控制LED开关
好了,理论讲完了。咱们来做一个完整的实战:按键控制LED开关,带中断和状态机消抖。
这个实战里,我会把中断和状态机结合起来。中断负责“唤醒”,状态机负责“消抖”。这样既不会错过按键事件,又不会阻塞主循环。
const int buttonPin = 0;
const int ledPin = 2;
volatile bool buttonInterrupt = false;
unsigned long lastDebounceTime = 0;
const unsigned long debounceDelay = 20;
enum ButtonState {
IDLE,
PRESS_DEBOUNCE,
PRESS_CONFIRMED,
RELEASE_DEBOUNCE
};
ButtonState state = IDLE;
bool ledState = false;
void IRAM_ATTR isr() {
buttonInterrupt = true;
}
void setup() {
pinMode(buttonPin, INPUT_PULLUP);
pinMode(ledPin, OUTPUT);
digitalWrite(ledPin, LOW);
attachInterrupt(digitalPinToInterrupt(buttonPin), isr, FALLING);
}
void loop() {
if (buttonInterrupt) {
buttonInterrupt = false;
// 中断只负责标记,消抖在loop里做
state = PRESS_DEBOUNCE;
lastDebounceTime = millis();
}
// 状态机消抖
switch (state) {
case IDLE:
// 等待中断触发
break;
case PRESS_DEBOUNCE:
if (millis() - lastDebounceTime >= debounceDelay) {
if (digitalRead(buttonPin) == LOW) {
state = PRESS_CONFIRMED;
} else {
state = IDLE; // 抖动干扰
}
}
break;
case PRESS_CONFIRMED:
ledState = !ledState;
digitalWrite(ledPin, ledState ? HIGH : LOW);
state = RELEASE_DEBOUNCE;
lastDebounceTime = millis();
break;
case RELEASE_DEBOUNCE:
if (millis() - lastDebounceTime >= debounceDelay) {
if (digitalRead(buttonPin) == HIGH) {
state = IDLE;
}
}
break;
}
// 这里可以放其他任务,比如WiFi、传感器等
// 不会因为按键而阻塞
}
我的建议:这个实战代码,是我在多个项目中验证过的。它兼顾了响应速度和稳定性。你可以在loop()里随意添加其他功能,按键不会干扰它们。嗯,这才是生产级代码该有的样子。
好了,这一章的内容就到这。按键虽小,但门道不少。从GPIO配置到消抖,从轮询到中断,每一步都有坑。希望你能把这些知识真正用起来,而不是停留在理论上。
下一章,咱们聊聊PWM输出和呼吸灯。到时候我会分享一个我踩过的坑——关于LED亮度不线性,很有意思。