Reading a push button properly: pull-up, debounce and interrupts

How to wire a button to the ESP32 without headaches: internal pull-up, why it bounces, software and hardware debounce, and interrupts done right.

Also available in: EN, ES

Series Introduction to the ESP32 Part 20 View the series →
A push button wired to an ESP32

Reading a button sounds like the easiest thing in the world: digitalRead() and done. Then the usual happens: one press counts as five, or the pin reads random noise while nobody is touching it. Both have an explanation and a fix.

First: never leave a pin “floating”

If you wire a button between the pin and 3V3 with nothing else, when it is not pressed the pin floats and reads garbage. You need a resistor to pull it to a known level:

  • Pull-up: the pin is HIGH at rest; pressing pulls it to GND. What I use almost always.
  • Pull-down: the pin is LOW at rest; pressing pulls it to 3V3.

The ESP32 has an internal pull-up, so you usually do not need a resistor:

constexpr int BTN = 4;

void setup() {
  Serial.begin(115200);
  pinMode(BTN, INPUT_PULLUP);   // rest = HIGH, pressed = LOW
}

void loop() {
  if (digitalRead(BTN) == LOW) {
    Serial.println("pressed");
  }
}

Careful: GPIO34–39 have no internal pull-up (input only). If you use one of those, you need an external resistor.

Why it “bounces” (and why you see 5 presses)

A button does not go cleanly from HIGH to LOW. For a few milliseconds it makes and breaks contact several times. The ESP32, running at 240 MHz, sees it all. That is bounce.

Software debounce (my favourite)

constexpr int BTN = 4;
constexpr unsigned long DEBOUNCE_MS = 30;

bool stable = HIGH;
bool last = HIGH;
unsigned long change = 0;

void loop() {
  bool reading = digitalRead(BTN);
  if (reading != last) {
    change = millis();
    last = reading;
  }
  if (millis() - change > DEBOUNCE_MS && reading != stable) {
    stable = reading;
    if (stable == LOW) Serial.println("valid press");
  }
}

The idea: you only accept a change if it stays stable for 30 ms. That is enough for 99 % of buttons.

Hardware debounce

If the button is critical or noisy, an RC (10 kΩ + 100 nF) and, if needed, a Schmitt trigger (74HC14) fix it. More parts, but zero code.

Interrupts: powerful and dangerous

With attachInterrupt() the ESP32 runs your function instantly. Great for not missing presses, but it has rules:

volatile bool pressed = false;

void IRAM_ATTR onPress() {   // IRAM_ATTR: it must live in IRAM
  pressed = true;
}

void setup() {
  pinMode(BTN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(BTN), onPress, FALLING);
}

void loop() {
  if (pressed) {
    pressed = false;
    Serial.println("interrupt");
  }
}

Golden rules:

  • The shared variable must be volatile.
  • The function must be IRAM_ATTR (otherwise it can fail with Wi-Fi).
  • Do not do heavy work inside the ISR: set a flag and handle it in loop(). No Serial.print or delay in there.
  • Even with an interrupt, bounce still exists: debounce anyway.

Typical mistakes

  1. A button with no pull-up and pins reading noise → you think the ESP32 is broken.
  2. Using GPIO34–39 for a button and discovering there is no internal pull-up.
  3. Putting Serial.println inside the ISR → random hangs.
  4. Forgetting volatile → the compiler “optimizes” and the flag never changes.
  5. No debounce and counting 5 presses per press.

Summary

  • Never leave an input pin floating: use INPUT_PULLUP.
  • Buttons bounce; 30 ms of debounce fixes it.
  • Interrupts: volatile, IRAM_ATTR, minimal work and debounce too.
  • GPIO34–39 have no pull-up: external resistor required.
  • With hardware (RC + Schmitt) you save code if the noise is serious.

Next step: serial monitor and basic debugging, to stop guessing why it is not working. Or go back to the series hub.

Related posts

↑↓ navigate ↵ open Esc close