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.

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);pvItemToQueueis a pointer to the data you want to send. The data is copied from this address.xTicksToWaitis the maximum time the task will wait in the Blocked state if the queue is full.portMAX_DELAYwill wait indefinitely.
- Receive:
BaseType_t xQueueReceive(QueueHandle_t xQueue, void * pvBuffer, TickType_t xTicksToWait);pvBufferis 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.
-
In
main.c, ensure your UART is initialized. -
Declare a global queue handle:
QueueHandle_t xNumberQueue; -
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. } -
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
printfdirected to UART, you'll need to have implemented the necessary_writesyscall, as we discussed in a previous module. -
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(); -
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.

5. Hands-On: A Structured Data Logger
Let's upgrade your previous project to use this professional pattern.
- Define a data structure for our messages:
typedef struct { TickType_t xTimestamp; uint32_t ulValue; } Data_t; - In
main(), modify your queue creation. It will now hold pointers toData_t.// Create a queue to hold up to 5 POINTERS to Data_t structs. xNumberQueue = xQueueCreate(5, sizeof(Data_t *)); - 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); } } - 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); } } } - 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()andvPortFree(). - The
xTicksToWaitparameter 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.