Build a Jitter-Free STM32 ADC Sampler With Timer and DMA

by LokiZhan in Circuits > Microcontrollers

41 Views, 0 Favorites, 0 Comments

Build a Jitter-Free STM32 ADC Sampler With Timer and DMA

ChatGPT Image Sep 14, 2026, 04_51_51 PM.png

Reading an ADC inside a while() loop works fine when all you need is the occasional potentiometer or temperature reading.

It becomes a problem once timing actually matters.

For waveform capture, vibration measurement, digital filtering, or control loops, I want every ADC conversion to begin at a predictable instant. I also do not want the CPU spending time moving every sample into memory.

The setup in this project is:

TIM2 → ADC1 → DMA → RAM

TIM2 defines exactly when a sample starts. ADC1 performs the conversion. DMA moves the result into a circular buffer. The CPU only deals with completed blocks of samples.

For this example, I am using an STM32F446 and sampling a potentiometer on PA0 at 10,000 samples per second.

Supplies

You will need:

  1. An STM32F446-based development board
  2. STM32CubeIDE with CubeMX / Device Configuration Tool
  3. ST-Link or the programming interface used by your board
  4. 10 kΩ potentiometer
  5. Breadboard and jumper wires
  6. USB cable
  7. Oscilloscope or logic analyzer, optional but useful for verification

Potentiometer Connection

Connect the potentiometer as follows:

  1. One outer pin → 3.3 V
  2. Other outer pin → GND
  3. Centre/wiper pin → PA0 / ADC1_IN0

Do not apply a voltage outside the ADC input range.

Why Not Sample the ADC in Software?

A basic STM32 ADC loop often looks something like this:

HAL_ADC_Start(&hadc1);
HAL_ADC_PollForConversion(&hadc1, HAL_MAX_DELAY);
sample = HAL_ADC_GetValue(&hadc1);

There is nothing inherently wrong with this approach for slow measurements.

The problem is timing.

The next conversion depends on whatever else the processor is doing. Interrupts, logging, RTOS scheduling, flash access, communication tasks, or simply additional code inside the loop can shift the actual sampling instant.

The average sample rate may still look correct, but individual samples are no longer guaranteed to be evenly spaced.

For a waveform or FFT input, that matters.

Instead of asking the CPU to decide when each conversion happens, we can give that job to a hardware timer.

Set the Sampling Rate

I want a sample rate of:

10 ksample/s

In this example, TIM2 receives a 90 MHz timer clock.

The timer update frequency is:

f_sample = f_TIM2 / ((PSC + 1) × (ARR + 1))

Choose:

PSC = 89
ARR = 99

The prescaler reduces the 90 MHz timer clock to:

90 MHz / (89 + 1) = 1 MHz

The auto-reload register then gives:

1 MHz / (99 + 1) = 10 kHz

So TIM2 generates one update event every:

100 µs

That update event will become the ADC trigger.

Do not simply copy these values to another STM32 project. Check the actual timer input clock in CubeMX first and recalculate PSC and ARR if necessary.

Check That the ADC Is Fast Enough

The timer determines when a conversion starts, but the ADC still needs enough time to finish before the next trigger arrives.

For this STM32F4 example:

  1. ADC clock = 22.5 MHz
  2. Sampling time = 15 ADC cycles
  3. 12-bit conversion processing = 12 cycles

Total conversion time is approximately:

Tconv = (15 + 12) / 22.5 MHz

which gives:

Tconv ≈ 1.2 µs

Our timer triggers every:

100 µs

So there is plenty of margin.

This becomes more important when using multiple ADC channels. If one timer event starts a sequence of four channels, the complete sequence must finish before the next trigger.

Configure ADC1 in CubeMX

Open the Device Configuration Tool in STM32CubeIDE.

Enable:

ADC1 → IN0 / PA0

Use these settings:

Resolution: 12 bits
Scan Conversion Mode: Disabled
Continuous Conversion Mode: Disabled
External Trigger Source: TIM2 TRGO
External Trigger Edge: Rising Edge
Data Alignment: Right
DMA Continuous Requests: Enabled
Number of Conversions: 1
Sampling Time: 15 cycles

The important setting here is:

Continuous Conversion Mode = Disabled

The ADC should perform one conversion only when TIM2 sends a trigger.

If continuous mode is enabled, the ADC can start free-running after the first trigger, which defeats the purpose of hardware-controlled sample timing.

Your generated ADC initialization should contain settings similar to:

hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4;
hadc1.Init.Resolution = ADC_RESOLUTION_12B;
hadc1.Init.ScanConvMode = DISABLE;
hadc1.Init.ContinuousConvMode = DISABLE;
hadc1.Init.DiscontinuousConvMode = DISABLE;
hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_RISING;
hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO;
hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT;
hadc1.Init.NbrOfConversion = 1;
hadc1.Init.DMAContinuousRequests = ENABLE;
hadc1.Init.EOCSelection = ADC_EOC_SINGLE_CONV;


Configure DMA

Add DMA for ADC1.

Configure it as:

Direction: Peripheral to Memory
Mode: Circular
Peripheral Increment: Disabled
Memory Increment: Enabled
Peripheral Data Width: Half Word
Memory Data Width: Half Word
Priority: High

The ADC result is 12 bits, while the ADC data register can conveniently be transferred as a 16-bit value.

That means a uint16_t buffer works well:

#define ADC_BUFFER_LENGTH 512U
#define ADC_HALF_LENGTH (ADC_BUFFER_LENGTH / 2U)

static uint16_t adc_buffer[ADC_BUFFER_LENGTH];

Circular mode is important because DMA will continuously reuse the buffer without requiring the CPU to restart each transfer.

Configure TIM2 TRGO

Configure TIM2 as a basic timer.

Use:

Prescaler: 89
Counter Period: 99
Counter Mode: Up
Clock Division: DIV1

Now open:

TIM2 → Master Configuration

Set:

Master Output Trigger = Update Event

This causes each TIM2 update event to generate the internal TRGO signal.

ADC1 listens for that signal and starts one conversion.

The relevant initialization looks like this:

TIM_MasterConfigTypeDef sMasterConfig = {0};

htim2.Init.Prescaler = 89;
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 99;
htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;

sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE;
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;

HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig);


Start DMA Before Starting the Timer

Once CubeMX has generated the initialization code, start the acquisition system.

The order matters.

First arm ADC + DMA:

if (HAL_ADC_Start_DMA(&hadc1,
(uint32_t *)adc_buffer,
ADC_BUFFER_LENGTH) != HAL_OK)
{
Error_Handler();
}

Then start TIM2:

if (HAL_TIM_Base_Start(&htim2) != HAL_OK)
{
Error_Handler();
}

So the complete startup sequence is:

HAL_ADC_Start_DMA(&hadc1,
(uint32_t *)adc_buffer,
ADC_BUFFER_LENGTH);

HAL_TIM_Base_Start(&htim2);

DMA is now ready before the first timer trigger arrives.

Because ADC1 uses an external trigger, starting DMA does not immediately begin continuous sampling. Nothing happens until TIM2 starts generating TRGO events.

This avoids losing the first samples while DMA is still being configured.

Use the Circular Buffer in Two Halves

A 512-sample buffer works well because we can divide it into two blocks:

Samples 0–255 → First half
Samples 256–511 → Second half

While DMA writes into one half, the application can process the other.

At 10 ksample/s:

256 samples / 10,000 samples/s = 25.6 ms

So the software gets roughly 25.6 ms to process each half-buffer before DMA returns to it.

I use counters rather than simple boolean flags:

static volatile uint32_t adc_half_count = 0;
static volatile uint32_t adc_full_count = 0;

Then use the HAL callbacks:

void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
if (hadc->Instance == ADC1)
{
adc_half_count++;
}
}

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
if (hadc->Instance == ADC1)
{
adc_full_count++;
}
}

Keep these callbacks short.

Do not put:

printf()
filesystem writes
network transfers
long filters
FFT processing
blocking RTOS calls

inside the DMA interrupt.

The callback should only indicate that data is ready.

Process the Samples Outside the Interrupt

The main loop can process whichever half DMA has finished with:

static uint32_t processed_half_count = 0;
static uint32_t processed_full_count = 0;

while (1)
{
if (processed_half_count != adc_half_count)
{
processed_half_count = adc_half_count;

process_samples(&adc_buffer[0],
ADC_HALF_LENGTH);
}

if (processed_full_count != adc_full_count)
{
processed_full_count = adc_full_count;

process_samples(&adc_buffer[ADC_HALF_LENGTH],
ADC_HALF_LENGTH);
}

/* Other application work */
}

Using counters has one useful advantage.

If adc_half_count advances more than once before the foreground code catches up, you know the processing code missed its deadline.

That is much easier to diagnose than silently overwriting a boolean flag.

Convert the ADC Samples to Voltage

For a quick test, calculate the average of each block:

static void process_samples(const uint16_t *samples,
uint32_t count)
{
uint32_t sum = 0;

for (uint32_t i = 0; i < count; i++)
{
sum += samples[i];
}

float average = (float)sum / (float)count;

float voltage = average * 3.3f / 4095.0f;

consume_measurement(average, voltage);
}

A 12-bit ADC has values from:

0 to 4095

If the ADC reference is exactly 3.3 V:

Voltage = ADC_Code × 3.3 / 4095

But for a real measurement system, do not assume that your 3.3 V rail is an exact reference.

If VDDA is 3.26 V instead of 3.30 V, that difference becomes a gain error in every calculated voltage.

For accurate measurements, measure or otherwise characterize the ADC reference.

Check Source Impedance

Hardware triggering fixes sampling timing.

It does not guarantee ADC accuracy.

The STM32 ADC contains an internal sampling capacitor that must charge to the input voltage during the selected sampling period.

A low-impedance source such as a potentiometer or op-amp can usually charge it quickly.

A high-resistance sensor divider may not.

If the ADC input has not settled before conversion begins, the timing can be perfect while the ADC value is still wrong.

Possible fixes include:

  1. Increase ADC sampling time
  2. Reduce source impedance
  3. Add an op-amp buffer
  4. Redesign the analog input network

Do not choose the shortest ADC sampling time just because it gives the highest theoretical sample rate.

Verify the Actual Sample Rate

Do not stop once the code compiles.

Measure it.

One simple method is to toggle a spare GPIO from a timer or processing event and observe it with an oscilloscope or logic analyzer.

At 10 ksample/s, the expected sample interval is:

100 µs

If your measured rate does not match the calculation, check the STM32 clock tree first.

STM32 timers can receive a multiplied timer clock depending on the APB prescaler configuration, so assuming the timer runs at the APB peripheral clock is a common source of mistakes.

What Happens at Higher Sample Rates?

At 10 ksample/s, the data bandwidth is tiny.

One 16-bit ADC channel produces:

10,000 × 2 bytes = 20 kB/s

That is easy for DMA.

At 1 MSPS:

1,000,000 × 2 bytes = 2 MB/s

Now memory bandwidth, DMA priorities, additional peripherals, and processing deadlines matter more.

The same architecture still works, but you need to consider the whole system.

If block processing takes too long, you can:

  1. Increase the DMA buffer size
  2. Reduce the sampling rate
  3. Move processing into a dedicated RTOS task
  4. Copy finished blocks into another queue
  5. Optimize DSP processing

For an RTOS project, the DMA callback can notify a processing task rather than using the counters shown here.

A Note for STM32F7 and H7 Users

The basic timer → ADC → DMA architecture also works on newer STM32 families, but Cortex-M7 devices introduce another issue:

data cache coherency.

DMA writes directly to RAM.

The CPU may still have an older copy of that memory stored in its D-cache.

That means the buffer can contain new samples in physical SRAM while the application still reads stale cached data.

On STM32F7/H7 designs, follow the cache-maintenance and memory-region rules for your specific MCU, or place DMA buffers in an appropriate non-cacheable region.

This issue does not apply in exactly the same way to the STM32F446 example used here.

Where to Go Next

Once this basic system is working, the same pattern can be extended to:

  1. Multi-channel sensor acquisition
  2. Current and voltage waveform capture
  3. Motor-control measurements
  4. Digital filters
  5. FFT input buffers
  6. Audio-frequency acquisition
  7. Data logging
  8. Triggered measurement systems

The useful part of the architecture is that each peripheral has one clear job:

Timer → timing

ADC → conversion

DMA → data movement

CPU → block processing

The CPU no longer needs to babysit every sample.

For a deeper discussion of ADC conversion timing, multi-channel throughput, circular DMA deadlines, STM32F7/H7 cache behavior, and production pitfalls, I have a more detailed STM32 timer-triggered ADC sampling guide covering the same architecture in more depth.

That is the main reason I prefer timer-triggered ADC with circular DMA whenever sampling timing matters: once the hardware owns the acquisition timing, the rest of the firmware becomes much easier to reason about.