Chapter 17: ESP32 Interrupt Handling in FreeRTOS Context

Chapter Objectives

  • Understand the concept and purpose of hardware interrupts in embedded systems.
  • Learn how interrupts are managed in ESP32 (Xtensa vs RISC-V overview).
  • Understand the structure and constraints of an Interrupt Service Routine (ISR).
  • Learn how to register and enable GPIO interrupts using ESP-IDF drivers.
  • Recognize the need for ISR-safe FreeRTOS API functions (...FromISR).
  • Master the use of FromISR functions (e.g., xSemaphoreGiveFromISR, xQueueSendFromISR, xTaskNotifyFromISR) to communicate with tasks.
  • Understand and implement deferred interrupt processing techniques.
  • Learn about interrupt priorities and critical sections (portENTER_CRITICAL/portEXIT_CRITICAL).
  • Use the ESP-IDF interrupt allocation API (esp_intr_alloc).
  • Identify and troubleshoot common interrupt handling issues.

Introduction

Imagine needing your ESP32 to react immediately when a button is pressed, a sensor value crosses a threshold, or data arrives on a communication peripheral. Constantly checking the status of these inputs in a loop (a technique called polling) is highly inefficient. It consumes CPU cycles checking for events that happen infrequently and introduces latency between the event occurring and the system reacting.

Hardware interrupts provide a much more efficient and responsive solution. An interrupt is a signal from a hardware peripheral to the CPU indicating that an event requiring immediate attention has occurred. When an interrupt occurs, the CPU automatically suspends its current work, saves its context, and jumps to a special function called an Interrupt Service Routine (ISR) designed to handle that specific event. Once the ISR completes, the CPU restores the saved context and resumes its previous work.

In a bare-metal system, ISRs are relatively straightforward. However, integrating interrupts with a Real-Time Operating System like FreeRTOS introduces complexities. ISRs execute outside the normal task scheduling flow, and directly calling standard FreeRTOS functions from an ISR can corrupt the scheduler’s internal state, leading to crashes. This chapter explores how to correctly handle hardware interrupts within the FreeRTOS context on the ESP32, ensuring both responsiveness and system stability using ESP-IDF v5.x APIs.

Theory

What are Interrupts?

An interrupt is an asynchronous signal from hardware or software indicating an event that needs immediate attention. In the context of microcontrollers like the ESP32, we primarily deal with hardware interrupts generated by peripherals:

  • GPIO: A change in the input level (rising edge, falling edge, high level, low level) on a configured pin.
  • Timers: A timer reaching a specific count or overflowing.
  • Communication Peripherals (UART, SPI, I2C): Data received, transmission complete, error conditions.
  • ADC/DAC: Conversion complete.
  • WiFi/Bluetooth Controllers: Network events, connection status changes.
Characteristic Description
Asynchronous Event Interrupts are triggered by hardware (or software) events that occur independently of the main program flow.
CPU Context Switch When an interrupt occurs, the CPU automatically suspends its current task, saves its context (registers, program counter), and jumps to a specific Interrupt Service Routine (ISR).
Efficiency Far more efficient than polling for infrequent events, as the CPU only performs work when an event actually occurs.
Responsiveness Provides low-latency response to critical hardware events.
Hardware Driven Primarily generated by hardware peripherals (GPIOs, timers, communication interfaces, etc.) signaling a need for attention.
Priority Based Interrupt controllers manage and prioritize multiple interrupt sources, ensuring more critical interrupts are handled first.
Special Execution Context ISRs run in a special, restricted context, outside of normal task scheduling, which imposes limitations on what operations they can perform.

When a peripheral triggers an interrupt, the CPU’s interrupt controller takes over.

Interrupt Controller

The interrupt controller is a crucial piece of hardware that manages interrupt requests from various peripherals. Its main tasks include:

  1. Detecting interrupt requests from peripherals.
  2. Prioritizing simultaneous interrupt requests.
  3. Forwarding the highest-priority, enabled interrupt request to the CPU core.
  4. Providing the CPU with information to identify the source of the interrupt (e.g., an interrupt vector address).

ESP32 variants use different CPU architectures, leading to different interrupt controller designs:

  • Xtensa (ESP32, ESP32-S2, ESP32-S3): Feature a multi-level interrupt system. Interrupts are assigned levels (1 to 5 typically usable by applications), and higher-level interrupts can preempt lower-level ISRs.
  • RISC-V (ESP32-C3, ESP32-C6, ESP32-H2): Implement the RISC-V standard Platform-Level Interrupt Controller (PLIC).

Tip: While the underlying hardware differs, the ESP-IDF interrupt handling APIs (esp_intr_alloc, GPIO ISR functions) provide a consistent interface, largely abstracting these architectural differences for common peripherals like GPIOs.

Hardware Interrupt Handling Flow Peripheral 1 Peripheral 2 Peripheral N Interrupt Lines Interrupt Controller (Prioritizes) CPU Core 1. Suspends current task 2. Saves context 3. Jumps to ISR Interrupt Service Routine (ISR) Identifies source via vector

Interrupt Service Routines (ISRs)

An ISR (also known as an interrupt handler) is the function executed by the CPU in direct response to an interrupt. ISRs have strict constraints due to their asynchronous nature and execution context:

  1. Execution Speed: ISRs must execute as quickly as possible. While an ISR runs, other interrupts (of the same or lower priority) might be blocked, and normal task scheduling is paused. Long ISRs increase system latency and can lead to missed events.
  2. Non-Blocking: ISRs must never block. They cannot wait for semaphores, queues (with a timeout), or call functions like vTaskDelay(). Blocking in an ISR can easily lead to system deadlock.
  3. Limited Functionality: Because they interrupt arbitrary code, ISRs typically run with limited context. They usually cannot perform complex computations, floating-point operations (unless specifically configured), or extensive I/O.
  4. Stack Usage: ISRs use the stack of the task they interrupted (or sometimes a dedicated interrupt stack, depending on configuration). Keep stack usage within ISRs minimal.

The primary role of an ISR is usually to:

  • Identify the exact cause of the interrupt (if multiple sources share one ISR).
  • Clear the interrupt flag in the peripheral hardware (to prevent immediate re-triggering).
  • Perform the absolute minimum, time-critical processing.
  • Optionally, signal a regular FreeRTOS task to perform longer processing (Deferred Interrupt Processing).
Constraint Description & Implication Best Practice / Rule
Execution Speed ISRs interrupt normal program flow and often block other (same or lower priority) interrupts. Long execution times increase system latency and can lead to missed real-time deadlines or events. Must execute as quickly as possible. Minimize processing.
Non-Blocking ISRs must never block or wait for an event. Calling functions like vTaskDelay(), or FreeRTOS API calls that can block (e.g., xQueueReceive with a non-zero timeout, xSemaphoreTake with timeout) will lead to system deadlock or crashes. Strictly no blocking calls.
Limited Functionality Due to the special context, ISRs cannot perform all operations a regular task can. Complex computations, floating-point math (unless FPU context is saved/restored, adding overhead), or extensive I/O are generally unsuitable. Most standard C library functions that might use locks or block are unsafe. printf or ESP_LOGx are generally unsafe. Perform only essential, time-critical actions. Defer complex work. Use ets_printf for temporary debug output only if absolutely necessary, with caution.
Stack Usage ISRs use the stack of the task they interrupted (or a dedicated interrupt stack on some systems/configurations). Excessive stack usage in an ISR can cause stack overflows for the interrupted task. Keep stack usage minimal. Avoid large local variables or deep function call chains within the ISR.
Reentrancy & Shared Data ISRs can preempt tasks or other lower-priority ISRs. Accessing shared data without protection can lead to race conditions. Use critical sections (portENTER_CRITICAL_ISR / portEXIT_CRITICAL_ISR) or atomic operations for brief accesses to shared data. Prefer signaling tasks to handle shared data.
IRAM Placement On ESP32, ISR code must typically reside in IRAM (Internal RAM) because flash cache might be disabled during ISR execution (e.g., if another task is performing flash operations). Accessing code from flash in such a state will crash. Always use IRAM_ATTR for ISR functions.
FreeRTOS API Calls Standard FreeRTOS API functions are not safe to call from ISRs. They may attempt to block or manipulate scheduler structures incorrectly. Only use the ISR-safe variants of FreeRTOS API functions (e.g., xQueueSendFromISR, xSemaphoreGiveFromISR).

Interrupt Priorities and Levels

Interrupt controllers allow assigning priorities or levels to different interrupt sources. This determines the order in which simultaneous interrupts are serviced and whether one ISR can preempt another.

  • Higher priority interrupts are serviced before lower priority ones.
  • On systems like Xtensa, a higher-priority ISR can interrupt (preempt) a currently running lower-priority ISR.

Careful priority assignment is crucial for real-time performance, ensuring that the most critical events are handled with the lowest latency. ESP-IDF’s esp_intr_alloc function allows specifying an interrupt level (1-3 are generally recommended for application use on Xtensa, avoiding levels used by the OS).

Critical Sections

Sometimes, an ISR needs to access data that is also accessed by regular tasks. If the ISR preempts a task while the task is modifying that shared data, a race condition can occur, leading to data corruption.

%%{ init: { 
  'theme': 'base', 
  'themeVariables': { 
    'fontFamily': 'Open Sans',
    'primaryColor': '#FB96C1',
    'primaryTextColor': '#0f0f0f',
    'primaryBorderColor': '#4C1D95',
    'lineColor': '#6B46C1',
    'secondaryColor': '#F0F4F8',
    'tertiaryColor': '#EEEEF3'
  }
} }%%

sequenceDiagram
    participant T1 as Task T1
    participant X as Variable X<br>(Memory)
    participant ISR as Interrupt<br>Service Routine
    
    Note over X: Initial Value: 42
    
    rect rgb(209, 250, 229)
    Note over T1,X: 1. Task reads variable X
    T1->>+X: Read X 
    X-->>-T1: Value = 42
    T1->>T1: Store 42 locally<br>Perform calculation<br>Prepare to write back
    end
    
    rect rgb(254, 215, 226)
    Note over ISR,X: 2. ISR preempts Task T1
    ISR->>+X: Read X
    X-->>-ISR: Value = 42
    ISR->>ISR: Modify value<br>(increment to 43)
    ISR->>+X: Write X = 43
    X-->>-ISR: Updated
    
    Note over X: Current Value: 43
    end
    
    rect rgb(254, 243, 199)
    Note over ISR,T1: 3. ISR completes, Task T1 resumes
    ISR-->>T1: Control returns to Task T1
    end
    
    rect rgb(254, 226, 226)
    Note over T1,X: 4. Task overwrites ISR's change
    T1->>+X: Write X = 42 (old value)
    X-->>-T1: Updated
    
    Note over X: Corrupted Value: 42<br>(ISR's change lost!)
    end
    
    rect rgb(226, 232, 240)
    Note over T1,ISR: RACE CONDITION OUTCOME:<br>Task T1 has overwritten the ISR's change to X<br>because it used the value it read before the interrupt occurred
    end
    
    %% Solution hint
    rect rgb(209, 250, 229)
    Note over T1,ISR: Solution: Use proper synchronization mechanisms<br>such as critical sections, disabling interrupts,<br>or atomic operations when accessing shared variables
    end

To prevent this, we use critical sections. A critical section is a piece of code that must execute atomically, without being interrupted by other tasks or relevant ISRs. FreeRTOS provides macros for this:

  • portENTER_CRITICAL(&spinlock) / portENTER_CRITICAL_ISR(&spinlock): Enters a critical section. Disables interrupts up to configMAX_SYSCALL_INTERRUPT_PRIORITY. Requires initializing a portMUX_TYPE spinlock = portMUX_INITIALIZER_UNLOCKED;.
  • portEXIT_CRITICAL(&spinlock) / portEXIT_CRITICAL_ISR(&spinlock): Exits the critical section, re-enabling interrupts.

Warning: Critical sections disable interrupts (or task switching), reducing system responsiveness. Keep them as short as absolutely possible, protecting only the minimum necessary shared resource access. Overuse can severely degrade real-time performance.

FreeRTOS and Interrupts: The ...FromISR API

Standard FreeRTOS API functions (e.g., xQueueSend, xSemaphoreGive, vTaskNotifyGive) are not safe to call directly from an ISR. They assume they are called from a task context and may manipulate scheduler data structures or attempt to block, which is forbidden in an ISR.

FreeRTOS provides a special set of functions specifically designed for safe use within ISRs. These typically have FromISR appended to their names:

  • xQueueSendFromISR()
  • xQueueReceiveFromISR()
  • xSemaphoreGiveFromISR()
  • xSemaphoreTakeFromISR() (Use with extreme caution, usually only with 0 block time)
  • xTaskNotifyFromISR()
  • xTaskNotifyGiveFromISR()
  • xEventGroupSetBitsFromISR()
  • xTimerStartFromISR(), xTimerStopFromISR(), etc.

The pxHigherPriorityTaskWoken Parameter:

Most ...FromISR functions have a final parameter, typically BaseType_t *pxHigherPriorityTaskWoken. This is crucial for maintaining real-time responsiveness.

  • Purpose: When an ISR makes a higher-priority task ready to run (e.g., by giving a semaphore or sending to a queue that the task was waiting for), the ISR needs to inform the FreeRTOS kernel.
  • Mechanism:
    1. Initialize a local BaseType_t variable in your ISR to pdFALSE before calling the ...FromISR function: BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    2. Pass the address of this variable to the ...FromISR function: xQueueSendFromISR(..., &xHigherPriorityTaskWoken);
    3. The ...FromISR function will set this variable to pdTRUE if its action caused a task with higher priority than the currently interrupted task to become ready.
    4. At the very end of the ISR, check the value of this variable. If it’s pdTRUE, request a context switch:
C
if (xHigherPriorityTaskWoken == pdTRUE) {
    portYIELD_FROM_ISR(); // On Xtensa
    // portYIELD_FROM_ISR(xHigherPriorityTaskWoken) // Sometimes seen, check FreeRTOS port specifics
}
// For RISC-V ports, the yield might be handled slightly differently or implicitly
// by the port layer after the ISR exits, but the pattern of checking
// xHigherPriorityTaskWoken remains important for the FromISR function itself.
// Consult the specific FreeRTOS port documentation for ESP32 RISC-V variants if needed.
// However, using portYIELD_FROM_ISR() is generally the portable way shown in examples.
graph TD
    A[Start of ISR] --> B(Initialize: BaseType_t xHigherPriorityTaskWoken = pdFALSE);
    B --> C{"Call FreeRTOS ...FromISR Function<br>e.g., xQueueSendFromISR(..., &xHigherPriorityTaskWoken)"};
    C --> D{...FromISR function executes};
    D -- Unblocked task has lower or same priority<br>OR no task unblocked --> E[xHigherPriorityTaskWoken remains pdFALSE];
    D -- Unblocked task has HIGHER priority<br>than interrupted task --> F[API sets xHigherPriorityTaskWoken = pdTRUE];
    E --> G{End of ISR Logic};
    F --> G;
    G --> H{"Check: if (xHigherPriorityTaskWoken == pdTRUE)"};
    H -- True --> I["Call portYIELD_FROM_ISR()"];
    H -- False --> J[Return from ISR normally];
    I --> K[Scheduler runs highest priority ready task];
    J --> L[Scheduler resumes interrupted task or other ready task];

    %% Styling
    classDef startNode fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6;
    classDef processNode fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF;
    classDef decisionNode fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E;
    classDef successNode fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46;
    classDef yieldNode fill:#FFFBEB,stroke:#F59E0B,stroke-width:1px,color:#B45309;


    class A startNode;
    class B,C,D,G processNode;
    class H decisionNode;
    class E,F,J,L processNode;
    class I yieldNode;
    class K successNode;
  • Effect: portYIELD_FROM_ISR() ensures that if the ISR unblocked a higher-priority task, the kernel switches to that task immediately upon ISR completion, rather than returning to the lower-priority interrupted task first.

Failure to correctly use pxHigherPriorityTaskWoken and portYIELD_FROM_ISR() can introduce unnecessary latency.

Feature Standard API (e.g., xQueueSend) ...FromISR API (e.g., xQueueSendFromISR)
Calling Context Task context only. Interrupt Service Routine (ISR) context only.
Blocking Behavior Can block (e.g., if queue is full and xTicksToWait > 0). Never blocks. Operations are typically non-waiting or have an immediate effect if possible.
Scheduler Interaction Can directly interact with the scheduler, potentially causing a context switch. Interacts with scheduler indirectly. Uses pxHigherPriorityTaskWoken to signal if a context switch might be needed after ISR exits.
Critical Sections May enter/exit critical sections that are not ISR-safe. Uses ISR-safe mechanisms for internal synchronization.
pxHigherPriorityTaskWoken Not applicable / Not present. Crucial parameter. A pointer to a BaseType_t variable, set to pdTRUE by the API if a task of higher priority than the interrupted task was unblocked.
Yielding May yield implicitly or explicitly (e.g., taskYIELD()). Requires explicit call to portYIELD_FROM_ISR() (or similar port-specific macro) at the end of the ISR if pxHigherPriorityTaskWoken is pdTRUE.
Safety in ISR Unsafe. Calling from ISR can corrupt scheduler, lead to deadlocks or crashes. Safe. Designed specifically for the restricted ISR environment.
Example Functions xQueueSend, xSemaphoreGive, vTaskNotifyGive, xEventGroupSetBits xQueueSendFromISR, xSemaphoreGiveFromISR, vTaskNotifyGiveFromISR, xEventGroupSetBitsFromISR

Deferred Interrupt Processing

Given the strict constraints on ISR execution time and complexity, it’s often impractical to perform all event handling directly within the ISR. The standard practice is deferred interrupt processing: the ISR does the bare minimum (clear flag, maybe capture quick data) and then signals (defers work to) a regular FreeRTOS task to handle the bulk of the processing.

%%{ init: { 
  'theme': 'base', 
  'themeVariables': { 
    'fontFamily': 'Open Sans',
    'primaryColor': '#fB96C1',
    'primaryTextColor': '#0f0f0f',
    'primaryBorderColor': '#4C1D95',
    'lineColor': '#6B46C1',
    'secondaryColor': '#F0F4F8',
    'tertiaryColor': '#EEEEF3'
  }
} }%%

sequenceDiagram
    participant HW as Hardware<br>Peripheral
    participant ISR as Interrupt<br>Service Routine
    participant Scheduler as FreeRTOS<br>Scheduler
    participant Task as High Priority<br>Task
    participant LPTask as Lower Priority<br>Tasks
    
    rect rgb(209, 250, 229)
    Note over HW: Hardware Event Occurs<br>(e.g., ADC conversion complete,<br>button press, UART data received)
    HW->>+ISR: Trigger Interrupt
    end
    
    rect rgb(254, 243, 199)
    Note over ISR: ISR - Quick Processing Only
    ISR->>ISR: 1. Clear interrupt flag
    ISR->>ISR: 2. Read minimal required data
    ISR->>+Task: 3. Signal Task (e.g., xTaskNotifyFromISR())
    Note right of ISR: ISR exits as quickly as possible<br>Minimal processing time
    end
    
    rect rgb(216, 180, 254)
    Note over Scheduler: Scheduler Determines Next Task
    ISR->>+Scheduler: 4. Request context switch<br>(if higher priority task became ready)
    Scheduler->>Scheduler: Check task priorities
    Scheduler-->>-LPTask: Preempt (if running)
    Scheduler->>+Task: 5. Schedule High Priority Task
    end
    
    rect rgb(221, 214, 254)
    Note over Task: Task - Longer Processing
    Task->>Task: 6. Process notification/signal
    Task->>Task: 7. Read complete data
    Task->>Task: 8. Perform longer processing<br>(filtering, calculations, etc.)
    Task->>Task: 9. Update system state
    Task->>HW: 10. Optional: Configure hardware<br>for next operation
    end
    
    rect rgb(226, 232, 240)
    Note over HW,Task: Best Practice: ISR does minimal work and signals task<br>Task performs longer processing at high priority<br>Lower priority tasks only run when high priority task blocks
    end
    
    %% Styling for return arrows
    ISR-->>-HW: Exit ISR
    Task-->>-Scheduler: Task Completes or Blocks
    Scheduler->>LPTask: Schedule next highest priority ready task

Common techniques for signaling a task from an ISR:

  1. Semaphores (xSemaphoreGiveFromISR): The ISR “gives” a binary semaphore. A dedicated task waits (xSemaphoreTake) on that semaphore. Simple and effective for signaling an event occurrence.
  2. Queues (xQueueSendFromISR): The ISR sends data (e.g., a sensor reading, event type) to a queue. A task waits (xQueueReceive) to process the data from the queue. Useful when data needs to be passed from the ISR to the task.
  3. Task Notifications (xTaskNotifyFromISR, xTaskNotifyGiveFromISR): A direct-to-task signaling mechanism, often faster and more memory-efficient than queues or semaphores for simple signals or passing single values. A task waits using ulTaskNotifyTake or xTaskNotifyWait.
  4. Event Groups (xEventGroupSetBitsFromISR): Useful for signaling multiple events or conditions. A task waits using xEventGroupWaitBits.

The choice depends on whether data needs to be passed and the complexity of the signaling required. Task Notifications are often preferred for simple ISR-to-task signaling due to their efficiency.

ESP-IDF Interrupt Allocation (esp_intr_alloc)

ESP-IDF provides a convenient API for allocating interrupt sources to CPU cores and handlers. The main function is esp_intr_alloc() (defined in esp_intr_alloc.h):

C
esp_err_t esp_intr_alloc(int source, int flags, intr_handler_t handler, void *arg, intr_handle_t *ret_handle);
  • source: The interrupt source identifier (e.g., ETS_GPIO_INTR_SOURCE for GPIOs, or specific peripheral interrupt sources found in soc/soc.h or peripheral header files). For GPIOs, this is often less relevant as the GPIO driver handles allocation internally.
  • flags: Bitmask specifying interrupt properties:
    • ESP_INTR_FLAG_LEVEL1ESP_INTR_FLAG_LEVEL5: Interrupt level (priority). Use levels 1-3 for applications.
    • ESP_INTR_FLAG_EDGE: Edge-triggered interrupt (default is level-triggered). Usually set via GPIO config.
    • ESP_INTR_FLAG_IRAM: Allocate handler code in IRAM. Crucial! ISR code must typically reside in IRAM because flash cache might be disabled during ISR execution (especially during flash operations). Forgetting this is a common cause of crashes.
    • ESP_INTR_FLAG_SHARED: Allows multiple peripherals/sources to share the same interrupt line and handler (handler must then determine the specific source).
    • ESP_INTR_FLAG_LOWMED: Levels 1-3.
    • ESP_INTR_FLAG_HIGH: Levels 4-5 (generally reserved for critical system interrupts).
Signaling Mechanism ISR API Function(s) Task API Function(s) (Waiting) Data Passing Use Case / Notes
Binary Semaphore xSemaphoreGiveFromISR() xSemaphoreTake() No direct data (signals event occurrence only). Simple and effective for signaling a single event type. Task unblocks when semaphore is given. Good for basic “event happened” notifications.
Counting Semaphore xSemaphoreGiveFromISR() xSemaphoreTake() No direct data (counts event occurrences). Useful if the ISR can trigger multiple events before the task processes them, and the task needs to know how many occurred (up to semaphore’s max count).
Queue xQueueSendFromISR()
xQueueSendToFrontFromISR()
xQueueOverwriteFromISR()
xQueueReceive() Yes (can send any data type that fits in the queue item size). Flexible for passing data from ISR to task (e.g., sensor readings, event details). Supports multiple data items. Choose overwrite or regular send based on needs.
Task Notifications xTaskNotifyFromISR()
xTaskNotifyGiveFromISR()
ulTaskNotifyTake()
xTaskNotifyWait()
Yes (can update task’s notification value, or act as a lightweight binary/counting semaphore). Direct-to-task signaling. Often more memory-efficient and faster than queues/semaphores for simple signals or passing a single uint32_t value. Versatile.
Event Groups xEventGroupSetBitsFromISR() xEventGroupWaitBits() No direct data (signals one or more event flags/bits). Useful when a task needs to wait for one or a combination of multiple distinct events/conditions signaled by one or more ISRs (or tasks).
  • handler: Pointer to the ISR function (void (*intr_handler_t)(void *arg)).
  • arg: A void pointer passed as an argument to the handler function when the interrupt occurs. Useful for passing context or identifying the source if the handler is shared.
  • ret_handle: Pointer to an intr_handle_t variable where the handle for the allocated interrupt will be stored. This handle is needed later to free the interrupt using esp_intr_free().

For GPIO interrupts, you typically don’t call esp_intr_alloc directly. Instead, you use the GPIO driver API:

  1. gpio_install_isr_service(int intr_alloc_flags): Installs the driver’s common GPIO ISR handler service. intr_alloc_flags can be used to specify flags like ESP_INTR_FLAG_IRAM, but often 0 is sufficient as the driver handles IRAM placement. Must be called once before registering GPIO ISRs.
  2. gpio_isr_handler_add(gpio_num_t gpio_num, gpio_isr_t isr_handler, void *args): Registers your specific ISR function (isr_handler) for a particular gpio_num. The args parameter is passed to your handler.
  3. gpio_isr_handler_remove(gpio_num_t gpio_num): Deregisters the handler.
  4. gpio_intr_enable(gpio_num_t gpio_num) / gpio_intr_disable(gpio_num_t gpio_num): Enables/disables interrupts for the specific GPIO pin after the handler is added.

The GPIO driver uses esp_intr_alloc internally to manage the underlying interrupt resources.

Practical Examples

Project Setup:

Follow the standard project setup (copy hello_world, open in VS Code). You’ll need a button or a wire to manually trigger a GPIO interrupt for testing. Connect a pushbutton between a chosen GPIO pin (e.g., GPIO 4) and GND, and enable the internal pull-up resistor on that GPIO.

Common Includes and Setup:

Add/ensure these includes are at the top of your main/your_main_file.c:

C
#include <stdio.h>
#include <string.h> // For memset
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h" // For semaphores
#include "freertos/queue.h"  // For queues
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_intr_alloc.h" // Include even if using GPIO driver API

static const char *TAG = "INTR_EXAMPLE";

// Define the GPIO pin to use for input (e.g., connected to a button)
#define BUTTON_GPIO GPIO_NUM_4

Example 1: Basic GPIO ISR (Logging Directly – Use with Caution)

This example shows the basic structure but demonstrates an anti-pattern (doing too much, like logging, directly in the ISR). Use this only for understanding the registration process.

C
// ISR handler function
// IRAM_ATTR ensures the function is placed in IRAM, critical for ISRs
static void IRAM_ATTR gpio_isr_handler_basic(void* arg) {
    // --- VERY IMPORTANT ---
    // Avoid complex operations like ESP_LOGI inside a real ISR.
    // This is only for demonstrating the ISR is being called.
    // In a real application, signal a task instead.
    ets_printf("GPIO[%lu] intr occurred!\n", (uint32_t) arg);
    // Note: Using ets_printf is slightly safer than ESP_LOGI in an ISR,
    // but still not recommended for production code ISRs.
}

void app_main(void) {
    ESP_LOGI(TAG, "Starting Basic GPIO ISR Example...");

    // Configure the GPIO pin
    gpio_config_t io_conf;
    memset(&io_conf, 0, sizeof(io_conf));
    // Interrupt on negative edge (button press connects GPIO to GND)
    io_conf.intr_type = GPIO_INTR_NEGEDGE;
    // Bit mask of the pins
    io_conf.pin_bit_mask = (1ULL << BUTTON_GPIO);
    // Set as input mode
    io_conf.mode = GPIO_MODE_INPUT;
    // Enable internal pull-up resistor
    io_conf.pull_up_en = GPIO_PULLUP_ENABLE;
    io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
    gpio_config(&io_conf);

    // Install the global GPIO ISR service
    // ESP_INTR_FLAG_DEFAULT (0) is fine for default priority level & CPU core
    esp_err_t install_err = gpio_install_isr_service(ESP_INTR_FLAG_DEFAULT);
     if (install_err != ESP_OK) {
        ESP_LOGE(TAG, "Failed to install GPIO ISR service: %s", esp_err_to_name(install_err));
        return; // Cannot proceed
    }

    // Add the specific handler for our button GPIO
    // Pass the GPIO number as the argument to the ISR
    esp_err_t add_err = gpio_isr_handler_add(BUTTON_GPIO, gpio_isr_handler_basic, (void*) BUTTON_GPIO);
    if (add_err != ESP_OK) {
        ESP_LOGE(TAG, "Failed to add ISR handler for GPIO %d: %s", BUTTON_GPIO, esp_err_to_name(add_err));
        gpio_uninstall_isr_service(); // Clean up
        return; // Cannot proceed
    }


    ESP_LOGI(TAG, "ISR handler registered for GPIO %d. Press the button...", BUTTON_GPIO);

    // Keep the main task running
    while(1) {
        vTaskDelay(pdMS_TO_TICKS(2000));
        ESP_LOGI(TAG, "Main task still running...");
    }

    // --- Cleanup (won't be reached in this loop) ---
    // gpio_isr_handler_remove(BUTTON_GPIO);
    // gpio_uninstall_isr_service();
}

Build, Flash, and Monitor:

Follow standard procedures. Press the button connected to BUTTON_GPIO.

Expected Output:

Each time you press the button, you should see the GPIO[X] intr occurred! message printed directly from the ISR context (via ets_printf). You’ll also see the “Main task still running…” message periodically.

Plaintext
I (XXX) INTR_EXAMPLE: Starting Basic GPIO ISR Example...
I (XXX) INTR_EXAMPLE: ISR handler registered for GPIO 4. Press the button...
I (XXX) INTR_EXAMPLE: Main task still running...
GPIO[4] intr occurred!
I (XXX) INTR_EXAMPLE: Main task still running...
GPIO[4] intr occurred!
GPIO[4] intr occurred! // Might see multiple if button bounces
I (XXX) INTR_EXAMPLE: Main task still running...

Example 2: Deferred Processing using Semaphore

This is the recommended pattern. The ISR gives a semaphore, and a separate task performs the actual work.

C
// Global semaphore handle
SemaphoreHandle_t gpio_sem = NULL;

// Task function to handle the deferred processing
static void gpio_handler_task(void* arg) {
    uint32_t gpio_num = (uint32_t) arg; // Could pass specific data if needed
    while(1) {
        // Wait indefinitely for the semaphore to be given by the ISR
        if(xSemaphoreTake(gpio_sem, portMAX_DELAY) == pdTRUE) {
            ESP_LOGI(TAG, "Handler Task: GPIO %lu interrupt processed!", gpio_num);
            // --- Perform actual work here ---
            // Example: Toggle an LED, read a sensor, etc.
            // Add a small delay to simulate work and help debounce
            vTaskDelay(pdMS_TO_TICKS(50));
        }
    }
}

// ISR handler function - kept minimal
static void IRAM_ATTR gpio_isr_handler_defer(void* arg) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    // Give the semaphore
    xSemaphoreGiveFromISR(gpio_sem, &xHigherPriorityTaskWoken);

    // Check if we need to yield
    if (xHigherPriorityTaskWoken == pdTRUE) {
        portYIELD_FROM_ISR();
    }
}

void app_main(void) {
    ESP_LOGI(TAG, "Starting Deferred ISR (Semaphore) Example...");

    // Create the binary semaphore
    // Must be created BEFORE the ISR is registered
    gpio_sem = xSemaphoreCreateBinary();
    if (gpio_sem == NULL) {
        ESP_LOGE(TAG, "Failed to create semaphore");
        return;
    }

    // Configure the GPIO pin (same as Example 1)
    gpio_config_t io_conf;
    memset(&io_conf, 0, sizeof(io_conf));
    io_conf.intr_type = GPIO_INTR_NEGEDGE;
    io_conf.pin_bit_mask = (1ULL << BUTTON_GPIO);
    io_conf.mode = GPIO_MODE_INPUT;
    io_conf.pull_up_en = GPIO_PULLUP_ENABLE;
    gpio_config(&io_conf);

    // Install the global GPIO ISR service
    esp_err_t install_err = gpio_install_isr_service(ESP_INTR_FLAG_DEFAULT);
     if (install_err != ESP_OK) {
        ESP_LOGE(TAG, "Failed to install GPIO ISR service: %s", esp_err_to_name(install_err));
        vSemaphoreDelete(gpio_sem); // Clean up semaphore
        return;
    }

    // Add the specific handler for our button GPIO
    esp_err_t add_err = gpio_isr_handler_add(BUTTON_GPIO, gpio_isr_handler_defer, NULL); // No arg needed here
     if (add_err != ESP_OK) {
        ESP_LOGE(TAG, "Failed to add ISR handler for GPIO %d: %s", BUTTON_GPIO, esp_err_to_name(add_err));
        gpio_uninstall_isr_service();
        vSemaphoreDelete(gpio_sem);
        return;
    }

    // Create the handler task
    // Pass the GPIO number as argument just for logging purposes in the task
    xTaskCreate(gpio_handler_task, "gpio_handler_task", 2048, (void*)BUTTON_GPIO, 10, NULL);

    ESP_LOGI(TAG, "ISR handler and task created for GPIO %d. Press the button...", BUTTON_GPIO);

    // Main task can now do other things or just idle
    while(1) {
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

Build, Flash, and Monitor:

Standard procedure. Press the button.

Expected Output:

When you press the button, the ISR runs very quickly, gives the semaphore, and yields if necessary. The gpio_handler_task then wakes up, takes the semaphore, and prints its message.

Plaintext
I (XXX) INTR_EXAMPLE: Starting Deferred ISR (Semaphore) Example...
I (XXX) INTR_EXAMPLE: ISR handler and task created for GPIO 4. Press the button...
I (XXX) INTR_EXAMPLE: Handler Task: GPIO 4 interrupt processed!
I (XXX) INTR_EXAMPLE: Handler Task: GPIO 4 interrupt processed!
I (XXX) INTR_EXAMPLE: Handler Task: GPIO 4 interrupt processed!

Example 3: Deferred Processing using Queue

This example passes data (a simple counter value) from the ISR to the handler task via a queue.

C
// Global queue handle
QueueHandle_t gpio_queue = NULL;
// Simple counter incremented by ISR
volatile uint32_t isr_counter = 0;

// Task function to handle the deferred processing
static void gpio_queue_handler_task(void* arg) {
    uint32_t received_count;
    while(1) {
        // Wait indefinitely for data to arrive on the queue
        if(xQueueReceive(gpio_queue, &received_count, portMAX_DELAY) == pdTRUE) {
            ESP_LOGI(TAG, "Queue Task: Interrupt processed! ISR count: %lu", received_count);
            // --- Perform actual work here based on received_count ---
            vTaskDelay(pdMS_TO_TICKS(50)); // Simulate work
        }
    }
}

// ISR handler function - kept minimal
static void IRAM_ATTR gpio_isr_handler_queue(void* arg) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    // Increment counter (use volatile or atomic if needed, simple increment ok here)
    isr_counter++;
    // Send the counter value to the queue. Don't block (0 wait ticks).
    xQueueSendFromISR(gpio_queue, &isr_counter, &xHigherPriorityTaskWoken);

    // Check if we need to yield
    if (xHigherPriorityTaskWoken == pdTRUE) {
        portYIELD_FROM_ISR();
    }
}

void app_main(void) {
    ESP_LOGI(TAG, "Starting Deferred ISR (Queue) Example...");

    // Create the queue to hold uint32_t values, length 10
    gpio_queue = xQueueCreate(10, sizeof(uint32_t));
     if (gpio_queue == NULL) {
        ESP_LOGE(TAG, "Failed to create queue");
        return;
    }

    // Configure the GPIO pin (same as Example 1)
    gpio_config_t io_conf;
    memset(&io_conf, 0, sizeof(io_conf));
    io_conf.intr_type = GPIO_INTR_NEGEDGE;
    io_conf.pin_bit_mask = (1ULL << BUTTON_GPIO);
    io_conf.mode = GPIO_MODE_INPUT;
    io_conf.pull_up_en = GPIO_PULLUP_ENABLE;
    gpio_config(&io_conf);

    // Install the global GPIO ISR service
    esp_err_t install_err = gpio_install_isr_service(ESP_INTR_FLAG_DEFAULT);
     if (install_err != ESP_OK) {
        ESP_LOGE(TAG, "Failed to install GPIO ISR service: %s", esp_err_to_name(install_err));
        vQueueDelete(gpio_queue);
        return;
    }

    // Add the specific handler for our button GPIO
    esp_err_t add_err = gpio_isr_handler_add(BUTTON_GPIO, gpio_isr_handler_queue, NULL);
     if (add_err != ESP_OK) {
        ESP_LOGE(TAG, "Failed to add ISR handler for GPIO %d: %s", BUTTON_GPIO, esp_err_to_name(add_err));
        gpio_uninstall_isr_service();
        vQueueDelete(gpio_queue);
        return;
    }

    // Create the handler task
    xTaskCreate(gpio_queue_handler_task, "gpio_queue_task", 2048, NULL, 10, NULL);

    ESP_LOGI(TAG, "ISR handler and queue task created for GPIO %d. Press the button...", BUTTON_GPIO);

    // Main task can now do other things or just idle
    while(1) {
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

Build, Flash, and Monitor:

Standard procedure. Press the button.

Expected Output:

Each button press triggers the ISR, which sends the incrementing counter value to the queue. The task receives the value and logs it.

Plaintext
I (XXX) INTR_EXAMPLE: Starting Deferred ISR (Queue) Example...
I (XXX) INTR_EXAMPLE: ISR handler and queue task created for GPIO 4. Press the button...
I (XXX) INTR_EXAMPLE: Queue Task: Interrupt processed! ISR count: 1
I (XXX) INTR_EXAMPLE: Queue Task: Interrupt processed! ISR count: 2
I (XXX) INTR_EXAMPLE: Queue Task: Interrupt processed! ISR count: 3

Variant Notes

  • Interrupt Controller: As mentioned, Xtensa-based chips (ESP32, S2, S3) and RISC-V-based chips (C3, C6, H2) have different interrupt controller hardware (Xtensa Interrupt Controller vs RISC-V PLIC). However, for common tasks like GPIO interrupt handling, the ESP-IDF driver API (gpio_install_isr_service, gpio_isr_handler_add) provides a consistent interface, abstracting most of these differences. When using esp_intr_alloc directly for other peripherals, the available interrupt sources and specific flags might vary slightly; consult the target-specific Technical Reference Manual and ESP-IDF documentation.
  • IRAM Placement (IRAM_ATTR): This is crucial for all variants. ISR code must generally reside in IRAM to prevent crashes if the flash cache is disabled (e.g., during flash write operations). The IRAM_ATTR macro ensures this.
  • Number of GPIOs: The total number of GPIO pins and how many are capable of triggering interrupts can vary between variants. Check the datasheet for your specific chip. The GPIO driver API handles valid pin numbers correctly.
  • CPU Core Affinity: On dual-core chips (ESP32, ESP32-S3), interrupts can typically be routed to either core. The esp_intr_alloc function and underlying drivers usually handle core affinity automatically or provide flags (e.g., ESP_INTR_FLAG_SHARED implies affinity might be less strict) if specific routing is needed, though this is an advanced topic usually not required for basic GPIO interrupts.

For most application-level interrupt handling using standard peripherals and the ESP-IDF drivers, the code examples provided are portable across the common ESP32 variants.

Common Mistakes & Troubleshooting Tips

Common Mistake / Pitfall Potential Symptom(s) Fix / Best Practice
Calling Non-ISR-Safe Functions
(e.g., vTaskDelay, xQueueSend, ESP_LOGI, printf)
Crashes (IllegalInstruction, LoadProhibited, StoreProhibited, “Guru Meditation Error”), scheduler corruption, deadlocks, unpredictable system behavior. Use only ...FromISR variants of FreeRTOS functions. Keep ISRs minimal; defer processing. Use ets_printf only for temporary, cautious debugging in ISRs, not in production.
Forgetting IRAM_ATTR for ISR Intermittent crashes, especially when flash operations (WiFi, other writes) occur. Backtrace might point to ISR address with cache-related errors. Always declare ISR handler functions with static void IRAM_ATTR isr_function_name(void* arg) { ... }. Ensure linker places it in IRAM.
Incorrect pxHigherPriorityTaskWoken Handling Increased task response latency. A high-priority task unblocked by ISR doesn’t run immediately after ISR, but only after the interrupted task yields or its timeslice ends. Correctly declare BaseType_t xHigherPriorityTaskWoken = pdFALSE;, pass &xHigherPriorityTaskWoken to ...FromISR calls, and conditionally call portYIELD_FROM_ISR(); at the ISR’s exit if true.
Long-Running or Blocking ISRs System becomes unresponsive, other interrupts missed, watchdog timer may reset device, high interrupt latency for other sources. Implement deferred interrupt processing. ISR performs minimal, time-critical actions (clear flag, capture data) and signals a task for bulk processing.
Interrupt Storm / Not Clearing Interrupt Flag ISR is called continuously, starving tasks, potentially locking up the system or causing watchdog resets. High CPU load. Ensure the hardware condition causing the interrupt is cleared within the ISR or deferred task (e.g., clear peripheral interrupt status bit, handle GPIO level, debounce edge). For level-triggered GPIOs, consider disabling/re-enabling interrupt.
Forgetting gpio_install_isr_service() gpio_isr_handler_add() returns ESP_ERR_INVALID_STATE. GPIO interrupts do not function. Call gpio_install_isr_service(0) (or with appropriate flags) once during initialization before adding any GPIO ISR handlers.
Race Conditions with Shared Data Corrupted shared data between ISR and tasks, or between different ISRs. Unpredictable behavior. Protect shared data access with critical sections (portENTER_CRITICAL_ISR / portEXIT_CRITICAL_ISR, portENTER_CRITICAL / portEXIT_CRITICAL). Keep critical sections extremely short. Prefer passing data via queues or notifications.
Incorrect Interrupt Flags in esp_intr_alloc Interrupt may not behave as expected (e.g., wrong priority, not in IRAM leading to crashes, not shared when it should be). Carefully select flags like ESP_INTR_FLAG_IRAM, priority level flags, ESP_INTR_FLAG_SHARED based on requirements.
Stack Overflow in ISR Context Crash, often corrupting the stack of the interrupted task. Can be hard to debug. Minimize local variable usage and function call depth within ISRs. Ensure tasks have sufficient stack if ISRs use their stack.

Exercises

  1. Deferred Processing with Task Notifications: Modify Example 2 to use FreeRTOS Task Notifications instead of a semaphore. The ISR should use vTaskNotifyGiveFromISR(), and the handler task should wait using ulTaskNotifyTake().
  2. Button Debouncing: Enhance Example 2 or 3 to handle button debouncing. When the interrupt occurs, instead of processing immediately, record the time (using xTaskGetTickCountFromISR()). In the deferred task, check if enough time (e.g., 50ms) has passed since the last processed interrupt before reacting. This prevents multiple triggers from a single noisy button press. (Hint: Store the last trigger tick count globally or pass it via queue/notification).
  3. Multiple GPIO Interrupt Sources: Configure interrupts on two different GPIO pins (e.g., GPIO 4 and GPIO 5) using the same ISR handler function. Use the arg parameter passed to the ISR (set during gpio_isr_handler_add) to identify which GPIO triggered the interrupt. Use a queue to send the triggering GPIO number to a single handler task, which then logs which button was pressed.

Summary

  • Interrupts allow peripherals to signal the CPU asynchronously, enabling efficient and responsive event handling.
  • ISRs are functions executed immediately upon an interrupt but must be very fast, non-blocking, and placed in IRAM (IRAM_ATTR).
  • Directly calling standard FreeRTOS functions from ISRs is unsafe. Use the ...FromISR variants (e.g., xSemaphoreGiveFromISR, xQueueSendFromISR, vTaskNotifyGiveFromISR).
  • The pxHigherPriorityTaskWoken parameter and portYIELD_FROM_ISR() are essential for minimizing latency when an ISR unblocks a high-priority task.
  • Deferred interrupt processing is the standard practice: ISR does minimal work and signals a regular task (via semaphore, queue, notification, event group) to perform longer processing.
  • ESP-IDF provides esp_intr_alloc for general interrupt allocation and specific drivers (like GPIO) provide easier APIs (gpio_install_isr_service, gpio_isr_handler_add) built upon it.
  • Use critical sections (portENTER/EXIT_CRITICAL[_ISR]) sparingly to protect shared data access between ISRs and tasks.
  • Interrupt handling APIs are largely consistent across ESP32 variants thanks to ESP-IDF abstraction layers.

Further Reading

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top