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:
- Starts converting channels in sequence (rank 1 → rank N)
- Stores each result into a memory address, incrementing via DMA
- When DMA fills the buffer half-way → HT interrupt
- 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
- FreeRTOS task and queue design — moving the pipeline into an RTOS task
- Layered firmware architecture — HAL → drivers → services → application
- SOLID in practice — embedded C++ edition — the design principles behind this architecture