Firmware 101: STM32 ADC Hands-on

Getting Started with ADC and STM32 ecosystem

Leonardo Cavagnis
8 min readOct 5, 2022

Hardware: the part of a computer that you can kick; Software: the one you can only curse at… Firmware: the part you can do both!”

It is usually thought that electronics work only with binary values (0–1), but embedded systems are devices that interact with the real world, and in the real world information is “analog” and not “digital”.

The “magic” system able of making an analog value “understandable” to a microcontroller is called: ADC. An analog-to-digital converter (ADC) is a device that converts an analog signal, such as a temperature picked up by a sensor, into a digital signal.

In this article, I describe how to manage an analog signal with an STM32 microcontroller by building a system capable of reading the value of a potentiometer.
A potentiometer is an electronic device composed of a rotary knob commonly used to control the volume of audio equipment. It produces a variable voltage output signal which is proportional to the physical position of the knob.

Potentiometer and ADC. Image by author.

Analog-to-Digital Converter: the basics

If you have a microphone the audio it picks up is analog. As explained before, you can use an ADC to move from real-world analog to software-friendly digital signals.

ADCs are characterized by:

  • Resolution [bit]: the number of bits to represent a digital signal.
  • Sampling rate [Hz]: how fast they work.
ADC Symbol. Image by author.

An 8-bit ADC with 1 kHz sampling rate has 256 (2⁸) levels in its digital signal and takes 1 millisecond to convert an analog signal into its digital form.

An analog signal is expressed in voltage [V] and other important features are:

  • Full-scale voltage: the maximum input voltage value convertible into digital.
  • Voltage resolution (Quantum): equal to its full-scale voltage divided by the number of levels.

An 8-bit ADC with 3.3V of full-scale voltage has a quantum equal to: 3.3V / 256 levels = 12.9mV.
The quantum is the minimum voltage value that ADC can discretize. When an analog value being sampled falls between two digital levels the analog signal will be represented by the nearest digital value. This causes a very slight error called “quantization error”.

The STM32 Nucleo Board

The STM32 development board in use belongs to the NUCLEO family: the NUCLEO-G431RB is equipped with an STM32G431RB microcontroller, led, buttons, and connectors (Arduino shield compatible). It provides an easy and fast way to build prototypes.

STM32 NUCLEO-G431RB development board. Image by author.

The STM32G431RB is a mainstream ARM Cortex-M4 microcontroller with 128KB flash memory, most common communication interfaces (I2C, SPI, UART, …), and peripherals (ADC, DAC, PWM, Timer, …).

STM32G431RB Block Diagram. Image by author.

Potentiometer circuit

The potentiometer has 3 terminals:

  • VCC: Power supply
  • GND: Ground
  • Ouput: The analog signal to read. It produces a voltage value from 0V to VCC according to the position of the knob.
Potentiometer pinout. Image by author.

The output signal will be connected to one of the 6 analog inputs of the NUCLEO board (marked with Ax). VCC and GND will be connected to the power section of the board: 3.3V and GND, respectively.

Circuit with potentiometer, breadboard, and NUCLEO board. Image by author.

Let’s setup the Cube!

The Nucleo board is compatible with the STM32Cube ecosystem: a combination of software tools and embedded software libraries for STM32 microcontrollers.

Let’s start!

  • Open STM32CubeIDE Software and go to File New… STM32 Project.
  • Click on Board Selector and select NUCLEO-G431RB in the dropdown menu.
Board selector section. Image by author.
  • Insert a project name and select STM32Cube as Targeted Project Type.
Setup STM32 project. Image by author.
  • After creating the project, a page will appear showing all the necessary features needed to configure the MCU.
STM32Cube device configuration. Image by author.

The STM32G431RB has 2 ADCs (named ADC1 and ADC2) with a maximum sampling rate of 4MHz (0.25us) and up to 19 multiplexed channels. Resolution of 12-bit with a full-scale voltage range of up to 3.6V.
With channels, it is possible to organize the conversions in a group. A group consists of a sequence of conversions that can be done on any channel and in any order, also with different sampling rates.

The Analog connector of the development board is connected to pins: PA0, PA1, PA4, PB0, PC1, and PC0 of the microcontroller.

Pinout of the analog connector — NUCLEO-G431RB. Image by author.

The potentiometer is wired to the PA0 pin and so the ADC1 Channel 1 (ADC1_IN1) will be used to convert the analog value.

  • Open the Pinout&Configuration tab and click on Analog → ADC1 in the Categories section.
  • In the channel 1 (IN1) dropdown menu select Single-ended.
    The ADC can be configured to measure the voltage difference between one pin and the ground (Single-ended configuration) or between two pins (Differential configuration).
ADC1 mode panel. Image by author.
  • Leave the ADC1 Configuration panel with the default values and save the project.
    * Clock prescaler: Synchronous clock mode divided by 4
    ADC Clock derives from the system clock (SYCLK) that is set to the maximum frequency: 170MHz.
    Divide by 4 means to set the maximum ADC sampling frequency (170MHz/4 = 42.5MHz).
    * Resolution: ADC 12-bit resolution
    Output digital signal has 4096 levels. The voltage full-scale range is equal to the microcontroller supply voltage (3.3V).
ADC1 configuration panel. Image by author.
  • After saving the configuration, the STM32CubeIDE will generate all the project files according to the user inputs.

The auto-generated main.c file:

int main(void) {
/* MCU Configuration */
/* Reset of all peripherals,
* Initializes the Flash interface and the Systick. */
HAL_Init();
/* Configure the system clock */
SystemClock_Config();
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_ADC1_Init();
/* Infinite loop */
while (1) { /*...*/ }
}

The MX_ADC1_Init() function initializes the ADC1 peripheral:

ADC_HandleTypeDef hadc1;static void MX_ADC1_Init(void) { ADC_MultiModeTypeDef multimode = {0};
ADC_ChannelConfTypeDef sConfig = {0};
/** Common config */
hadc1.Instance = ADC1;
hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4;
hadc1.Init.Resolution = ADC_RESOLUTION_12B;
[...]
if (HAL_ADC_Init(&hadc1) != HAL_OK) {
Error_Handler();
}
/** Configure the ADC multi-mode */
multimode.Mode = ADC_MODE_INDEPENDENT;
if (HAL_ADCEx_MultiModeConfigChannel(&hadc1, &multimode) != HAL_OK){
Error_Handler();
}
/** Configure Regular Channel */
sConfig.Channel = ADC_CHANNEL_1;
sConfig.Rank = ADC_REGULAR_RANK_1;
[...]
if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK) {
Error_Handler();
}
}

Polling, Interrupt, and DMA: What’s the right one?

An ADC reading can be managed in 3 ways:

  1. Polling
    The microcontroller starts the ADC reading and waits for the conversion completion. It resumes the main code execution only when ADC is finished its operations.
    * Pro: Easy to implement (no additional peripherals/methods needed)
    * Cons: Waste CPU time
  2. Interrupt
    The microcontroller starts the ADC reading, but it continues executing the main code. Upon conversion is completed, the ADC “notifies” the microcontroller with an “Interrupt” message. When the microcontroller receives an “Interrupt”, it stops current execution and manages the interrupt function.
    * Pro: Save CPU time
    * Cons: Inefficient with frequent interrupts
  3. DMA
    The microcontroller instructs the DMA, an independent control unit, to start and manage the ADC conversion. The microcontroller continues executing the main code during DMA operation.
    Upon conversion is completed, DMA “notifies” the microcontroller with an “Interrupt” routine.
    * Pro: Relieve CPU from ADC operations
    * Cons: DMA is slower than CPU

There is no approach that is better than the others: it depends on the application type and user requirements.

Let’s dive in!

1. Polling

  • Start ADC reading.
HAL_ADC_Start(&hadc1);
  • Wait until the conversion is complete.
HAL_ADC_PollForConversion(&hadc1, 1000);

1000 is the timeout value expressed in milliseconds. If the conversion doesn’t finish by 1000ms, the function is forced to end.

  • Read the analog value.
uint32_t val = HAL_ADC_GetValue(&hadc1);

Example of polling approach in the main function:

int main(void) {
/* MCU Configuration */
[...]
/* Infinite loop */
while (1) {
HAL_ADC_Start(&hadc1);
HAL_ADC_PollForConversion(&hadc1, 1000);
uint32_t val = HAL_ADC_GetValue(&hadc1);
HAL_ADC_Stop(&hadc1);
HAL_Delay(1000);
}
}

2. Interrupt

  • Start ADC reading in interrupt mode.
HAL_ADC_Start_IT(&hadc1);
  • At the end of ADC conversion, the interrupt callback function will be called.
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc);
  • Inside the callback function: read conversion result and stop ADC.
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) {
uint32_t val = HAL_ADC_GetValue(hadc);
HAL_ADC_Stop_IT(hadc);
}

Example of interrupt approach in the main function:

int main(void) {
/* MCU Configuration */
[...]
/* Infinite loop */
while (1) {
HAL_ADC_Start_IT(&hadc1);
HAL_Delay(1000);
}
}

3. DMA

Direct Memory Access, known as DMA, is a data transfer technique in which peripherals (e.g., ADC, UART, etc.) communicate directly with the memory without passing through the processing unit.
The DMA Controller is a hardware peripheral of the microcontroller.

DMA communication architecture. Image by author.

To enable the DMA feature, open the device configuration tool:

  • In the ADC1 Configuration panel: open the DMA Settings section, click on the Add button and select ADC1 in the DMA requests dropdown menu.
DMA Settings. Image by author.
  • Open the Parameter Settings section and enable:
    * Continuous Conversion Mode
    It allows the ADC to convert in the background the channels continuously without any intervention from the CPU.
    * DMA Continuous Requests
    It enables the DMA peripheral in the continuous conversion mode.
Parameter Settings: DMA. Image by author.

Now, let’s go back to coding!

  • Start ADC reading in DMA mode.
    buffer is the destination buffer address of the conversion result.
HAL_ADC_Start_DMA(&hadc1, 
buffer,
BUFFER_SIZE);
  • At the end of ADC conversion, the interrupt callback function will be called.
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc);
  • Inside the callback function: stop ADC conversion.
    Conversion results are automatically transferred by DMA into the destination buffer.
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) {
HAL_ADC_Stop_DMA(hadc);
}

Example of DMA approach in the main function:

#define BUFFER_SIZE 3
uint16_t buffer[BUFFER_SIZE] = {0};
int main(void) {
/* MCU Configuration */
[...]
/* Infinite loop */
while (1) {
HAL_ADC_Start_DMA(&hadc1, buffer, BUFFER_SIZE);
[...]
}
}

…Not enough?

What you have seen here is the basic approach of the ADC functioning with the 3 most used methods.
It is also possible to use the ADC peripheral in a more advanced way, such as: synchronizing the reading and conversion phase on the rising edge of a PWM signal (useful for example in motor control applications), multi-channel readings, and much more…

If you are interested in an article about ADC advanced topics, leave a comment!

--

--

Leonardo Cavagnis

Passionate Embedded Software Engineer, IOT Enthusiast and Open source addicted. Proudly FW Dev @ Arduino