Blue Pill (STM3F103C8): ADC Scan Mode with Rust

In previous articles , we discussed the ADC on the STM32F103C8, starting from an introduction to ADC, using single conversion mode, to continuous conversion mode . While in those previous articles we only read a single channel, in this tutorial, we will attempt to read multiple channels using scan mode.

Because Scan Mode reads multiple channels, the best way to implement it is by using the DMA (Direct Memory Access) peripheral. The DMA buffer will then be automatically filled with values from the specified channels. Reading directly from the DATA field in the DR register is not feasible, as that field will be automatically overwritten by the value of the subsequent channel.

Introduction to ADC Scan Mode on the Blue Pill (STM32F103C8)

In scan mode, the STM32F103C8 ADC converts a predefined list of multiple channels one by one and then stops. The CPU only needs to issue a single command to the ADC to start the conversion; the ADC then automatically converts from the first to the last channel within the specified channel group (the CPU no longer needs to command the ADC for each subsequent channel). The STM32F103C8 ADC in scan mode can also be combined with continuous conversion mode and discontinuous conversion mode:

  • Scan Mode with Continuous Conversion: When combined with scan mode, after the ADC finishes converting the last channel in the specified group, it will automatically restart the conversion from the first channel to the last, repeating this process continuously.
  • Scan Mode with Discontinuous Conversion: When combined with discontinuous conversion, the channel group is divided into several subgroups of a specified size. When the CPU commands the ADC to convert, it will process all channels from the first to the last within the first subgroup; a new command from the CPU is required to convert the next subgroup. However, if further combined with continuous conversion mode, the ADC will automatically proceed to the next subgroup upon finishing the first. Once the final subgroup is completed, it will restart from the first subgroup continuously. Upon the completion of each channel conversion in each subgroup, the ADC sets the EOC (End of Conversion) bit in the SR register to 1.

Hardware Preparation

The hardware used in this article remains the same as in previous ones: the Blue Pill board (STM32F103C8), an ST-Link USB Downloader/Debugger, potentiometers, a breadboard, and several jumper wires (female-to-female and male-to-male). however, since this tutorial covers reading multiple channels simultaneously, we will be using two potentiometers as inputs. You may use more potentiometers; you will simply need to adjust the circuit layout and the program code accordingly.

Analog Input Scan Mode on the Blue Pill (STM32F103C8) with Rust

In this tutorial, we will attempt to read the input voltage values from two potentiometers using two channels, PA0 and PA1. The resulting digital values will then be displayed on your PC or laptop terminal. This tutorial will cover standard scan mode and scan mode with continuous conversion. Scan mode with discontinuous conversion will be discussed in the next article.

Blue Pill (STM32F103C8) Circuit with Potentiometers for Scan Mode

We will continue to use potentiometers as voltage divider devices, which will act as sensors connected to the GPIO pins PA0 and PA1. Below is the schematic diagram used to test scan mode:

Potentiometer and STM32 Blue Pill Circuit for Analog Input using Scan Mode
Potentiometer and STM32 Blue Pill Circuit for Analog Input using Scan Mode

Please set up the circuit on the breadboard as shown in the schematic before we proceed to the programming stage for ADC scan mode. If you are using more potentiometers, please adjust the layout accordingly: connect Pin 1 of the potentiometer to GND , Pin 2 to the chosen ADC channel, and Pin 3 to 3.3V.

Programming the Blue Pill (STM32F103C8) for Analog Input using Scan Mode

First, let’s create a new Rust project as described in this article . Add a new binary named ‘scan-mode’: open the Cargo.toml file and fill it with the following code:

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

Next, open the src/main.rs file and change its content to the following code to inform the compiler that we are not using the standard library and that the program is not running on an operating system:

1#![no_std]
2#![no_main]

Define all the libraries we will be using by adding the following code:

 1use cortex_m_rt::entry;
 2use defmt_rtt as _;
 3use panic_probe as _;
 4
 5use stm32f1xx_hal::{
 6    adc::{self, Adc, SetChannels},
 7    gpio::{Analog, PA0, PA1},
 8    pac::{self, ADC1},
 9    prelude::*,
10    rcc::Config,
11    time::Hertz,
12};

The cortex_m_rt library (crate) defines the program’s entry point and handles the startup process. defmt_rtt is used to send logging data to a PC or laptop using the Real-Time Transfer protocol. The stm32f1xx_hal library allows us to access the STM32F103C8 microcontroller peripherals safely. Finally, panic_probe is used to handle runtime errors and will automatically transmit error logs to the host PC or laptop

Next, create a new struct to hold all the channels within a single group.

1struct AdcChannels(PA0<Analog>, PA1<Analog>);

Since we will be using pins PA0 and PA1, we are creating a tuple struct named AdcChannels with the fields PA0 and PA1 , both of which are of the Analog type. If you are using more channels, please add them here, or if you are using different channels please adjust them accordingly.

Implement the SetChannels trait for our created struct type to the ADC1-type peripheral.

1impl SetChannels<AdcChannels> for Adc<ADC1> {
2    fn set_samples(&mut self) {}
3
4    fn set_sequence(&mut self) {
5        self.set_regular_sequence(&[0, 1]);
6    }
7}

In the set_samples method we can configure the Sample Time for each individual channel, however here we are using the default sample time. In the set_sequence method, we define the order in which the channels will be converted or read. In this case, we are setting the sequence to Channel 0 followed by Channel 1, but you may adjust this according to your requirements."

Pro Tips: The ADC channel sequence can also be configured randomly or with repetitions. For example: [0, 1, 0, 0, 1, 0]; in this case, the ADC will convert from channel 0 to 1, then back to 0, and so on until the final channel (channel 0) is reached. We must also adjust the DMA buffer size to match the length of that channel sequence. For instance, in the example sequence [0, 1, 0, 0, 1, 0], the DMA buffer should be configured to hold 6 values rather than 2.

Create the main function as the program entry point (the starting point where the program begins execution):

1#[entry]
2fn main() -> ! {
3    // Here we can access peripherals,
4    // doing setup,
5    // and run main program loop
6}

Inside the main function, we will access the Blue Pill (STM32F103C8) peripherals, then set up the configuration for the clock , GPIO , and other components. Finally, we will write the primary program logic within the loop block.

Inside the main function, add the following code:

1defmt::println!("ADC Scan Mode");
2
3let dp = pac::Peripherals::take().unwrap();
4let cp = pac::CorePeripherals::take().unwrap();

The first line is used to send a message to the PC/laptop terminal while simultaneously marking the start of the scan mode program. The subsequent lines are used to access the STM32F103C8 microcontroller peripherals and the Cortex-M3 CPU peripherals (CorePeripherals), storing them in the variables dp and cp, respectively.

1let mut flash = dp.FLASH.constrain();
2let rcc = dp.RCC.constrain();

Accessing the Flash peripheral and the Reset & Clock Control (RCC) peripheral. The Flash peripheral is required to adjust the wait states during clock configuration. The RCC peripheral is used to configure the system clock of the STM32F103C8 microcontroller.

1let clock_cfgr = Config::default()
2        .use_hse(Hertz::MHz(8))
3        .sysclk(Hertz::MHz(72))
4        .hclk(Hertz::MHz(72))
5        .adcclk(Hertz::MHz(9));
6
7let mut clocks = rcc.freeze(clock_cfgr, &mut flash.acr);

Creating a clock configuration that uses an external clock ( use_hse ) with an 8 MHz frequency, setting the System clock ( sysclk ) to 72 MHz, the Bus clock ( hclk ) to 72 MHz, and the ADC clock ( adcclk ) to 9 MHz. These configurations are then applied to the RCC while simultaneously adjusting the flash memory wait states.

1let mut delay = cp.SYST.delay(&clocks.clocks);

Creating a delay instance using the System Timer to provide a time interval within the main program loop.

1 let mut gpioa = dp.GPIOA.split(&mut clocks);
2
3let potentio_1 = gpioa.pa0.into_analog(&mut gpioa.crl);
4let potentio_2 = gpioa.pa1.into_analog(&mut gpioa.crl);

Accessing the GPIO Port A peripheral and configuring pins PA0 and PA1 as analog inputs, which will be used to read the input voltage from the potentiometers.

1let dma = dp.DMA1.split(&mut clocks);

Accessing the DMA1 (Direct Memory Access) peripheral for later use with the ADC.

1let adc1 = adc::Adc::new(dp.ADC1, &mut clocks);

Accessing the ADC1 peripheral.

1let adc_channels = AdcChannels(potentio_1, potentio_2);
2
3let adc_scan_dma = adc1.with_scan_dma(adc_channels, dma.1);

Create a variable from the struct we previously defined, using the following fields: GPIO PA0 ( potentio_1 ) and PA1 ( potentio_2 ) as analog inputs. Next, use the with_scan_dma method to configure ADC1 for scan mode while simultaneously utilizing DMA channel 1.

Note: Only DMA channel 1 can be used with the ADC, and only the ADC1 peripheral is capable of using DMA. A more detailed discussion regarding DMA will be covered in the next article.

1let buf = cortex_m::singleton!(: [u16; 2] = [0; 2]).unwrap();
2
3let mut transfer = adc_scan_dma.read(buf);

Create a buffer to store the ADC conversion results. We are using two 16-bit unsigned integers because we are only using two channels (index 0 for the first channel and index 1 for the second); if you use more channels, please adjust the size accordingly. The next line commands the ADC to perform the conversion from the first channel through the second, which will then automatically use DMA to send the conversion results to the buffer we created.

Next, create a loop block, where our main code will be executed repeatedly.

1loop {
2    // main code
3}

The loop block, which will execute the main program code repeatedly.

Inside the loop block, add our main program code as follows:

 1let (recovered_buf, recovered_adc_dma) = transfer.wait();
 2
 3defmt::println!(
 4    "Potentio 1: {}, Potentio 2: {}",
 5    recovered_buf[0],
 6    recovered_buf[1]
 7);
 8
 9transfer = recovered_adc_dma.read(recovered_buf);
10delay.delay_ms(200u16);

The first line is used to read the ADC values from the DMA buffer once they have been populated. Next, those values are sent to the PC/laptop terminal. Then, the CPU commands the ADC to perform another conversion using the read method. The final line provides a 200 ms delay for each loop iteration.

Every time the CPU needs to read an ADC value, it must issue a command to the ADC to perform a conversion. To avoid this, scan mode can be combined with continuous conversion, as discussed below.

Show full code: ADC Scan Mode
 1#![no_std]
 2#![no_main]
 3
 4use cortex_m_rt::entry;
 5use defmt_rtt as _;
 6use panic_probe as _;
 7
 8use stm32f1xx_hal::{
 9    adc::{self, Adc, SetChannels},
10    gpio::{Analog, PA0, PA1},
11    pac::{self, ADC1},
12    prelude::*,
13    rcc::Config,
14    time::Hertz,
15};
16
17struct AdcChannels(PA0<Analog>, PA1<Analog>);
18
19impl SetChannels<AdcChannels> for Adc<ADC1> {
20    fn set_samples(&mut self) {}
21
22    fn set_sequence(&mut self) {
23        self.set_regular_sequence(&[0, 1]);
24    }
25}
26
27#[entry]
28fn main() -> ! {
29    defmt::println!("ADC Scan Mode");
30
31    let dp = pac::Peripherals::take().unwrap();
32
33    let cp = pac::CorePeripherals::take().unwrap();
34
35    let mut flash = dp.FLASH.constrain();
36
37    let rcc = dp.RCC.constrain();
38
39    let clock_cfgr = Config::default()
40        .use_hse(Hertz::MHz(8))
41        .sysclk(Hertz::MHz(72))
42        .hclk(Hertz::MHz(72))
43        .adcclk(Hertz::MHz(9));
44
45    let mut clocks = rcc.freeze(clock_cfgr, &mut flash.acr);
46
47    let mut delay = cp.SYST.delay(&clocks.clocks);
48
49    let mut gpioa = dp.GPIOA.split(&mut clocks);
50
51    let potentio_1 = gpioa.pa0.into_analog(&mut gpioa.crl);
52    let potentio_2 = gpioa.pa1.into_analog(&mut gpioa.crl);
53
54    let dma = dp.DMA1.split(&mut clocks);
55
56    let adc1 = adc::Adc::new(dp.ADC1, &mut clocks);
57
58    let adc_channels = AdcChannels(potentio_1, potentio_2);
59
60    let adc_scan_dma = adc1.with_scan_dma(adc_channels, dma.1);
61
62    let buf = cortex_m::singleton!(: [u16; 2] = [0; 2]).unwrap();
63
64    let mut transfer = adc_scan_dma.read(buf);
65
66    loop {
67        let (recovered_buf, recovered_adc_dma) = transfer.wait();
68
69        defmt::println!(
70            "Potentio 1: {}, Potentio 2: {}",
71            recovered_buf[0],
72            recovered_buf[1]
73        );
74
75        transfer = recovered_adc_dma.read(recovered_buf);
76        delay.delay_ms(200u16);
77    }
78}

Run the program using the command ‘cargo run --bin scan-mode’ in the VSCode terminal. The following is the output of the program:

The experimental results show that as soon as we turn the knob on potentiometer 1, the ADC value on channel 0 (PA0) changes accordingly; similarly, when the knob on potentiometer 2 is turned, the ADC value for channel 1 (PA1) also changes.

Programming the Blue Pill (STM32F103C8) for Analog Input using Scan Continuous Mode

In this section, we will combine scan mode with continuous conversion, so the CPU does not need to repeatedly command the ADC to perform conversions within the loop.

First, let’s define a new binary executable named ‘scan-mode-continuous’ in our existing Rust project:

1[[bin]]
2name = "scan-mode-continuous"
3path = "src/scan_mode_continuous.rs"
4test = false
5bench = false

Create the file src/scan_mode_continuous.rs. The code we will use in this section is identical to the previous one, with the differences being the implementation of the SetChannels trait, the creation of the DMA buffer, the delay process, and the method of reading the ADC values.

The following is the code for the implementation of the SetChannels trait:

 1struct AdcChannels(PA0<Analog>, PA1<Analog>);
 2
 3impl SetChannels<AdcChannels> for Adc<ADC1> {
 4    fn set_samples(&mut self) {
 5        self.set_channel_sample_time(0, adc::SampleTime::T_239);
 6        self.set_channel_sample_time(1, adc::SampleTime::T_239);
 7    }
 8
 9    fn set_sequence(&mut self) {
10        self.set_regular_sequence(&[0, 1]);
11        self.set_continuous_mode(true);
12    }
13}

In the set_samples method, we set the sample time for each channel to 239.5 cycles. Meanwhile, in the set_sequence method, we add the code to enable continuous conversion mode.

Creating a DMA buffer to store the ADC values.

1let buffer = cortex_m::singleton!(: [[u16; 200]; 2] = [[0; 200]; 2]).unwrap();

Since we will be using the circular_read method, we need an array consisting of two sections: a first part and a second part. When the CPU reads the first part, the DMA will simultaneously fill the second part. We have configured each section as a 16-bit unsigned integer array with a length of 200, where 100 slots are allocated for each channel. If you use more channels, please adjust the length accordingly. In each section (both first and second), the array will be populated with interleaved ADC values following the sequence defined in the set_sequence method, as follows: [channel0, channel1, channeln, ..., channel0, channel1, channeln].

Preparing the System Timer as a counter to manage the timing for sending ADC results to the PC/laptop terminal.

1let mut counter = cp.SYST.counter_us(&clocks.clocks);
2let _ = counter.start(230_000.micros()).unwrap();
3let mut last_update_time = counter.now();
4let interval = 200_000u32.micros::<1, 1_000_000>();

Creating a counter to be used as a delay, because blocking delays cannot be used when implementing circular_read. We are using the System Timer to create a counter that increments every microsecond with a maximum value of 230, 000 microseconds (230 ms). We also need a last_update_time variable to store the previous counter value and an interval variable to define the delay duration, which we have set to 200, 000 microseconds (200 ms).

Note: We cannot use blocking delays when using circular_read because it would affect the timing of the DMA buffer reads, making the system prone to overrun errors.

The process of reading ADC values:

 1let mut circ_buffer = adc_scan_dma.circ_read(buffer);
 2loop {
 3    if let Ok(_) = circ_buffer.readable_half() {
 4        // average value of each channel
 5        let avgs = circ_buffer.peek(|data, _h| {
 6            let mut sum_ch0 = 0u32;
 7            let mut sum_ch1 = 0u32;
 8
 9            // Data inside buffer: [ch0, ch1, ch2, ch0, ch1, ch2, ...]
10            for chunk in data.chunks_exact(2) {
11                sum_ch0 += chunk[0] as u32;
12                sum_ch1 += chunk[1] as u32;
13            }
14
15            let len = (data.len() / 2) as u32;
16            [(sum_ch0 / len) as u16, (sum_ch1 / len) as u16]
17        });
18
19        // Send ADC value to the PC/laptop
20        // ...
21
22    }
23}

The first line commands the ADC to perform the conversion and uses the DMA to store the values into the DMA buffer. Next, it checks the buffer status; if a portion is ready to be read (either the first or second half), it calculates the average value for each channel. As previously explained, the ADC values in the DMA buffer are interleaved according to the defined channel sequence. Therefore, we use the chunks_exact method to retrieve these interleaved values, please adjust the parameters based on the number of channels you are using. Also, adjust the divisor when calculating the len value to match your channel count; here, we use (data.len() / 2) because we are only using two channels.

Sending ADC values to the PC/laptop terminal:

1let now = counter.now();
2
3if last_update_time.ticks().wrapping_sub(now.ticks()) >= interval.ticks() {
4    if let Ok(a) = avgs {
5        defmt::println!("Potentio 1: {} | Potentio 2: {}", a[0], a[1],);
6    }
7    last_update_time = now;
8}

First, we store the current counter value in the now variable. Then, we subtract last_update_time from now ; if the result is greater than the interval , the ADC values will be sent to the PC/laptop terminal. This way, the program will send data to the terminal every 200000 microseconds (200 ms). Finally, update the last_update_time variable with the value of now .

By using continuous conversion mode, the CPU only needs to command the ADC to perform a conversion once. Thereafter, the ADC will automatically perform conversions repeatedly.

Show full code: ADC Scan Mode with Continuous Conversion
  1#![no_std]
  2#![no_main]
  3
  4use cortex_m_rt::entry;
  5use defmt_rtt as _;
  6use panic_probe as _;
  7
  8use stm32f1xx_hal::{
  9    adc::{self, Adc, SetChannels},
 10    gpio::{Analog, PA0, PA1},
 11    pac::{self, ADC1},
 12    prelude::*,
 13    rcc::Config,
 14    time::Hertz,
 15};
 16
 17struct AdcChannels(PA0<Analog>, PA1<Analog>);
 18
 19impl SetChannels<AdcChannels> for Adc<ADC1> {
 20    fn set_samples(&mut self) {
 21        self.set_channel_sample_time(0, adc::SampleTime::T_239);
 22        self.set_channel_sample_time(1, adc::SampleTime::T_239);
 23    }
 24
 25    fn set_sequence(&mut self) {
 26        self.set_regular_sequence(&[0, 1]);
 27        self.set_continuous_mode(true);
 28    }
 29}
 30
 31#[entry]
 32fn main() -> ! {
 33    defmt::println!("ADC Scan Mode Continuous");
 34
 35    let dp = pac::Peripherals::take().unwrap();
 36
 37    let cp = pac::CorePeripherals::take().unwrap();
 38
 39    let mut flash = dp.FLASH.constrain();
 40
 41    let rcc = dp.RCC.constrain();
 42
 43    let clock_cfgr = Config::default()
 44        .use_hse(Hertz::MHz(8))
 45        .sysclk(Hertz::MHz(72))
 46        .hclk(Hertz::MHz(72))
 47        .adcclk(Hertz::MHz(9));
 48
 49    let mut clocks = rcc.freeze(clock_cfgr, &mut flash.acr);
 50
 51    let mut gpioa = dp.GPIOA.split(&mut clocks);
 52
 53    let potentio_1 = gpioa.pa0.into_analog(&mut gpioa.crl);
 54    let potentio_2 = gpioa.pa1.into_analog(&mut gpioa.crl);
 55
 56    let dma = dp.DMA1.split(&mut clocks);
 57
 58    let adc1 = adc::Adc::new(dp.ADC1, &mut clocks);
 59
 60    let adc_channels = AdcChannels(potentio_1, potentio_2);
 61
 62    let adc_scan_dma = adc1.with_scan_dma(adc_channels, dma.1);
 63
 64    let buffer = cortex_m::singleton!(: [[u16; 200]; 2] = [[0; 200]; 2]).unwrap();
 65
 66    let mut circ_buffer = adc_scan_dma.circ_read(buffer);
 67
 68    let mut counter = cp.SYST.counter_us(&clocks.clocks);
 69    let _ = counter.start(230_000.micros()).unwrap();
 70    let mut last_update_time = counter.now();
 71    let interval = 200_000u32.micros::<1, 1_000_000>();
 72
 73    loop {
 74        if let Ok(_) = circ_buffer.readable_half() {
 75            // nilai rata-rata untuk masing-masing channel
 76            let avgs = circ_buffer.peek(|data, _h| {
 77                let mut sum_ch0 = 0u32;
 78                let mut sum_ch1 = 0u32;
 79
 80                // Data tersimpan dalam buffer: [ch0, ch1, ch2, ch0, ch1, ch2, ...]
 81                for chunk in data.chunks_exact(2) {
 82                    sum_ch0 += chunk[0] as u32;
 83                    sum_ch1 += chunk[1] as u32;
 84                }
 85
 86                let len = (data.len() / 2) as u32;
 87                [(sum_ch0 / len) as u16, (sum_ch1 / len) as u16]
 88            });
 89
 90            let now = counter.now();
 91
 92            if last_update_time.ticks().wrapping_sub(now.ticks()) >= interval.ticks() {
 93                if let Ok(a) = avgs {
 94                    defmt::println!("Potentio 1: {} | Potentio 2: {}", a[0], a[1],);
 95                }
 96                last_update_time = now;
 97            }
 98        }
 99    }
100}

Run the program using the command ‘cargo run --bin scan-mode-continuous’ in the VSCode terminal. The following are our experimental results:

Source Code and Conclusion

The best way to read multichannel ADC values on the Blue Pill (STM32F103C8) is by using scan mode combined with the DMA (Direct Memory Access) peripheral. Furthermore, scan mode can also be combined with continuous conversion mode, so the CPU does not need to be occupied with repeatedly commanding the ADC to perform conversions.

The source code used in this article can be found on the Github repository .

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