ESP32 GPIO explained: inputs, outputs, pull-ups and interrupts
- 1 ESP32 GPIO explained: inputs, outputs, pull-ups and interrupts
- 2 How to use ESP32 timers: stop using delay() in your firmware
- 3 ESP32 MQTT tutorial: publish and subscribe with PubSubClient
- 4 ESP32 FreeRTOS: tasks, queues and semaphores in plain English
- 5 ESP32 watchdog timer: build firmware that recovers from crashes
- 6 ESP32 OTA updates: push firmware wirelessly in production
If you’ve spent any time with the ESP32, you’ve probably hit at least one of these: a pin that reads random values for no apparent reason, an interrupt that fires dozens of times per button press, or a reset that only happens when you add a serial print. These aren’t beginner mistakes — they’re consequences of not understanding what the ESP32 GPIO subsystem actually does under the hood. This post fixes that.
The basics you probably already know (but let’s be precise)
pinMode(pin, mode) configures a GPIO. digitalWrite(pin, value) drives it. digitalRead(pin) samples it. You know this. What the Arduino abstraction hides is what’s actually happening in the ESP32’s IO MUX and GPIO matrix — and that matters the moment you do anything non-trivial.
The ESP32 has 34 physical GPIO pins (on the WROOM module, fewer are accessible). They’re not all equal:
| GPIO Range | Input | Output | Notes |
|---|---|---|---|
| 0–19, 21–23, 25–27, 32–39 | Yes | Yes (most) | General purpose |
| 34–39 | Yes | No | Input-only, no internal pull-up/down |
| 6–11 | Avoid | Avoid | Connected to flash SPI |
GPIO 6–11 are connected to the internal flash chip’s SPI bus. Touching them will corrupt flash reads or reset the chip. This trips up people porting code from Arduino Uno.
Floating pins: the source of mysterious bugs
Here’s the wrong way to read a button:
// WRONG: floating input
void setup() {
pinMode(4, INPUT); // No pull-up/down
}
void loop() {
int val = digitalRead(4);
Serial.println(val); // Prints random 0s and 1s when button is open
}
An unconnected input pin is a floating antenna. It picks up electromagnetic interference from your power supply, nearby traces, your hand moving close to the board. The result is a pin that reads seemingly random HIGH/LOW states. This becomes a real problem when that pin controls something — a relay, a motor enable, a safety interlock.
The fix is a pull-up or pull-down resistor. ESP32 has internal ones:
// Correct: enable internal pull-up
pinMode(4, INPUT_PULLUP); // Pin reads HIGH when open, LOW when button shorts to GND
// Or pull-down (not available on all MCUs, ESP32 supports it)
pinMode(4, INPUT_PULLDOWN); // Pin reads LOW when open, HIGH when button connects to 3.3V
The internal pull-up on ESP32 is approximately 45kΩ. That’s fine for most cases but weaker than the 10kΩ you’d use externally. For noise-sensitive applications or long cable runs, use an external resistor.
Warning: GPIO 34–39 have no internal pull-up or pull-down hardware. If you use them as inputs, you must add external resistors.
INPUT_PULLUPon these pins does nothing — no error, no warning, just floating behavior.
The GPIO matrix: flexible routing with a catch
On an ATmega328 (Arduino Uno), each peripheral is hard-wired to specific pins. UART0 is always on pins 0 and 1. SPI is always on 11, 12, 13. You can’t move them.
The ESP32 has a GPIO matrix that allows most peripherals to be routed to almost any GPIO. UART2 TX can be on GPIO 17 or GPIO 33 — your choice. This is powerful for PCB layout. The catch: the GPIO matrix adds one APB clock cycle of latency to output signals. For signals above ~1 MHz, this matters. High-speed SPI and I2S bypass the matrix and use the IO MUX directly, which is why those peripherals have preferred pins.
For most firmware work, the matrix is transparent. You just need to know it exists when someone asks you why a GPIO-matrix-routed output looks slightly different on a scope than a direct IO MUX output.
Debouncing: why your button fires 50 times per press
Mechanical buttons bounce. When the contacts close, they physically bounce apart and reconnect multiple times over 5–50ms before settling. If you’re reading the pin with an interrupt on every edge, you’ll see dozens of events per button press.
Hardware debouncing
Add an RC filter before the GPIO:
Button ──┬── 10kΩ ── GPIO
│
GND (via 100nF cap)
A 10kΩ resistor and 100nF capacitor gives a time constant of τ = RC = 1ms. The cap smooths out bounces shorter than a few milliseconds. Combined with a Schmitt trigger input (ESP32 inputs have this), you get clean edges. This is the preferred solution for production hardware where you control the PCB.
Software debouncing in an ISR context
If you’re doing software debouncing, the common mistake is doing it inside the ISR:
// WRONG: blocking delay inside ISR
void IRAM_ATTR buttonISR() {
delay(50); // NEVER do this in an ISR
if (digitalRead(BUTTON_PIN) == LOW) {
// handle press
}
}
delay() calls vTaskDelay() internally, which yields the current task. You cannot yield from an ISR. This will crash or hang your firmware.
The correct pattern: record the event time in the ISR, do the debounce check in normal task context:
#define BUTTON_PIN 4
#define DEBOUNCE_MS 50
volatile bool buttonPressed = false;
volatile uint32_t lastPressTime = 0;
void IRAM_ATTR buttonISR() {
uint32_t now = millis();
if (now - lastPressTime > DEBOUNCE_MS) {
buttonPressed = true;
lastPressTime = now;
}
}
void setup() {
pinMode(BUTTON_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
}
void loop() {
if (buttonPressed) {
buttonPressed = false;
Serial.println("Button pressed");
}
}
This works but has a subtle issue: millis() in an ISR is generally safe on ESP32 (it reads a hardware counter), but buttonPressed and lastPressTime are shared between ISR and normal code without synchronization. More on that in the volatile section.
attachInterrupt() and IRAM_ATTR
attachInterrupt(digitalPinToInterrupt(pin), ISR_function, mode);
mode is one of: RISING, FALLING, CHANGE, LOW, HIGH.
The digitalPinToInterrupt() macro is technically redundant on ESP32 (every GPIO can be an interrupt source and the number maps 1:1), but it’s good practice for portability.
IRAM_ATTR tells the linker to place the function in IRAM (Internal RAM) instead of flash. This is mandatory for ISR functions on ESP32. Here’s why:
The ESP32 executes code from flash via a cache. If the cache misses when the CPU fetches an instruction (because the cache line was evicted), it stalls while flash is read — this can take hundreds of microseconds. ISRs need to execute immediately and deterministically. Code placed in IRAM bypasses the cache entirely; it’s always resident in fast internal RAM.
If you forget IRAM_ATTR on an ISR, your code will usually work in development (the function is often cached), but will crash intermittently in production when the cache is cold, which is exactly the kind of bug that’s hell to reproduce.
// WRONG: ISR not in IRAM
void buttonISR() { ... }
// CORRECT: ISR explicitly placed in IRAM
void IRAM_ATTR buttonISR() { ... }
Any function called from the ISR also needs IRAM_ATTR. If your ISR calls a helper function that’s in flash, the CPU will try to cache-fetch it during the interrupt — potentially while the flash cache is disabled (which happens during OTA writes, for example). The result is a crash.
ISR rules: what you can and can’t do
ISRs run with interrupts (partially) disabled at elevated priority. The rules are strict:
You CAN:
- Read/write GPIO registers directly
- Call functions marked
IRAM_ATTR - Use
xQueueSendFromISR()and other*FromISRFreeRTOS variants - Read
millis()andmicros()(hardware counter, always available) - Set
volatilevariables
You CANNOT:
- Call
delay(),vTaskDelay(), or anything that blocks - Call non-IRAM functions (crashes when flash cache is disabled)
- Use
Serial.print()(internally uses mutexes which may be held by the interrupted task) - Allocate memory (
malloc,new) — heap operations use mutexes - Call most Arduino library functions that aren’t explicitly ISR-safe
The standard pattern for ISR-to-task communication is a FreeRTOS queue with xQueueSendFromISR(), or a simple volatile flag for one-shot signals.
volatile variables: necessary but not sufficient
volatile tells the compiler: “this variable may change outside the normal flow of execution — do not cache it in a register, reload it from memory every time.”
Without volatile, the compiler may optimize:
bool buttonPressed = false;
void loop() {
while (!buttonPressed) { // Compiler: "buttonPressed is never set here, optimize to while(true)"
// wait
}
}
The compiler doesn’t know about the ISR. It sees a variable that’s never written in this loop and optimizes the check away. volatile prevents this:
volatile bool buttonPressed = false;
But volatile is not an atomicity guarantee. On a 32-bit processor like the Xtensa LX6, reading or writing a 32-bit value is atomic — one instruction. But a 64-bit value or a struct requires multiple instructions, and an ISR can interrupt mid-way. Use volatile for primitive types (bool, uint8_t, uint32_t) that fit in one CPU word. For larger data, use a FreeRTOS queue or disable interrupts around the access.
Complete example: interrupt-driven button with LED toggle and debounce
Here’s a complete, production-quality example. A button on GPIO 4 toggles an LED on GPIO 2, with interrupt-driven detection and proper debouncing:
#include <Arduino.h>
#define BUTTON_PIN 4
#define LED_PIN 2
#define DEBOUNCE_MS 50
// Shared between ISR and main code — must be volatile
volatile bool pendingToggle = false;
volatile uint32_t lastInterruptTime = 0;
void IRAM_ATTR buttonISR() {
uint32_t now = millis();
if ((now - lastInterruptTime) > DEBOUNCE_MS) {
lastInterruptTime = now;
pendingToggle = true;
}
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
pinMode(BUTTON_PIN, INPUT_PULLUP);
// Trigger on falling edge: pin goes HIGH→LOW when button is pressed
// (with INPUT_PULLUP, button pull pin to GND)
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
Serial.println("Ready. Press button to toggle LED.");
}
void loop() {
if (pendingToggle) {
// Clear the flag first to avoid missing rapid presses
// This is safe because pendingToggle is bool (atomic read/write on LX6)
pendingToggle = false;
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
Serial.printf("LED toggled at %lu ms\n", millis());
}
// Your other non-blocking code goes here
vTaskDelay(pdMS_TO_TICKS(1)); // Yield to FreeRTOS scheduler
}
GPIO current limits: ESP32 vs Arduino Uno
This is where people burn GPIO pins, sometimes literally.
| Parameter | ESP32 | Arduino Uno (ATmega328) |
|---|---|---|
| Max current per GPIO (source/sink) | 40 mA | 40 mA |
| Recommended max per GPIO | 12 mA | 20 mA |
| Total current all GPIOs combined | ~1200 mA (practical: much less) | 200 mA |
| 3.3V GPIO voltage | Yes | No (5V) |
The ESP32 GPIO spec says 40 mA max per pin, but the chip’s internal power rails are not designed to sustain that. Espressif recommends staying under 12 mA per GPIO in practice. The total current from all GPIOs combined should stay well under the chip’s power budget.
The Uno operates at 5V logic. The ESP32 operates at 3.3V and is NOT 5V tolerant on most pins. Feeding 5V logic into an ESP32 GPIO will eventually damage it, often not immediately, which makes it a particularly insidious failure mode.
For anything drawing more than 10 mA — LEDs at full brightness, relays, motors — use a transistor or MOSFET as a buffer. Don’t drive them directly from GPIO.
// Driving a relay via NPN transistor
// GPIO → 1kΩ → Base of 2N2222
// Collector → relay coil → 5V
// Emitter → GND
// Flyback diode across relay coil (1N4007)
// In code, this is still just:
digitalWrite(RELAY_PIN, HIGH); // Transistor conducts, relay energizes
What’s next
Now that you have solid GPIO fundamentals — including the interrupt and volatile machinery — the next logical step is networking. The ESP32 MQTT tutorial covers exactly that: getting your ESP32 talking to a broker reliably, with proper reconnection handling for WiFi drops and broker disconnects. The interrupt pattern you saw here will reappear there in the context of the MQTT callback function, which runs under similar constraints.
Related posts
Comments
Enjoyed this tutorial?
Get new ESP32, Arduino, and industrial IoT tutorials straight to your inbox — no spam, unsubscribe anytime.