Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 50

FreeRTOS Queues for Safe Inter-Task Communication

Recommended resources

Hello! Welcome back to our FreeRTOS module.

In our previous lesson, you mastered task timing, learning the critical difference between relative delays with vTaskDelay() and the precise, periodic execution enabled by vTaskDelayUntil(). You now have a set of independent tasks running predictably. The next logical step, and a cornerstone of any real-time system, is to make these tasks communicate with each other safely and efficiently.

This lesson directly addresses that challenge. Our learning outcome is to implement inter-task communication using FreeRTOS queues to pass data safely. You will learn how a "producer" task, like one reading a sensor, can send its data to a "consumer" task, like one that processes or displays that data, without risking the data corruption that can happen with simple global variables.

1. The "Why": Why Not Just Use a Global Variable?

In a single-threaded program, it's common to have one part of the code write to a global variable and another part read from it. In a preemptive multitasking environment like FreeRTOS, this is extremely dangerous.

Imagine a producer task is in the middle of updating a multi-byte data structure (e.g., sensor_data.timestamp = ...; sensor_data.value = ...;). What if, right after it updates the timestamp but before it updates the value, the RTOS scheduler preempts it and runs the consumer task? The consumer would read a corrupted mix of old and new data. This is called a race condition.

Queues solve this problem by providing a thread-safe mechanism to pass data. FreeRTOS guarantees that each message (a piece of data) sent to the queue is handled as a single, indivisible operation.

FreeRTOS Queue for Inter-Task Communication
This diagram shows a classic RTOS architecture. Two "producer" tasks (reading temperature and LDR sensors) send their data into a single queue. A "consumer" task (updating an LCD) safely retrieves the data from the queue, one piece at a time.

2. Simple Queues: Passing Data by Value

A FreeRTOS queue is a First-In, First-Out (FIFO) buffer. You create it with a specific length (how many items it can hold) and item size (the size of each data item).

When you send an item to a queue, FreeRTOS copies the data into the queue's internal storage. When you receive an item, it copies the data from its storage into your variable. This is known as passing by value. It's simple and safe, ideal for small data types like integers or small structs.

Let's watch a demonstration of the core API functions.

FreeRTOS Tutorial 5 || Simple QUEUE || STM32 || NO CMSIS || CubeIDE

This video from ControllersTech provides a clear, step-by-step guide to using simple queues. We'll focus on the fundamental concepts and API calls.

Please watch the following segments: Intro to Queues (00:07 - 01:16): This gives a great visual of the FIFO principle. Creating the Queue (03:51 - 05:20): Pay attention to the xQueueCreate function and its parameters: queue length and item size. Sending to the Queue (08:25 - 09:40): Focus on the xQueueSend function. Note the xTicksToWait parameter, which determines how long the task should block if the queue is full. Receiving from the Queue (10:53 - 12:10): Focus on xQueueReceive. Again, note the xTicksToWait parameter for blocking if the queue is empty. Blocking Visualization (13:21 - 15:00): This section is excellent. It shows what happens when tasks try to send to a full queue—they enter the Blocked state, allowing other tasks to run.

Key API Functions:

  • Create: QueueHandle_t xQueueCreate(UBaseType_t uxQueueLength, UBaseType_t uxItemSize);
    • Returns a handle to the queue.
  • Send: BaseType_t xQueueSend(QueueHandle_t xQueue, const void * pvItemToQueue, TickType_t xTicksToWait);
    • pvItemToQueue is a pointer to the data you want to send. The data is copied from this address.
    • xTicksToWait is the maximum time the task will wait in the Blocked state if the queue is full. portMAX_DELAY will wait indefinitely.
  • Receive: BaseType_t xQueueReceive(QueueHandle_t xQueue, void * pvBuffer, TickType_t xTicksToWait);
    • pvBuffer is a pointer to a variable where the received data will be copied.

3. Hands-On: A Simple Producer-Consumer System

Let's put this into practice. You'll create two tasks: one that periodically produces a number and another that consumes it and prints it via UART.

  1. In main.c, ensure your UART is initialized.

  2. Declare a global queue handle:

    QueueHandle_t xNumberQueue;
    
  3. In main() before starting the scheduler, create the queue:

    // Create a queue to hold up to 5 unsigned 32-bit integers.
    xNumberQueue = xQueueCreate(5, sizeof(uint32_t));
    
    // Optional: Check if the queue was created successfully.
    if (xNumberQueue == NULL) {
        // Queue creation failed. Handle error.
    }
    
  4. Create the producer and consumer task functions:

    void vProducerTask(void *pvParameters)
    {
        TickType_t xLastWakeTime = xTaskGetTickCount();
        const TickType_t xFrequency = pdMS_TO_TICKS(1000); // Produce a number every second
        uint32_t ulNumberToSend = 0;
    
        while(1)
        {
            // Send the number to the queue, waiting for a maximum of 100ms if the queue is full.
            if (xQueueSend(xNumberQueue, &ulNumberToSend, pdMS_TO_TICKS(100)) != pdPASS)
            {
                // Failed to send to the queue, perhaps it was full for too long.
                printf("Failed to send to queue.\r\n");
            }
    
            ulNumberToSend++;
    
            // Wait for the next cycle.
            vTaskDelayUntil(&xLastWakeTime, xFrequency);
        }
    }
    
    void vConsumerTask(void *pvParameters)
    {
        uint32_t ulReceivedNumber;
    
        while(1)
        {
            // Wait indefinitely for a number to become available on the queue.
            if (xQueueReceive(xNumberQueue, &ulReceivedNumber, portMAX_DELAY) == pdPASS)
            {
                printf("Received: %lu\r\n", ulReceivedNumber);
            }
        }
    }
    

    Note: To use printf directed to UART, you'll need to have implemented the necessary _write syscall, as we discussed in a previous module.

  5. In main(), create the tasks and start the scheduler. Give the consumer a higher priority to ensure it runs as soon as data is available.

    xTaskCreate(vProducerTask, "Producer", 128, NULL, 1, NULL);
    xTaskCreate(vConsumerTask, "Consumer", 128, NULL, 2, NULL); // Higher priority
    
    vTaskStartScheduler();
    
  6. Compile, flash, and run. Open your serial terminal. You should see the numbers 0, 1, 2... being printed once per second. You have successfully decoupled the two tasks!


4. Structured Queues: Passing Data by Reference

Passing by value is inefficient for large data structures. Copying a 200-byte sensor data packet on and off the stack for every reading is a waste of CPU cycles and stack space.

The professional approach is to pass a pointer to the data. The queue item size becomes sizeof(struct MyData *). This is fast and efficient, but introduces a new responsibility: dynamic memory management.

  • The producer task must allocate a block of memory from the heap to store the data structure.
  • It then sends the pointer to this memory block via the queue.
  • The consumer task receives the pointer, reads the data from the memory block, and—this is critical—frees the memory to prevent a memory leak.

FreeRTOS provides its own thread-safe memory allocation functions: pvPortMalloc() and vPortFree().

Let's see how this advanced pattern is implemented.

FreeRTOS Tutorial 6 || Structured Queue || Memory Allocation || STM32

This next video from ControllersTech demonstrates exactly this professional pattern: using structured queues with dynamic memory allocation.

Please watch these segments carefully: Setup (00:34 - 04:38): See how a struct is defined and how xQueueCreate is now configured to hold pointers (sizeof(my_struct *)). Sender Logic (04:38 - 08:46): This is key. Observe the use of pvPortMalloc() to allocate memory before filling the struct and xQueueSend() to send the pointer. Receiver Logic (08:46 - 11:46): This is the other half of the puzzle. Watch how xQueueReceive() gets the pointer, the data is used, and then vPortFree() is called to release the memory. This prevents memory leaks.

The core pattern is "sender allocates, receiver frees." This clear ownership model prevents confusion and errors like double-frees or memory leaks.

FreeRTOS Structured Queue and Memory Allocation in STM32 Debug Session
This screenshot shows a debug session tracking heap memory. The pointers being passed in the queue correspond to specific memory addresses allocated by `pvPortMalloc` on the heap (e.g., `ucHeap+1964`). After the receiver processes the data, `vPortFree` returns this memory to the available pool.

5. Hands-On: A Structured Data Logger

Let's upgrade your previous project to use this professional pattern.

  1. Define a data structure for our messages:
    typedef struct {
        TickType_t xTimestamp;
        uint32_t ulValue;
    } Data_t;
    
  2. In main(), modify your queue creation. It will now hold pointers to Data_t.
    // Create a queue to hold up to 5 POINTERS to Data_t structs.
    xNumberQueue = xQueueCreate(5, sizeof(Data_t *));
    
  3. Modify your vProducerTask:
    void vProducerTask(void *pvParameters)
    {
        TickType_t xLastWakeTime = xTaskGetTickCount();
        const TickType_t xFrequency = pdMS_TO_TICKS(1000);
        uint32_t ulCounter = 0;
    
        while(1)
        {
            // 1. ALLOCATE memory for a new data structure.
            Data_t *pData = (Data_t *) pvPortMalloc(sizeof(Data_t));
    
            if (pData != NULL)
            {
                // 2. FILL the structure.
                pData->xTimestamp = xTaskGetTickCount();
                pData->ulValue = ulCounter++;
    
                // 3. SEND the POINTER to the queue.
                if (xQueueSend(xNumberQueue, &pData, pdMS_TO_TICKS(100)) != pdPASS)
                {
                    printf("Failed to send pointer. Freeing memory.\r\n");
                    vPortFree(pData); // Don't leak memory if send fails
                }
            }
            vTaskDelayUntil(&xLastWakeTime, xFrequency);
        }
    }
    
  4. Modify your vConsumerTask:
    void vConsumerTask(void *pvParameters)
    {
        Data_t *pReceivedData;
    
        while(1)
        {
            // 1. RECEIVE the POINTER from the queue.
            if (xQueueReceive(xNumberQueue, &pReceivedData, portMAX_DELAY) == pdPASS)
            {
                // 2. USE the data.
                printf("Received Data! Timestamp: %lu, Value: %lu\r\n",
                       pReceivedData->xTimestamp, pReceivedData->ulValue);
    
                // 3. FREE the memory that the sender allocated.
                vPortFree(pReceivedData);
            }
        }
    }
    
  5. Compile, flash, and run. The output should look similar, but now it includes a timestamp. Under the hood, your system is far more efficient and robust, using a pattern common in professional firmware.

Conclusion

You have now mastered one of the most fundamental IPC (Inter-Process Communication) mechanisms in any RTOS. This skill is essential for designing modular, maintainable, and robust embedded systems.

Key Takeaways:

  • Queues provide a thread-safe FIFO buffer for passing data between tasks or from an interrupt to a task.
  • For small, fixed-size data, passing by value is simple. FreeRTOS handles the copying.
  • For larger data structures, passing by reference (sending a pointer) is more efficient.
  • When passing by reference, you must manage memory. The standard professional pattern is "sender allocates, receiver frees" using pvPortMalloc() and vPortFree().
  • The xTicksToWait parameter allows tasks to block efficiently without wasting CPU time, a core benefit of an RTOS.

In our next lesson, we will explore a different kind of inter-task communication. What if you don't need to send data, but simply need to signal that an event has occurred (like a button press or a completed transmission)? For this, we use semaphores. You will learn to use a binary semaphore to synchronize a task with a hardware interrupt.