Skip to main content

Welcome to a Grasp lesson

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

Lesson 39

Message Queues for Inter-Task Communication

Recommended resources

Hello! Welcome to our next lesson on RTOS concepts.

In our previous session, we explored how semaphores are used for signaling, particularly in the "deferred interrupt processing" pattern. We noted that while a semaphore effectively signals that an event has occurred, it doesn't carry any associated data. This often forces us to use shared global variables to pass information from an ISR to a task, a practice that can be prone to errors and race conditions.

Today, we'll address that challenge head-on by exploring a more powerful and safer alternative. This lesson is dedicated to the learning outcome: Implement inter-task communication by passing data through message queues.

Message queues are a cornerstone of RTOS-based designs, enabling you to build decoupled, scalable, and robust applications. Mastering them is essential for any senior embedded role, especially in complex domains like driver development and ADAS, where different software components must communicate reliably.

1. The "Why" and "What" of Message Queues

Before diving into the RTOS specifics, let's understand the general concept. At a high level, queues are a fundamental mechanism in computing for managing asynchronous communication and workflows. They act as a buffer between a producer of information and a consumer, decoupling them so they don't have to interact at the same time.

What is a Message Queue and when and why would I use it

This video gives a broad overview of message queues in distributed systems. While it's not specific to an RTOS, it does an excellent job of explaining the core principles of why queues are so useful for creating scalable and reliable systems.

Watch the following three segments: The Core Idea (03:16 - 05:31): Focus on the real-world analogies (registration desk, traffic) to understand how queues manage contention for a shared resource and create scalability. Formal Definition (22:11 - 24:37): This part formally defines a message queue as an object that accepts, stores, and makes messages available, emphasizing reliability. Key Features (36:17 - 39:36): Pay attention to the concepts of 'competing consumers' and 'load leveling'. These ideas are directly applicable to RTOS tasks competing to process data.

In the context of an RTOS like FreeRTOS, a message queue is a kernel object specifically designed for passing data between tasks, or from an ISR to a task, in a thread-safe manner. It's essentially a FIFO (First-In, First-Out) buffer.

Message Queue Operation in RTOS
This diagram shows the basic model of inter-task communication. A 'Sender Task' places messages into the queue, and a 'Receiver Task' retrieves them. The queue itself is managed by the RTOS, ensuring that data is not lost or corrupted.

A key characteristic of FreeRTOS queues is that they operate by copying data by value. When a task sends an item to a queue, the RTOS copies the bytes of that item into the queue's internal storage buffer. When a task receives an item, the RTOS copies the bytes from its buffer into a variable provided by the receiving task.

This "queue-by-copy" approach has several advantages:

  • Simplicity and Safety: It completely decouples the sender and receiver. The sender can immediately modify or discard its local copy of the data after sending it, without affecting the message in the queue.
  • No Shared Ownership: You don't need to worry about which task "owns" the data or is responsible for freeing its memory. The RTOS handles the memory management for the queue's storage.

2. The Core FreeRTOS Queue API

To work with queues, you'll primarily use three functions. The official FreeRTOS documentation provides the definitive reference for these.

FreeRTOS-Kernel-Book/ch05.md

Let's review the primary API functions for creating and using queues from the official FreeRTOS documentation.

Read the sections covering the main API functions: '5.3.1 The xQueueCreate() API Function', '5.3.2 The xQueueSendToBack() ... API Functions', and '5.3.3 The xQueueReceive() API Function'. Focus on the parameters for each function, especially uxItemSize for xQueueCreate and xTicksToWait for send/receive operations.

Let's summarize the key functions:

  1. xQueueCreate(uxQueueLength, uxItemSize)

    • Creates a new queue and returns a handle to it.
    • uxQueueLength: The maximum number of items the queue can hold.
    • uxItemSize: The size, in bytes, of a single item.
    • Returns NULL if the queue cannot be created (usually due to insufficient heap memory).
  2. xQueueSend(xQueue, pvItemToQueue, xTicksToWait)

    • Sends (copies) an item to the back of the queue. xQueueSendToBack is an identical alias.
    • xQueue: The handle of the queue.
    • pvItemToQueue: A pointer to the item that will be copied into the queue.
    • xTicksToWait: The maximum time the task should block if the queue is full. Setting this to portMAX_DELAY will block indefinitely.
  3. xQueueReceive(xQueue, pvBuffer, xTicksToWait)

    • Receives (copies) an item from the front of the queue, removing it.
    • xQueue: The handle of the queue.
    • pvBuffer: A pointer to a buffer where the received item will be copied. This buffer must be at least uxItemSize bytes long.
    • xTicksToWait: The maximum time the task should block if the queue is empty.

The xTicksToWait parameter is fundamental to how RTOS tasks are designed. A task waiting to receive data from a queue will enter the Blocked state, consuming no CPU time. It is automatically moved back to the Ready state by the scheduler as soon as an item becomes available in the queue.


3. A Practical Example: Passing Structures

While you can pass simple types like integers, the real power of queues becomes apparent when you pass structs. This allows you to create a versatile communication system where one task can process various types of messages from multiple sources.

This pattern is highly relevant to your background. In AUTOSAR, you work with PDUs and signals that are essentially structured data packets. Using structs in FreeRTOS achieves a similar goal at the task level.

Consider a design where a central "controller" task needs to react to different system events:

  • A CAN task decodes a message and needs to send a motor speed value.
  • An HMI task detects a button press and needs to send a new setpoint.

Instead of creating separate communication channels for each, you can use a single queue that transfers a structure.

/* Define an enumerated type to identify the source/type of data. */
typedef enum
{
    eMotorSpeedUpdate,
    eNewHmiSetpoint
} DataSource_t;

/* Define the structure type that will be passed on the queue. */
typedef struct
{
    DataSource_t eDataSource; // What type of message is this?
    int32_t lDataValue;       // The actual data payload.
} Data_t;

// Queue handle, created with xQueueCreate(10, sizeof(Data_t))
QueueHandle_t xControllerQueue;

With this setup:

  • The CAN task can populate a Data_t struct with eDataSource = eMotorSpeedUpdate and send it.
  • The HMI task can populate a Data_t struct with eDataSource = eNewHmiSetpoint and send it.
  • The controller task receives Data_t structs and uses a switch statement on the eDataSource member to determine how to interpret lDataValue.

FreeRTOS-Kernel-Book/ch05.md

The FreeRTOS documentation and a practical example from Digi-Key demonstrate this powerful pattern beautifully.

First, read section '5.4 Receiving Data From Multiple Sources' which introduces the concept with a clear diagram. Then, review 'Example 5.2' which provides a full code implementation of this pattern.

Introduction to RTOS - Solution to Part 5 (FreeRTOS Queue Example)

Next, let's look at another complete, real-world code example. This tutorial uses a struct to pass messages between a command-line task and an LED blinking task.

Read the 'Challenge', 'Solution', and 'Explanation' sections. Pay close attention to how the Message struct is defined and used with xQueueCreate, xQueueSend, and xQueueReceive in the doCLI and blinkLED tasks.


4. Handling Large Data: The Pointer-Passing Pattern

Copying data is simple, but it can be inefficient if the items are large. Imagine processing frames from an ADAS camera or large data packets from Automotive Ethernet. Copying hundreds or thousands of bytes into and out of a queue on every transfer would be a significant performance bottleneck.

The solution is to revert to passing data "by reference," but in a controlled way. Instead of copying the entire data block, you copy a pointer to the data block.

The queue is created to hold pointers. For example, to pass pointers to a large buffer structure:
xPointerQueue = xQueueCreate(5, sizeof(LargeBuffer_t *));
Note the *—the item size is the size of a pointer, not the size of the structure itself.

Crucial Memory Management Consideration:
When you pass pointers, you lose the automatic memory management of the queue-by-copy method. You must establish a clear protocol for memory ownership. This is a classic interview question for a senior role.

  • Who allocates the memory? The sending task typically allocates a buffer (either dynamically from the heap or from a pre-allocated static pool).
  • Who frees the memory? The receiving task becomes the new owner of the buffer and is responsible for freeing it or returning it to the pool after it has finished processing the data.

Failure to manage this correctly leads to memory leaks (if no one frees the buffer) or dangling pointers and data corruption (if the sender frees it too early or both tasks try to use it simultaneously).

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

This video provides an excellent hands-on demonstration of passing pointers to structs through a queue. It specifically covers the critical topic of dynamic memory allocation and deallocation.

Watch these segments closely: Sender Logic (06:10 - 09:12): See how pvPortMalloc is used to allocate memory for the struct before it's sent to the queue. Receiver Logic (09:12 - 11:46): Note how vPortFree is used to deallocate the memory after the data has been received and processed. This is the key to preventing memory leaks. Debugging Session (13:22 - 18:01): This is a great visualization. Watch how the memory addresses are allocated and freed as items move through the system. This reinforces the memory ownership concept.

Test your understanding!

You are designing a logging task for an automotive ECU. This task, log_task, is responsible for writing messages to a non-volatile memory device (like an EEPROM or Flash). Other tasks, such as can_driver_task and sensor_task, need to send log messages to it.

The log messages are strings and can have variable lengths, up to 256 characters. To optimize performance, you want to avoid copying these potentially long strings into a queue.

  1. How would you configure the FreeRTOS queue for this purpose?
  2. Describe the sequence of operations in sensor_task to send a log message.
  3. Describe the sequence of operations in log_task to receive and process a message. What is the most critical step log_task must perform after it's done with the message?
Show answer
  1. Queue Configuration: The queue should be configured to hold pointers to characters (or pointers to a buffer structure).
    log_queue = xQueueCreate(queue_length, sizeof(char *));
    The item size is the size of a pointer, not the size of the string.

  2. Sender Task (sensor_task) Operations:
    a. Allocate a buffer large enough to hold the string (e.g., char* log_buffer = pvPortMalloc(257);).
    b. Write the formatted log string into the allocated buffer using snprintf or similar.
    c. Send the pointer log_buffer to the queue: xQueueSend(log_queue, &log_buffer, ...);. Notice you send the address of the pointer variable.

  3. Receiver Task (log_task) Operations:
    a. Declare a pointer variable to receive the message: char* received_log_ptr;.
    b. Block on the queue to receive the pointer: xQueueReceive(log_queue, &received_log_ptr, portMAX_DELAY);.
    c. Use the received pointer to access the string and write it to non-volatile memory.
    d. Critical Step: After the data is safely written, the log_task must free the memory that was allocated by the sender task: vPortFree(received_log_ptr);. This prevents a memory leak.


5. Interview Preparation: Key Questions

Be prepared to discuss the trade-offs and design patterns related to queues.

  • Question: "What's the difference between passing data by value vs. by reference in a FreeRTOS queue? What are the pros and cons of each?"

    • Your Answer: "Passing by value, the default in FreeRTOS, copies the data into the queue. The pro is safety and simplicity—it decouples the tasks and the RTOS manages the data's lifetime. The con is inefficiency for large data items. Passing by reference involves copying only a pointer to the data. The pro is high performance and low memory overhead for large data. The con is increased complexity; the application programmer must manage the memory manually, defining a clear ownership protocol to prevent leaks or data corruption."
  • Question: "You have a central processing task that needs to receive commands of different types from multiple other tasks. What's a robust and scalable way to design this using a single queue?"

    • Your Answer: "The best approach is to define a struct that acts as a message envelope. This struct would contain at least two members: an enum or integer ID to identify the message type, and a union or void* pointer for the data payload. All sender tasks create and populate this struct and send it to the single queue. The receiver task reads the struct, checks the message ID, and then processes the payload accordingly. This is scalable because adding new message types only requires updating the enum and the receiver's processing logic, not changing the entire communication infrastructure."
  • Question: "If you pass pointers through a queue, what are the critical memory management considerations you need to address?"

    • Your Answer: "The most critical consideration is establishing a clear ownership model. Typically, the sender allocates a memory buffer, populates it, and sends the pointer, relinquishing ownership. The receiver takes ownership upon receiving the pointer and becomes responsible for freeing that memory after use. This prevents memory leaks. It's also vital to ensure the sender doesn't access the buffer after sending the pointer and that the receiver doesn't use the pointer after freeing it. Using a memory pool instead of dynamic heap allocation can make this process more deterministic and prevent fragmentation."

Conclusion

You've now covered a fundamental building block of any non-trivial RTOS application. Message queues provide a clean, thread-safe, and flexible way to orchestrate the flow of data between different parts of your system.

Key Takeaways:

  • Queues decouple tasks, allowing them to communicate asynchronously.
  • FreeRTOS queues default to "pass-by-value," which is safe but can be slow for large data.
  • Passing structs is a powerful pattern for sending varied messages to a single task.
  • Passing pointers is efficient for large data but requires careful manual memory management to avoid leaks and corruption.

Next Lesson Preview:
So far, our examples have focused on task-to-task communication. However, a major source of data in embedded systems is hardware interrupts. In our next lesson, we will complete the picture by exploring how to use queues to safely and efficiently pass data from an ISR to a waiting task. We will look at the special ...FromISR API variants and revisit the "deferred interrupt processing" pattern in the context of queues.