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:
-
Pass-by-Value: The queue stores a full copy of the
struct. When you callxQueueSend(), the entirestructis 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
memcpyoperation to copy the data into and out of the queue consumes CPU time and stack space.
-
Pass-by-Reference (Pointer): The queue stores a pointer to the
struct. When you callxQueueSend(), only the pointer (typically 4 bytes on a 32-bit MCU) is copied into the queue.- Pros: Highly efficient, regardless of the
structsize. Minimal data is copied. - Cons: Requires careful memory management. The memory for the
structmust be allocated by the sender and explicitly freed by the receiver. If the receiver forgets to free the memory, you have a memory leak.
- Pros: Highly efficient, regardless of the
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
structsto 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
pvPortMallocin the producer task andvPortFreein 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.