Difficulty: Beginner-Intermediate | Time: 20 Minutes | Cost: < $8

Reading a physical pushbutton with a microcontroller seems trivial until you hit the realities of mechanical switch bounce, floating pins, and real-time operating system constraints. The ESP32 is a dual-core powerhouse running FreeRTOS, which means a poorly written button interrupt won't just miss a press—it can starve the watchdog timer and hard-crash the chip. This guide covers the exact hardware, wiring, and bulletproof polling code you need to interface an ESP32 button reliably, alongside a deep dive into debugging the most common interrupt crashes.

Hardware Spec Sheet & Parts List

Component Exact Variant / Specification Notes
Microcontroller ESP32-WROOM-32 DevKit V1 (30-pin) Code targets this exact 30-pin layout. 38-pin variants shift GPIO numbers.
Pushbutton 6x6mm Tactile Switch (4-pin) Standard SPST momentary. Fits perfectly across the breadboard center trench.
Resistor 10kΩ (1/4W, 5% tolerance) Used as an external pull-up. The ESP32's internal pull-ups are ~45kΩ, which is weak for noisy environments.
Capacitor 100nF (0.1µF) Ceramic (X7R) Forms an RC low-pass filter with the 10kΩ resistor for hardware debounce.
Wiring 22 AWG Solid Core Hookup Wire Standard breadboard wire. Keep runs under 10cm to minimize EMI pickup.

Pin Mapping & Wiring Steps

Not all ESP32 GPIO pins are created equal. Some are tied to the onboard SPI flash, and others are "strapping pins" that dictate boot modes. We use GPIO 32 for this build because it is a general-purpose ADC1 pin with no boot-strapping conflicts and supports both input and output.

ESP32 Pin Component Connection Function
3V3 10kΩ Resistor (Leg 1) Provides the HIGH logic level for the pull-up.
GPIO 32 10kΩ Resistor (Leg 2) & 100nF Cap (Leg 1) & Button (Pin 1) The digital input reading the button state.
GND 100nF Cap (Leg 2) & Button (Pin 3) Completes the circuit and discharges the capacitor.
Wiring Steps:
  1. Place the 6x6mm button across the center trench of your breadboard.
  2. Connect one side of the button to GND. Connect the opposite side to GPIO 32.
  3. Wire the 10kΩ resistor between 3V3 and GPIO 32 (this pulls the pin HIGH when the button is open).
  4. Wire the 100nF capacitor between GPIO 32 and GND. This creates an RC filter with a time constant of τ = 10kΩ × 100nF = 1ms, physically absorbing the microsecond-scale mechanical bounce of the switch contacts.

Compilable ESP32 Button Code with Debounce

The code below uses a non-blocking polling state machine. While hardware interrupts (attachInterrupt) seem appealing, they are notorious for causing watchdog resets on the ESP32 if the switch bounces violently or if the ISR executes slow functions like Serial.print(). Polling with millis() is safer for human-interface buttons.

Target Board Variant: ESP32-WROOM-32 DevKit V1 (30-pin) via Arduino IDE (ESP32 Core v2.0.x or v3.0.x).

/*
 * ESP32 Button Polling with Software Debounce & Error Handling
 * Target: ESP32-WROOM-32 DevKit V1 (30-pin)
 */

#define BUTTON_PIN 32
#define DEBOUNCE_DELAY_MS 50
#define SERIAL_BAUD 115200

// State variables
bool lastReading = HIGH;
bool stableState = HIGH;
unsigned long lastDebounceTime = 0;
unsigned long pressCount = 0;

void setup() {
  Serial.begin(SERIAL_BAUD);
  
  // Error Handling: Wait for Serial monitor to connect (useful for native USB boards, 
  // but safe for UART bridges too with a timeout)
  unsigned long serialTimeout = millis();
  while (!Serial && (millis() - serialTimeout < 2000)) {
    delay(10);
  }

  // Verify pin validity before configuring
  if (BUTTON_PIN < 0 || BUTTON_PIN > 39) {
    Serial.println("FATAL: Invalid GPIO pin defined. Check BUTTON_PIN.");
    while (1) { delay(1000); } // Halt execution safely
  }

  // Configure pin. We use INPUT_PULLUP as a fallback safety net, 
  // even though we have an external 10k hardware pull-up.
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  
  Serial.println("ESP32 Button Interface Initialized.");
  Serial.printf("Monitoring GPIO %d. Press the button...\n", BUTTON_PIN);
}

void loop() {
  // Read the physical pin state
  bool currentReading = digitalRead(BUTTON_PIN);

  // If the reading changed, reset the debounce timer
  if (currentReading != lastReading) {
    lastDebounceTime = millis();
  }

  // If the state has been stable longer than the debounce delay
  if ((millis() - lastDebounceTime) > DEBOUNCE_DELAY_MS) {
    // If the stable state is different from the current debounced state
    if (currentReading != stableState) {
      stableState = currentReading;
      
      // Button pressed (Active LOW due to pull-up resistor)
      if (stableState == LOW) {
        pressCount++;
        Serial.printf("[PRESSED] Count: %lu | Time: %lums\n", pressCount, millis());
      } else {
        Serial.println("[RELEASED]");
      }
    }
  }

  // Save the raw reading for the next loop iteration
  lastReading = currentReading;
}

Debugging: "Guru Meditation Error" on Button Press

If you decided to use attachInterrupt() instead of polling and your ESP32 reboots every time you press the button, you are likely staring at this exact serial output:

Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1)

This is the ESP32's FreeRTOS Interrupt Watchdog Timer (WDT) killing your program. The WDT expects the CPU to service interrupts and return to the RTOS scheduler quickly. When a button triggers an interrupt, the ISR halts normal execution. If the ISR takes too long, the WDT assumes the core is deadlocked and resets the chip.

Ranked Causes for this Error

  1. Calling forbidden functions inside the ISR: You cannot use Serial.print(), delay(), or millis() inside an ISR. These rely on background RTOS tasks or hardware timers that are paused while the ISR runs.
  2. Missing the IRAM_ATTR flag: By default, Arduino code runs from flash memory. If flash is being accessed (e.g., by the other core) when your interrupt fires, the CPU stalls waiting for flash access, triggering the WDT. ISRs must be copied to fast RAM using void IRAM_ATTR myISR() { ... }.
  3. Severe Switch Bounce: A cheap tactile switch can bounce 50 times in 2 milliseconds. If each bounce fires an interrupt, the CPU spends 100% of its time entering and exiting the ISR, starving the FreeRTOS Idle Task, which in turn triggers the Task Watchdog.
The First Three Things to Check When It Fails:
  1. Check Boot Strap Conflicts: Did you wire the button to GPIO 0, 2, or 12? If your button pulls GPIO 0 LOW on boot, the ESP32 enters UART bootloader mode and your code won't run. If it pulls GPIO 12 HIGH, the flash voltage regulator misconfigures and the chip brownouts.
  2. Check the Pull-Up Configuration: If you omitted the external 10kΩ resistor, did you remember to set pinMode(pin, INPUT_PULLUP)? A floating pin will read random EMI noise from your mains wiring, causing thousands of phantom interrupts per second.
  3. Check ISR Contents: Open your ISR function. If there is a Serial.print or a delay() inside it, delete it immediately. Set a volatile bool flag inside the ISR, and handle the Serial printing in the main loop().

For deeper architectural rules on ESP32 interrupts, consult the official Espressif GPIO API Reference, which explicitly details IRAM requirements and interrupt allocation.

Extending and Simplifying the Build

How to Simplify: If you are building a simple toy or a non-critical interface and want to skip the RC hardware filter and manual millis() math, use the Bounce2 library. It abstracts the debounce state machine into a few lines of code. Just install it via the Arduino Library Manager, instantiate Bounce debouncer = Bounce();, and call debouncer.update() in your loop.

How to Extend: If you want to build a multi-button macro pad or a volume knob, you cannot dedicate one GPIO per button—you will run out of pins. Extend the build by wiring a resistor ladder (R-2R) or using an I2C GPIO expander like the PCF8574. For rotary encoders, you must use hardware interrupts (with the IRAM_ATTR and volatile flags strictly enforced) because polling an encoder at human speeds often misses quadrature steps.

ESP32 Button FAQ

Why does my ESP32 button trigger randomly when I touch the wire?

This is the classic "floating pin" problem. When the button is not pressed, the wire between the button and the GPIO pin acts as an antenna, picking up 50/60Hz electromagnetic interference from nearby AC mains wiring and your own body's capacitance. The ESP32 interprets this noise as rapid HIGH/LOW transitions. The fix is to ensure the pin is tied to 3V3 via a pull-up resistor (either the internal 45kΩ INPUT_PULLUP or an external 10kΩ resistor) so it has a firm, unambiguous HIGH state when the switch is open.

Can I use GPIO 0 or GPIO 2 for an ESP32 button?

You can, but with strict caveats. GPIO 0, 2, 5, 12, and 15 are "strapping pins" sampled by the ESP32's bootloader during the first 100ms of power-on. If your button is wired to pull GPIO 0 to GND (active LOW) and you happen to press it while the board is resetting or powering on, the ESP32 will boot into UART flash mode instead of running your sketch. GPIO 2 is tied to the onboard LED on many DevKits and must be LOW or floating to boot. For reliable, headache-free button wiring, stick to GPIOs 32, 33, 34, 35, 25, 26, or 27.

How do I wake an ESP32 from deep sleep with a button?

Standard GPIO pins lose power and state in deep sleep. To wake the ESP32 with a button, you must use the Ultra-Low-Power (ULP) co-processor or the RTC (Real-Time Clock) GPIO matrix. You can configure the wake source using esp_sleep_enable_ext0_wakeup(GPIO_NUM_32, 0) in the Arduino IDE before calling esp_deep_sleep_start(). This tells the RTC controller to monitor GPIO 32 and trigger a full system reset when the pin drops to 0V (LOW). For a complete implementation guide, refer to this deep sleep wake-up source tutorial. Note that only RTC-capable GPIOs (like 32, 33, 34, 35) can be used for ext0/ext1 wakeups; GPIO 25-27 cannot wake the chip from deep sleep.