Getting Started with ADC on STM32F103C8 (Blue Pill) using Rust

Microcontrollers are often used to measure input voltage levels rather than just reading HIGH and LOW states. For instance, when working with a moisture sensor, measuring the moisture level requires using an Analog Input to convert the voltage input into a digital value, which is then calculated to determine the percentage of moisture. If you use a standard digital input as discussed in the previous article, you will only obtain HIGH and LOW values without knowing the actual moisture level.

Introduction to Analog to Digital Converter (ADC) on STM32F103C8

The STM32F103C8 features 10 pins that can be configured as analog inputs—specifically pins PA0-PA7 and PB0-PB1—with a maximum acceptable voltage of 3.3V. When an STM32F103C8 pin is configured as an analog input, it connects to the Analog to Digital Converter (ADC) peripheral, which converts the input voltage into a digital value. The STM32F103C8 is equipped with 2 ADC units (ADC1 and ADC2) featuring a 12-bit resolution (0-4095) and a precision of approximately 0.8mV (an input voltage change of 0.8mV will cause a digital value change of 1). The ADC frequency of the STM32F103C8 can be configured up to 14MHz.

Modes and Features of the ADC STM32F103C8

The STM32F103C8 ADC can operate in 4 distinct modes:

  1. Single conversion mode: The ADC performs a single conversion on the selected channel and then stops. This mode is typically used when reading sensors whose values do not change rapidly (such as temperature or humidity sensors).
  2. Scan mode: The ADC scans and converts a predefined list of multiple channels sequentially, one by one, before stopping. Scan mode is commonly combined with DMA (Direct Memory Access) to prevent data from being overwritten in the same register. This mode is used to monitor multiple sensors simultaneously and synchronously.
  3. Continuous conversion mode: The ADC automatically converts data continuously and repeatedly without waiting for further CPU commands. In single-channel operation, the ADC automatically restarts conversion as soon as the current one finishes. In multi-channel operation, it cycles back to the first channel and converts through the sequence continuously.
  4. Discontinuous conversion mode: This mode divides a group of channels into smaller subgroups within Scan Mode. The ADC converts from the first channel of the first subgroup to the last channel of the final subgroup, setting the eoc (End of Conversion) register bit to 1 at the end of each subgroup conversion.

The advanced features of the STM32F103C8 ADC include:

  1. Simultaneous sample and hold: Utilizes two ADC units simultaneously (in parallel). This allows it to sample two different channels (channel 1 to ADC1 and channel 2 to ADC2) at the exact same instant.
  2. Interleaved sample and hold: The ADC1 and ADC2 units work alternately to read and convert a single channel. This mode effectively doubles the ADC sampling rate, making it ideal for high-frequency signal reading.
  3. Single shunt: Reads and converts a channel with a very low input voltage (in the millivolt range). This is typically used for current monitoring by measuring the voltage drop across a shunt resistor.

ADC Peripheral Support on STM32F103C8

The STM32F103C8 ADC can also be integrated with other peripherals, such as:

  1. ADC with DMA (Direct Memory Access): The ADC transfers the conversion results directly to the RAM without CPU intervention, thereby reducing CPU overhead. The CPU only needs to read the data from the RAM once the conversion process is complete.
  2. ADC with Analog Watchdog: The ADC conversion values are monitored by the Analog Watchdog. If the value falls outside the predefined upper or lower thresholds, the Analog Watchdog triggers an interrupt.
  3. ADC with Timer: The ADC performs conversions at precise periodic intervals based on a configured Timer.

Hardware Preparation

The hardware used in this tutorial is almost identical to the hardware used in the previous article. While the previous article utilized a push button, this tutorial will use a potentiometer to adjust the input voltage level to the STM32F103C8 GPIO pin.

Potentiometer

A potentiometer is a three-pin variable resistor. Unlike a standard resistor that has a fixed resistance value, the resistance of a potentiometer can be adjusted manually by turning its knob. This tutorial will use a potentiometer with a resistance value of 5kΩ.

Potentiometer for input analog on Blue Pill (STM32F103C8)
Potentiometer for input analog on Blue Pill (STM32F103C8)

Pro Tip: A 5kΩ value is ideal for this experiment as it provides a stable voltage divider without drawing excessive current from the STM32F103C8’s 3.3V rail. The middle pin will output a variable voltage between 0V and 3.3V, which the ADC then converts into a digital value ranging from 0 to 4095.

Using Analog Input on Blue Pill(STM32F103C8) with Rust

This tutorial will focus on how to use the ADC in single conversion mode. The use of continuous conversion, scan mode, and other advanced features of the STM32F103C8 ADC will be covered in detail in subsequent articles.

There are several methods available for utilizing the Blue Pill’s ADC, including the Poll, Direct Memory Access (DMA), and Interrupt methods. The following are the variations of single conversion mode on the STM32F103C8 ADC:

  1. Single conversion Poll: The ADC only performs a conversion when commanded by the CPU, and the CPU waits for the conversion process to complete before reading the result. After that, the ADC will not perform another conversion until it receives another command from the CPU. This method is suitable when you do not need to read the sensor frequently or only need to sample it at specific times.
  2. Single conversion DMA: The ADC performs a conversion when commanded by the CPU, and the conversion result is automatically transferred directly to the RAM (DMA buffer). During the conversion process, the CPU can execute other tasks. Once the DMA buffer is populated, the CPU reads the result from it. Following this, the ADC idles until the next CPU command. When properly configured, this method significantly reduces CPU overhead, making it ideal for reading sensors rapidly and continuously.
  3. Single conversion Interrupt: The ADC performs a conversion when commanded by the CPU. Once the conversion is complete, the ADC signals the CPU, causing it to temporarily pause its main program execution to run the ISR (Interrupt Service Routine). Afterward, the ADC stops until commanded again. If too many conversions are triggered per second using this method, the CPU will be overwhelmed by constant interrupts, which increases CPU overhead. Consequently, this method is typically reserved for reading critical or emergency sensors.

In this tutorial, we will read the input voltage level from a potentiometer and display its corresponding digital value on a PC/laptop terminal. This tutorial will utilize GPIO pin PA0 as the analog input.

3.1 Blue Pill (STM32F103C8) and Potentiometer Circuit

The potentiometer is used as a voltage divider device that will act like a sensor. Let’s gather the necessary components and start building the circuit according to the following schematic diagram:

Schematic of potentiometer dan Blue Pill (STM32F103C8) for Input Analog
Schematic of potentiometer dan Blue Pill (STM32F103C8) for Input Analog

According to the schematic diagram, the voltage level received by pin PA0 will change as the potentiometer is turned, which in turn changes the digital value. When the potentiometer knob is turned toward GND, the resistance between pin PA0 and GND decreases, while the resistance between pin PA0 and 3.3V increases; thus, the voltage entering pin PA0 becomes lower. Conversely, when the knob is turned toward 3.3V, the resistance between pin PA0 and GND increases, while the resistance between pin PA0 and 3.3V decreases, causing the voltage entering pin PA0 to become higher.

3.2 Programming the Blue Pill (STM32F103C8) as an Analog Input with Single Conversion Poll

Please create a new Rust project and configure it according to the Setting up the STM32F103C8 with Rust article. Open the Cargo.toml file and add the following code to define a new binary named ‘single-conversion-poll’:

1[[bin]]
2name = "single-conversion-poll"
3path = "src/main.rs"
4test = false
5bench = false

Next, open the src/main.rs file and insert the following code (as usual, the functional explanation for each line is provided as comments):

 1#![no_std]
 2#![no_main]
 3
 4use defmt_rtt as _;
 5use panic_probe as _;
 6
 7use cortex_m::delay::Delay;
 8use cortex_m_rt::entry;
 9use stm32f1xx_hal::{
10    adc,
11    flash::FlashExt,
12    gpio::GpioExt,
13    pac,
14    prelude::*,
15    rcc::{Config, RccExt},
16    time::Hertz,
17};
18
19#[entry]
20fn main() -> ! {
21    defmt::println!("STM32F103C8 Access GPIO as Analog Input Single Conversion Poll");
22
23    // Access STM32F103C8 peripherals
24    let dp = pac::Peripherals::take().unwrap();
25
26    // Access STM32F103C8 core peripherals (Cortex-M3)
27    let cp = pac::CorePeripherals::take().unwrap();
28
29    // Access FLASH peripheral to adjust 'wait states' during Clock configuration
30    let mut flash = dp.FLASH.constrain();
31
32    // Access Reset & Control Clock (RCC) peripheral for configuration
33    let rcc = dp.RCC.constrain();
34
35    // Create Reset & Control Clock (RCC) configuration
36    let clock_config = Config::default()
37        .use_hse(Hertz::MHz(8)) // Use 8MHz external crystal oscillator (HSE)
38        .sysclk(Hertz::MHz(72)) // Set CPU frequency to 72MHz
39        .hclk(Hertz::MHz(72)) // Set BUS frequency (SRAM and FLASH access path) to 72MHz
40        .adcclk(Hertz::MHz(9)); // Set ADC frequency to 9MHz
41
42    // Apply clock configuration to RCC and adjust flash memory wait states
43    let mut clocks = rcc.freeze(clock_config, &mut flash.acr);
44
45    // Create a delay instance using the Cortex-M3 System Timer (SYST)
46    let mut delay = Delay::new(cp.SYST, clocks.clocks.hclk().to_Hz());
47
48    // Access ADC1 peripheral
49    let mut adc1 = adc::Adc::new(dp.ADC1, &mut clocks);
50
51    // Access GPIO Port A peripheral
52    let mut gpioa = dp.GPIOA.split(&mut clocks);
53    // Configure PA0 as an analog input
54    let mut potentio = gpioa.pa0.into_analog(&mut gpioa.crl);
55
56    // The program will run repeatedly in an infinite loop
57    loop {
58        // CPU commands ADC1 to read and convert the input voltage into a digital value
59        let data: u16 = adc1.read(&mut potentio).unwrap();
60        // Calculate the input voltage from the read digital value
61        let voltage_input = 3.3f32 * data as f32 / 4095.0f32;
62        // Send message to PC/laptop
63        defmt::println!(
64            "Digital value: {} || voltage input: {}V",
65            data,
66            voltage_input
67        );
68        // Wait for 200 ms
69        delay.delay_ms(200);
70    }
71}

Connect the ST-Link to your PC/laptop, then execute the Rust code by running the ‘cargo run --bin single-conversion-poll’ command in the VSCode terminal. Try turning the potentiometer knob; the analog input reading results will appear in the terminal. The following are the results of the single conversion poll analog input experiment:

Programming the Blue Pill (STM32F103C8) as Input Analog with Single Conversion DMA

Pada STM32F103C8 hanya ADC1 yang bisa digunakan dengan DMA, sedangkan ADC2 tidak bisa. DMA harus diinisialisasi terlebih dahulu sebelum digunakan, dan penjelasan detail mengenai DMA (Direct Memory Access) akan dibahas pada artikel berikutnya.

Note: For ADC1, the only DMA channel that can be used is Channel 1; if you connect it to another channel, the program will not function.

Let’s start, on Cargo.toml file, add the following code to define a new binary:

1[[bin]]
2name = "single-conversion-dma"
3path = "src/single_conversion_dma.rs"
4test = false
5bench = false

Next, create the src/single_conversion_dma.rs file, and insert the following code:

 1#![no_std]
 2#![no_main]
 3
 4use cortex_m::delay;
 5use defmt_rtt as _;
 6use panic_probe as _;
 7
 8use cortex_m_rt::entry;
 9use stm32f1xx_hal::{adc, pac, prelude::*, rcc::Config, time::Hertz};
10
11#[entry]
12fn main() -> ! {
13    defmt::println!("STM32F103C8 Access GPIO as Analog Input Single Conversion DMA");
14
15    // Access STM32F103C8 peripherals
16    let dp = pac::Peripherals::take().unwrap();
17
18    // Access STM32F103C8 core peripherals (Cortex-M3)
19    let cp = pac::CorePeripherals::take().unwrap();
20
21    // Access FLASH peripheral to adjust 'wait states' during Clock configuration
22    let mut flash = dp.FLASH.constrain();
23
24    // Access Reset & Control Clock (RCC) peripheral for configuration
25    let rcc = dp.RCC.constrain();
26
27    // Create Reset & Control Clock (RCC) configuration
28    let clock_config = Config::default()
29        // Use 8MHz external crystal oscillator (HSE)
30        .use_hse(Hertz::MHz(8))
31        // Set CPU frequency to 72MHz
32        .sysclk(Hertz::MHz(72))
33        // Set BUS frequency (SRAM and FLASH access path) to 72MHz
34        .hclk(Hertz::MHz(72))
35        // Set ADC frequency to 9MHz
36        .adcclk(Hertz::MHz(9));
37
38    // Apply clock configuration to RCC and adjust flash memory wait states
39    let mut clocks = rcc.freeze(clock_config, &mut flash.acr);
40
41    // Create a delay instance using the Cortex-M3 System Timer (SYST)
42    let mut delay = delay::Delay::new(cp.SYST, clocks.clocks.hclk().to_Hz());
43
44    // Initialize ADC1 peripheral
45    let adc1 = adc::Adc::new(dp.ADC1, &mut clocks);
46
47    // Configure GPIO PA0 as Analog Input
48    let mut gpioa = dp.GPIOA.split(&mut clocks);
49    let potentio = gpioa.pa0.into_analog(&mut gpioa.crl);
50
51    // Initialize DMA1
52    let dma1_channels = dp.DMA1.split(&mut clocks);
53
54    // Link ADC with DMA Channel 1
55    let mut adc_dma = adc1.with_dma(potentio, dma1_channels.1);
56
57    // Prepare Buffer to be filled by DMA
58    // (Example: taking 10 samples for averaging)
59    let mut buffer = cortex_m::singleton!(: [u16; 10] = [0; 10]).unwrap();
60
61    loop {
62        // CPU commands ADC to convert and use DMA to store data in the buffer
63        let transfer = adc_dma.read(buffer);
64
65        // Read conversion results from the DMA buffer
66        let (recovered_buffer, recovered_adc_dma) = transfer.wait();
67
68        // Process data (average 10 samples) and calculate voltage
69        // from the digital values read
70        let sum: u32 = recovered_buffer.iter().map(|&v| v as u32).sum();
71        let avg = (sum / recovered_buffer.len() as u32) as u16;
72        let voltage = (avg as f32 / 4095.0) * 3.3;
73
74        // Send message to PC/laptop
75        defmt::println!("Raw Avg: {} | Volt: {}V", avg, voltage);
76
77        // Return buffer and adc variables to be reused in the next loop iteration
78        adc_dma = recovered_adc_dma;
79        buffer = recovered_buffer;
80
81        // Wait for 200ms
82        delay.delay_ms(200);
83    }
84}

Execute the Rust code by running the command ‘cargo run --bin single-conversion-dma’ in the VSCode terminal, then try turning the potentiometer knob. The following are the results of the single conversion DMA analog input experiment:

Programming the Blue Pill (STM32F103C8) as Input Analog with Single Conversion Interrupt

Since the stm32f1xx_hal crate does not provide methods for reading the ADC via interrupts, the ADC configuration must be handled at a lower level by directly accessing the ADC registers. This program will be kept as simple as possible to ensure it is easy to understand. A detailed explanation regarding interrupts will be covered in a subsequent article.

First, lets define new binary. Open the Cargo.toml file and add the following code:

1[[bin]]
2name = "single-conversion-interrupt"
3path = "src/single_conversion_interrupt.rs"
4test = false
5bench = false

Next, create the src/single_conversion_interrupt.rs file, and insert the following code:

  1#![no_std]
  2#![no_main]
  3
  4use core::sync::atomic::AtomicI16;
  5use core::sync::atomic::Ordering;
  6
  7use cortex_m::delay;
  8use defmt_rtt as _;
  9use panic_probe as _;
 10
 11use cortex_m_rt::entry;
 12use stm32f1xx_hal::adc::SampleTime;
 13use stm32f1xx_hal::gpio::PinExt;
 14use stm32f1xx_hal::pac::Interrupt;
 15use stm32f1xx_hal::pac::interrupt;
 16use stm32f1xx_hal::{adc, pac, prelude::*, rcc::Config, time::Hertz};
 17
 18// Global variable to store the ADC value. Uses an atomic type to prevent
 19// race conditions when data is modified in the interrupt and read in the main loop.
 20static ADC_VALUE: AtomicI16 = AtomicI16::new(0);
 21
 22#[entry]
 23fn main() -> ! {
 24    defmt::println!("STM32F103C8 Access GPIO as Analog Input Single Conversion Interrupt");
 25
 26    // Accessing STM32F103C8 peripherals
 27    let dp = pac::Peripherals::take().unwrap();
 28
 29    // Accessing STM32F103C8 core peripherals (Cortex-M3)
 30    let cp = pac::CorePeripherals::take().unwrap();
 31
 32    // Accessing FLASH peripheral to adjust 'wait states' during Clock configuration.
 33    let mut flash = dp.FLASH.constrain();
 34
 35    // Accessing Reset & Control Clock (RCC) peripheral for configuration
 36    let rcc = dp.RCC.constrain();
 37
 38    // Creating Reset & Control Clock (RCC) configuration
 39    let clock_config = Config::default()
 40        // Use 8MHz external crystal oscillator (HSE)
 41        .use_hse(Hertz::MHz(8))
 42        // Set CPU frequency to 72MHz
 43        .sysclk(Hertz::MHz(72))
 44        // Set BUS frequency (SRAM and FLASH access path) to 72MHz
 45        .hclk(Hertz::MHz(72))
 46        // Set ADC frequency to 9MHz
 47        .adcclk(Hertz::MHz(9));
 48
 49    // Apply clock configuration to RCC and adjust flash memory wait states
 50    let mut clocks = rcc.freeze(clock_config, &mut flash.acr);
 51
 52    // Create a delay instance using the Cortex-M3 System Timer (SYST).
 53    let mut delay = delay::Delay::new(cp.SYST, clocks.clocks.hclk().to_Hz());
 54
 55    // Initialize ADC1 peripheral
 56    let mut adc1 = adc::Adc::new(dp.ADC1, &mut clocks);
 57
 58    // Enable End of Conversion (EOC) interrupt on ADC1
 59    adc1.enable_eoc_interrupt();
 60
 61    // Configure GPIO PA0 as Analog Input
 62    let mut gpioa = dp.GPIOA.split(&mut clocks);
 63    let potentio = gpioa.pa0.into_analog(&mut gpioa.crl);
 64
 65    // Set ADC sample time for channel PA0 to default
 66    adc1.set_channel_sample_time(potentio.pin_id(), SampleTime::default());
 67
 68    // Enable ADC1_2 interrupts in the NVIC.
 69    unsafe {
 70        pac::NVIC::unmask(Interrupt::ADC1_2);
 71    }
 72
 73    // Access the ADC1 register pointer directly
 74    let adc_reg = unsafe { &*pac::ADC1::ptr() };
 75
 76    // Set the ADC channel to be converted. Here using PA0 which corresponds to channel 0.
 77    adc_reg
 78        .sqr3()
 79        .modify(|_, w| unsafe { w.sq1().bits(potentio.pin_id()) });
 80
 81    loop {
 82        // Send message to PC/laptop
 83        defmt::println!("Main program");
 84
 85        // CPU commands ADC1 to start conversion
 86        // by set bit SWSTART in CR2 register
 87        adc_reg.cr2().modify(|_, w| w.swstart().set_bit());
 88
 89        // Load value from global variable ADC_VALUE and calculate voltage
 90        let val = ADC_VALUE.load(Ordering::Relaxed);
 91        let voltage = (val as f32 / 4095.0) * 3.3;
 92
 93        // Send results to PC/laptop
 94        defmt::println!("Raw Avg: {} | Volt: {}V", val, voltage);
 95
 96        // Wait for 200ms
 97        delay.delay_ms(200);
 98    }
 99}
100
101// Interrupt Handler: function executed by the CPU when the ADC sends an interrupt signal.
102// Function name must match the interrupt vector: Since we use ADC1_2 interrupt, the name is ADC1_2.
103#[interrupt]
104fn ADC1_2() {
105    // Send message to PC/laptop
106    defmt::println!("Interrupt ADC");
107
108    // Access the ADC1 register pointer directly
109    let adc_reg = unsafe { &*pac::ADC1::ptr() };
110
111    // Check if ADC1 triggered an interrupt due to EOC (End of Conversion) or another event.
112    // If the EOC bit in the SR (Status Register) is set to 1, it means the ADC has finished
113    // the conversion."
114    if adc_reg.sr().read().eoc().bit_is_set() {
115        // Read value from ADC1 Data Register (DR)
116        let val = adc_reg.dr().read().bits();
117        // Store the ADC value into global variable ADC_VALUE
118        ADC_VALUE.store(val as i16, Ordering::Relaxed);
119    }
120}

The complete list of peripheral registers for the STM32F103C8 can be found in the reference manual.

Note: The ADC on the STM32F103C8 (Blue Pill) features a dedicated interrupt named ADC1_2 , which can be shared by both ADC1 and ADC2. This interrupt notifies the CPU when a specific event occurs within the ADC (such as eoc , jeoc , etc.).

Run the Rust program by entering the command ‘cargo run --bin single-conversion-interrupt’ in the VSCode terminal, then try turning the potentiometer knob. The following are the results of the single conversion interrupt analog input experiment:

Issues and Wrap Up

The following are the issues and insights encountered while creating this tutorial:

  • The stm32f1xx-hal crate does not provide methods for reading the ADC via interrupts: The solution is to directly access the ADC registers.

Single conversion DMA is the most efficient approach because it minimizes CPU overhead. In the Poll method, the CPU actively waits until the ADC conversion process finishes, whereas in the Interrupt method, triggering too many conversions per second will overwhelm the CPU with constant interrupts, leading to increased overhead.

The source code used in this tutorial can be accessed in this GitHub repository.

If you encounter any other issues, have questions, or would like to provide feedback and suggestions, please feel free to reach out to the author via the contact page.