I've been doing some STM32 programming recently as part of my Mini-Mapper project (using an STM32F767ZI). I needed to collect samples from several analog inputs at a fixed frequency, for monitoring motor torque. A simple thing to do, right? But the obvious way to do it isn't necessarily the best.

In this series of three articles, I'm going to try to show a better way. Some of this will be quite boring (it's just configuring microcontroller peripherals, after all), so I allowed myself a bit of time for a fun "finisher" at the end.

ADC sampling approaches

Simplest idea: check elapsed time in your main loop, and when enough time has gone by, loop over the ADC inputs you want to sample, start an ADC conversion for each one, wait until it's done, then go on to the next one. There are two problems with that: first, there will probably be some jitter in your sample timing because of whatever else is happening in your main loop; second, you're using CPU cycles waiting for the ADC, cycles that could be spent on application code instead.

Like many ARM devices, STM32 chips have peripheral interconnects that allow you to "wire up" peripherals to run autonomously. (Here's an application note that describes this for the STM32F7 series.) In our case, we want a timer to trigger an ADC, the ADC to do conversions for a number of inputs, and for the conversion results to end up somewhere we can get at them. Ideally we get a single interrupt when each series of conversions is complete.

That should be routine, but it's not completely obvious how to make it all work together. From what I've seen from Stack Overflow questions and in other places, it's confusing for a lot of people. So I'm going to show one way to set this up. We'll be using a timer, an ADC and a DMA (direct memory access) controller together to make this work.

We'll end up with a solution that samples our analog inputs with less jitter and that only interrupts our code when a full set of samples is available. This approach also represents a form of encapsulation — once this timer+ADC+DMA system is working, you can treat it as an autonomous sub-system in your microcontroller and more or less forget about the details of what it's doing.

Steps, hardware and software approach

There are three articles in this series:

  1. In this article, we'll look at basic MCU setup shared among all the examples we'll use, and we'll see how to set up the STM32F767's ADC to collect a single sample in "polled mode".

  2. In the second article, we'll get the ADC to talk to a DMA controller so that we can collect a series of samples into a buffer.

  3. In the third article, we'll link the ADC to a timer to get regular samples. We'll demonstrate how to use this with a very simple USB oscilloscope example.

All of the examples run on a Nucleo-144 development board with an STM32F767ZI MCU. The code for the examples is in one GitHub repository. It's set up to build using just makefiles and GCC. The makefile has a GCC_INSTALL_ROOT variable that points to the location of the arm-none-eabi GCC toolchain. Running make will build all the examples in the build directory. I use OpenOCD to flash these to the Nucleo board.

We're not going to use any libraries for these examples: we'll be writing to the STM32F767's registers directly to configure peripherals. The registers are all defined in the CMSIS header files for the part. It's more common to use the ST Micro hardware abstraction layer (HAL) libraries for these kinds of examples. You can get things going very quickly with the HAL if you know what you're doing, but using the HAL obscures some of the details of the peripheral setup. So, we'll embrace the registers! (Even if you end up using the HAL later on, it's a good idea to do some examples in this way to start with, because it gives you a better understanding of the peripherals you're using.)

Example overview

It's easy to get lost in details as we start to configure the MCU and its peripherals, so let me state briefly exactly what this first example we're going to look at does. We're going to start by looking at the ex1.c file.

This example uses polling to perform ADC conversions. This means that we manually start the ADC conversion process, then we wait for the conversion to complete, checking a flag in the ADC in a busy loop to see when the conversion is done.

This approach is simple to use, and we only need to configure the ADC (i.e. no DMA, no timers), but it's neither flexible nor efficient, so we'll move on to better ways of doing things later. The main reason for looking at this approach first is that it allows us to look at the common MCU initialisation code, and to see some of the main ADC configuration features.

Common initialisation: common_init

Like all of the examples we're going to look at, ex1.c starts by calling common_init, a function defined in common.c. This function does some basic setup of the MCU.

First, it enables instruction and data caches.

Then, it configures the main MCU clock to run at 216 MHz, using the 8 MHz external clock on the Nucleo board as input. Clock setup on these processors is complicated (take a look at the clock tree on page 153 of the STM32F76xxx reference manual...), so don't worry about the details for now.

After configuring the system clock, it uses the SysTick_Config function from the CMSIS headers to set the ARM SysTick timer interrupt to run at 1 kHz. A simple tick timer is useful for timing non-critical events in the main loop of your program (switching an LED on for a while, for instance).

Next, GPIOs are initialised for the three LEDs on the Nucleo board, and if it's being used, for the single user button. If it is being used, the button is set up to generate an interrupt that sets a simple flag we can use in the main loop of our program. (Again, the details of setting up interrupts for external sources like GPIOs are a little complicated, so don't worry about that.)

Finally, we configure serial debugging output so that we can print messages from our program to see what's going on. There are a number of ways to do this, but for these examples, we use the USART3 peripheral on the STM32F767, which is connected to the ST-Link debugging hardware on the Nucleo board. Once we have the USART set up, any output we write to it will appear on a virtual serial port on the PC connected to the ST-Link USB port on the Nucleo board.

Configure polling ADC

After the call to common_init, the ex1.c initialisation code calls a function configure_polled_adc to set up the ADC for what we want to do here. This uses the configure_common_adc function in common.c to do some setup that we need for all the examples. We then move on to the configuration we need for the polled ADC method we're using in the first example. We'll walk through each of the six configuration steps:

1. GPIO selection and setup

First, the analog input GPIOs are configured. The pins we set up depend on how many input channels we want. On the Nucleo board, pins PA4, PA5, PA6 and PA7 are available for use with channels 4-7 of ADC1, so we use those.

Finding out which pins to use requires using a combination of the datasheet for the STM32F767xx parts and the manual for the Nucleo board. Important: the pinout information you need is in the datasheet for the STM32 parts, not in the reference manual! The things to look at are:

  • The first figure in Section 6.13 of the Nucleo manual, which shows the extension connectors on the board. From this you can see which MCU pins are accessible.

  • Table 11 in the datasheet ("STM32F765xx, STM32F767xx, STM32F768Ax and STM32F769xx pin and ball definitions"). This shows the peripheral and other functions that are available on each pin of the MCU.

The most important thing to look at here is the "Additional functions" column of Table 11 in the datasheet. Among other things this lists which ADC channels are connected to which MCU pins. The STM32F767 has three distinct ADCs: ADC1, ADC2 and ADC3. All we need to do is to identify four pins (for four analog inputs) that are assigned to ADC channels on the same ADC and that are accessible on the Nucleo board.

A bit of back-and-forth between the datasheet and the Nucleo board manual gives us the following assignments:

  • ADC channel ADC1_CH4 on pin PA4
  • ADC channel ADC1_CH5 on pin PA5
  • ADC channel ADC1_CH6 on pin PA6
  • ADC channel ADC1_CH7 on pin PA7

To configure these pins as analog inputs, we use the MODER register for the GPIOA GPIO port (Section 6.4.1 in the reference manual):

STM32F767 reference manual Section 6.4.1

We need to set the bits corresponding to each pin in the MODER register to 0x03 (the field in the MODER register for each pin is two bits)

  MODIFY_REG(port->MODER, 0x03 << (pin * 2), 0x03 << (pin * 2));

The MODIFY_REG macro is defined in the CMSIS headers, and takes the address of a register, a mask (a binary value with ones in the positions of the register field we want to modify and zeroes everywhere else) and a value. It basically does:

  REG = (REG & ~MASK) | VALUE;

which clears out the bits defined by the mask and sets the bits defined by the value. The CMSIS headers define mask and bit position macros for shifting values to make using this convenient.

On STM32 devices every peripheral is clocked separately, and if a peripheral isn't clocked, it doesn't consume any (or much) power. This is nice, but it means that you need to remember to enable the peripheral clock for each peripheral that you use. This is true for GPIO ports, some of the interrupt machinery (used for detecting button presses here), USARTs, ADCs, DMA controllers, more or less everything in fact. (A HAL library can help with this, since it can be written to enable the relevant peripheral clock automatically during peripheral configuration.)

Forgetting to enable peripheral clocks is a common source of bugs in STM32 code!

We need to enable the peripheral clock for the GPIO port we're using. All the pins we want to use as analog inputs are on GPIO port GPIOA, so we do this:

  RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;

RCC is "reset and clock control" and is the part of the MCU that manages all reset and clock distribution. Section 5 of the STM32F767 reference manual covers this in detail. Getting all the clocks going right is one of the hardest parts of getting an STM32 device working. There's a huge amount of flexibility available, but you obviously pay a price for that in complexity.

2. ADC interrupts and clocks

If we're interested in getting interrupts from the ADC, we need to enable them. The STM32F7xx ADC can generate interrupts for end of conversion events and for some error conditions.

When configuring interrupts on ARM processors you bump up against one slightly weird thing: because some features of ARM MCUs are defined by ARM, and some features are defined by the manufacturer (in our case, ST Micro), some operations are defined in the core ARM definitions (mostly processor core-specific things), while the rest are defined in the manufacturer's CMSIS definitions. For the STM32F767, the core stuff is defined in a header file called core_cm7.h (for "Cortex-M7") and the manufacturer stuff is in stm32f767xx.h.

One thing defined by ARM is the interrupt controller for Cortex-M7 chips, called the Nested Vectored Interrupt Controller, or NVIC, so everything to do with enabling and disabling interrupts, setting interrupt priorities and so on, is defined in core_cm7.h. However, because the peripherals that can generate interrupts depend on the exact device you're using, all the interrupt numbers (e.g. ADC_IRQn, DMA_Stream0_IRQn and so on) are defined in the manufacturer's CMSIS header.

It's useful to have a clear idea of where the split is between ARM core functionality and manufacturer device-specific functionality.

This split can reveal itself in interesting ways. For example, the SysTick timer that's commonly used for RTOS context switching and general time tracking is an ARM core function, even though it runs off a clock generated by a manufacturer-specific clock system. On the STM32F767, one result of this is that you can't trigger peripheral actions based on the SysTick timer — you have to use an STM32 timer peripheral. We'll see how timer event triggering works on the STM32F767 later on.

If we're going to use ADC interrupts, we enable them and set their priority by doing:

  NVIC_SetPriority(ADC_IRQn, 0);
  NVIC_EnableIRQ(ADC_IRQn);

Those functions with names starting NVIC_... are defined in the core_cm7.h header.

Just as for the GPIOs, we need to enable the peripheral clock for the ADC. Forget to do that, and the ADC just won't do anything at all. So we do something like this:

  volatile uint32_t tmpreg;
  SET_BIT(RCC->APB2ENR, RCC_APB2ENR_ADC1EN);
  tmpreg = READ_BIT(RCC->APB2ENR, RCC_APB2ENR_ADC1EN);
  (void)tmpreg;

There are two things to notice here. First, there's a little dance to make sure that the change to the peripheral clock has taken before we do any further configuration on the ADC. That's what the business with reading the value from the RCC_APB2ENR_ADC1EN bit in the RCC->APB2ENR register is about here: we declare tmpreg as volatile so that the compiler won't optimise out this read, and the final line saying (void)tmpreg is there just to prevent the compiler from complaining that we don't make use of tmpreg after we set it. This is to meet the requirement listed in the reference manual that "Just after enabling the clock for a peripheral, software must wait for a 2 peripheral clock cycles delay before accessing the peripheral registers" (Section 5.2.12).

The second thing to notice is that, to enable the ADC peripheral clock, we wrote a bit in the RCC->APB2ENR register, while to enable the GPIOA GPIO port, we wrote a bit in the RCC->AHB1ENR register. These two peripherals are in different clock domains: one on the APB2 peripheral bus, and one on the AHB1 bus. It's worth taking a look through the RCC registers to see which peripherals are on which buses, especially since the clocks on different buses run at different frequencies, and that can make a difference to the timing of your peripheral, so you need to know which peripheral goes with which clock!

It's important to know which peripherals are on which buses, so that you can work out which peripheral clocks they use.

Once we've enabled the peripheral clock for the ADC, we can configure the ADC conversion clock rate. This is the APB2 clock divided by some prescaler factor. One slight wrinkle is that this setting is shared among all three ADCs on the STM32F767. You set a single conversion clock prescaler that's used for all three ADCs by setting a value in the ADC123_COMMON->CCR register. For our purposes, we'll use the peripheral clock divided by two (the smallest prescaler that's allowed).

3. Disable ADC continuous mode

The terminology here is confusing! "Continuous mode" means that as soon as the ADC completes a conversion, it immediately starts a new one, generating conversion results as quickly as it can. Sometimes you want this "firehose" kind of conversion, when you have a signal or set of signals that you want to sample as fast as you possibly can. In other applications, you have a specific sample rate in mind, and you use a timer to enforce that. (That's what we're going to do later on.) If you're using a timer to trigger ADC conversions, you do not want continuous mode! You want the ADC to perform a single conversion at each trigger event, then to stop and wait for the next trigger event.

One additional source of confusion is that the ADC can convert more than one input for each trigger event. We'll see how this works later, but you often have a situation where an external event (e.g. a timer) triggers a conversion and the ADC performs conversions on a set of inputs one after another before stopping and waiting for the next trigger event. Although this setup involves rapid conversion of multiple data items, it isn't "continuous mode"! This mode is called "scan mode". (To add to the confusion, there's also a "discontinuous mode", but let's not talk about that...)

"Continuous mode" and "scan mode" are different things!

In any case, we need to disable continuous mode, like this:

  MODIFY_REG(ADC1->CR2, ADC_CR2_CONT, 0);

4. Set ADC trigger source

For this example, we're going to start ADC conversions explicitly, i.e. not using a trigger from another peripheral. The trigger source for ADC conversions is controlled by the EXTEN and EXTSEL fields in the ADC's CR2 configuration register:

STM32F767 reference manual Section 15.13.3

To disable external triggering, we zero out the EXTEN field:

  MODIFY_REG(ADC1->CR2, ADC_CR2_EXTEN, 0);

Once this is done, the only way to start an ADC conversion is to set the SWSTART bit in CR2:

STM32F767 reference manual Section 15.13.3: ADC CR2 SWSTART

5. Select ADC channels

For this example, we'll convert a single analog input for each ADC conversion that we trigger. It is possible to do multiple conversions in a polling setup (or using interrupts without DMA), but I can't recommend it. Using DMA is much more reliable for that, and we'll see how in the next article.

You can make STM32F767's ADCs convert two distinct sets of any channels you like in any order, which means that the registers to configure channel selection are a little complicated. We're going to use "regular" conversions (the other kind are "injected"). The channel list is configured in the ADC1->SQR1, ADC1->SQR2 and ADC1->SQR3 sequence registers:

STM32F767 reference manual Section 15.13.9-11: ADC SQRx

We need to set two things here: the L field in the SQR1 register tells the ADC how many input channels to convert. The value in L is one less than the channel count, so we set L to zero, since we're converting only one channel.

  MODIFY_REG(ADC1->SQR1, ADC_SQR1_L, 0);

Then we need to tell the ADC which channels to convert. This is a little weird, because the channels are given in reverse order, running from the LSBs to MSBs of SQR3, then from the LSBs to the MSBs of SQR2 and finally from the LSBs to part-way through SQR1. There are 16 possible slots, each of which needs 5 bits to record which input to convert (the input channels are numbered from 0 to 18, and the highest numbered channels correspond to some interal sensors in the MCU rather than the to external analog inputs).

ADC channel ranks run backwards from the LSBs of register SQR3.

We're going to do a single conversion on channel 4, so we need to set the least significant 5 bits of SQR3 (the position for the first conversion):

  MODIFY_REG(ADC1->SQR3, 0x0000001FU, 0x04);

6. Set ADC sample time

The last thing we need to do is to set the sample time used by the ADC for doing our conversions. As for the channel selection, the sample times for the different channels are spread over multiple registers, in this case SMPR1 and SMPR2:

STM32F767 reference manual Section 15.13.4-5: ADC SMPRx

For channel 4, the relevant field that we need is in ADC1->SMPR2. We'll choose 56 cycles of the ADC sample clock as our sample time. Because the ADC sample clock is APB2CLK / 2 = 27 MHz, this gives a sample time of about 2 μs. In fact, the total time for the ADC conversion is 12 cycles more than the value we set as the sample time, so a sample takes 68 cycles, i.e. about 2.5 μs. We set this up by doing:

  MODIFY_REG(ADC1->SMPR2, ADC_SMPR2_SMP4, 0x03 << ADC_SMPR2_SMP4_Pos);

Here, our choice of sample time is slightly arbitrary. In a real application, we'd base the sample time on the characteristics of the signal we were sampling. Our 2.5 μs sample time is fast enough for many applications (audio, for example). If you want to sample faster, the fastest APB2 clock allowed on the STM32F767 is 90 MHz, which gives a maximum ADC sample clock of 45 MHz. That means a minimum conversion time of 3+12 cycles = 333 ns. You can shave even more off the conversion time by using the "fast conversion" options, which reduce the ADC resolution from 12 bits down to lower values to get quicker conversions. The absolute fastest possible is for 6 bit conversions, which take 3 + 6 = 9 cycles, or 200 ns.


To summarise: we've set ADC1 up to do a single conversion on input channel 4 (which is connected to pin PA4 on the STM32F767ZI) with a sample time of about 2.5 μs, with manual triggering only (i.e. conversions must be started by setting the SWSTART bit in the ADC1->CR2 configuration register).

Main program

After the ADC setup, the ex1.c program goes into a simple while (true) { ... } loop. This uses a SysTick event count to time events (an "I'm alive" blinky and an LED indicator that an ADC conversion has been triggered), and otherwise just waits for button press events to start an ADC conversion.

This "super-loop" structure doesn't scale to much larger applications with many different events and multiple tasks that need to run at once (for that you'd use an RTOS), but it's perfect for these small examples, because you can see more or less the whole application on one screen.

Main loop

Here's the full main loop of the ex1.c example program:

  char buff[64];
  uint32_t blink_start = systick_count;
  uint32_t adc_blink_start;
  bool adc_blink_on = false;
  while (1) {
    // "I'm alive" blinky.
    if (systick_count - blink_start >= 100) {
      LED1_PORT->ODR ^= 1 << LED1_PIN;
      blink_start = systick_count;
    }

    // "ADC in progress" blinky.
    if (adc_blink_on && systick_count - adc_blink_start > 250) {
      CLEAR_BIT(LED2_PORT->ODR, 1 << LED2_PIN);
      adc_blink_on = false;
    }

    // Handle button press: start "ADC in progress" blinky, do polled
    // ADC and print result.
    if (button_pressed) {
      button_pressed = false;
      usart_print("BUTTON\r\n");
      SET_BIT(LED2_PORT->ODR, 1 << LED2_PIN);
      adc_blink_on = true;
      adc_blink_start = systick_count;

      SET_BIT(ADC1->CR2, ADC_CR2_ADON);
      uint16_t sample = polled_adc();
      CLEAR_BIT(ADC1->CR2, ADC_CR2_ADON);

      sprintf(buff, "ADC: %d\r\n", sample);
      usart_print(buff);
    }
  }

The first if statement inside the main loop blinks an LED continuously (5 times per second) to give an indication that the application is still alive. The systick_count variable is updated regularly (at 1 kHz) by the SysTick interrupt handler set up in the common configuration code we looked at earlier.

When an ADC conversion is started, the adc_blink_on flag is set and another LED is switched on. The second if statement deals with switching this LED off after 250 ms.

The most important part of the code is in the third if statement, which responds to button press events. The button_pressed flag is set in the interrupt handler for the button press event, and we first clear this flag. After writing a message using the USART we set up earlier, switching on the ADC conversion LED and setting up the variables needed to switch the LED off later, the code calls the polled_adc function to perform an ADC conversion.

One thing to notice here is that we switch the ADC on before doing a conversion and switch it off again after doing a conversion — this is what setting and clearing the ADON bit in the ADC1->CR2 register does. This isn't mandatory, since you can just leave the ADC switched on all the time if you like, but it saves power if you can switch the ADC off when you're not using it.

Use polling ADC

Here's the polled_adc function:

static uint16_t polled_adc(void) {
  // Manually start ADC conversion.
  SET_BIT(ADC1->CR2, ADC_CR2_SWSTART);

  // Wait for conversion complete.
  while (!READ_BIT(ADC1->SR, ADC_SR_EOC)) { __asm("nop"); }

  // Read converted data (clears EOC flag).
  return (uint16_t)(READ_BIT(ADC1->DR, ADC_DR_DATA));
}

The ADC conversion is started by setting the SWSTART bit in the ADC1->CR2 configuration register. If you remember from back when we were configuring the ADC, this manual software trigger is the only way to start an ADC conversion in our setup, since we've not enabled any other trigger sources.

Once we start the ADC conversion, we could go and do something else while the conversion is happening, but here we spin waiting for the EOC (end of conversion) flag to be set in the ADC->SR status register. This busy waiting is what's meant by "polling" in this context. It's not an efficient use of CPU resources, but it's definitely simple!

Once the ADC signals that the conversion is complete by setting the EOC flag, we read the converted value from the ADC1->DR data register. The way we have things configured, the 12-bit converted value is right-aligned in the 16-bit data register. This data alignment can be set up differently if it's convenient. (One small detail that will be important later: reading the data register resets the EOC flag.)

Conclusions

And that's all there is to polled ADC conversion. It often happens that more than 90% of our effort goes into configuring the peripherals we're going to use. Using the peripherals sometimes feels like an anticlimax!

You can see that pattern in this overly long article: almost all of the article is about setup and configuration. Once you understand the peripherals on the device you're using, you can shortcut some of this configuration effort using the device configuration tools in the STM32Cube IDE, but I think that it's useful to do some examples at this lower level first to get a good understanding of what the options are and how everything fits together. Working this way forces you to read the documentation, which (if you're like me), you'd probably avoid otherwise.

Now that we've covered most of the ADC configuration we need, the next article will look at how you use DMA for analog-to-digital conversion.