Hello! Welcome back to our course on C++ for the ESP32.
In our last lesson, we saw how semaphores act as a powerful signaling mechanism, allowing an ISR or a task to notify another task that an event has occurred. However, semaphores don't carry any data themselves. We briefly looked at the producer-consumer pattern, where data was passed using a shared global buffer protected by semaphores and a mutex. This can be complex to manage.
What if you want to send the actual data from one task to another directly and safely, without managing shared buffers manually? This is precisely the problem that FreeRTOS queues are designed to solve. They are one of the most fundamental and widely used tools for inter-task communication.
By the end of this lesson, you will be able to use FreeRTOS queues to safely and efficiently share data of various types between different tasks.
1. Why Not Just Use Global Variables?
With your background in C++, you might first think of using a global variable to share data between two tasks. One task writes to it, and the other reads from it. While simple, this approach is fraught with peril in a multitasking environment.
Let's watch a short video that explains the dangers of using global variables for inter-task communication and introduces queues as the robust solution.
Introduction to RTOS Part 5 - Queue | Digi-Key Electronics
This video from Digi-Key clearly demonstrates the race conditions and data corruption issues that can arise from using global variables in an RTOS, setting the stage for why queues are essential.
Please watch from the beginning until 02:50. Pay close attention to the examples of: A value being overwritten before it's read. A non-atomic write of a 64-bit value on a 32-bit system being interrupted, leading to corrupted data.
As the video highlighted, when an operation is not atomic (meaning it can be interrupted partway through), you can get unpredictable and corrupted data. This is especially true when dealing with data larger than the processor's native word size (like a double or a struct) or when multiple tasks might write to the same variable.
Queues solve this by providing a thread-safe mechanism for passing data.
2. What is a FreeRTOS Queue?
A FreeRTOS queue is a data structure that allows you to send data from one task to another in a First-In, First-Out (FIFO) manner.
Here are the core concepts:
- Thread-Safe: The RTOS guarantees that operations on a queue are atomic. You can safely have multiple tasks sending to and receiving from the same queue without risk of data corruption.
- Data Copying (By Value): When you send an item to a queue, the RTOS copies the entire item into the queue's internal buffer. This is a crucial feature. It means the sending task can immediately reuse its local variable or let it go out of scope without affecting the data that's now safely stored in the queue.
- Blocking: Tasks can be configured to wait (block) if they try to read from an empty queue or write to a full queue. This is highly efficient, as a blocked task consumes no CPU time until data becomes available or space opens up.

Let's now turn to a practical example to see how this works in code.
3. Basic Queue Operations
The core functions you'll use to manage queues are:
xQueueCreate(): Creates a new queue.xQueueSend(): Sends (copies) an item to the back of the queue.xQueueReceive(): Receives (copies and removes) an item from the front of the queue.
The following tutorial from Random Nerd Tutorials provides an excellent first example where one task reads a potentiometer value and sends it to another task via a queue to control an LED's brightness.
ESP32 FreeRTOS Queues: Inter-Task Communication ...
This guide provides a complete, hands-on example for using queues on the ESP32 with the Arduino IDE.
Read the sections 'FreeRTOS Queue Basics' and all of 'Example 1: Using Queues to Control LED Brightness'. Focus on how the following pieces of code work: Creating the queue: potQueue = xQueueCreate(QUEUE_SIZE, sizeof(uint16_t)); Sending to the queue: xQueueSend(potQueue, &potValue, portMAX_DELAY); Receiving from the queue: if (xQueueReceive(potQueue, &potValue, portMAX_DELAY)) { ... }
Breaking Down the Code
Let's look at those key functions from the example more closely:
-
Creation:
potQueue = xQueueCreate(QUEUE_SIZE, sizeof(uint16_t));- The first argument,
QUEUE_SIZE, defines the maximum number of items the queue can hold. - The second argument,
sizeof(uint16_t), defines the size of each individual item. The RTOS uses this to allocate the correct amount of memory.
- The first argument,
-
Sending:
xQueueSend(potQueue, &potValue, portMAX_DELAY);- The first argument is the handle to the queue.
- The second argument is a pointer to the data you want to send. The RTOS will copy
sizeof(uint16_t)bytes from this memory location. - The third argument is the timeout.
portMAX_DELAYtells the task to wait indefinitely if the queue is full. If you pass0, the function will return immediately (pdFAIL) if the queue is full.
-
Receiving:
if (xQueueReceive(potQueue, &potValue, portMAX_DELAY))- The second argument is a pointer to a local variable where the received data will be copied.
- The timeout works similarly:
portMAX_DELAYwill block the task until an item is available. If you pass0, the function will return immediately if the queue is empty. - The function returns
pdTRUEon success andpdFALSEif the timeout expires before an item is received. This is why it's used in anifstatement.
Test your understanding!
Imagine in the LEDBrightnessTask from the tutorial, you change portMAX_DELAY to 0 in the xQueueReceive call. What would be the consequence for the task's behavior and the overall CPU usage?
Show answer
If the timeout is set to 0, the xQueueReceive function becomes non-blocking. The task will no longer wait for a new value. Instead, its for(;;) loop will run continuously. On each iteration, it will check the queue; if it's empty, xQueueReceive will immediately return pdFALSE, and the loop will repeat. This creates a "busy-wait" loop, consuming 100% of the CPU core's time allocated to that task, which is very inefficient. The blocking mechanism is key to the efficiency of an RTOS.
4. Sending Complex Data with Structs
The real power of queues becomes apparent when you need to pass more than a single number. Since queues copy a block of memory of a specified size, you can easily send an entire C++ struct.
This is incredibly useful for bundling related data. For instance, an Inertial Measurement Unit (IMU) sensor task could package accelerometer, gyroscope, and temperature readings into a single struct and send it to a processing task in one atomic operation.
The Random Nerd Tutorials guide has another excellent example that does just this.
ESP32 FreeRTOS Queues: Inter-Task Communication ...
This example demonstrates how to define a custom struct to hold sensor data and pass it through a queue.
Read 'Example 3: Display Sensor Data on a Screen (Sharing a Data Structure)'. Pay attention to: The definition of the SensorData struct. How the queue is created using sizeof(SensorData). How the SensorTask populates the struct and sends it. How the DisplayTask receives the struct and accesses its members.
This pattern of packaging data into structs is a clean, scalable, and highly common practice in embedded systems design.
5. Advanced Communication: Two-Way Exchange
So far, we've seen one-way communication. But you can easily establish two-way communication by using two queues. A common scenario is a "command-and-response" pattern: one task sends a command to another, and the second task sends a status or result back.
Let's look at the solution to the challenge posed in the Digi-Key video you watched earlier. It implements exactly this pattern.
- Task A (CLI): Listens for serial commands from the user. If it receives "delay xxx", it sends the number
xxxto Task B viadelay_queue. It also listens onmsg_queuefor status messages from Task B to print to the console. - Task B (Blinker): Blinks an LED. It listens on
delay_queuefor new delay values. When it receives one, it sends a confirmation message ("Message received") back to Task A viamsg_queue.
Introduction to RTOS - Solution to Part 5 (FreeRTOS Queue ...)
This article provides the full C++ solution and a detailed explanation for implementing this two-way communication pattern.
First, read the 'Challenge' section to understand the goal. Then, study the full code in the 'Solution' section and the detailed 'Explanation' that follows. This will consolidate everything you've learned about creating queues, sending different data types (integers and structs), and checking for messages without blocking.
This example is a great demonstration of how you can build more complex, interactive systems by combining multiple tasks and queues.
Conclusion
You've now added FreeRTOS queues to your toolkit, moving beyond simple task synchronization to full-fledged, thread-safe data exchange. This ability to decouple data production from data consumption is fundamental to building robust, scalable embedded applications.
Key Takeaways:
- Queues are the preferred method for safely passing data between tasks, avoiding the pitfalls of global variables.
- Data is copied by value into a queue, ensuring the data is preserved even if the original variable goes out of scope.
- The API functions
xQueueCreate,xQueueSend, andxQueueReceiveare the primary tools for working with queues. - Using blocking calls with a timeout (like
portMAX_DELAY) is highly efficient, as waiting tasks do not consume CPU time. - Queues are versatile and can be used to send any data type, including complex
structs, which is a powerful pattern for bundling related information.
In this lesson, we focused on passing data between tasks. But what happens when two tasks need to access a single, shared resource, like the Serial port, an SD card, or an I2C peripheral? If one task starts a multi-step I2C transaction and gets preempted, another task could interrupt and corrupt the communication. Queues don't solve this problem of exclusive resource access. For that, we need a mutex, which will be the topic of our next lesson.