Introduction
Welcome back to the capstone project module. In our previous lesson, we designed the complete software architecture for our Automotive Sensor Node. We broke down the system's requirements into a set of independent FreeRTOS tasks, defining their responsibilities and how they would communicate using queues and semaphores. This architectural blueprint is our roadmap for implementation.
Today, we will bring the first part of that design to life. Our goal is to implement a FreeRTOS task that periodically reads an analog sensor via ADC and DMA. Specifically, we will implement the TemperatureTask which, according to our design, is responsible for periodically measuring the MCU's internal temperature.
This lesson is a critical step. You will learn a professional and highly efficient pattern for acquiring sensor data in an RTOS environment, combining three key technologies:
- ADC (Analog-to-Digital Converter): To read the analog temperature sensor.
- DMA (Direct Memory Access): To transfer the ADC result to memory without CPU intervention.
- FreeRTOS: To manage the periodic execution and synchronize the data acquisition process efficiently.
Let's begin by configuring the necessary peripherals.
1. Peripheral Configuration: ADC and DMA
Our first step is to configure the ADC to read the internal temperature sensor channel and set up a DMA channel to automatically fetch the result. We will use STM32CubeIDE for this.
-
Open your project in STM32CubeIDE and go to the
.iocfile (the device configuration tool). -
ADC Configuration:
- In the 'Pinout & Configuration' view, navigate to
Analog>ADC1. - Check the box for
IN16, which corresponds to theTemperature Sensor. - Under 'Parameter Settings' for ADC1, configure the following:
- Scan Conversion Mode: Enabled. (This is good practice even for a single channel and necessary for multiple channels).
- Continuous Conversion Mode: Disabled. We want our FreeRTOS task to trigger each conversion on demand.
- End of Conversion Selection:
EOC flag at the end of all conversions. - Number Of Conversions: Set to
1.
- Expand the
Ranksection. ForRank 1, ensureChannelis set toChannel 16and set theSampling Timeto the longest available (e.g.,239.5 Cycles). A longer sampling time for the temperature sensor improves accuracy.
- In the 'Pinout & Configuration' view, navigate to
-
DMA Configuration:
- Switch to the
DMA Settingstab within the ADC1 configuration. - Click
Addto add a DMA request. SelectADC1. - Configure the DMA stream (e.g.,
DMA1 Channel 1):- Mode:
Normal. We use Normal mode because we are initiating a single transfer and stopping. Circular mode would be for continuous, hardware-triggered acquisition. - Direction:
Peripheral To Memory. - Peripheral Data Width:
Half Word(The STM32F103 ADC has 12-bit resolution, which fits in a 16-bit half-word). - Memory Data Width:
Half Word.
- Mode:
- Switch to the
-
NVIC (Interrupt) Configuration:
- Go to the
NVIC Settingstab. - Ensure the
ADC1 and ADC2 global interruptscheckbox is enabled. The HAL framework uses this single interrupt to handle the completion callback for DMA.
- Go to the

After these steps, save the .ioc file to generate the updated initialization code.
2. The Task-ISR Synchronization Pattern
Our FreeRTOS task needs to trigger an ADC conversion and then wait for the DMA to complete the transfer. A naive approach would be to start the conversion and then poll a flag in a while loop. This is extremely inefficient as it wastes CPU cycles that other tasks could be using.
The professional solution is to use a binary semaphore to synchronize the task with the DMA completion interrupt.
The workflow is as follows:
- The
TemperatureTaskstarts the ADC conversion withHAL_ADC_Start_DMA(). - The task then immediately calls
xSemaphoreTake(), attempting to take a binary semaphore. Since the semaphore is initially empty, the task enters the Blocked state, consuming no CPU time. - The DMA works in the background, transferring the ADC result to memory.
- Once the DMA transfer is complete, it triggers an interrupt. The HAL framework routes this to a callback function,
HAL_ADC_ConvCpltCallback(). - Inside this callback (which is an ISR context), we call
xSemaphoreGiveFromISR()to release the semaphore. - Releasing the semaphore unblocks the
TemperatureTask, moving it to the Ready state. When it becomes the highest priority ready task, it resumes execution right after thexSemaphoreTake()call.
This pattern perfectly decouples the task from the hardware, allowing the CPU to perform other work while waiting for the ADC/DMA operation.
Periodic task for triggering ADC conversions with stm32 HAL. How to use DMA?
The following forum discussion on the official FreeRTOS website provides an excellent, concise code example of this exact synchronization pattern. It's the cornerstone of what we are about to implement.
Please read the response from 'aggarg' which provides a clear code snippet. Pay close attention to: The creation of the semaphore handle: xSemaphore = xSemaphoreCreateBinary(); How the task waits for the semaphore: xSemaphoreTake( xSemaphore, portMAX_DELAY ); How the interrupt service routine gives the semaphore: xSemaphoreGiveFromISR(...) inside HAL_ADC_ConvCpltCallback.
3. Implementing the TemperatureTask
Now, let's translate this pattern into code in main.c.
3.1. Add Global Variables and the Callback
First, we need to declare a semaphore handle and a buffer to store the ADC result. Add these in the USER CODE BEGIN PV section of main.c.
/* Private variables ---------------------------------------------------------*/
/* USER CODE BEGIN PV */
SemaphoreHandle_t adcSemaphore;
uint16_t adc_val; // Buffer to store the raw ADC result from DMA
/* USER CODE END PV */
Next, we must provide our own implementation of the HAL_ADC_ConvCpltCallback. The HAL driver declares this function as __weak, allowing us to override it. Add this function in the USER CODE BEGIN 4 section.
/* USER CODE BEGIN 4 */
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// Give the semaphore to unblock the task
xSemaphoreGiveFromISR(adcSemaphore, &xHigherPriorityTaskWoken);
// If giving the semaphore unblocked a task with higher priority than the
// currently running task, yield the CPU.
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
/* USER CODE END 4 */
3.2. Create the FreeRTOS Task
Now, define the TemperatureTask itself. This function will contain the periodic logic. You can also place this in the USER CODE BEGIN 4 section.
void vTemperatureTask(void *pvParameters)
{
// Store the tick count for vTaskDelayUntil
TickType_t xLastWakeTime = xTaskGetTickCount();
const TickType_t xFrequency = pdMS_TO_TICKS(1000); // Run every 1000ms
// Variables for temperature calculation
float temperature;
const float v_dda = 3.3; // Assuming V_DDA is 3.3V
const float v25 = 1.43; // From STM32F103 datasheet, voltage at 25 C
const float avg_slope = 4.3e-3; // From datasheet, 4.3 mV/C
for(;;)
{
// Start ADC conversion with DMA
// The result will be stored in the 'adc_val' variable
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)&adc_val, 1);
// Wait for the semaphore, which is given by the ADC/DMA completion ISR
if (xSemaphoreTake(adcSemaphore, portMAX_DELAY) == pdTRUE)
{
// --- Conversion is complete ---
// Stop the DMA. This is good practice in Normal mode.
HAL_ADC_Stop_DMA(&hadc1);
// Convert the raw ADC value to voltage
float vsense = (v_dda * adc_val) / 4095.0;
// Calculate temperature using the formula from the datasheet
temperature = ((vsense - v25) / avg_slope) + 25.0;
// Print the result to UART for debugging
// Ensure you have printf redirection to UART set up
printf("Internal Temperature: %.2f C\r\n", temperature);
}
// Wait for the next cycle
vTaskDelayUntil(&xLastWakeTime, xFrequency);
}
}
Note on the formula: The temperature calculation is based on the formula provided in the STM32F103 datasheet. V25 (voltage at 25°C) and Avg_Slope are typical values. For production systems, these values should be calibrated using the factory-programmed values stored at specific memory addresses on your chip.
3.3. Initialize and Start the Task
Finally, in your main() function, create the semaphore and the task before starting the scheduler.
/* USER CODE BEGIN 2 */
adcSemaphore = xSemaphoreCreateBinary();
xTaskCreate(vTemperatureTask, "TempTask", 128, NULL, 1, NULL);
/* USER CODE END 2 */
/* USER CODE BEGIN RTOS_SCHEDULER */
/* start scheduler */
vTaskStartScheduler();
/* USER CODE END RTOS_SCHEDULER */
Before compiling, ensure you have enabled ADC calibration in your main() function, typically just before the while(1) loop in a non-RTOS project. In our case, place it right before creating the tasks.
/* USER CODE BEGIN 2 */
HAL_ADCEx_Calibration_Start(&hadc1); // Calibrate the ADC
adcSemaphore = xSemaphoreCreateBinary();
xTaskCreate(vTemperatureTask, "TempTask", 128, NULL, 1, NULL);
/* USER CODE END 2 */
4. Testing and Verification
Now you are ready to test the implementation.
- Compile and Flash: Build the project and flash it to your Blue Pill board.
- Connect a Serial Terminal: Connect your USB-to-UART adapter to the STM32 and open a serial terminal (e.g., STM32CubeIDE's built-in serial console, Tera Term, PuTTY) with the correct COM port and baud rate settings.
- Observe the Output: Reset the board. You should see the internal temperature being printed to the console every second.
- Debug (Optional): To see the raw values, you can place a breakpoint inside the
if (xSemaphoreTake(...) == pdTRUE)block. When the code halts, inspect theadc_valvariable in the 'Live Expressions' window to see the raw 12-bit reading from the ADC.
This video demonstrates the overall process of configuring ADC, DMA, and a timer trigger. While we are using a software trigger from a FreeRTOS task, the general setup in CubeIDE and the debugging process are very similar.
(Sponsored) STM32H7 ADC + DMA + Timer Firmware Tutorial - Phil's Lab #138
This video from Phil's Lab shows a complete ADC+DMA project workflow, including debugging with live expressions. It uses a timer trigger, but the principles of verifying the data in the debugger are identical to our approach.
Watch the debugging section from 25:13 to 27:06. Observe how the 'Live Expressions' window is used to monitor the raw ADC data array and the converted voltage values in real-time. You can use this same technique to inspect your adc_val and temperature variables.
Conclusion
Congratulations! You have successfully implemented the first functional block of our capstone project. You've created a self-contained, periodic, and efficient data acquisition task using a professional-grade synchronization pattern.
Key Takeaways:
- ADC+DMA for Efficiency: You configured the ADC and DMA to perform analog conversions and memory transfers without consuming CPU cycles.
- Semaphore for Task-ISR Sync: You learned the critical FreeRTOS pattern of using a binary semaphore to signal a task from an ISR, avoiding inefficient polling.
- Periodic Tasks with
vTaskDelayUntil: You structured the logic within afor(;;)loop and usedvTaskDelayUntilto ensure precise, periodic execution. - HAL Callbacks: You leveraged the
HAL_ADC_ConvCpltCallbackto hook into the end of the hardware operation and trigger the software synchronization.
Preview of the Next Lesson:
In the next lesson, we will continue building our system by implementing the CanTxTask. We will modify our TemperatureTask to send its data to a FreeRTOS queue and then implement the CAN task to read from that queue, package the data into a CAN message, and transmit it on the bus. This will be our first example of inter-task communication in the project.