08 · Interrupts & the RTOS¶
Interrupts are how hardware talks to your firmware: a pin changes, a timer
fires, a byte arrives, and the CPU drops everything to run your ISR. But an
ISR is not a task — it runs outside the scheduler's world, and calling
normal FreeRTOS functions from it will corrupt the kernel. This module
covers the ISR-safe ...FromISR API variants, the golden pattern of
deferring work from ISRs to tasks, and portYIELD_FROM_ISR — the one
line that makes deferred handling feel instantaneous.
Why ISRs are special¶
An ISR can fire between any two instructions — including halfway through a
kernel operation. Regular API calls like xQueueSend may block, and may
trigger an immediate context switch; neither is legal mid-interrupt. So
FreeRTOS provides parallel interrupt-safe versions of the communication
APIs:
| Task context | ISR context |
|---|---|
xQueueSend(q, &v, timeout) |
xQueueSendFromISR(q, &v, &woken) |
xSemaphoreGive(s) |
xSemaphoreGiveFromISR(s, &woken) |
xTaskNotifyGive(h) |
vTaskNotifyGiveFromISR(h, &woken) |
xEventGroupSetBits(eg, b) |
xEventGroupSetBitsFromISR(eg, b, &woken) |
xTimerStart(t, wait) |
xTimerStartFromISR(t, &woken) |
Two systematic differences: the FromISR versions never block (no
timeout parameter — an ISR cannot sleep), and they take a
BaseType_t *pxHigherPriorityTaskWoken out-parameter explained below.
Calling the non-ISR version from an ISR is a real crash on ESP32
(assert failed in the queue code) — not a style nit.
The pattern: defer work to a task¶
The entire discipline of RTOS interrupt handling is one sentence: the ISR records that the event happened and wakes a task; the task does the work.
Why keep ISRs minimal?
- While your ISR runs, same-or-lower-priority interrupts wait — long ISRs create system-wide jitter (serial bytes dropped, tick delayed).
- ISR context is fragile: tiny stack, no blocking, on ESP32 code should be
in IRAM (
IRAM_ATTR) and avoidSerial/printf/heap entirely. - Work in a task is schedulable, measurable, and debuggable; work in an ISR is invisible to every tool you'll meet in Module 9.
Button → notification → handler task, the canonical form:
const uint8_t BUTTON_PIN = 27;
const uint8_t LED_PIN = 25;
TaskHandle_t buttonTaskHandle;
void IRAM_ATTR buttonISR() { // keep it tiny
BaseType_t higherPrioWoken = pdFALSE;
vTaskNotifyGiveFromISR(buttonTaskHandle, &higherPrioWoken);
portYIELD_FROM_ISR(higherPrioWoken); // context-switch NOW if needed
}
void buttonTask(void *pv) {
uint32_t presses = 0;
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // sleep until the ISR fires
presses++;
digitalWrite(LED_PIN, !digitalRead(LED_PIN)); // the "work"
Serial.printf("[%lu ms] press #%lu\n", millis(), presses);
// crude debounce: ignore bounces for 200 ms, then drain stale notifications
vTaskDelay(pdMS_TO_TICKS(200));
ulTaskNotifyTake(pdTRUE, 0); // clear any bounce notifications
}
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
xTaskCreate(buttonTask, "button", 2048, NULL, 4, &buttonTaskHandle); // high prio
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
}
void loop() { vTaskDelay(portMAX_DELAY); }
In Wokwi, add a pushbutton between GPIO 27 and GND. Every press wakes
buttonTask — which, at priority 4, typically starts running within
microseconds of the ISR returning. The ISR itself is three lines and
touches nothing dangerous. (Attach the interrupt after creating the task —
otherwise a press could notify a NULL handle.)
portYIELD_FROM_ISR: why deferred still feels instant¶
Here's the subtlety that separates working code from correct code. The scheduler normally re-evaluates "who should run" at the tick or when a task makes an API call. Your ISR just made a high-priority task Ready — but the interrupted code might be the idle task, and without help, the handler wouldn't run until the interrupted task hits the next scheduling point — adding up to one full tick (1 ms) of latency, sometimes more.
That's what the woken flag is for:
- Initialize
BaseType_t higherPrioWoken = pdFALSE; - Pass
&higherPrioWokento everyFromISRcall — the kernel sets it topdTRUEif the call unblocked a task with higher priority than the one the interrupt preempted. - End the ISR with
portYIELD_FROM_ISR(higherPrioWoken);— if the flag is set, the context switch happens as the ISR returns, so the CPU goes ISR → handler task directly, never resuming the interrupted task first.
Skip step 3 and everything still "works" — just with sporadic extra latency, the kind of bug that only shows on a scope. Make the three-step pattern muscle memory.
Queues from ISRs — when events carry data¶
When the interrupt produces data (a byte, a timestamp, an encoder delta),
use xQueueSendFromISR. Periodic hardware-timer example — the ESP32's
esp_timer calls its callback from ISR-like context, so the FromISR rules
apply:
QueueHandle_t sampleQueue;
void IRAM_ATTR onSampleTimer(void *arg) { // esp_timer callback
BaseType_t woken = pdFALSE;
uint32_t stamp = (uint32_t)esp_timer_get_time(); // µs since boot — ISR-safe
xQueueSendFromISR(sampleQueue, &stamp, &woken); // never blocks; drops if full
portYIELD_FROM_ISR(woken);
}
void processTask(void *pv) {
uint32_t stamp;
for (;;) {
xQueueReceive(sampleQueue, &stamp, portMAX_DELAY);
Serial.printf("sample at %lu us\n", stamp);
}
}
void setup() {
Serial.begin(115200);
sampleQueue = xQueueCreate(16, sizeof(uint32_t));
xTaskCreate(processTask, "proc", 2048, NULL, 3, NULL);
const esp_timer_create_args_t args = {
.callback = &onSampleTimer, .arg = NULL,
.dispatch_method = ESP_TIMER_TASK, // use ESP_TIMER_ISR only if required
.name = "sampler"
};
esp_timer_handle_t t;
esp_timer_create(&args, &t);
esp_timer_start_periodic(t, 250000); // every 250 ms (µs units)
}
void loop() { vTaskDelay(portMAX_DELAY); }
If the queue is full, xQueueSendFromISR returns errQUEUE_FULL
immediately — decide in the ISR whether dropping is acceptable (usually:
count the drops in a variable a task reports later; never print from the
ISR).
Final rules of thumb: no mutexes in ISRs (priority inheritance is
meaningless there — use a binary semaphore or notification), keep ISR
handlers in IRAM_ATTR on ESP32, no Serial/malloc/blocking, and
measure ISR length in microseconds, not milliseconds.
Cheat sheet¶
| Rule / API | Detail |
|---|---|
| Never call blocking / normal API in ISR | Use the ...FromISR variant, always |
FromISR calls never block |
No timeout param; check return for errQUEUE_FULL |
woken pattern |
init pdFALSE → pass to every FromISR call → portYIELD_FROM_ISR(woken) last |
portYIELD_FROM_ISR(w) |
Switch straight to the newly-woken task as the ISR exits |
| Defer work | ISR: record + notify. Task: everything else |
IRAM_ATTR |
Put ESP32 ISRs in IRAM; no Serial, no heap, no mutexes inside |
| Best ISR→task signal | Task notification (fastest); queue when the event carries data |
| Attach order | Create the handler task before attachInterrupt |
How It Actually Works¶
An ISR on FreeRTOS runs outside the task scheduling world entirely — it
executes on whatever stack/mode the CPU uses for interrupts, not a task
stack, and it never itself gets "scheduled" or preempted by a task. Every
FreeRTOS API usable from an ISR has an ...FromISR twin because the normal
versions may attempt to block (illegal — an ISR cannot be suspended waiting
for something) and because the ISR variant takes a
BaseType_t *pxHigherPriorityTaskWoken out-parameter: when
xQueueSendFromISR (or vTaskNotifyGiveFromISR) wakes a task, it cannot
switch to it immediately — the CPU is still inside interrupt context — so it
just sets that flag to pdTRUE and returns. The deferred-work pattern's
portYIELD_FROM_ISR(xHigherPriorityTaskWoken) at the end of the ISR is what
actually requests the context switch, by setting the same pending-switch bit
that a preemption from the tick handler would set, so the switch happens the
instant the CPU finishes the ISR epilogue and returns from interrupt mode —
typically only a few microseconds after the hardware event, which is why a
task woken from an ISR can feel indistinguishable from running inside the
ISR itself while still keeping ISR bodies microsecond-short.
Exercise¶
- Build the button demo, then press the button rapidly: confirm the
debounce drain works by printing the press count. Remove
portYIELD_FROM_ISRand re-test — can you observe any difference in this small sketch? Explain why the bug it would cause is invisible here but real (what else would the CPU have to be busy doing?). - Change the button ISR to
xQueueSendFromISRa struct{millis(), pressCount}into a 4-deep queue instead of a notification. Hold the button down with Wokwi's autorepeat or click furiously — watcherrQUEUE_FULLdrops happen by counting them in avolatile uint32_t dropCountthatloop()prints every 2 s. - In comments: list the three things the ISR in exercise 2 is still allowed to do, and three things it must never do — with the failure mode each violation causes.