STM32 DMA Tutorial: Timer-Triggered Multi-Channel ADC and UART DMA
In the earlier ADC tutorial, the CPU started each conversion, waited for it, and copied the result. That is fine for one knob read five times a second, but it does not scale. If you need several channels sampled at an exact rate, for example for a data logger or a digital filter, the CPU would spend most of its time babysitting the ADC. The Direct Memory Access (DMA) controller solves this by moving data between peripherals and memory on its own.
In this tutorial you will build a small data-acquisition system on a Nucleo-F401RE. A timer triggers the ADC 1000 times per second, the ADC converts three channels in sequence, and DMA writes the results into a circular buffer. The CPU only wakes up every 50 ms to average a block of samples and hands the text to the UART, which also uses DMA, so printing no longer blocks the program.
How DMA Works on STM32
A DMA controller is a separate bus master inside the chip. You give it a source address, a destination address, a data width and a count, and connect it to a peripheral's request signal. Every time the peripheral has data ready, it raises a request and the DMA copies one item, then advances the memory address. The STM32F401 has two DMA controllers, DMA1 and DMA2, each with eight streams. Each peripheral request is wired to particular streams and channels; CubeMX knows the mapping and selects a valid one when you click Add.
| DMA setting | Value used | Why |
|---|---|---|
| Mode for ADC | Circular | After the last item the DMA restarts at the beginning of the buffer forever |
| Mode for UART TX | Normal | Each message is sent once and then the DMA stops |
| Data width for ADC | Half Word (16 bit) | A 12-bit result fits in 16 bits |
| Data width for UART | Byte | Characters are 8 bits |
| Memory increment | Enabled | Each item goes to the next location in the array |
With a circular buffer, the HAL gives you two callbacks. HAL_ADC_ConvHalfCpltCallback runs when the first half is full, and HAL_ADC_ConvCpltCallback runs when the second half is full. While you process one half, the DMA fills the other, so no sample is ever overwritten before you read it. This is called double buffering.
Inputs
| Signal | Pin | Arduino header | ADC channel |
|---|---|---|---|
| Potentiometer 1 wiper | PA0 | A0 | ADC1_IN0 |
| Potentiometer 2 or LDR divider | PA1 | A1 | ADC1_IN1 |
| Third analog input | PA4 | A2 | ADC1_IN4 |
Any 0 to 3.3 V sources will do. Two potentiometers and a light-dependent resistor with a 10 k resistor in a divider make a good test set, because you can change each one by hand and see the effect.
CubeMX Configuration
- TIM3: Clock Source Internal Clock, Prescaler 83, Counter Period 999, Trigger Event Selection TRGO set to Update Event. This produces a 1 kHz trigger from the 84 MHz timer clock.
- ADC1: enable IN0, IN1 and IN4. Set Scan Conversion Mode Enabled, Continuous Conversion Mode Disabled, DMA Continuous Requests Enabled, End Of Conversion Selection at the end of all conversions.
- ADC1 regular conversion: Number Of Conversion 3, External Trigger Conversion Source Timer 3 Trigger Out event, edge Rising. Set Rank 1 to Channel 0, Rank 2 to Channel 1, Rank 3 to Channel 4, each with 84 cycles sampling time.
- ADC1 DMA Settings: Add ADC1, Mode Circular, Data Width Half Word for both peripheral and memory.
- USART2 DMA Settings: Add USART2_TX, Mode Normal, Data Width Byte. In the NVIC Settings tab of USART2, enable USART2 global interrupt; the HAL needs it to finish a DMA transmission.
- Generate the code. In
main(), confirm thatMX_DMA_Init()is called beforeMX_ADC1_Init()andMX_USART2_UART_Init(); some CubeMX versions generated them in the wrong order, which makes DMA silently fail.
The Code
/* USER CODE BEGIN Includes */
#include <stdio.h>
/* USER CODE END Includes */
/* USER CODE BEGIN PV */
#define NUM_CH 3U
#define SETS_PER_HALF 50U /* 50 ms of data at 1 kHz */
#define BUF_LEN (NUM_CH * SETS_PER_HALF * 2U)
static uint16_t adc_buf[BUF_LEN]; /* filled by DMA */
static char tx_buf[96]; /* read by UART DMA */
volatile uint32_t avg[NUM_CH]; /* latest block averages */
volatile uint8_t block_ready = 0;
volatile uint8_t uart_busy = 0;
/* USER CODE END PV */
/* USER CODE BEGIN 0 */
/* Average one half of the buffer. Samples are stored as
ch0, ch1, ch4, ch0, ch1, ch4, ... in rank order. */
static void process_half(const uint16_t *half)
{
uint32_t sum[NUM_CH] = { 0 };
for (uint32_t i = 0; i < SETS_PER_HALF; i++)
{
for (uint32_t c = 0; c < NUM_CH; c++)
{
sum[c] += half[i * NUM_CH + c];
}
}
for (uint32_t c = 0; c < NUM_CH; c++)
{
avg[c] = sum[c] / SETS_PER_HALF;
}
block_ready = 1;
}
/* First half of adc_buf is full; DMA is now writing the second half */
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
if (hadc->Instance == ADC1) process_half(&adc_buf[0]);
}
/* Second half is full; DMA wraps around to the first half */
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
if (hadc->Instance == ADC1) process_half(&adc_buf[BUF_LEN / 2]);
}
/* UART DMA transfer finished; tx_buf may be reused */
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART2) uart_busy = 0;
}
/* USER CODE END 0 */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_DMA_Init(); /* must come before ADC and UART init */
MX_USART2_UART_Init();
MX_ADC1_Init();
MX_TIM3_Init();
/* USER CODE BEGIN 2 */
/* Arm the ADC + DMA first, then start the timer that triggers it */
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, BUF_LEN);
HAL_TIM_Base_Start(&htim3);
uint32_t blocks = 0;
/* USER CODE END 2 */
while (1)
{
/* USER CODE BEGIN 3 */
if (block_ready && !uart_busy)
{
block_ready = 0;
blocks++;
/* Copy volatile values once, convert to millivolts */
uint32_t mv0 = avg[0] * 3300U / 4095U;
uint32_t mv1 = avg[1] * 3300U / 4095U;
uint32_t mv2 = avg[2] * 3300U / 4095U;
int n = snprintf(tx_buf, sizeof(tx_buf),
"#%lu A0=%4lu mV A1=%4lu mV A2=%4lu mV\r\n",
blocks, mv0, mv1, mv2);
if (n > 0)
{
uart_busy = 1;
HAL_UART_Transmit_DMA(&huart2, (uint8_t *)tx_buf, (uint16_t)n);
}
}
/* The CPU is free here: blink LD2 as proof it is not blocked */
static uint32_t t = 0;
if (HAL_GetTick() - t >= 250)
{
t = HAL_GetTick();
HAL_GPIO_TogglePin(LD2_GPIO_Port, LD2_Pin);
}
/* USER CODE END 3 */
}
}
What Happens at Run Time
- Every 1 ms, TIM3's update event pulses TRGO. The ADC converts channels 0, 1 and 4 in rank order, and after each result the DMA copies the 16-bit value into
adc_buf. - After 50 sets (150 values), the half-transfer callback runs; after 100 sets, the full-transfer callback runs and the DMA wraps around. The CPU therefore handles one interrupt every 50 ms instead of three thousand per second.
HAL_ADC_Start_DMAtakes auint32_t *for historical reasons, but the DMA writes half-words because of the data width setting, so auint16_tbuffer is correct.HAL_UART_Transmit_DMAreturns immediately. Theuart_busyflag prevents overwritingtx_bufwhile the DMA is still reading it.- Callbacks check
hadc->Instanceandhuart->Instance, which keeps the code correct when more ADCs or UARTs are added later.
Troubleshooting
- Buffer stays all zeros:
MX_DMA_Init()runs afterMX_ADC1_Init(), or the timer was never started. - Only the first block arrives: DMA Continuous Requests is disabled, so the ADC stops issuing DMA requests after one buffer.
- UART prints once and stops: USART2 global interrupt is disabled, so
HAL_UART_TxCpltCallbacknever clearsuart_busy. - Channels appear swapped: the rank order in CubeMX differs from the order assumed in
process_half. - Sample rate is wrong: check that TRGO is set to Update Event and recompute 84 MHz / ((PSC + 1) x (ARR + 1)).
Summary
You built a timer-triggered, multi-channel ADC with a circular DMA buffer and processed it with half and full transfer callbacks, then sent results through UART DMA without blocking. This structure of timer, ADC, DMA and callbacks is the basis of most STM32 data-acquisition and signal-processing firmware. Next, you will keep accurate time with the real-time clock and set alarms.