
FreeRTOS task notifications were introduced in v8.2.0 as a lightweight alternative to queues for task-to-task and ISR-to-task signaling. The marketing claims “up to 45% faster and uses less RAM” — but what does that mean in cycle counts on real hardware? This article measures actual latency on an STM32F407 (Cortex-M4 @ 168 MHz) and breaks down where the cycles go.
| Mechanism | Use Case | Data Payload |
|---|---|---|
xTaskNotifyGive() / ulTaskNotifyTake() | Binary event, counting semaphore replacement | 32-bit value (bits) |
xTaskNotify() / xTaskNotifyWait() | Extended: value overwrite, increment, bit-set | 32-bit value |
xQueueSend() / xQueueReceive() | General purpose, FIFO, multi-waiter | Arbitrary (void*) |
Task notifications are per-task. Each task control block (TCB) gains a uint32_t ulNotifiedValue and uint8_t ucNotifyState when configUSE_TASK_NOTIFICATIONS is 1 (default since v10.0.0). No separate kernel object allocation occurs.
Queues allocate a Queue_t structure (~64 bytes) plus the item buffer. Each send/receive walks the queue’s linked list of waiting tasks.
Test setup: Cortex-M4 @ 168 MHz, FreeRTOS v10.4.3, configUSE_PORT_OPTIMISED_TASK_SELECTION=1, compiler -O2. Cycles measured via DWT_CYCCNT for the path from ISR trigger to task wake-up (receiving task priority 5, no other tasks preempting during measurement).
+------------------------------------------------------------------+| Latency Breakdown (cycles) |+------+------------------+------------------+---------------------+| Path | Notify Give | Queue Send | Delta || | (ISR -> Task) | (ISR -> Task) | |+------+------------------+------------------+---------------------+| Send | 58 | 142 | -84 (59% less) || Wait | 42 | 98 | -56 (57% less) || Total| 100 | 240 | -140 (58% less) |+------+------------------+------------------+---------------------+
The “Wait” row measures the receiving task’s ulTaskNotifyTake() vs xQueueReceive() when the signal is already pending (no context switch). The “Send” row includes the ISR entry/exit overhead for both paths.
At 168 MHz: 100 cycles = 595 ns for notifications vs 240 cycles = 1.43 µs for queues. Both are sub-microsecond, but the 2.4x difference matters in tight control loops.
vTaskNotifyGiveFromISR)void vTaskNotifyGiveFromISR(TaskHandle_t xTaskToNotify, BaseType_t *pxHigherPriorityTaskWoken) {TCB_t *pxTCB = xTaskToNotify;pxTCB->ulNotifiedValue++; // 1 cycle (increment)if (pxTCB->ucNotifyState != taskWAITING_NOTIFICATION) {pxTCB->ucNotifyState = taskNOTIFIED; // 1 cycle} else {// Task already waiting — move to ready listpxTCB->ucNotifyState = taskNOT_WAITING_NOTIFICATION;vTaskPlaceOnReadyList(pxTCB); // ~30 cyclesif (pxTCB->uxPriority > pxCurrentTCB->uxPriority) {*pxHigherPriorityTaskWoken = pdTRUE;}}}
Critical path: interrupt masking, increment, state check, and possible ready-list insert. While it enters a critical section to protect TCB notification values, it performs no memory copy or queue state management.
xQueueSendFromISR)BaseType_t xQueueSendFromISR(QueueHandle_t xQueue, const void *pvItemToQueue, BaseType_t *pxHigherPriorityTaskWoken) {Queue_t *pxQueue = xQueue;// 1. Check space (critical section)// 2. Copy item to queue buffer (memcpy, N bytes)// 3. If tasks waiting: retrieve head task of xTasksWaitingToReceive (O(1) list removal)// 4. Unblock this highest-priority waiter// 5. If waiter priority > current: set pxHigherPriorityTaskWoken}
Queue send enters a critical section, copies the payload via memcpy, retrieves the highest-priority waiting task from the head of the list ($O(1)$ removal because the list is pre-sorted), and moves it to the ready list. The overhead is dominated by queue state checks, payload memory copy, and critical section duration, rather than list walks.
+------------------------------------------------------------------+| ISR -> TASK SIGNAL PATH |+------------------------------------------------------------------+| || [ ISR ] || | || | vTaskNotifyGiveFromISR() || | | || | v || | +------------------+ || | | portSET_INTERRUPT| Critical section entry || | | MASK_FROM_ISR()| || | +------------------+ || | | || | v || | +------------------+ || | | TCB.ulNotified++ | (1 write, 1 check) || | | TCB.ucNotifyState| || | +------------------+ || | | || | v (if task waiting) || | +------------------+ || | | vTaskPlaceOn | O(1) ready list insert || | | ReadyList() | || | +------------------+ || | | || | v || | +------------------+ || | | portCLEAR_INTERRU| Critical section exit || | | UPT_MASK_FROM_ | || | | ISR() | || | +------------------+ || | || | vs || | || | xQueueSendFromISR() || | | || | v || | +------------------+ || | | portSET_INTERRUPT| Critical section entry || | | MASK_FROM_ISR()| || | +------------------+ || | | || | v || | +------------------+ || | | memcpy(payload, | N bytes copy || | | queue->buffer) | || | +------------------+ || | | || | v || | +------------------+ || | | Get head entry of| O(1) retrieve highest-priority task || | | xTasksWaiting | || | +------------------+ || | | || | v || | +------------------+ || | | vTaskPlaceOn | O(1) ready list insert || | | ReadyList() | || | +------------------+ || | | || | v || | +------------------+ || | | portCLEAR_INTERRU| Critical section exit || | | UPT_MASK_FROM_ | || | | ISR() | || | +------------------+ || | |+------------------------------------------------------------------+
ulTaskNotifyTake(pdTRUE, timeout) acts as a faster semaphoreulTaskNotifyTake(pdFALSE, timeout) decrements without clearingxTaskNotify(xTask, bitmask, eSetBits) for multiple event flags in one 32-bit wordxQueueSendFromISR works before scheduler starts; notifications require a valid TCB| Configuration | RAM/Task | Queue Overhead |
|---|---|---|
configUSE_TASK_NOTIFICATIONS=1 | +8 bytes | — |
| Binary semaphore (queue len=1, item=0) | — | ~76 bytes (structure only) |
| Counting semaphore (queue len=N) | — | ~76 bytes (structure only) |
| Queue (10 items × 4 bytes) | — | ~76 bytes + 40 bytes buffer |
Notifications add fixed per-task cost. Queues add per-object cost. For 10 tasks each with a binary semaphore: 10 × 76 = 760 bytes vs 10 × 8 = 80 bytes for notifications.
/* ISR: DMA transfer complete */void DMA2_Stream7_IRQHandler(void) {BaseType_t xHigherPriorityTaskWoken = pdFALSE;if (DMA2->HISR & DMA_HISR_TCIF7) {DMA2->HIFCR = DMA_HIFCR_CTCIF7;vTaskNotifyGiveFromISR(xWorkerTaskHandle, &xHigherPriorityTaskWoken);}portYIELD_FROM_ISR(xHigherPriorityTaskWoken);}/* Worker task */void vWorkerTask(void *pvParameters) {uint32_t ulNotifiedValue;for (;;) {ulNotifiedValue = ulTaskNotifyTake(pdTRUE, portMAX_DELAY);/* ulNotifiedValue == 1 (binary) or count (counting) */ProcessDmaBuffer();}}
This replaces the classic xSemaphoreGiveFromISR() + xSemaphoreTake() pair with 2.4x lower latency and no separate semaphore object.
#define EVENT_UART_RX (1UL << 0)#define EVENT_SPI_TX (1UL << 1)#define EVENT_I2C_ERR (1UL << 2)/* Multiple ISRs notify same task with different bits */void UART_IRQHandler(void) {BaseType_t xHigher = pdFALSE;xTaskNotifyFromISR(xCommTask, EVENT_UART_RX, eSetBits, &xHigher);portYIELD_FROM_ISR(xHigher);}void SPI_IRQHandler(void) {BaseType_t xHigher = pdFALSE;xTaskNotifyFromISR(xCommTask, EVENT_SPI_TX, eSetBits, &xHigher);portYIELD_FROM_ISR(xHigher);}/* Task waits for ANY event */void vCommTask(void *pv) {uint32_t ulEvents;for (;;) {xTaskNotifyWait(0, UINT32_MAX, &ulEvents, portMAX_DELAY);if (ulEvents & EVENT_UART_RX) HandleUartRx();if (ulEvents & EVENT_SPI_TX) HandleSpiTx();if (ulEvents & EVENT_I2C_ERR) HandleI2cErr();}}
One task, one notification value, multiple event sources. No queue, no event group object (which adds ~48 bytes).
FreeRTOS task notifications deliver 2-3x lower latency than queues for signaling workloads by eliminating the queue object overhead, queue structure state logic, payload copy, and event list lookup. They keep critical sections extremely short, add 8 bytes per TCB, and shine in ISR-to-task, binary semaphore, and bitmask-event patterns. Queues remain essential for payload transfer, multi-consumer, and FIFO ordering. Use both — each for what it’s designed for.
tasks.c — vTaskNotifyGiveFromISR, xQueueSendFromISR implementations — https://github.com/FreeRTOS/FreeRTOS-Kernel/blob/main/tasks.cQuick Links
Legal Stuff
