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
HIGHat rest; pressing pulls it toGND. What I use almost always. - Pull-down: the pin is
LOWat rest; pressing pulls it to3V3.
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(). NoSerial.printordelayin there. - Even with an interrupt, bounce still exists: debounce anyway.
Typical mistakes
- A button with no pull-up and pins reading noise → you think the ESP32 is broken.
- Using GPIO34–39 for a button and discovering there is no internal pull-up.
- Putting
Serial.printlninside the ISR → random hangs. - Forgetting
volatile→ the compiler “optimizes” and the flag never changes. - 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.