Проблема опроса АЦП
Наивный подход к чтению АЦП на 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 → ранг N)
- Сохраняет каждый результат в адрес памяти, продвигаясь через DMA
- Когда DMA заполняет буфер наполовину → прерывание HT
- Когда 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, проверяйте отклик фильтра, затем разворачивайте.
В этом и состоит выигрыш архитектуры.
Что дальше
- Задачи и очереди FreeRTOS — перенос конвейера в задачу RTOS
- Многоуровневая архитектура прошивки — HAL → драйверы → сервисы → приложение
- SOLID на практике — принципы проектирования этой архитектуры