The problem with polling ADC

The naive approach to reading an ADC on STM32 looks like this:

1while (true) {
2    HAL_ADC_Start(&hadc1);
3    HAL_ADC_PollForConversion(&hadc1, 10);
4    uint32_t val = HAL_ADC_GetValue(&hadc1);
5    process(val);
6}

It works for a single channel at low sample rates. It falls apart the moment you need:

  • multiple channels at precise intervals
  • CPU to do anything else while sampling
  • consistent timing (polling introduces jitter)

The right tool is ADC continuous conversion mode driven by DMA, with double-buffering via the half-transfer / transfer-complete interrupt pair. This article builds up that pipeline from scratch.


Hardware model

The STM32 ADC peripheral in continuous + scan mode:

  1. Starts converting channels in sequence (rank 1 → rank N)
  2. Stores each result into a memory address, incrementing via DMA
  3. When DMA fills the buffer half-way → HT interrupt
  4. When DMA reaches the end → TC interrupt, then wraps around

This gives you a circular DMA buffer with two half-buffers you can process while the other is being filled — true double-buffering without any locking.

ADC HW  →  DMA  →  [ buf[0..N/2-1] | buf[N/2..N-1] ]
                         ↑ HT callback    ↑ TC callback
                    process first half  process second half

CubeMX configuration

In CubeMX, configure ADC1:

Setting Value
Continuous Conversion Mode Enabled
DMA Continuous Requests Enabled
Scan Conversion Mode Enabled (if multi-channel)
Rank 1..N Your channels
DMA Mode Circular
DMA Data Width Half Word (16-bit)

Add a DMA stream for ADC1 in the DMA settings tab (e.g. DMA2 Stream 0 for ADC1).


Buffer layout

With 4 channels and a double-buffer of depth 4 per half:

1// adc_pipeline.h
2static constexpr size_t ADC_CHANNELS    = 4;
3static constexpr size_t ADC_HALF_DEPTH  = 4;   // samples per channel per half
4static constexpr size_t ADC_BUF_SIZE    = ADC_CHANNELS * ADC_HALF_DEPTH * 2;
5
6// Layout: [ch0,ch1,ch2,ch3, ch0,ch1,ch2,ch3, ... ] × HALF_DEPTH × 2
7extern uint16_t adcDmaBuf[ADC_BUF_SIZE];

DMA fills this in order: rank 1, rank 2, rank 3, rank 4, rank 1, rank 2 … all the way to ADC_BUF_SIZE, then wraps. The HT fires at ADC_BUF_SIZE/2, TC at the end.


Starting DMA conversion

1// main.cpp or adc_pipeline.cpp
2uint16_t adcDmaBuf[ADC_BUF_SIZE];
3
4void AdcPipeline::start() {
5    HAL_ADC_Start_DMA(&hadc1,
6                      reinterpret_cast<uint32_t*>(adcDmaBuf),
7                      ADC_BUF_SIZE);
8}

HAL_ADC_Start_DMA configures DMA, enables the ADC, and returns immediately — the hardware runs independently from this point.


Callbacks — the critical part

HAL provides two weak callbacks you override. Both are called from the DMA interrupt context — keep them short.

 1// Called when first half of buffer is full
 2void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) {
 3    if (hadc->Instance != ADC1) return;
 4    AdcPipeline::instance().onHalfComplete();
 5}
 6
 7// Called when second half (full buffer) is complete
 8void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) {
 9    if (hadc->Instance != ADC1) return;
10    AdcPipeline::instance().onFullComplete();
11}

The pipeline class processes the freshly filled half in each callback:

1void AdcPipeline::onHalfComplete() {
2    // First half is ready: indices 0 .. ADC_BUF_SIZE/2 - 1
3    processHalf(adcDmaBuf, ADC_CHANNELS, ADC_HALF_DEPTH);
4}
5
6void AdcPipeline::onFullComplete() {
7    // Second half is ready: indices ADC_BUF_SIZE/2 .. ADC_BUF_SIZE - 1
8    processHalf(adcDmaBuf + ADC_BUF_SIZE / 2, ADC_CHANNELS, ADC_HALF_DEPTH);
9}

Critical rule: never read adcDmaBuf[0..N/2-1] in onFullComplete or adcDmaBuf[N/2..N-1] in onHalfComplete — DMA is actively writing those locations.


Deinterleaving channels

DMA stores samples interleaved by rank. processHalf unpacks them:

 1void AdcPipeline::processHalf(const uint16_t* src,
 2                               size_t channels,
 3                               size_t depth) {
 4    // src layout: [ch0, ch1, ch2, ch3, ch0, ch1, ...]
 5    for (size_t ch = 0; ch < channels; ++ch) {
 6        uint32_t sum = 0;
 7        for (size_t s = 0; s < depth; ++s) {
 8            sum += src[s * channels + ch];
 9        }
10        // Simple averaging; replace with filter of choice
11        channelAvg_[ch] = static_cast<uint16_t>(sum / depth);
12    }
13    dataReady_.store(true, std::memory_order_release);
14}

The flag dataReady_ (a std::atomic<bool>) signals the main task that fresh averaged values are available to read.


Reading from the main task

1// In main loop or RTOS task:
2if (adcPipeline.dataReady()) {
3    auto vals = adcPipeline.getChannels(); // copies, no shared state
4    filter.update(vals);
5    display.show(filter.output());
6}
1// AdcPipeline:
2bool dataReady() {
3    return dataReady_.load(std::memory_order_acquire);
4}
5
6std::array<uint16_t, ADC_CHANNELS> getChannels() {
7    dataReady_.store(false, std::memory_order_relaxed);
8    return channelAvg_;  // std::array copy is safe here
9}

The ISR writes channelAvg_ and sets the flag; the task reads the flag then reads channelAvg_. With acquire/release ordering, the compiler and CPU cannot reorder the data write past the flag write, or the flag read before the data read.


Full class interface

 1class AdcPipeline {
 2public:
 3    static AdcPipeline& instance();
 4
 5    void start();
 6    bool dataReady() const;
 7    std::array<uint16_t, ADC_CHANNELS> getChannels();
 8
 9    // Called from HAL callbacks — keep them short
10    void onHalfComplete();
11    void onFullComplete();
12
13private:
14    void processHalf(const uint16_t* src, size_t channels, size_t depth);
15
16    std::array<uint16_t, ADC_CHANNELS> channelAvg_{};
17    std::atomic<bool> dataReady_{false};
18};

Note the singleton instance() — it exists only to bridge the C-linkage HAL callback into the class. The pipeline itself has no global mutable state beyond the DMA buffer, which must be in a DMA-accessible memory region.


Memory placement

On some STM32 families (F4, H7), DMA cannot access DTCM or CCM RAM. Place the buffer in SRAM1/AXI SRAM explicitly:

1// STM32H7: place in SRAM4 accessible to BDMA, or SRAM1 for DMA1/2
2__attribute__((section(".dma_buffer")))
3uint16_t adcDmaBuf[ADC_BUF_SIZE];

In the linker script:

.dma_buffer (NOLOAD) :
{
    *(.dma_buffer)
} >SRAM1

Forgetting this is one of the most common causes of silent DMA failure — the HAL starts without error but the buffer never fills.


Common pitfalls

1. Cache coherency on H7

Cortex-M7 has D-cache. If the DMA buffer is in cached memory, the CPU may read stale cache lines. Either place the buffer in a non-cacheable region (MPU) or invalidate the cache range in the callback before reading:

1void AdcPipeline::onHalfComplete() {
2    SCB_InvalidateDCache_by_Addr(
3        reinterpret_cast<uint32_t*>(adcDmaBuf),
4        ADC_BUF_SIZE / 2 * sizeof(uint16_t));
5    processHalf(adcDmaBuf, ADC_CHANNELS, ADC_HALF_DEPTH);
6}

2. Wrong DMA data width

ADC results on STM32 are 12-bit, stored in a 16-bit register. DMA data width must be Half Word (16-bit), not Word or Byte. With Word, each sample occupies 4 bytes and the interleaving breaks.

3. Not enabling DMA continuous requests

DMA Continuous Requests in CubeMX must be enabled, otherwise the ADC generates one DMA request per sequence and stops — you get one buffer fill and silence.

4. Processing the wrong half

A common off-by-one: calling processHalf(adcDmaBuf + ADC_BUF_SIZE/2, ...) in the HT callback (should be TC). Symptom: channels appear shifted by one half-period. Use a volatile debug counter in each callback to confirm they fire alternately.


Putting it together — the pipeline

With the DMA layer encapsulated, the rest of the firmware sees only:

AdcPipeline → IFilter → IDisplay

Swap ADC for a simulated sine wave by implementing a SimPipeline that satisfies the same polling interface. Run the filter and display code on a PC, verify the filter response, then deploy. That’s the payoff of the architecture.

What’s next