
ADC модуль это основа любого электронного измерения. Основа любого DMM. Всё что за корпусом микроконтроллера это аналоговый мир. ADC это портал который позволяет аналоговым сигналам вкатиться в мир цифры.
Постановка задачи
Научиться записывать c PA3 (ADC1_IN3) на STM32F407VE 12-битные семплы в потоковом режиме на частоте дискретизации равной 8kHz. Для точной установки частоты дискретизации использовать аппаратный таймер TIM2 . Использовать встроенный в микроконтроллер ADC. Использовать ADC1. Использовать DMA. Записанные семплы складировать в FIFO. В случае переполнения FIFO выдавать ошибку. При этом не использовать частые прерывания. Не нарушать непрерывность записи семплов. Все эксперименты производить на отладочной плате JZ-F407VET6.
Как отлаживаться?
На плате есть свободные GPIO пины, можно использовать DAC, двухлучевой осциллограф и логический анализатор. Можно просматривать последний принятый семпл утилитой J-Scope через J-link программатор.
Реализация:
Фаза 1: Настройка GPIO
Сначала выберем пины. Учитывая схемотехнику электронной платы JZ-F407VET6 есть только такие варианты.

Я выбрал пин PA3. Этот пин надо сконфигурировать на аналоговую функцию.

Подтяжку к питанию отключить. PinMux выставить в ноль.

Фаза 2: Настройка аппаратного таймера
Для ЦОС алгоритмов важно извлекать ADC семплы с точным и стабильным периодом. То есть надо задавать частоту дискретизации. Аппаратный таймер для ADC выступает в роли метронома для пианиста, чтобы не выбиваться из ритма. ADC1 можно запускать таймерами 1, 2, 3, 4, 5 или 8
Для этого таймера я выбрал 32 битный TIM2 (0x40000000). Задал частоту счета 8k Hz, направление счета вверх. Настроил таймер на режим мастера и настроил генерировать событие (сигнал) при переполнении таймера (Update). Для отладки можно ещё включить прерывания и в обработчике по переполнению таймера переключать состояние какого-н GPIO. Это позволит убедиться, что частота в самом деле установилась как надо.
Таймер будет генерировать импульсы с точной частотой, запуская синхронные АЦП преобразования.
17:18-->timer_diag +----+-----+----------+-----+------------+-------+-------+---------+---------+--------+ |Num | En | busFreq | bit | periodReg | psc | dir |period,s | freq,Hz |busName | +----+-----+----------+-----+------------+-------+-------+---------+---------+--------+ | 2 | On | 84.0M | 32 | 656 | 16 | Up | 0.125m | 8.003k | APB1 | | 3 | On | 84.0M | 16 | 1000 | 84 | Up | 1.000m | 1.000k | APB1 | | 4 | On | 84.0M | 16 | 10500 | 8 | Up | 1.000m | 1.000k | APB1 | | 5 | On | 84.0M | 32 | 4294967295 | 65535 | Up | 3.351M | 0.298u | APB1 | | 8 | On | 168.0M | 16 | 10500 | 16 | Up | 1.000m | 1.000k | APB2 | +----+-----+----------+-----+------------+-------+-------+---------+---------+--------+ 17:30-->
Проверка логическим анализатором показала, что таймер в самом деле переполняется на частоте 8kHz.

Фаза 3: Настройка Analog-to-digital converter (ADC)
Внутри STM32F407VE заложено три 12 битных SAR-ADC. То есть значения которые мы будем получать будут варьироваться от 0 до 4095. Частота дискретизации может достигать 2.4 мега семплов в секунду. Я выбрал ADC1. ADC1 имеет номер прерывания 18. В распоряжении 16 каналов. Я выбрал канал номер три (PA3), разрешение 12 бит. Для настройки ADC надо подать тактирование на ADC подсистему. Режим непрерывного преобразования (continuous conversion) мне не нужен. Так как в этом режме преобразования происходят без паузы. Режим сканирования (Scan mode) мне пока тоже не нужен, так как я собираюсь читать напряжение только с одного единственного пина (PA3). Выбираю выравнивание значащих битов вправо (Right alignment), чтобы можно было визуализировать семплы в UART без лихних преобразований. Ключевой момент в том, чтобы выбрать событие для начала преобразования. Этим событием я выбрал переполнение таймера 2 (0110: Timer 2 TRGO event). Указываю, что надо работать в режиме DMA. Надо указать время накопления заряда на внутреннем конденсаторе в периодах тактирования.
static bool adc_compose_init(const AdcConfig_t* const Config, ADC_InitTypeDef* const pInit) { bool res = false; if(pInit) { pInit->ScanConvMode = DISABLE; pInit->ContinuousConvMode = DISABLE; pInit->NbrOfConversion = 1; pInit->DMAContinuousRequests = ENABLE; pInit->DataAlign = ADC_DATAALIGN_RIGHT; pInit->DiscontinuousConvMode = DISABLE; pInit->ExternalTrigConv = AdcExternalTriggerSourceToExternalTrigConv(Config->trigger_source); pInit->Resolution = AdcResolution2code(Config->resolution); pInit->ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; pInit->NbrOfDiscConversion = 1; pInit->ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_RISINGFALLING; pInit->EOCSelection = ADC_EOC_SEQ_CONV; res = true; } return res; }
Фаза 4: Настройка DMA
DMA — Сердце потоковой записи. Должно перекачивать данные от ADC в память. Режим работы - Peripheral To Memory. DMA должно работать в режиме цирка (Circular mode), на каждой итерации увеличивать память, выравнивать запись по 16 бит.
#ifndef DMA_CHANNEL_CONFIG_ADC_H #define DMA_CHANNEL_CONFIG_ADC_H #ifdef __cplusplus extern "C" { #endif #include "dma_channel_types.h" #include "adc_config.h" bool CallBackHalfAdc1(void); bool CallBackDoneAdc1(void); /* Table 43. DMA2 request mapping */ #define DMA_CHANNEL_ADC1 \ { \ .num = DMA_CHANNEL_NUM_ADC1, \ .DmaPad = {.dma_num = 2, .channel = 0}, \ .name = "adc1", \ .dir = DMA_MCAL_DIR_PERIPH_TO_MEMORY, \ .CallBackHalf = CallBackHalfAdc1, \ .CallBackDone = CallBackDoneAdc1, \ .mem_inc = DMA_INC_ON, \ .per_inc = DMA_INC_OFF, \ .valid = true, \ .aligment_mem = DMA_ALIGNMENT_WORD, \ .aligment_per = DMA_ALIGNMENT_WORD, \ .mode = DMA_MODE_CIRCULAR, \ .interrupt_on = true, \ .priority = DMA_PRIOR_MED, \ .base_addr_source = (uint32_t) &(ADC1->DR), \ .base_addr_destination = (uint32_t) Adc1RxSamples, \ .move_size = (uint32_t) ADC1_RX_SAMPLE_CNT, \ .block_size = (uint32_t) DMA_MEMCPY_SIZE, \ .block_count = 1, \ .fifo = DMA_FIFO_OFF, \ .memory_burst = DMA_BURST_SINGLE, \ .periph_burst = DMA_BURST_SINGLE, \ }, #define DMA_CHANNEL_ADC \ DMA_CHANNEL_ADC1 #ifdef __cplusplus } #endif #endif /* DMA_CHANNEL_CONFIG_ADC_H */
C ADC1 может работать только DMA2, причем только каналы 0 и 4. Я выбрал канал ноль.

DMA надо настроить так, чтобы оно писало в циклическом режиме один и тот же массив. Данные следует выгребать в прерываниях по половинном заполнении и при полоном заполнении. Под выгребанием семплов подразумевается перекачка семплов из массива в очередь ADC1.RxFiFo.

Фаза 5: Организация FIFO
DMA в прерывании запускает две функции. Одна стартует при половинной записи массива, вторая при полной записи. Внутри этих прерываний надо успеть переложить семплы для обработки в приложении, пока не стартанет второй обработчик прерываний. Поэтому надо аккуратно извлечь семплы из массива приемника и переложить из в RxFiFo.
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { AdcHandle_t* Node = AdcHalHandle2Handle(hadc); if(Node) { Node->conv_done = true; Node->conv_done_cnt++; uint32_t i = 0 ; for(i=Node->RxSamplesCnt/2;i<Node->RxSamplesCnt;i++){ i_status ret = iqueue_enqueue(&Node->iQueue, &Node->RxSamples[i]); iqueue_ret_res(ret); } } } void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { AdcHandle_t* Node = AdcHalHandle2Handle(hadc); if(Node) { Node->half_cplt_done = true; Node->half_cplt_done_cnt++; uint32_t i = 0; for(i=0;i<Node->RxSamplesCnt/2;i++){ i_status ret = iqueue_enqueue(&Node->iQueue, &Node->RxSamples[i]); iqueue_ret_res(ret); } } }
Реализация FIFO это отдельная большая задача. Однако я выбрал готовую реализацию FIFO c open source репозитория разработчики-барсуки. Этот программный компонент называется iQueue.
i_status iqueue_init(iqueue_t* _queue, uint32_t _max_elements, size_t _element_size, void* _storage); i_status iqueue_enqueue(iqueue_t* _queue, void* _element); i_status iqueue_dequeue(iqueue_t* _queue, void* _element); i_status iqueue_size(iqueue_t* _queue, size_t* _size);
На время заполнения FiFo надо отключить DMA прерывания (активировать критическую секцию). Без этого может произойти одновременный доступ на изменение переменных FIFO в обработчике прерываний (push) и в функции main (pull). Это приведет к непредсказуемым значениям внутри FIFO.
Фаза 6: Обработка семплов в приложении
Понятное дело, что семплы мы записывали для, того чтобы их как-то обрабатывать. Обработка может быть весьма затратной в плане вычислительных ресурсов. В самом простом случае семплы можно просто отобразить в консоль или на экран. Как приложение получит семплы? Приложение просто будет извлекать семплы из очереди iQueue при помощи функции iqueue_dequeue (pull).
float AdcSample12ToVoltageVef3_3(const uint32_t sample) { float voltage_v = 0.0f; voltage_v = (3.3f *( (float) sample))/((float)ADC_MAX_VAL_12BIT); return voltage_v; } bool adc_proc_one(uint8_t num) { bool res = false; log_level_t ll = log_level_get(LG_ADC); AdcHandle_t* Node = AdcGetNode(num); if(Node) { i_status ret; size_t size = 0; ret = iqueue_size(&Node->iQueue, &size); if(I_OK==ret) { uint32_t i = 0; for(i=0; i<size; i++) { uint16_t sample = 0; ret = iqueue_dequeue(&Node->iQueue, (void*) &sample); if(I_OK == ret) { if(LOG_LEVEL_DEBUG == ll) { cli_printf("\rCode:%4u,%5.3f V", sample, AdcSample12ToVoltageVef3_3(sample)); } } } } } return res; }
Фаза 7: Отображение семплов в DAC
Вот мы научились получать какие-то 12-битные семплы. Где гарантия, что это в самом деле отсчеты напряжения, а не какие-то случайные значения в поломанной программе?
Можно отобразить записанные семплы обратно на улицу при помощи модуля DAC. Затем на двухлучевом осциллографе отобразить записываемый сигнал с отображаемым сигналом. Если есть корреляция, то значит связка ADC+DAC работает корректно. Внутри STM32F407VE заложено два канала 12-битного DAC модуля (базовый адрес DAC=0x40007400). На PCB JZ-F407VET6 эти пины выходят на P4.6 P4.3. Это прямо на удобную PLD вилку.
Pin Function |
GPIO |
LQFP100 |
PinMux |
Connector |
Dir |
Source |
DAC_OUT1 |
PA4 |
29 |
0 |
P4.6 |
out |
FromADC |
DAC_OUT2 |
PA5 |
30 |
0 |
P4.3 |
out |
FromDDS |
При этом, чтобы что-то отображать надо что-то записывать. Поэтому второй канал DAC я запрограммировал генерировать программный синус сигнал, а первый канал DAC я настроил просто отображать принятые ADC семплы. Получилось полностью аналоговое эхо. Чтобы успевать высвобождать очередь TxFIFO, отображение семплов происходит внутри обработчика прерываний по таймеру 2.

Для программной реализации генерации синуса пришлось написать отдельный программный компонент DDS (Direct Digital Synthesis). Реализация DDS - это отдельная большая задача. Его задача - обсчитывать семплы для генерации sin функции. Хороший DDS может генерировать все виды сигналов: PWM, SAW, Fence, Chirp, DTMF, BPSK и прочие.
float calc_sin_sample(uint64_t time_us, float frequency, float phase_ms, float des_amplitude, float in_offset) { float lineVal = 0.0f; float argument = 0.0f; float amplitude = 0.0f; float amplitude_scaled = 0.0f; float cur_time_ms = ((float)time_us) / 1000.0f; lineVal = ((cur_time_ms + phase_ms) / 1000.0f) * frequency; argument = 2.0f * M_PI * lineVal; amplitude = (float)sinf((float)argument); amplitude_scaled = (des_amplitude * amplitude) + in_offset; return amplitude_scaled; }
Как можно заметить, сигналы совпадают по частоте, амплитуде и форме. Значит связка ADC, DMA, RxFIFO,TxFIFO DAC работает корректно. Отличие по фазе пропорционально размеру TxFIFO.

Идеи проектов на ADC в STM32
1--Можно сделать тестер пальчиковых батареек типа AAA или AA. Померять значение напряжения, сравнить больше ли, чем 1,5V и, если больше, то зеленым светодиодом показать, что батарейка работает. Если измеренное напряжение меньше 1.5V, то включить красный светодиод.
2--Можно сделать мультиметр, измерять напряжения, сопротивления через делитель напряжения. Подключить аналоговый термопары.
3--Если подключить экран, то можно сделать осциллограф.
4--Между ADC и DAC можно реализовать какую-н цифровую фильтрацию сигнала. Сделать эхо эффект.
5--При помощи ADC делают непрерывное слежение за уровнем заряда батарей в BMS платах.
Результат
Удалось научиться читать приложенное к пину напряжение при помощи встроенного в STM32 модуля ADC. Как видите, чтобы просто прочитать напряжение при помощи ADC Вам надо настроить GPIO, ADC, DMA, TIMER. Добавить надёжную абстрактную структуру данных FIFO. Запустить UART, чтобы можно было всё это отлаживать через CLI. Плюс еще запустить DAC и реализовать программный компонент DDS, чтобы отлаживаться в режиме аналогового эха.
Такова специфика программирования микроконтроллеров. Собираешься делать одно, а по ходу работы выясняется, что надо ещё целая куча всего другого.
Словарь
Сокращение |
Расшифровка |
ADC |
Analog-to-digital converter |
DMM |
Digital Multi Meter |
DDS |
Direct Digital Synthesis |
DMA |
Direct memory access |
DAC |
Digital-to-analog converter |
FIFO |
first in, first out |
Ссылки
Название |
URL |
АЦП преобразования в указанные моменты времени на STM32 @DIVON |
|
Звуковая карта USB на STM32. Часть 2: Используем встроенный АЦП |
|
Обзор учебно-тренировочной платы JZ-F407VET6 (или электронная парта) |
|
Бинарь Demo прошивки для JZ-F407VET6 |
https://github.com/aabzel/Artifacts/tree/main/jz_f407vet6_adc_dma_dac_gcc_m |
Реализация очереди IQUEUE |
https://github.com/devcoons/iso15765-canbus/blob/master/lib/lib_iqueue.h |
STM32 ADC and DAC with DMA |
|
Successive-approximation ADC |
|
STM32F429: аналого-цифровые преобразователи (АЦП) |
https://microsin.net/programming/arm/stm32f429-analog-to-digital-converters.html |
STM32 ADC (АЦП) и DMA. Обзор, настройка и пример проекта. |
https://microtechnics.ru/stm32-adc-aczp-i-dma-obzor-nastrojka-i-primer-proekta/ |
Пуск I2S Трансивера на Artery [часть 2] (DMA, FSM, PipeLine) |
Вопросы
--Зачем нужно выравнивание ADC семплов влево? Ведь это потом неудобно анализировать при печати.
Комментарии (11)

x89377
26.04.2026 13:16"Хорошей задачей для студентов будет задача исправить эти ошибки."
Студентам это лучше не показывать, а то они сразу в академики пойдут.
aabzel Автор
26.04.2026 13:16а то они сразу в академики пойдут.
Да. Автор теоремы Котельникова - Владимир Александрович как раз был академиком.

0xdefec8ed
26.04.2026 13:16Сторонняя "умная" реализация fifo здесь действительно не нужна - звуковой семпл это не какая-то сложная структура, добавляются семплы с известной периодичностью, как и читаются. Фифо у вас уже есть, и декью при этом - просто чтение данных по указателю на начало или половину буфера + n семплов назад. Реально сдвигать очередь не нужно - запись всегда идёт в другую половину.
Как только вы попробуете крутить хоть сколько-нибудь сложное DSP, это станет очевидно по быстродействию.

aabzel Автор
26.04.2026 13:16Да. Можно добавить второй DMA канал на отправку в DAC прямо из того же массива куда пишет DMA от ADC.
DDS-ом один раз сгенерировать массив предварительно рассчитанного 1го периода синуса в RAM память. (Чем меньше частота синуса, тем длиннее массив).
И уже третьим каналом DMA из предварительно рассчитанного одного периода синтезированного синуса в RAM памяти отправлять семплы в DAC.Тогда для CPU вообще не остается никакой работы.

rukhi7
26.04.2026 13:16DDS это точно НЕ вычисление синуса (значений гармонической функции), это чтение значений из таблицы этой гармонической функции записанной с высоким разрешением по времени. Вы маленько путаете людей, но если для популяризации абревиатуры и чтобы примерно обозначить о чем эта абривиатура, наверно сойдет.
Если читать из таблицы то ДМА будет очень в тему в этом случае, кстати!

aabzel Автор
26.04.2026 13:16Если читать из таблицы то ДМА будет очень в тему в этом случае, кстати!
Да. Я так и делал, когда тестировал аудиокодеки.
Обзор Aппаратного Aудио кодека MAX9860 (2x ADC+DAC)
https://habr.com/ru/articles/758140/"
Как отладить I2S сейчас? Можно воспроизвести статический синус. Именно статический чтобы исключить ошибки в динамическом выделении памяти. Если выбрать частоту, например, 1kHz, то получится, что надо рассчитать всего 48 PCM отсчетов, чтобы синтезировать гармонический сигнал. В прошивке достаточно зарезервировать 48*2*2=192 байт.Прямо в прошивке расчитать 1 период для синуса 1kHz, амплитудой 3333 PCM, частоты дискретизации 48kHz и проигрывать его в цикле по DMA.

Look Up Table предварительно рассчитанного синуса для FS: 48kHz На шине I2S должен появится такой цифровой сигнал. На проводе BCLK 1.724 MHz, LRCLK 53.591 kHz, OUTN 1.118 kHz
....
"
0xdefec8ed
26.04.2026 13:16Как при частоте fs = 48 кгц вы получили LRCLK 53.591 кГц?

aabzel Автор
26.04.2026 13:16Там был MCU nrf5340.
От своего PLL он не мог дать на I2S ровно 48kHz.
Поэтому пришлось работать с наиболее приближенной частотой к 48kHz. Это было значение 53.591 kHz
rukhi7
Очень поучительная статья и очень поучительная, можно сказать базовая фундаментальная задача программирования Цифровой Обработки Сигналов (ЦОС) в частности и эмбеддед программирования вообще. Только в реальном мире (а не в тепличных учебных условиях) дополнительная ФИФО очередь будет только мешать, и скорее всего все сломает всю работу алгоритма, так как она работает только при наличии очень большой памяти, которой в эмббедед мире никогда нет (не хватает). Потом вызывать функцию для копирования каждого сэмпла, да еще не одну - это просто убийство производительности, которая очень ограничена например в любом STM процессоре.
Как по мне это совсем не так:
Это нисколько не сложнее реализации циркулярного буфера (с использованием ДМА или без использования). Обычно в этом месте требуется реализация циркулярного буфера который используется как ФИФО с контролем переполнения этого буфера.
Ну и непонятно почему одну функцию копирования половины буфера надо скопировать два раза как:
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) {
и как:
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) {
Вообще говоря не хорошо оставлять копирование целого буфера, да еще и с многократным использованием каких-то сторонних (библиотечных?) функций непонятной производительности внутри прерывания. Это прям нарушение базовых принципов работы с прерываниями.
Хорошей задачей для студентов будет задача исправить эти ошибки. Я даже думаю лучший вариант со всеми исправленными ошибками и корректными методами измерения эффекта увеличения производительности при этом, вполне потянул бы на кандидатскую диссертацию, а может и на докторскую. Если возьметесь буду рад оказать вашей группе любое техническое (экспертное на базе своего опыта)... любое содействие в этом направлении.
aabzel Автор
Вторая fifo (для dac) нужна только для отладки факта корректной записи adc модулем. Из приложения я ее конечно же исключу.
TxFifo выступает в роли строительных лесов.