A Task Notification is a lightweight notification channel directly associated with a task, designed to synchronize or send simple values without creating extra objects.
We’ve already used queues to pass data. FreeRTOS also offers semaphores and event groups to synchronize tasks, but they are all independent objects.
They also have a “price”: they are independent objects that must be created, consume Heap RAM, and require more management from the Kernel.
What if I just want to notify a task that “something happened”? Or send it a number? Do I really need to create a full queue for that?
The answer is NO. FreeRTOS has a tool designed precisely for this case: Task Notifications.
What are task notifications?
Unlike queues or semaphores, which are external objects to tasks (a queue exists even if no one uses it), a Notification belongs directly to a Task.
Each task has in its TCB one or more 32-bit notification values, depending on configTASK_NOTIFICATION_ARRAY_ENTRIES. The traditional configuration uses a single entry.
This means that:
- They don’t need to be created: There is no
xNotificationCreate. They are already there. - No separate object is needed: The state is part of the TCB, although it naturally occupies space within it.
- They have little overhead: In many cases, they require less RAM and processing than an equivalent queue or semaphore.
Comparison with a queue or a semaphore
| Feature | Queue / Semaphore | Task Notification |
|---|---|---|
| Speed | Depends on the object and content | Typically lower overhead |
| RAM Usage | Object and, in a queue, buffer | State integrated into TCB |
| Recipients | Multiple | Only one |
| Capacity | Configurable FIFO queue | One value, counter, or mask per entry |
| Ease of Use | Standard API | Specific API |
How do they work?
The concept is to send an event directly to a specific task, knowing its Handle.
When we send a notification to a task, we can manipulate those internal 32 bits in several ways, allowing Notifications to act as:
- Binary Semaphore: “Wake up, something happened.”
- Counting Semaphore: “5 things happened.”
- Mailbox: “Take this 32-bit value.”
- Event Group: “Event A (bit 0) and Event C (bit 2) occurred.”
As a semaphore (Give / Take)
The most common use is as a “tap” to wake up a task. It is ideal for synchronizing a task with an Interrupt (ISR) or with another task.
We use the simplified macros: xTaskNotifyGive (give) and ulTaskNotifyTake (take).
Example: Task waiting for a button
// Global variable for the Handle of the task to be woken up
TaskHandle_t hLEDTask = NULL;
void LEDTask(void *pvParameters) {
for(;;) {
// 1. Wait for notification.
// pdTRUE = clear counter to 0 on exit.
// portMAX_DELAY = Infinite wait
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
// If we reach here, someone has notified us
Serial.println("Received! Blinking LED.");
digitalWrite(LED_BUILTIN, HIGH);
vTaskDelay(pdMS_TO_TICKS(200));
digitalWrite(LED_BUILTIN, LOW);
}
}
void ButtonTask(void *pvParameters) {
pinMode(0, INPUT_PULLUP); // ESP32 BOOT button
for(;;) {
if(digitalRead(0) == LOW) {
// Simple debounce
vTaskDelay(pdMS_TO_TICKS(50));
if(digitalRead(0) == LOW) {
Serial.println("Button pressed. Sending notification...");
// 2. Send notification DIRECTLY to the task
xTaskNotifyGive(hLEDTask);
// Wait for release
while(digitalRead(0) == LOW) {
vTaskDelay(pdMS_TO_TICKS(10));
}
}
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
void setup() {
Serial.begin(115200);
pinMode(LED_BUILTIN, OUTPUT);
// Create the task and save its Handle (CRITICAL)
xTaskCreate(LEDTask, "LED", 2048, NULL, 1, &hLEDTask);
xTaskCreate(ButtonTask, "Btn", 2048, NULL, 1, NULL);
}
void loop() {}Notice the simplicity. We didn’t create any semaphore object in setup(). We use the task’s Handle to “tap it on the shoulder.”
Sending values with xTaskNotify
We can also use those 32 bits to send data. For example, sending a command or a status.
The generic function is:
BaseType_t xTaskNotify(
TaskHandle_t xTaskToNotify, // Who to notify
uint32_t ulValue, // The value (data or bit mask)
eNotifyAction eAction // WHAT to do with that value
);The eAction parameter is the key. It defines how we modify the 32 bits of the destination task:
eSetBits: Performs a logical OR (useful for flags).eIncrement: Increments the counter (likexTaskNotifyGive).eSetValueWithOverwrite: Overwrites the previous value with the new one.eSetValueWithoutOverwrite: Only writes if the previous value has already been read.
Example: Sending commands
TaskHandle_t hMotor = NULL;
// Define commands
#define CMD_STOP 0x00
#define CMD_FORWARD 0x01
#define CMD_REVERSE 0x02
void MotorTask(void *param) {
uint32_t receivedCommand;
for(;;) {
// Wait indefinitely for a notification
// Save the received value in 'receivedCommand'
xTaskNotifyWait(
0x00, // Bits to clear on entry (none)
0xFFFFFFFF, // Bits to clear on exit (all -> reset)
&receivedCommand, // Where to save the value
portMAX_DELAY // Timeout
);
switch(receivedCommand) {
case CMD_FORWARD: Serial.println("Motor: FORWARD"); break;
case CMD_REVERSE: Serial.println("Motor: REVERSE"); break;
case CMD_STOP: Serial.println("Motor: STOP"); break;
}
}
}
void setup() {
Serial.begin(115200);
xTaskCreate(MotorTask, "Motor", 2048, NULL, 1, &hMotor);
vTaskDelay(pdMS_TO_TICKS(1000));
// Send commands by overwriting the value
xTaskNotify(hMotor, CMD_FORWARD, eSetValueWithOverwrite);
vTaskDelay(pdMS_TO_TICKS(2000));
xTaskNotify(hMotor, CMD_STOP, eSetValueWithOverwrite);
}
void loop() {}When not to use them
Although they are wonderful, Task Notifications cannot replace Queues and Semaphores in 100% of cases. They have important limitations:
- Each notification belongs to a task: There can be multiple senders, but only its owner task waits on that entry. If you need to wake up multiple tasks with the same bits, consider an Event Group.
- They don’t store history: If you send 5 consecutive values (
eSetValueWithOverwrite) before the receiving task reads them, it will only read the last one. The previous 4 are lost. If you need a buffer (FIFO) where nothing is lost, use a Queue. - Each entry only contains 32 bits: You cannot send a complete structure. Sending a pointer is possible, but it reintroduces the problem of ownership and buffer lifetime.