Проблема опроса АЦП

Наивный подход к чтению АЦП на STM32 выглядит так:

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}

Работает для одного канала на низких частотах дискретизации. Рассыпается, когда нужно:

  • несколько каналов с точными интервалами
  • CPU для чего-то ещё во время дискретизации
  • стабильное тактирование (опрос вносит джиттер)

Правильный инструмент — непрерывное преобразование АЦП, управляемое DMA, с двойной буферизацией через пару прерываний half-transfer / transfer-complete. Эта статья строит такой конвейер с нуля.


Аппаратная модель

Периферия АЦП STM32 в режиме непрерывного сканирования:

  1. Начинает преобразование каналов по порядку (ранг 1 → ранг N)
  2. Сохраняет каждый результат в адрес памяти, продвигаясь через DMA
  3. Когда DMA заполняет буфер наполовину → прерывание HT
  4. Когда DMA достигает конца → прерывание TC, затем возврат в начало

Это даёт кольцевой DMA-буфер с двумя полу-буферами, которые можно обрабатывать, пока другой заполняется — настоящая двойная буферизация без блокировок.

АЦП HW  →  DMA  →  [ buf[0..N/2-1] | buf[N/2..N-1] ]
                         ↑ HT callback    ↑ TC callback
                    обработка первой половины  обработка второй половины

Настройка CubeMX

В CubeMX настройте ADC1:

Настройка Значение
Continuous Conversion Mode Enabled
DMA Continuous Requests Enabled
Scan Conversion Mode Enabled (для многоканального)
Rank 1..N Ваши каналы
DMA Mode Circular
DMA Data Width Half Word (16-бит)

Добавьте поток DMA для ADC1 на вкладке настроек DMA (например, DMA2 Stream 0 для ADC1).


Разметка буфера

С 4 каналами и двойным буфером глубиной 4 на полу-буфер:

1// adc_pipeline.h
2static constexpr size_t ADC_CHANNELS    = 4;
3static constexpr size_t ADC_HALF_DEPTH  = 4;   // отсчётов на канал на полу-буфер
4static constexpr size_t ADC_BUF_SIZE    = ADC_CHANNELS * ADC_HALF_DEPTH * 2;
5
6// Разметка: [ch0,ch1,ch2,ch3, ch0,ch1,ch2,ch3, ... ] × HALF_DEPTH × 2
7extern uint16_t adcDmaBuf[ADC_BUF_SIZE];

DMA заполняет в порядке: ранг 1, ранг 2, ранг 3, ранг 4, ранг 1, ранг 2… до ADC_BUF_SIZE, затем возврат. HT срабатывает на ADC_BUF_SIZE/2, TC — в конце.


Запуск DMA-преобразования

1// main.cpp или 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 настраивает DMA, включает АЦП и возвращается немедленно — с этого момента железо работает независимо.


Callback-и — критическая часть

HAL предоставляет два слабых callback-а для переопределения. Оба вызываются из контекста прерывания DMA — делайте их краткими.

 1// Вызывается, когда первая половина буфера заполнена
 2void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) {
 3    if (hadc->Instance != ADC1) return;
 4    AdcPipeline::instance().onHalfComplete();
 5}
 6
 7// Вызывается, когда вторая половина (весь буфер) завершена
 8void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) {
 9    if (hadc->Instance != ADC1) return;
10    AdcPipeline::instance().onFullComplete();
11}

Класс конвейера обрабатывает только что заполненную половину в каждом callback:

1void AdcPipeline::onHalfComplete() {
2    // Первая половина готова: индексы 0 .. ADC_BUF_SIZE/2 - 1
3    processHalf(adcDmaBuf, ADC_CHANNELS, ADC_HALF_DEPTH);
4}
5
6void AdcPipeline::onFullComplete() {
7    // Вторая половина готова: индексы ADC_BUF_SIZE/2 .. ADC_BUF_SIZE - 1
8    processHalf(adcDmaBuf + ADC_BUF_SIZE / 2, ADC_CHANNELS, ADC_HALF_DEPTH);
9}

Критическое правило: никогда не читайте adcDmaBuf[0..N/2-1] в onFullComplete и adcDmaBuf[N/2..N-1] в onHalfComplete — DMA активно пишет в эти ячейки.


Разделение каналов

DMA хранит отсчёты перемежёнными по рангу. processHalf распаковывает их:

 1void AdcPipeline::processHalf(const uint16_t* src,
 2                               size_t channels,
 3                               size_t depth) {
 4    // Разметка src: [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        // Простое усреднение; замените на выбранный фильтр
11        channelAvg_[ch] = static_cast<uint16_t>(sum / depth);
12    }
13    dataReady_.store(true, std::memory_order_release);
14}

Флаг dataReady_ (std::atomic<bool>) сигнализирует главной задаче, что свежие усреднённые значения готовы к чтению.


Чтение из главной задачи

1// В главном цикле или задаче RTOS:
2if (adcPipeline.dataReady()) {
3    auto vals = adcPipeline.getChannels(); // копирует — нет общего состояния
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 здесь безопасно
9}

ISR пишет channelAvg_ и устанавливает флаг; задача читает флаг, затем channelAvg_. С порядком acquire/release компилятор и CPU не могут переставить запись данных после записи флага или чтение флага перед чтением данных.


Полный интерфейс класса

 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    // Вызываются из HAL callback-ов — держите их краткими
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};

Синглтон instance() существует только для того, чтобы перебросить C-linkage HAL callback в класс. Сам конвейер не имеет глобального изменяемого состояния, кроме DMA-буфера, который должен находиться в DMA-доступной области памяти.


Размещение в памяти

На некоторых семействах STM32 (F4, H7) DMA не может получить доступ к DTCM или CCM RAM. Явно размещайте буфер в SRAM1/AXI SRAM:

1// STM32H7: размещаем в SRAM4 для BDMA или в SRAM1 для DMA1/2
2__attribute__((section(".dma_buffer")))
3uint16_t adcDmaBuf[ADC_BUF_SIZE];

В скрипте линкера:

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

Забыть об этом — одна из самых распространённых причин тихого сбоя DMA — HAL запускается без ошибок, но буфер никогда не заполняется.


Типичные ловушки

1. Когерентность кэша на H7

Cortex-M7 имеет D-кэш. Если DMA-буфер находится в кэшируемой памяти, CPU может читать устаревшие строки кэша. Либо размещайте буфер в некэшируемой области (через MPU), либо инвалидируйте диапазон кэша в callback перед чтением:

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. Неправильная ширина данных DMA

Результаты АЦП на STM32 — 12-битные, хранятся в 16-битном регистре. Ширина данных DMA должна быть Half Word (16-бит), не Word или Byte. При Word каждый отсчёт занимает 4 байта и перемежение нарушается.

3. Не включены непрерывные запросы DMA

DMA Continuous Requests в CubeMX должен быть включён, иначе АЦП генерирует один запрос DMA на последовательность и останавливается — вы получаете одно заполнение буфера и тишину.

4. Обработка неправильной половины

Типичная ошибка на единицу: вызов processHalf(adcDmaBuf + ADC_BUF_SIZE/2, ...) в callback HT (должен быть TC). Симптом: каналы выглядят сдвинутыми на полпериода. Используйте volatile счётчик отладки в каждом callback для подтверждения их поочерёдного срабатывания.


Итог — конвейер

С инкапсулированным слоем DMA остальная часть прошивки видит только:

AdcPipeline → IFilter → IDisplay

Замените АЦП симулированной синусоидой, реализовав SimPipeline с тем же интерфейсом опроса. Запускайте фильтр и код отображения на PC, проверяйте отклик фильтра, затем разворачивайте. В этом и состоит выигрыш архитектуры.

Что дальше