ADC — Reading Analog Voltages
The real world is analog. Temperature is not 25 or 26 degrees — it is 25.37 degrees. A battery does not suddenly go from "full" to "empty" — its voltage droops gradually. A joystick does not just point "left" or "right" — it has a smooth range of positions. To work with these real-world signals, you need an ADC — an Analog-to-Digital Converter.
How an ADC Works
An ADC measures a voltage and converts it to a number. That is really all it does.
On most STM32 chips, the ADC measures voltages between 0V and 3.3V (the reference voltage). It outputs a number between 0 and some maximum value that depends on the resolution:
| Resolution | Max Value | Voltage per Step | Typical Use |
|---|---|---|---|
| 8-bit | 255 | 12.94 mV | Quick and dirty, low-precision |
| 10-bit | 1,023 | 3.23 mV | Basic sensors |
| 12-bit | 4,095 | 0.81 mV | Most STM32 families (F1, F4, G0) |
| 16-bit | 65,535 | 0.05 mV | STM32H7 (high precision) |
The formula to convert a reading to voltage is:
voltage = (reading / max_value) x reference_voltage
For a 12-bit ADC on a 3.3V system:
voltage = (reading / 4095) x 3.3
For the H743 at 16-bit resolution:
voltage = (reading / 65535) x 3.3
💡 Fun Fact: A 16-bit ADC can distinguish voltage differences of about 50 microvolts. That is roughly the voltage generated by the thermoelectric effect of touching two different metals together. At this resolution, even PCB trace layout and component placement start to matter.
ADC in Embassy
Basic Reading
Embassy makes ADC straightforward. Here is the simplest possible example — read one pin, print the value:
use embassy_stm32::adc::{Adc, SampleTime}; #[embassy_executor::main] async fn main(_spawner: Spawner) { let p = embassy_stm32::init(Default::default()); let mut adc = Adc::new(p.ADC1); // Some STM32 families need a brief delay for the ADC to stabilize Timer::after_millis(1).await; let mut pin = p.PA0; // Analog input pin loop { let raw = adc.blocking_read(&mut pin); let voltage = (raw as f32 / 4095.0) * 3.3; defmt::info!("Raw: {}, Voltage: {:.3}V", raw, voltage); Timer::after_millis(100).await; } }
Sampling Time
The ADC does not measure instantaneously — it needs time to "sample" the voltage. STM32 ADCs let you configure the sampling time, which is the number of ADC clock cycles spent measuring the input before converting:
| Sampling Time | When to Use |
|---|---|
| Short (1.5 - 7.5 cycles) | Fast signals, low-impedance sources |
| Medium (28.5 - 56 cycles) | General purpose |
| Long (239.5 - 640 cycles) | High-impedance sources, precision measurements |
A longer sampling time gives the ADC's internal capacitor more time to charge to the input voltage, producing more accurate readings — at the cost of speed.
#![allow(unused)] fn main() { // Configure sampling time on a per-channel basis adc.set_sample_time(SampleTime::CYCLES239_5); }
🧠 Think About It: Why would a high-impedance source (like a voltage divider with large resistors) need a longer sampling time? Think about RC time constants — the ADC's internal sampling capacitor needs to be charged through the source impedance.
Reading the Internal Temperature Sensor
Every STM32 has an internal temperature sensor connected to the ADC. It is not very accurate (plus or minus 3 degrees Celsius typical), but it is free and requires no external components:
#![allow(unused)] fn main() { use embassy_stm32::adc::Temperature; let mut adc = Adc::new(p.ADC1); let mut temp_channel = Temperature; // Internal channels typically need longer sampling time adc.set_sample_time(SampleTime::CYCLES239_5); let raw = adc.blocking_read(&mut temp_channel); // The conversion formula varies by STM32 family — check the datasheet // For STM32F4: temp_celsius = ((raw - V25) / avg_slope) + 25 // V25 and avg_slope are in the datasheet's electrical characteristics defmt::info!("Internal temp raw: {}", raw); }
Practical: Battery Monitoring with a Voltage Divider
Here is a real-world problem. You have a 12V LiPo battery powering your robot, and you want to monitor its voltage. But the ADC can only read up to 3.3V. If you connect 12V directly to the ADC pin, you will destroy it.
The solution is a voltage divider — two resistors that scale the voltage down to a safe range.
Battery (12V) ──── [R1 = 10k] ──┬── [R2 = 3.3k] ──── GND
│
ADC Pin (reads here)
The voltage at the ADC pin is:
V_adc = V_battery x R2 / (R1 + R2)
V_adc = 12V x 3300 / (10000 + 3300)
V_adc = 12V x 0.248
V_adc = 2.98V (safely under 3.3V)
To convert back from ADC reading to battery voltage:
V_battery = V_adc x (R1 + R2) / R2
V_battery = V_adc x 4.03
Here is the code:
#![allow(unused)] fn main() { const DIVIDER_RATIO: f32 = (10_000.0 + 3_300.0) / 3_300.0; // 4.03 loop { let raw = adc.blocking_read(&mut battery_pin); let v_adc = (raw as f32 / 4095.0) * 3.3; let v_battery = v_adc * DIVIDER_RATIO; defmt::info!("Battery: {:.2}V", v_battery); if v_battery < 10.5 { defmt::warn!("LOW BATTERY! {:.2}V", v_battery); // Trigger low-battery alarm, land the drone, etc. } Timer::after_millis(500).await; } }
💡 Fun Fact: The resistor values 10k and 3.3k are chosen because 3.3k is a standard E24 series value and the ratio gives a comfortable margin below the 3.3V maximum. For a 4S LiPo (16.8V max), you might use 10k + 2.2k instead, giving V_adc = 16.8 x 0.18 = 3.03V.
Practical: Reading a Potentiometer
A potentiometer (knob) is the simplest analog input — it outputs a voltage between 0V and 3.3V depending on its position. Wire the three pins: one outer leg to 3.3V, the other to GND, and the middle (wiper) to an ADC pin.
#![allow(unused)] fn main() { loop { let raw = adc.blocking_read(&mut pot_pin); // Map to a 0-100% range let percent = (raw as f32 / 4095.0) * 100.0; // Or map to a servo angle (0-180 degrees) let angle = (raw as f32 / 4095.0) * 180.0; defmt::info!("Pot: {:.1}% Servo: {:.1} deg", percent, angle); Timer::after_millis(50).await; } }
Noise and Accuracy
Raw ADC readings are noisy. You will see the value jitter by a few counts even when the input voltage is perfectly stable. Here are common techniques to deal with this:
Averaging: Read multiple samples and average them.
#![allow(unused)] fn main() { fn read_averaged(adc: &mut Adc, pin: &mut impl AdcPin, samples: u32) -> u16 { let mut sum: u32 = 0; for _ in 0..samples { sum += adc.blocking_read(pin) as u32; } (sum / samples) as u16 } let stable_reading = read_averaged(&mut adc, &mut battery_pin, 16); }
Decoupling capacitor: Place a 100nF ceramic capacitor between the ADC pin and GND, as close to the MCU as possible. This filters high-frequency noise before it reaches the ADC.
Reference voltage: The ADC's accuracy is only as good as its reference voltage. Most STM32s use VDDA (analog supply voltage) as the reference. If VDDA is noisy, all your readings will be noisy. Use proper decoupling on the VDDA pin.
🧠 Think About It: If you average 16 samples, you reduce random noise by a factor of 4 (the square root of 16). This is called oversampling. Some STM32 ADCs have hardware oversampling built in — check if your chip supports it before writing your own averaging loop.
Summary
The ADC is your bridge between the analog real world and your digital code. The core idea is simple — voltage in, number out. But the details matter: resolution determines your precision, sampling time affects accuracy, voltage dividers let you measure higher voltages safely, and averaging tames noise.
For most projects, 12-bit resolution at 400 kHz sampling rate with a simple software average is more than enough. Do not overthink it — read the pin, convert to voltage, and move on. You can always add sophistication later.
Next chapter, we will look at DMA — the hardware feature that lets your ADC (and UART, and SPI) run autonomously without bothering the CPU.