Skip to main content

Welcome to a Grasp lesson

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

Lesson 88

Inter-Task Communication: Sensor Data with FreeRTOS Queues

Recommended resources

Introduction

Welcome back! In our last lesson, we successfully built the core data transmission pipeline for our automotive sensor node. We have a TemperatureTask that reads the MCU's internal sensor and a CanTxTask that broadcasts this data over the CAN bus. Critically, we linked these two tasks using a FreeRTOS queue, allowing the temperature reading (a single float value) to be passed safely from the producer task to the consumer task.

While functional, this implementation represents a simple case. Professional embedded systems, especially in automotive contexts, often require more complex data packets to be exchanged. For instance, you might need to send not just a sensor value, but also a timestamp, a sensor status flag, or an ID for the specific sensor that was read.

This lesson builds directly on our previous work to address this challenge. We will refine our inter-task communication to handle structured data. This directly fulfills the learning outcome: Use a FreeRTOS queue to safely pass sensor data from the acquisition task to the CAN task, by exploring what "safely" means in the context of more complex data structures and memory management.


1. Why Structured Data is Essential

Imagine our project evolves. We might add a second sensor, like an external pressure sensor. How would the CanTxTask know which data it just received from the queue? Is it temperature or pressure?

A common solution is to package all relevant information into a single C struct. This creates a self-contained data packet. For our sensor node, a good structure might look like this:

typedef enum {
    SENSOR_TYPE_INTERNAL_TEMP,
    SENSOR_TYPE_EXTERNAL_PRESSURE,
    // etc.
} SensorType_t;

typedef struct {
    SensorType_t sensor_id;
    float        sensor_value;
    uint32_t     timestamp; // e.g., from HAL_GetTick()
} SensorData_t;

With this struct, the TemperatureTask can package the temperature value along with an identifier (SENSOR_TYPE_INTERNAL_TEMP) and a timestamp. The CanTxTask can then receive this packet and make intelligent decisions, such as using different CAN message IDs based on the sensor_id.


2. Passing Structs in Queues: The Professional Approach

FreeRTOS queues can be configured to hold items of any data type, including structs. There are two ways to pass a struct from one task to another:

  1. Pass-by-Value: The queue stores a full copy of the struct. When you call xQueueSend(), the entire struct is copied into the queue's internal buffer.

    • Pros: Very simple and memory-safe. The receiver gets its own copy, so there's no risk of data being modified by the sender after it's been sent.
    • Cons: Inefficient for large structs. The memcpy operation to copy the data into and out of the queue consumes CPU time and stack space.
  2. Pass-by-Reference (Pointer): The queue stores a pointer to the struct. When you call xQueueSend(), only the pointer (typically 4 bytes on a 32-bit MCU) is copied into the queue.

    • Pros: Highly efficient, regardless of the struct size. Minimal data is copied.
    • Cons: Requires careful memory management. The memory for the struct must be allocated by the sender and explicitly freed by the receiver. If the receiver forgets to free the memory, you have a memory leak.

For professional development, understanding and correctly implementing the pass-by-reference method is a crucial skill. It's the most common approach for performance-critical applications. We will now refactor our project to use this method.


3. Implementing a Structured Queue with Dynamic Memory

To manage memory safely when passing pointers, FreeRTOS provides its own thread-safe versions of malloc and free:

  • pvPortMalloc(size): Allocates a block of memory from the FreeRTOS heap.
  • vPortFree(pointer): Frees a previously allocated block of memory.

Let's watch a video that demonstrates this concept practically.

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

This ControllersTech video provides an excellent, step-by-step guide to converting a simple queue to one that handles structured data using pointers. Pay close attention to the memory allocation and deallocation steps.

Watch the following segments: 3:34 - 4:38: How to define a struct and create a queue to hold it. 5:47 - 8:46: Implementing the sender task. Note the use of pvPortMalloc to allocate memory before populating the struct and sending it to the queue. 8:46 - 11:46: Implementing the receiver task. This is the most critical part. Observe how the received data is processed and then the memory is freed using vPortFree to prevent leaks.

Now, let's apply these principles to our capstone project code.

Step 1: Define the Data Structure and Update the Queue

First, in main.c, let's define our SensorData_t structure and update our queue handle. We'll simplify the struct for now to just include the temperature.

/* USER CODE BEGIN PTD */
// Define a structure for our sensor data
typedef struct {
    float temperature;
} SensorData_t;
/* USER CODE END PTD */

/* Private variables ---------------------------------------------------------*/
/* USER CODE BEGIN PV */
SemaphoreHandle_t adcSemaphore;
// No longer need adc_val as a global variable.

// Change the queue handle name for clarity
QueueHandle_t sensorDataQueue;
/* USER CODE END PV */

Next, in main(), modify the xQueueCreate call. We are now storing pointers to SensorData_t, not the struct itself.

/* USER CODE BEGIN 2 */
HAL_ADCEx_Calibration_Start(&hadc1); // Calibrate the ADC

adcSemaphore = xSemaphoreCreateBinary();

// Create a queue to hold up to 5 POINTERS to SensorData_t objects.
sensorDataQueue = xQueueCreate(5, sizeof(SensorData_t *)); // Note the asterisk!

// Check if the queue was created successfully (good practice)
if (sensorDataQueue == NULL)
{
    Error_Handler();
}

xTaskCreate(vTemperatureTask, "TempTask", 128, NULL, 1, NULL);
xTaskCreate(vCanTxTask, "CanTxTask", 256, NULL, 1, NULL); // Increased stack for CanTxTask
/* USER CODE END 2 */

Note: I've increased the stack size for CanTxTask slightly to 256 to safely accommodate the function calls and local variables. With dynamic allocation, it's wise to be a bit more generous with stack space.

Step 2: Update the Producer (vTemperatureTask)

This task now needs to allocate memory, fill it, and send a pointer.

// Inside vTemperatureTask in USER CODE BEGIN 4

void vTemperatureTask(void *pvParameters)
{
    uint16_t raw_adc;
    float temperature;
    SensorData_t *data_to_send; // A pointer to our struct

    for(;;)
    {
        HAL_ADC_Start_DMA(&hadc1, (uint32_t*)&raw_adc, 1);

        if (xSemaphoreTake(adcSemaphore, portMAX_DELAY) == pdTRUE)
        {
            temperature = ((1.43f - ((float)raw_adc * 3.3f / 4095.0f)) / 0.0043f) + 25.0f;
            
            // 1. Allocate memory for the struct
            data_to_send = (SensorData_t *)pvPortMalloc(sizeof(SensorData_t));

            // 2. ALWAYS check if allocation was successful
            if (data_to_send != NULL)
            {
                // 3. Populate the struct
                data_to_send->temperature = temperature;

                // 4. Send the POINTER to the queue.
                // If the queue is full, we don't block, and we must free the memory we just allocated.
                if (xQueueSend(sensorDataQueue, &data_to_send, (TickType_t)0) != pdPASS)
                {
                    // Failed to post the message, free the memory
                    vPortFree(data_to_send);
                }
                // If sending was successful, the receiver is now responsible for freeing the memory.
            }
        }
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

This implementation is robust: it checks for allocation failure and handles the case where the queue might be full, preventing a memory leak.

Step 3: Update the Consumer (vCanTxTask)

This task receives the pointer, uses the data, and—most importantly—frees the memory.

// Inside vCanTxTask in USER CODE BEGIN 4

void vCanTxTask(void *pvParameters)
{
    CAN_TxHeaderTypeDef   TxHeader;
    uint8_t               TxData[8];
    uint32_t              TxMailbox;
    
    SensorData_t *received_data; // A pointer to hold the received address

    TxHeader.StdId = 0x100;
    TxHeader.RTR = CAN_RTR_DATA;
    TxHeader.IDE = CAN_ID_STD;
    TxHeader.DLC = 4;
    TxHeader.TransmitGlobalTime = DISABLE;

    if (HAL_CAN_Start(&hcan) != HAL_OK)
    {
        Error_Handler();
    }
    
    for(;;)
    {
        // 1. Wait to receive a POINTER from the queue
        if (xQueueReceive(sensorDataQueue, &received_data, portMAX_DELAY) == pdPASS)
        {
            // We successfully received a pointer to a SensorData_t struct.
            
            // 2. Use the data from the struct
            memcpy(TxData, &(received_data->temperature), sizeof(float));

            if (HAL_CAN_AddTxMessage(&hcan, &TxHeader, TxData, &TxMailbox) != HAL_OK)
            {
                Error_Handler();
            }
            
            // 3. CRITICAL: Free the memory that the sender allocated.
            vPortFree(received_data);
        }
    }
}

4. Verification

Compile and flash your updated code. The external behavior should be identical to the previous lesson: you should still see CAN frames with ID 0x100 and a 4-byte payload being transmitted every second.

The crucial difference is internal. You have now implemented a more scalable and memory-efficient communication mechanism that is standard practice in professional firmware. You've also taken responsibility for memory management, a key step in becoming a proficient embedded C developer.

For further reading and to see the code presented in a textual format, you can review the "Structured Queue" section of the following article.

FreeRTOS Queue Tutorial for STM32

This article from ControllersTech complements the video, providing code snippets for creating and using a structured queue. It reinforces the concepts of using pvPortMalloc and vPortFree.

Read the sections titled 'Structured Queue'. Focus on how the code creates a pointer to a struct, allocates memory with pvPortMalloc, sends the pointer, and how the receiver task frees the memory with vPortFree after processing the data.


Conclusion

Congratulations on completing this significant upgrade to our project's architecture. While the external result is the same, the internal design is now far more robust and professional.

Key Takeaways:

  • Structured Data: You learned to use C structs to package multiple pieces of related data for clean and scalable inter-task communication.
  • Passing Pointers: You implemented the pass-by-reference queueing method, which is highly efficient for transferring data between tasks.
  • RTOS Memory Management: You took control of dynamic memory by using pvPortMalloc in the producer task and vPortFree in the consumer task, learning the fundamental pattern for preventing memory leaks in an RTOS environment.

Preview of the Next Lesson:

Our sensor node is now a proficient data transmitter. The next logical step is to teach it how to listen. In the upcoming lesson, you will implement a CAN receive handler. We will configure the hardware's acceptance filters to listen only for relevant CAN messages and use an interrupt to process incoming data, which we will then use to control an actuator on our board. This will complete the communication loop and make our device truly interactive.