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

Oftentimes, when using a microcontroller, we need to measure input voltage levels beyond just HIGH and LOW. For instance, when using a humidity sensor, we must use an Analog Input to convert the voltage level into a digital value, which is then calculated to determine the actual humidity percentage. If we were to use standard Digital Inputs, as shown in the previous articles, we would only receive HIGH or LOW values without knowing the true, precise level of humidity.

1. 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 input voltage of 3.3V. When an STM32F103C8 pin is configured as an analog input, it is connected to the Analog to Digital Converter (ADC) peripheral, which converts the input voltage into a digital value. The STM32F103C8 is equipped with two 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 result in a digital value change of 1). The ADC frequency of the STM32F103C8 can be configured up to 14MHz.

1.1 Modes and Features of the ADC STM32F103C8

The STM32F103C8 ADC can operate in four different modes:

  • Single-shot mode: The ADC performs a single conversion on a selected channel and then stops. This mode is typically used for reading sensors with values that do not change rapidly, such as temperature or humidity sensors.
  • Scan mode: The ADC scans and converts a predefined list of channels one by one before stopping. Scan mode is usually combined with DMA (Direct Memory Access) to prevent data from being overwritten in the same register. This is used for monitoring multiple sensors simultaneously and synchronously.
  • Continuous mode: The ADC performs conversions repeatedly and continuously without requiring further commands from the CPU.
  • Discontinuous Conversion Mode: This mode divides a channel group into smaller subgroups within Scan Mode. The ADC will convert the channels from the first subgroup through to the last channel of the final subgroup. At the end of each subgroup conversion, the ADC sets the eoc (End of Conversion) flag to 1.

The advanced features of the STM32F103C8 ADC include:

  • Simultaneous Sample and Hold: Utilizing two ADC units simultaneously (in parallel). This allows us to sample two different channels (e.g., Channel 1 to ADC1 and Channel 2 to ADC2) at the exact same moment.
  • Interleaved Sample and Hold: ADC1 and ADC2 units work alternately to read or convert a single channel. This mode effectively doubles the ADC sampling rate. It is typically used for high-frequency signal acquisition.
  • Single Shunt: Reading or converting a channel with a very small input voltage (millivolts). This is commonly used for current monitoring by measuring the voltage drop across a shunt resistor.

1.2 ADC Peripheral Support on STM32F103C8

We can also integrate the STM32F103C8 ADC with other peripherals, such as:

  • ADC with DMA (Direct Memory Access): The ADC sends conversion results directly to the RAM without CPU intervention, significantly reducing CPU overhead. The CPU only needs to read the conversion data from RAM once the process is complete.
  • ADC with Analog Watchdog: The ADC conversion values are monitored by the Analog Watchdog. If the values fall outside a predefined upper or lower threshold, the Analog Watchdog will trigger an interrupt.
  • ADC with Timer: The ADC performs conversions at specific intervals triggered by a predefined Timer

2. Hardware Preparation

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

2.1 Potentiometer

A potentiometer is a variable resistor that has three terminals. Unlike a standard resistor which has a fixed resistance value, a potentiometer allows us to manually adjust its resistance by turning its lever or knob. In this tutorial, we will be using 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: The 5kΩ value is ideal for this experiment as it provides a stable voltage divider without drawing too much current from the STM32’s 3.3V rail. The middle pin (wiper) will provide a variable voltage between 0V and 3.3V, which the ADC will then convert into a digital value from 0 to 4095.

3. Using Analog Input on Blue Pill(STM32F103C8) with Rust

In this tutorial, we will focus on how to use the ADC in single conversion mode. The use of continuous conversion, scan mode, and other STM32F103C8 ADC features will be discussed in further detail in upcoming articles. There are several methods available for using the Blue Pill’s ADC, including Poll, Direct Memory Access (DMA), and Interrupt. The following are the types of single conversion mode methods for the STM32F103C8 ADC:

  • Single Conversion Poll: The ADC will only perform a conversion when commanded by the CPU, and then the CPU waits for the conversion result to be read. After that, the ADC will not perform another conversion until it receives another command from the CPU. This method is suitable when we do not need to read the sensor frequently or only need to read it at specific times.
  • Single Conversion DMA: The ADC performs a conversion when commanded by the CPU, and the result is then sent directly to the RAM (DMA buffer). During the conversion process, the CPU can perform other tasks; once the DMA buffer is filled, the CPU reads the result from the buffer. After that, the ADC stops until commanded again by the CPU. If configured correctly, this method significantly reduces the CPU load. This method is typically used for fast and continuous sensor reading.
  • Single Conversion Interrupt: The ADC performs a conversion when commanded by the CPU. Once the conversion is complete, the ADC notifies the CPU, which then temporarily pauses its main program to execute the ISR (Interrupt Service Routine). After that, the ADC will not perform another conversion until commanded again. If too many conversions are performed per second using this method, the frequent interrupts will instead increase the CPU load. This method is generally used for reading emergency or critical sensors.

We will attempt to read the input voltage from a potentiometer, and its corresponding digital value will then be displayed on a PC/laptop terminal. For this guide, we will be using GPIO pin PA0 as the analog input.

3.1 Blue Pill (STM32F103C8) and Potentiometer Circuit

We will utilize the potentiometer as a voltage divider that functions like a sensor. Let’s gather the necessary components and begin 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 STM32F103C8 with Rust article. Open the Cargo.toml file and insert the following code to define new binary:

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:

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

On the STM32F103C8, only ADC1 can be used with DMA, while ADC2 does not support it. To use DMA, we must initialize the DMA peripheral before it can be utilized. A detailed explanation regarding DMA (Direct Memory Access) will be covered in our next article.

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:

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

Since the stm32f1xx_hal crate does not provide a direct method for reading the ADC via interrupts, we will dive deeper into the low-level access of ADC registers to enable interrupt functionality. We will keep this program as simple as possible for better understanding. A detailed explanation regarding interrupts will be covered in our next 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 datahseet.

Note: The ADC on the STM32F103C8 (Blue Pill) features a dedicated interrupt called ADC1_2 , which is shared by both ADC1 and ADC2. This interrupt notifies the CPU whenever a specific event occurs within the ADC, such as EOC (End of Conversion), JEOC (Injected End of Conversion), and others.

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

Here are some of the challenges we faced during the creation of this tutorial:

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

Single conversion DMA is the most efficient method because it does not heavily burden the CPU. In the Poll method, the CPU must wait until the ADC conversion process is complete, whereas in the Interrupt method, performing too many conversions per second will cause the CPU to be constantly interrupted, which actually increases the CPU load

The source code used in this tutorial is available and can be accessed on the GitHub repository.

If you encounter any other issues, have questions, or would like to provide feedback and suggestions, please feel free to contact us through our contact page.