A interrupt is a hardware event that temporarily stops normal execution to handle something urgent via an ISR.
We inaugurate the final block of the course by entering a delicate area. Until now, everything we have programmed (Tasks, Queues, Timers) occurred within the world managed by the FreeRTOS Kernel.
The real world is asynchronous. A button is pressed, a WiFi packet arrives, an I2C sensor finishes reading. These events trigger interrupts (ISRs - Interrupt Service Routines).
An interrupt causes the core to pause the current task and execute a specific routine. Depending on the architecture and configured priorities, another, higher priority interrupt can still interrupt that ISR.
In an RTOS, interrupts are delicate: they are necessary for responsiveness, but if we don’t follow some basic rules, we can cause random resets (Guru Meditation Error on ESP32).
Today we are going to learn how to tame ISRs.
API from Task and ISR
| Action | Standard Function (Task) | ISR Function (Interrupt) |
|---|---|---|
| Semaphore Give | xSemaphoreGive | xSemaphoreGiveFromISR |
| Semaphore Take | xSemaphoreTake | FORBIDDEN (Blocking) |
| Queue Send | xQueueSend | xQueueSendFromISR |
| Queue Receive | xQueueReceive | xQueueReceiveFromISR |
| Notification | xTaskNotify | xTaskNotifyFromISR |
| Delay | vTaskDelay | FORBIDDEN |
Basic Rules of an ISR
When we write code inside an interrupt function (for example, the function we call with attachInterrupt), we are outside the control of the Scheduler. Therefore, very strict rules apply:
Keep the ISR as short as possible
This is a universal law in embedded systems, but it is especially important in an RTOS. While you are in an ISR, no task is running. If your ISR takes 5ms, you have frozen the system for 5ms.
- Bad: Printing over
Serial, doing complex calculations, reading slow sensors. - Good: Read a register, clear a flag, notify a task, and exit.
This pattern is called Deferred Interrupt Processing: The ISR only signals (“Hey, something happened!”), and a Task handles the heavy work.
Do not use blocking functions
An ISR is not a Task. It has no TCB, no full context managed by the Scheduler. Therefore, it cannot go to sleep.
If you call a function that tries to block, the system will crash.
- Forbidden:
vTaskDelay,vTaskDelayUntil. - Forbidden:
xQueueReceivewith a timeout. - Forbidden:
xSemaphoreTakewith a timeout. - Forbidden: Trying to take a Mutex (because Mutexes imply blocking and priority inheritance, concepts that do not exist in an ISR).
Use the FromISR functions
This is the rule that causes the most compilation and runtime errors for beginners.
The standard FreeRTOS API (xQueueSend, xSemaphoreGive) is not safe inside an interrupt. FreeRTOS provides a parallel API specifically for ISRs, which always ends in ...FromISR.
- Instead of
xSemaphoreGiveusexSemaphoreGiveFromISR. - Instead of
xQueueSendusexQueueSendFromISR. - Instead of
xTaskNotifyusexTaskNotifyFromISR.
What is portYIELD_FROM_ISR for?
Many FromISR functions receive a parameter called pxHigherPriorityTaskWoken.
What is it for? Let’s understand the flow:
- The system is executing a Low Priority task.
- The Interrupt fires.
- The ISR gives a Semaphore to a High Priority task that was waiting.
- Now the High Priority task is Ready.
If the ISR ends without doing anything else, the Scheduler does not find out immediately. The CPU will return to the Low Priority task, and the High task will not run until the next system Tick (which could be 1ms away). We have lost responsiveness!
To avoid this, the FromISR functions “tell us” if we have woken up an important task.
// Boolean variable (BaseType_t in FreeRTOS)
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// The function modifies this variable to pdTRUE if it woke an important task
xSemaphoreGiveFromISR(mySemaphore, &xHigherPriorityTaskWoken);
// If it is TRUE, we request a VOLUNTARY and IMMEDIATE context switch
if(xHigherPriorityTaskWoken == pdTRUE) {
portYIELD_FROM_ISR();
}By calling portYIELD_FROM_ISR, we ask the Scheduler to decide again upon exiting the interrupt. If appropriate, the CPU continues with the newly unblocked task. Latency still depends on the ISR, higher priority interrupts, and active critical sections.
Complete Example
Let’s implement the deferred processing pattern using a button and a binary semaphore, applying everything we’ve learned.
On the ESP32, IRAM_ATTR places the function in RAM. It is necessary when the ISR must remain available with the Flash cache disabled, but it is not sufficient by itself: all functions and data it accesses must also be safe for that context.
#include <Arduino.h>
// Global resources
SemaphoreHandle_t buttonSemaphore;
#define BUTTON_PIN 0
// --- ISR (Interrupt) ---
// Rule 1: Short. Rule 2: Non-blocking. Rule 3: FromISR API.
void IRAM_ATTR isrButton() {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// Send the signal
xSemaphoreGiveFromISR(buttonSemaphore, &xHigherPriorityTaskWoken);
// Request a context switch if necessary
if(xHigherPriorityTaskWoken) {
portYIELD_FROM_ISR();
}
}
// --- Deferred Task (Processing) ---
void HandlerTask(void *pvParameters) {
for(;;) {
// Here we CAN block waiting
if(xSemaphoreTake(buttonSemaphore, portMAX_DELAY) == pdTRUE) {
// Heavy work (e.g., writing to SD, Serial, Network)
Serial.println("Interrupt received. Processing...");
// Simulate workload
for(int i=0; i<5; i++) {
digitalWrite(LED_BUILTIN, HIGH);
vTaskDelay(pdMS_TO_TICKS(50));
digitalWrite(LED_BUILTIN, LOW);
vTaskDelay(pdMS_TO_TICKS(50));
}
}
}
}
void setup() {
Serial.begin(115200);
pinMode(LED_BUILTIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
// Create binary semaphore
buttonSemaphore = xSemaphoreCreateBinary();
if (buttonSemaphore == NULL) {
Serial.println("Failed to create semaphore");
return;
}
// Create task with HIGH priority
// We want that as soon as the button is pressed, this task interrupts the rest
xTaskCreate(HandlerTask, "Handler", 2048, NULL, 3, NULL);
// Setup the interrupt
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), isrButton, FALLING);
}
void loop() {
// Low priority background task
Serial.println("Loop doing irrelevant things...");
vTaskDelay(pdMS_TO_TICKS(1000));
}Critical Sections
Sometimes, the ISR and a Task share a simple variable (e.g., a volatile int counter) and we don’t want to use the overhead of a semaphore or queue.
If the Task is reading the variable and the ISR fires right in the middle and modifies it, we will have data corruption.
To prevent an ISR from interrupting a sensitive piece of code, we use Critical Sections.
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
// Inside a task: takes the spinlock and masks
// interrupts managed by this mechanism on the current core.
portENTER_CRITICAL(&mux);
// --- Atomic and very fast code ---
globalCounter++;
// ----------------------------------
// 2. Exit critical section (Re-enable interrupts)
portEXIT_CRITICAL(&mux);On a dual-core ESP32, the spinlock prevents the other core from entering a section protected by the same portMUX_TYPE. From an ISR, the variants portENTER_CRITICAL_ISR and portEXIT_CRITICAL_ISR are used.
Use critical sections with extreme caution. They increase the latency of masked interrupts and cause the other core to spin if it competes for the same spinlock. Reserve this mechanism for very short, non-blocking operations.