Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 18

GPIO Interrupts: Responding to Pin Changes

Recommended resources

Hello! Welcome to the next lesson in our C++ for ESP32 course.

In the previous lesson, we established how to reliably read the state of a button using polling and internal pull-up/pull-down resistors. While polling (gpio_get_level() inside a loop) works, it's inefficient. Your ESP32's processor has to spend all its time repeatedly asking "Is the button pressed yet?". For a powerful dual-core chip designed for multitasking, this is a significant waste of resources.

Today, we'll learn a much more elegant and efficient solution: GPIO interrupts. This lesson will teach you how to configure the ESP32 to automatically trigger a function the moment a pin's state changes. This event-driven approach frees up the CPU to handle other tasks, which is essential for building responsive and complex embedded systems.

1. The Concept of an Interrupt

An interrupt is a signal to the processor that an event of immediate importance has occurred. When an interrupt is triggered, the processor pauses its current task, executes a special function to handle the event, and then resumes its original task. This special function is called an Interrupt Service Routine (ISR).

Think of it like this: polling is like repeatedly checking your front door to see if a visitor has arrived. An interrupt is like having a doorbell. You can focus on your work and only go to the door when the bell rings.

The general flow is as follows:

Interrupt Handling Process Flow
This diagram shows the flow of execution. The main program runs until an interrupt occurs. The system then switches to execute the Interrupt Service Routine (ISR). Once the ISR is complete, execution returns to the main program where it left off.

The ESP32 supports various interrupt sources, such as timers and communication peripherals, but today we will focus on external interrupts, which are triggered by a change in a GPIO pin's voltage level.


2. Setting Up GPIO Interrupts in ESP-IDF

Configuring a GPIO pin to trigger an interrupt involves a few distinct steps using the ESP-IDF C API. We'll start with this procedural approach, as it forms the foundation for any C++ wrapper you might build later.

The process is:

  1. Configure the GPIO pin: Set the pin as an input with the appropriate pull-up or pull-down resistor, just as we did in the last lesson.
  2. Install the global ISR service: This is a one-time setup for your application that enables the GPIO interrupt handling mechanism. You do this with gpio_install_isr_service().
  3. Register a specific handler for the pin: You attach your custom ISR function to a specific GPIO pin. This is done with gpio_isr_handler_add().
  4. Set the interrupt trigger type: You must specify what kind of event triggers the interrupt (e.g., the signal changing from HIGH to LOW).

The following video demonstrates this entire process to make a button control an LED.

ESP32 with ESP-IDF #2 | GPIO Interrupt & Using push button to control when our LED lights up

This video from 'ltkdt' provides a clear, step-by-step demonstration of setting up a GPIO interrupt using the ESP-IDF functions.

Watch the video from the beginning until 02:45. The first part (until 01:21) re-explains the interrupt concept and shows the key API functions. The second part (01:21 - 02:45) walks through the code, showing the configuration struct, the call to gpio_install_isr_service(), and adding the handler with gpio_isr_handler_add().

Safe GPIO Pins for Interrupts

Almost all GPIO pins on the ESP32 can be used for interrupts, but some are used for other critical functions (like connecting to the flash memory or during boot-up). Using the wrong pin can prevent your device from starting correctly.

Configuring & Handling ESP32 GPIO Interrupts

The article 'Configuring & Handling ESP32 GPIO Interrupts' from Last Minute Engineers provides an excellent summary of which pins are safe to use.

Read the section 'Which GPIO Pins Can You Use for Interrupts?'. Pay close attention to the table that categorizes pins as 'Safe to use', 'Use with caution', and 'Best to avoid'. This is a valuable reference for any project.


3. Writing an Interrupt Service Routine (ISR)

The ISR is the heart of your interrupt-driven logic, but it operates under very strict constraints because it halts the normal execution of your program. Here are the essential rules for writing a reliable ISR on the ESP32.

Rule 1: Keep it Short and Fast

An ISR must execute as quickly as possible. Avoid any long calculations, loops, or functions that might block, such as vTaskDelay() or printing to the serial port (printf, std::cout). The standard practice is to do the absolute minimum work in the ISR, like setting a flag, and let the main loop() or another FreeRTOS task handle the rest.

Rule 2: Use the IRAM_ATTR Attribute

By default, your code is stored in external flash memory. Accessing flash can be slow and is not always possible (e.g., if the flash cache is disabled for another operation). An ISR must be available instantly. The IRAM_ATTR macro tells the compiler to place your ISR code into the ESP32's fast internal RAM (IRAM), ensuring it can be executed immediately without delay or potential crashes.

void IRAM_ATTR my_isr_function(void* arg) {
    // ISR code goes here
}

Rule 3: Use volatile for Shared Variables

If a variable is modified inside an ISR and read in your main code (or vice-versa), you must declare it with the volatile keyword. This tells the compiler that the variable's value can change at any time, preventing it from making optimizations that might cause your main code to miss updates from the ISR.

volatile bool button_pressed = false;

void IRAM_ATTR my_isr_function(void* arg) {
    button_pressed = true;
}

void app_main() {
    // ... setup code ...
    while(1) {
        if (button_pressed) {
            // Handle the button press
            button_pressed = false; // Reset the flag
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

This video provides a concise explanation of why IRAM_ATTR and volatile are so important for reliability.

Tech Note 127 - ESP32/8266 Hints and Tips for Reliable Interrupt Operation

Let's watch this technical note from David (G6EJD) for a clear explanation of these critical concepts.

Watch from 00:29 to 03:29. The first segment explains why variables shared with an ISR must be volatile. The second segment explains the need for IRAM_ATTR to place the ISR in fast, reliable internal RAM.


4. A C++ Approach to GPIO Interrupts

Since your goal is to use C++, a great practice is to wrap the low-level C functions from ESP-IDF into a C++ class. This encapsulates the logic, improves type safety, and makes the API easier to use. The following resource demonstrates how to build a GpioInput class that handles interrupts.

ESP32 GPIO in C++ Part 2 - EmbeddedTutorials

The tutorial 'ESP32 GPIO in C++ Part 2' from EmbeddedTutorials.com builds a C++ class to manage GPIO inputs with interrupts. We will walk through its key components.

First, read 'Update the Header' to see the methods being added to the GpioInput class, like enableInterrupt and setEventHandler. Then, skim through 'Update the source file' to see how these methods are implemented using the ESP-IDF functions we've discussed. Finally, look at 'Create an implementation' to see how the class is used in main.cpp.

Let's break down the key ideas from that tutorial:

  1. Encapsulation: The GpioInput::enableInterrupt() method hides the details of calling gpio_install_isr_service(), gpio_set_intr_type(), and gpio_isr_handler_add(). It also uses a static boolean _interrupt_service_installed to ensure the global ISR service is only installed once.

  2. The Static ISR Callback: The ISR itself, GpioInput::gpio_isr_callback, is a static member function. It must be static because the C-style ESP-IDF interrupt handler doesn't know about C++ objects or this pointers. It's a standalone function that belongs to the class, not an instance.

    // Inside GpioInput class
    static void IRAM_ATTR gpio_isr_callback(void* arg);
    
  3. Deferring Work with an Event Loop: This is a more advanced and robust alternative to using a simple volatile flag.

    • Inside the ISR (gpio_isr_callback), the only thing it does is call esp_event_isr_post(). This function sends a message (an "event") to a system-wide event queue in a way that is safe to do from an ISR.
    • In your main application, you write an event handler function (button_event_handler in the tutorial).
    • You then register this handler using esp_event_handler_instance_register().
    • When the ISR posts an event, the default event loop task (running in the background) receives it and calls your registered handler.

This pattern is extremely powerful. It keeps the ISR incredibly short and fast, and the actual processing happens outside the interrupt context, so you can safely use printf, vTaskDelay, or other complex functions inside your event handler.


5. The Switch Bounce Problem

There is one final "gotcha" with physical buttons. When you press a mechanical switch, the metal contacts don't connect cleanly. They "bounce" against each other for a few milliseconds, creating a rapid series of HIGH-LOW transitions. Your ESP32 is so fast that it sees each of these bounces as a separate press, triggering the interrupt multiple times for a single physical press.

Switch Bounce Signal
An oscilloscope view of a switch bounce. Instead of a clean transition, the signal oscillates rapidly before settling.

This problem is called switch bounce, and it must be handled for reliable button input. While it can be solved with hardware (RC filters), a simple software solution is often sufficient. This is called debouncing.

The most common software technique is to record the time of an interrupt and ignore any subsequent interrupts for a short period (e.g., 50-250 milliseconds).

Configuring & Handling ESP32 GPIO Interrupts

Let's return to the Last Minute Engineers article to see a practical example of software debouncing.

Read the sections 'The Switch Bounce Problem' and 'Example 2: Debounced Button Interrupt'. The explanation and code example clearly demonstrate how to use a timestamp to reject false triggers caused by switch bounce.

Here is the core logic, adapted for ESP-IDF/C++ (using esp_timer_get_time() which returns microseconds):

volatile int64_t last_interrupt_time = 0;
const int64_t DEBOUNCE_TIME_US = 250000; // 250ms

void IRAM_ATTR my_debounced_isr(void* arg) {
    int64_t interrupt_time = esp_timer_get_time();
    if (interrupt_time - last_interrupt_time > DEBOUNCE_TIME_US) {
        // This is a valid press
        // Set a flag or post to a queue/event loop
        last_interrupt_time = interrupt_time;
    }
}
Test your understanding!

You are building a system where a button press needs to toggle an LED. Your main loop is busy performing a complex calculation that takes about 100ms to complete. Why is using an interrupt to detect the button press a better design than polling the button's state inside the main loop?

Show answer

If you were polling, you would only check the button's state once every 100ms. If a user presses and releases the button quickly (in less than 100ms), your polling loop could completely miss the press. An interrupt, however, guarantees that the press is detected instantly, regardless of what the main loop is doing. The ISR can set a flag that the main loop checks after its calculation is complete, ensuring the button press is never missed and the system feels responsive.


Conclusion

Congratulations! You've just learned one of the most fundamental and powerful features of an embedded microcontroller. Moving from polling to an event-driven interrupt model is a major step toward writing efficient and professional embedded code.

Key Takeaways:

  • Interrupts are a highly efficient way to respond to events, allowing the CPU to perform other tasks until it's needed.
  • The setup process in ESP-IDF involves installing the ISR service and adding a handler for a specific pin and trigger type.
  • An Interrupt Service Routine (ISR) must be fast, non-blocking, and placed in IRAM using IRAM_ATTR.
  • Variables shared between an ISR and main code must be declared volatile.
  • A best practice is to defer work from the ISR to a task using mechanisms like event loops or FreeRTOS queues.
  • Physical buttons require debouncing to handle mechanical switch bounce.

In our next lesson, we will explore another essential peripheral for controlling outputs: the LEDC (LED Control) peripheral. We will learn how to use it to generate Pulse-Width Modulation (PWM) signals, which are key for tasks like dimming LEDs and controlling the speed of DC motors.