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。如果不做处理,一次按下会被误判成多次。

最简单的消抖方法就是:等它稳定了再读

延时消抖的思路:

  1. 检测到电平变化(比如从高变低)
  2. 延时10-20ms
  3. 再次读取电平
  4. 如果电平状态一致,说明是有效按下
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亮度不线性,很有意思。