Queues are one of the most important communication tools in FreeRTOS. Once you start creating multiple tasks, the next challenge is usually getting those tasks to exchange data safely and clearly.
A sensor task may need to send measurements to a display task. A UART receive task may need to pass commands to a processing task. A button-handling task may need to notify another part of the system that an event occurred.
At first, many beginners try to solve this with global variables. That can work in very small examples, but it quickly becomes messy and risky. Multiple tasks may read and write the same data at different times, and the code becomes harder to reason about. Queues help solve that problem by giving tasks a structured way to send data to each other.
A queue in FreeRTOS is a communication object that stores items in order. One task can send data into the queue, and another task can receive data from it. If the queue is empty, a receiving task can wait. If the queue is full, a sending task can wait. This makes queues practical, safe, and very useful in real embedded applications.
This article explains what queues are, why they matter, how they work, how to create them, and how to use them in beginner-friendly FreeRTOS projects.
What a queue is
A queue is a first-in, first-out data structure. This means the first item placed into the queue is the first item removed.
That simple idea is enough to support many useful designs.
Imagine a sensor task that reads temperature values every 100 milliseconds. Instead of writing directly into some shared global variable that another task polls, it can place each reading into a queue. Another task, such as a display or logging task, can read those values from the queue in the same order they were produced.
That ordering is part of what makes queues easy to understand. They behave like a line. Items go in at one end and come out at the other end in sequence.
Why queues matter in FreeRTOS
Queues matter because multiple tasks often need to communicate without stepping on each other.
In a multitasking system, tasks run independently. One task may produce data. Another may consume it. Without a structured communication method, you end up with code that is harder to maintain and easier to break.
Queues help because they:
- move data safely between tasks
- preserve ordering
- let tasks block while waiting
- reduce the need for unsafe shared variables
- support cleaner task separation
This is one of the reasons queues appear so early in FreeRTOS learning. Once you have two or three tasks, queues often become the natural next step.
A simple model
A useful way to think about a queue is this:
One task puts messages or values into a mailbox.
Another task checks the mailbox and takes them out one by one.
If the mailbox is empty, the receiver can wait.
If the mailbox is full, the sender can wait or fail immediately depending on how the code is written.
This is a much better mental model than thinking of queues as mysterious RTOS-only objects. They are really just ordered communication channels between execution contexts.
Creating a queue
Before you can use a queue, you need to create it. The basic API for this is xQueueCreate().
A typical example looks like this:
QueueHandle_t sensorQueue;
sensorQueue = xQueueCreate(5, sizeof(int));
This creates a queue that can hold 5 items, where each item is the size of an int.
This line is very important, and both parameters matter.
The first value is the queue length. It tells FreeRTOS how many items the queue can store.
The second value is the size of each item.
In this example, the queue can hold up to five integers.
If the queue is created successfully, the returned handle can be used later with send and receive functions. If creation fails, the handle will be invalid, usually because there was not enough memory.
Understanding queue length and item size
Beginners often confuse queue length with total memory size. It helps to separate the ideas clearly.
If you write:
xQueueCreate(5, sizeof(int));
this means:
- the queue holds 5 items
- each item is one integer
If you write:
xQueueCreate(10, sizeof(float));
this means:
- the queue holds 10 items
- each item is one float
If you write:
xQueueCreate(3, sizeof(MyStruct));
this means:
- the queue holds 3 items
- each item is one full
MyStruct
This is one reason queues are flexible. They can carry simple values or more complex structured data.
Sending data to a queue
To place an item into a queue, you usually use xQueueSend().
Example:
int value = 25;
xQueueSend(sensorQueue, &value, portMAX_DELAY);
This sends the value 25 into the queue.
The second parameter is the address of the data being sent. The queue copies the item into its own storage. This is important. You are not simply storing a reference to the original variable. The queue stores its own copy of the item.
The third parameter controls how long the task should wait if the queue is full.
In this example, portMAX_DELAY means the sending task is willing to wait indefinitely for space if needed.
Receiving data from a queue
To remove an item from a queue, you usually use xQueueReceive().
Example:
int receivedValue;
xQueueReceive(sensorQueue, &receivedValue, portMAX_DELAY);
This waits for an item to arrive and then copies it into receivedValue.
Again, the third parameter controls how long the task should wait if the queue is empty. With portMAX_DELAY, the task can block until data becomes available.
This is one of the strongest features of queues. A receiving task does not need to waste CPU time polling repeatedly. It can sleep until meaningful data arrives.
A complete beginner example
Here is a simple example with two tasks. One task sends numbers into a queue. The other receives and prints them.
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
QueueHandle_t numberQueue;
void SenderTask(void *pvParameters)
{
int value = 0;
(void) pvParameters;
for (;;)
{
value++;
xQueueSend(numberQueue, &value, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void ReceiverTask(void *pvParameters)
{
int receivedValue;
(void) pvParameters;
for (;;)
{
if (xQueueReceive(numberQueue, &receivedValue, portMAX_DELAY) == pdTRUE)
{
serial_print_int(receivedValue);
}
}
}
int main(void)
{
hardware_init();
numberQueue = xQueueCreate(5, sizeof(int));
xTaskCreate(SenderTask, "SENDER", 256, NULL, 1, NULL);
xTaskCreate(ReceiverTask, "RECEIVER", 256, NULL, 1, NULL);
vTaskStartScheduler();
for (;;)
{
}
}
This is a very good first queue example because the behavior is easy to understand.
The sender task increments a counter and sends it every 500 milliseconds.
The receiver task waits for values and prints them when they arrive.
The queue acts as the channel between the two tasks.
Why queues are better than a simple global variable
A beginner may ask why not just do this:
volatile int sensorValue = 0;
and let one task write it while another reads it.
That can work in very simple cases, but queues provide several advantages.
A queue preserves every item up to its configured capacity. A single global variable only stores one current value.
A queue supports blocking behavior. A task can wait efficiently for data.
A queue keeps communication more structured. The producer sends. The consumer receives.
A queue reduces the need for ad hoc shared-state designs that become harder to scale.
A global variable can be useful for state. A queue is often better for messages, events, and streams of data.
Blocking behavior and why it matters
One of the best things about queues is that they let tasks block naturally.
Suppose a receiver task has nothing useful to do until data arrives. Without a queue, you might write a loop that keeps checking a variable over and over. That wastes CPU time.
With a queue, you can do this:
xQueueReceive(myQueue, &item, portMAX_DELAY);
Now the task sleeps until there is data.
The same idea works on the sending side. If a queue is full, a sender can wait for space instead of failing immediately.
This kind of blocking behavior is one of the reasons RTOS designs feel cleaner than busy-wait designs.
Queue send with a timeout
You do not always want to wait forever when sending. Sometimes it is better to try for a short time and then handle failure.
Example:
if (xQueueSend(myQueue, &value, pdMS_TO_TICKS(100)) != pdTRUE)
{
serial_print("Queue send failed\n");
}
This allows the task to wait up to 100 milliseconds for space in the queue.
If the queue remains full, the send fails and the program can respond accordingly.
This is useful when data freshness matters or when indefinite blocking is not acceptable.
Queue receive with a timeout
The same idea works when receiving.
Example:
if (xQueueReceive(myQueue, &value, pdMS_TO_TICKS(200)) == pdTRUE)
{
serial_print_int(value);
}
else
{
serial_print("No data received\n");
}
This lets the task wait up to 200 milliseconds for an item.
If nothing arrives, the task can take some other action.
This is useful when a task has other responsibilities besides waiting on one queue.
Sending structures through queues
Queues are not limited to single numbers. They can also carry structures, which is very powerful in embedded systems.
Example structure:
typedef struct
{
int temperature;
int humidity;
} SensorData;
Queue creation:
QueueHandle_t sensorQueue;
sensorQueue = xQueueCreate(5, sizeof(SensorData));
Sending:
SensorData data = {25, 60};
xQueueSend(sensorQueue, &data, portMAX_DELAY);
Receiving:
SensorData received;
xQueueReceive(sensorQueue, &received, portMAX_DELAY);
This is often much cleaner than trying to coordinate multiple shared variables across tasks.
Real example: sensor task and display task
Imagine a small monitoring device.
One task reads sensor data every 100 milliseconds.
Another task updates a display.
A queue is a natural fit:
typedef struct
{
int temperature;
int humidity;
} SensorData;
QueueHandle_t sensorQueue;
void SensorTask(void *pvParameters)
{
SensorData data;
(void) pvParameters;
for (;;)
{
data.temperature = read_temperature();
data.humidity = read_humidity();
xQueueSend(sensorQueue, &data, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void DisplayTask(void *pvParameters)
{
SensorData received;
(void) pvParameters;
for (;;)
{
if (xQueueReceive(sensorQueue, &received, portMAX_DELAY) == pdTRUE)
{
show_temperature(received.temperature);
show_humidity(received.humidity);
}
}
}
This is a strong beginner example because it shows tasks with different roles connected by a queue.
Queue full and queue empty conditions
A queue can become full if items are sent faster than they are received.
A queue can become empty if items are received faster than they are sent.
Both conditions are normal. They are not errors by themselves. They simply reflect the balance between producer and consumer behavior.
If the queue becomes full often, you may need to:
- increase queue length
- slow the producer
- speed up the consumer
- rethink system timing
If the queue is often empty, that may be fine if the consumer is expected to wait. Or it may tell you that the producer is not generating data as often as intended.
Understanding these conditions helps you debug system behavior more clearly.
One producer and one consumer
The simplest queue design is one producer and one consumer.
For example:
- a sensor task sends data
- a logger task receives data
This is the easiest pattern to understand and a good place to start.
Once you understand that pattern, you can move on to more advanced uses such as multiple producers, multiple consumers, or ISR-to-task communication.
Multiple producers
A queue can also be used by multiple tasks that all send data into the same channel.
For example:
ButtonTasksends button eventsSensorTasksends measurement eventsCommandTasksends serial command events
A single processing task could receive from one shared queue if the data format is designed carefully.
This is often useful in event-driven systems, though it requires a clear message structure.
Multiple consumers
A normal queue does not broadcast one item to multiple consumers. When a task receives an item, that item is removed from the queue.
This is an important beginner lesson.
If two receiver tasks both call xQueueReceive() on the same queue, they are competing for items. One task gets an item, and the other does not get that same item.
This means queues are good for handing work from one part of the system to another, not for automatic broadcasting.
Queueing pointers versus queueing data
A more advanced topic is whether to send full data items or pointers.
Beginners should usually start by sending full items, such as integers or structures. That is easier and safer to understand.
Sending pointers is possible, but it requires more careful thinking about memory lifetime, ownership, and whether the pointed-to data remains valid long enough.
For first projects, sending copied data is usually the better path.
Using queues with interrupts
FreeRTOS also allows queues to be used from interrupt service routines through special ISR-safe APIs. This is a powerful pattern for event-driven systems.
For example, a UART receive interrupt might place bytes into a queue for a task to process later.
That said, beginners should first become comfortable with task-to-task queue use before moving into ISR-based queue communication, because ISR rules add more complexity.
Common beginner mistakes
One common mistake is creating a queue with the wrong item size. If you want to send a structure, the queue must be created with sizeof(ThatStructure) rather than the size of some other type.
Another common mistake is confusing a pointer with the actual data size. The queue size must match what is being copied into it.
Another mistake is forgetting to check whether queue creation succeeded.
Another is assuming a queue broadcasts data to multiple consumers. It does not.
Another is sending data too quickly into a small queue and then wondering why sends block or fail.
Another is avoiding queues entirely and relying on messy shared global variables for everything.
A final common mistake is using a queue when a different mechanism might be better. Queues are very useful, but they are not the answer to every synchronization problem.
A good first progression
A practical learning path for queues is:
- First, create one sender task and one receiver task using integers.
- Then send structures instead of simple values.
- Then add timeouts.
- Then build a more realistic sensor-to-display or command-to-processing example.
- After that, move on to semaphores, mutexes, task notifications, and ISR-safe queue use.
This progression helps keep the learning curve manageable.
Conclusion
Queues are one of the most practical and important communication tools in FreeRTOS. They allow tasks to exchange data in a structured, ordered, and efficient way. Instead of relying on ad hoc shared variables, you can design systems where producer tasks send data and consumer tasks receive it cleanly.
The key ideas are straightforward. A queue stores items in first-in, first-out order. You create it with a length and item size. Tasks send items with xQueueSend() and receive them with xQueueReceive(). Tasks can block while waiting, which makes queue-based designs efficient and clean.

