Previously: Semaphore vs Mutex | Next: Heap Management
Why ISRs Are Different — The One Rule You Cannot Break
An Interrupt Service Routine runs outside any task context. It pre-empts whatever was executing, borrows that stack frame, does its work, and returns. Because of this, an ISR can never call any FreeRTOS API that could block — if it tried, the scheduler would attempt to switch tasks while already inside an interrupt, which is undefined behaviour and typically results in a hard fault or silent data corruption.
The solution FreeRTOS gives you is a strict two-step handoff: the ISR does the absolute minimum (capture data, signal a waiting task) using a special set of FromISR APIs, and a dedicated task wakes up to handle the rest in normal thread context. This pattern is sometimes called deferred interrupt processing, and it is the correct architecture for almost every real-world interrupt on an STM32.
The Two-Step Handoff Pattern
The handoff has two halves that mirror each other exactly.
Half 1 — The Minimal ISR
The ISR runs at hardware priority. Its only jobs are to capture whatever time-sensitive data the peripheral has produced and to unblock the waiting task by calling a ...FromISR variant. Finally it calls portYIELD_FROM_ISR() (or passes pdTRUE to the macro that does the same thing) so the scheduler can immediately switch to the newly-unblocked task instead of returning to whatever was pre-empted.
/* Example: UART receive interrupt on STM32 */
static BaseType_t xHigherPriorityTaskWoken;
void USART2_IRQHandler(void)
{
xHigherPriorityTaskWoken = pdFALSE;
uint8_t byte = (uint8_t)(USART2->DR & 0xFF); /* capture data */
/* Signal the processing task -- non-blocking, ISR-safe */
xQueueSendFromISR(xUartRxQueue, &byte, &xHigherPriorityTaskWoken);
/* Request a context switch if a higher-priority task was woken */
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
Half 2 — The Processing Task
The task blocks forever on the queue (or semaphore, or event group). When the ISR posts to it, the scheduler unblocks the task and it runs to completion in normal thread context, where it is free to call any FreeRTOS API, allocate memory, or write to a display.
void vUartProcessingTask(void *pvParameters)
{
uint8_t byte;
for (;;)
{
/* Blocks here -- zero CPU cost while waiting */
if (xQueueReceive(xUartRxQueue, &byte, portMAX_DELAY) == pdTRUE)
{
process_byte(byte); /* safe: normal task context */
}
}
}
The Full FromISR API Family
Every synchronisation primitive that can be used from an ISR has a FromISR counterpart. The pattern is identical for each one: pass a pointer to a BaseType_t xHigherPriorityTaskWoken variable, initialise it to pdFALSE before the call, and pass the result to portYIELD_FROM_ISR() afterwards.
| Normal API | ISR-safe equivalent | What it does |
|---|---|---|
xSemaphoreGive() | xSemaphoreGiveFromISR() | Release a binary or counting semaphore |
xQueueSend() | xQueueSendFromISR() | Post an item to a queue |
xQueueSendToFront() | xQueueSendToFrontFromISR() | Post to front of queue |
xQueueReceive() | xQueueReceiveFromISR() | Receive without blocking (rare in ISR) |
xTaskNotify() | xTaskNotifyFromISR() | Send a direct-to-task notification |
xTaskNotifyGive() | vTaskNotifyGiveFromISR() | Lightweight notify (counting) |
xEventGroupSetBits() | xEventGroupSetBitsFromISR() | Set bits in an event group |
xTimerStart() etc. | xTimerStartFromISR() etc. | Control a software timer |
Notice that mutexes have no FromISR variant. Mutexes implement priority inheritance, which requires task context. If you need mutual exclusion between an ISR and a task, use a binary semaphore instead, or use a lock-free ring buffer and let the task protect the shared resource with a mutex on its side.
The Syscall-Priority Boundary: configMAX_SYSCALL_INTERRUPT_PRIORITY
This is the single most important — and most misunderstood — configuration constant in FreeRTOS for Cortex-M targets. It controls which interrupts are allowed to call FreeRTOS APIs at all.
How BASEPRI Works
FreeRTOS protects its internal kernel data by raising the Cortex-M BASEPRI register to configMAX_SYSCALL_INTERRUPT_PRIORITY during critical sections. When BASEPRI is set to a value N, the processor masks all interrupts whose hardware priority number is numerically greater than or equal to N — meaning lower urgency in NVIC terms. Any interrupt with a hardware priority strictly lower than N (numerically smaller, higher urgency) is not masked and can still fire. Those interrupts must never call any FreeRTOS API.
The rule is therefore: interrupts at or below configMAX_SYSCALL_INTERRUPT_PRIORITY (numerically >= the configured value) may call ...FromISR APIs safely. Interrupts above configMAX_SYSCALL_INTERRUPT_PRIORITY (numerically < the configured value) must never call any FreeRTOS API — these interrupts are untouched by the kernel and have guaranteed hard real-time latency.
/* FreeRTOSConfig.h -- typical STM32 settings */
/* Cortex-M uses 4 bits of priority on most STM32 families (0-15) */
#define configPRIO_BITS 4
/* Lowest usable interrupt priority (highest numeric value = least urgent) */
#define configLIBRARY_LOWEST_INTERRUPT_PRIORITY 15
/* Highest priority FROM which FreeRTOS APIs may be called.
* Interrupts at priorities 5-15 may use FromISR APIs.
* Interrupts at priorities 0-4 must NEVER call FreeRTOS. */
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5
/* These macros shift the library values into the BASEPRI format */
#define configMAX_SYSCALL_INTERRUPT_PRIORITY \
( configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS) )
#define configKERNEL_INTERRUPT_PRIORITY \
( configLIBRARY_LOWEST_INTERRUPT_PRIORITY << (8 - configPRIO_BITS) )
The #1 Misconfiguration: NVIC Priority Grouping vs FreeRTOS Numeric Convention
This is the bug that shows up most often in FreeRTOS + STM32 projects, and it is completely silent until it causes a hard fault or corrupted kernel state at runtime.
The Problem
STM32 HAL initialises the NVIC priority grouping with HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4) by default. NVIC_PRIORITYGROUP_4 means all 4 priority bits are used for preempt priority and zero bits for sub-priority. This is exactly what FreeRTOS requires — it assumes all priority bits encode preempt priority and sub-priority is zero. If CubeMX or your board BSP sets a different priority group (e.g. NVIC_PRIORITYGROUP_2), the numbers FreeRTOS uses for BASEPRI no longer match the NVIC's interpretation of the priority register, and the boundary becomes meaningless.
The Numeric Direction Trap
Cortex-M priority numbering is inverted relative to what most developers expect: a lower numeric value means higher urgency. Priority 0 is the highest possible urgency; priority 15 (for a 4-bit implementation) is the lowest. FreeRTOS uses the same convention, but developers accustomed to thinking "higher number = higher priority" (as in task priorities) frequently assign an NVIC priority of 0 or 1 to a peripheral interrupt and then call xSemaphoreGiveFromISR() inside it — which violates the boundary because those priorities are above configMAX_SYSCALL_INTERRUPT_PRIORITY.
The Correct Pattern in CubeMX / HAL
/* Always set grouping BEFORE configuring any peripheral interrupt */
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4); /* required by FreeRTOS */
/*
* configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY = 5 in this example.
* Peripheral interrupts that call FromISR APIs must use priority 5-15.
* Never assign priority 0-4 to an interrupt that calls FreeRTOS APIs.
*/
HAL_NVIC_SetPriority(USART2_IRQn, 6, 0); /* preempt=6, sub=0 -- safe */
HAL_NVIC_EnableIRQ(USART2_IRQn);
/* A hard-real-time interrupt that must NEVER be delayed by FreeRTOS */
HAL_NVIC_SetPriority(TIM1_UP_IRQn, 2, 0); /* preempt=2 -- CANNOT call FreeRTOS */
HAL_NVIC_EnableIRQ(TIM1_UP_IRQn);
Quick Sanity Check with configASSERT
FreeRTOS provides an assertion helper for Cortex-M ports. With configASSERT enabled, the Cortex-M port checks IPSR and BASEPRI on every kernel entry; a violation halts immediately rather than corrupting the heap silently. Add this to your FreeRTOSConfig.h:
#define configASSERT( x ) \
if( ( x ) == 0 ) { taskDISABLE_INTERRUPTS(); for( ;; ); }
Choosing the Right FromISR Primitive
Three primitives dominate deferred ISR work in practice, each with a different cost and use-case.
Binary Semaphore — Signal Without Data
Use a binary semaphore when the ISR only needs to say "an event happened" and the task fetches the data itself (e.g., from a peripheral register). It is the lightest option and has the lowest ISR-side overhead.
/* ISR */
xSemaphoreGiveFromISR(xAdcReadySem, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
/* Task */
xSemaphoreTake(xAdcReadySem, portMAX_DELAY);
uint32_t value = ADC1->DR; /* read peripheral directly */
Queue — Signal With Data
Use a queue when the ISR must pass one or more data items to the task. The queue stores a copy of the data so the ISR's stack frame can safely be released before the task runs.
/* ISR */
SensorReading_t reading = { .raw = DMA_buffer[0], .timestamp = xTaskGetTickCountFromISR() };
xQueueSendFromISR(xSensorQueue, &reading, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
/* Task */
SensorReading_t rx;
xQueueReceive(xSensorQueue, &rx, portMAX_DELAY);
process_sensor_data(&rx);
Direct Task Notification — Fastest Signal
Task notifications are the leanest option when there is exactly one task waiting on one ISR. They require no kernel object, use a 32-bit value embedded in the TCB, and have lower overhead than semaphores or queues. They cannot broadcast to multiple tasks.
/* ISR -- increment notification value (acts like a counting semaphore) */
vTaskNotifyGiveFromISR(xProcessingTaskHandle, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
/* Task */
ulTaskNotifyTake(pdTRUE, portMAX_DELAY); /* pdTRUE clears on exit = binary behaviour */
handle_event();
portYIELD_FROM_ISR: Why It Matters
When a FromISR call unblocks a task, that task is made ready but does not run immediately — the ISR is still executing. portYIELD_FROM_ISR(xHigherPriorityTaskWoken) sets the PendSV exception pending at the end of the ISR. When all higher-priority exceptions have returned, PendSV fires and the scheduler performs a context switch. If xHigherPriorityTaskWoken is pdFALSE the macro does nothing, so it is safe to call unconditionally.
Omitting portYIELD_FROM_ISR is not a correctness bug in itself — the unblocked task will still run eventually — but it introduces latency: the task will not pre-empt the currently running task until the next tick or the next natural scheduler invocation.
DMA + ISR Integration
DMA transfer-complete callbacks (HAL_UART_RxCpltCallback etc.) run in interrupt context under HAL. The same rules apply: do not block, use FromISR APIs, call portYIELD_FROM_ISR. A common pattern is to post the DMA buffer index into a queue and let a task process the data in thread context, while the ISR re-arms DMA for the other buffer in a ping-pong scheme.
/* Called from HAL DMA IRQ -- this IS interrupt context */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART2)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
uint8_t bufIdx = current_dma_buffer; /* ping-pong index */
xQueueSendFromISR(xDmaQueue, &bufIdx, &xHigherPriorityTaskWoken);
/* Re-arm DMA for the other buffer */
HAL_UART_Receive_DMA(huart, dma_buffers[1 - bufIdx], DMA_BUF_SIZE);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
}
Summary
| Concept | Key Point |
|---|---|
| ISR must never block | No xSemaphoreTake(), no xQueueReceive() with timeout, no vTaskDelay() in an ISR |
| FromISR APIs | Non-blocking variants of all sync primitives; require a xHigherPriorityTaskWoken out-parameter |
portYIELD_FROM_ISR() | Requests an immediate context switch via PendSV if a higher-priority task was woken |
configMAX_SYSCALL_INTERRUPT_PRIORITY | BASEPRI value; interrupts numerically >= this may use FreeRTOS APIs; interrupts below this must not |
| NVIC_PRIORITYGROUP_4 | Must be set before FreeRTOS starts; all 4 priority bits = preempt priority, zero sub-priority bits |
| No mutex FromISR | Mutexes require task context; use binary semaphore for ISR-to-task mutual exclusion |
| Task notifications | Fastest 1:1 ISR-to-task signal; lowest overhead; cannot broadcast to multiple tasks |
Related Reading
For a worked end-to-end example of the interrupt pattern in context — including how to structure the ISR handler file and register the vector — see the companion post: FreeRTOS Interrupt Pattern on STM32.