Пришло время разобраться, что из себя представляет модуль ADC, он же АЦП, в микроконтроллерах STM32. Давайте по привычной схеме, сначала кратко пройдемся по теории, во второй части статьи - практический пример.
Теоретическая часть.
ADC в микроконтроллерах STM32.
И начнем с основных характеристик АЦП в STM32:
- АЦП является 12-ти битным.
- Возможна генерация прерывания по окончанию преобразования, по окончанию преобразования с инжектированного канала, а также возможно прерывание от Analog Watchdog (что это такое расскажу чуть ниже).
- Возможно одиночное преобразование и преобразование в непрерывном режиме.
- В наличии присутствует самокалибровка.
- Доступен запуск преобразования от внешнего события.
- Может работать в связке с DMA.
Структурная схема из даташита:
Пока не забыл про Analog Watchdog, опишу его работу.
Он нужен для того, чтобы следить, что измеренное напряжение не выходит за определенные значения. Причем может сканироваться как конкретный канал, так и группа каналов. В регистры ADC_HTR и ADC_LTR заносим значения верхнего и нижнего порога соответственно, и в случае, если проверяемое напряжение выходит за эти пределы, генерируется прерывание. Полезнейшая вещь 👍
Каналы АЦП делятся на регулярные и инжектированные. Причем, если запустить измерение инжектированных каналов, то измерение регулярных будет приостановлено. Еще одна очень полезная функция.
Как и в микроконтроллерах AVR, возможно выравнивание результата по правому или по левому краю. Тут правда результат 12-ти битный, но суть та же. Вот как все это выглядит в регистрах:
Пробежим быстренько по основным регистрам для работы с АЦП. Хоть мы и не будем с ними взаимодействовать напрямую, так как задействуем библиотеку HAL, все равно необходимо уметь работать в том числе и непосредственно с регистрами. Не буду переписывать весь даташит, пройдемся по основному:
- Регистр ADC_SR. Статусный регистр. Тут хранятся флаги – например, флаг, сигнализирующий об окончании преобразования.
- Регистр ADC_CR1. Контрольный регистр. Тут всякие биты, разрешающие или запрещающие те или иные события. Например, включение Analog Watchdog для регулярных каналов, аналогично для инжектированных, включение различных прерываний – все это находится здесь, в этом регистре.
- Регистр ADC_CR2. Еще один контрольный регистр, содержащий биты, отвечающие за использование АЦП совместно с DMA, а также за запуск преобразования от внешнего источника. Кроме того, тут можно разрешить или запретить использование датчика температуры. Важный бит – SWSTART, который запускает преобразование регулярных каналов.
- Регистры ADC_SMPRx отвечают за время выборки.
- В ADC_SQR и ADC_JSQR выбираем номера нужных нам каналов.
- И, наконец, регистр данных – ADC_DR – там и только там мы будем забирать наши драгоценные результаты.
Обзорный тур по теоретическим аспектам позволил нам проникнуться духом АЦП, поэтому самое время перейти непосредственно к делу.
Один из самых популярных вопросов, который мне задают, связан с последовательными преобразованиями нескольких входных каналов. И это не удивительно, ведь АЦП - по праву один из самых востребованных модулей микроконтроллера в проектах любой сложности. А режим опроса нескольких каналов - один из самых удобных. И если сюда добавить еще и DMA, то получаем абсолютно автоматизированный процесс измерения напряжений на входах. Нам остается только анализировать готовые данные 👍
Сегодня мы как раз это и реализуем, ставим себе следующую задачу:
- измерять напряжение на 6-ти входах микроконтроллера
- задействовать DMA для сохранения результатов в специальный буфер
- анализировать данные будем по минимуму, давайте просто пересчитаем единицы АЦП в Вольты для наглядности.
Между делом накидаю еще ссылок с использованием ADC в STM32:
- Измерение напряжения питания микроконтроллера STM32.
- Фильтрация и избавление от шумов в данных АЦП.
- Мониторинг напряжения аккумулятора на микроконтроллере STM32.
Продолжаем... Наверно никого не удивлю выбором контроллера для реализации этого проекта - использовать буду STM32F10x, а точнее STM32F103VET6. И, конечно, последние несколько лет почти ни один проект для STM32 не обходится без использования STM32CubeMx.
Практическая часть.
Инициализация.
Процесс будет абсолютно одинаковым для любых входов АЦП, вообще ни малейшего отличия, поэтому пусть будут:
- PA0: ADC1, канал 0
- PA2: ADC1, канал 2
- PC1: ADC1, канал 11
- PC2: ADC1, канал 12
- PC3: ADC1, канал 13
- PC5: ADC1, канал 15
Всего 6 каналов, как и планировали. Приступаем к делу. Запускаем CubeMx и создаем новый проект. Я обычно работаю в IARv8, но, на самом деле, не знаю, насколько он пользуется популярностью по сравнению с 7-ой версией, поэтому для этого проекта использую именно IARv7. Напишите в комментариях, кто какую версию использует для своих проектов на сегодняшний день )
Проект создан, сразу же активируем необходимые выводы микроконтроллера. Это у нас - 6 входов АЦП, 2 входа для внешнего кварца и 2 входа для подключения отладчика:
Далее привычным нам образом настраиваем тактирование всего и вся:
Задаем все по максимуму - 72 МГц для APB2, 36 МГц - APB1, на модули ADC1, 2, 3 отправляем 12 МГц. В качестве источника тактирования выбираем внешний кварцевый резонатор, который мы уже активировали на вкладке Pinout&Configuration ранее.
Базовую подготовку и настройку проекта на этом заканчиваем и переходим непосредственно к интересующим нас ADC и DMA. Окно настроек АЦП:
Параметры конфигурируем следующим образом:
Давайте разберем все значения подробнее:
- Mode - здесь оставляем Independent mode, в общем-то, другие варианты сейчас недоступны (потому что мы задействовали только один модуль ADC)
- Data alignment - выравнивание данных по правому краю
- Scan Conversion Mode - этот параметр активизируем в том случае, если собираемся работать с несколькими каналами
- Continious Conversion Mode - отключаем, будем запускать преобразования программно
- Enable Regular Conversions - каналы настраиваем как регулярные
- Number Of Conversion - здесь указываем число, равное числу каналов, которые мы будем опрашивать (в данном примере у нас 6 каналов)
- External Trigger Conversion Source - этот параметр позволяет настроить событие, по которому будет запускаться АЦП, пока не используем
- и, наконец, далее следуют настройки для каждого из каналов в отдельности.
Для каналов АЦП у нас есть три параметра. Rank и Channel позволяют установить очередность опроса. Поскольку нам очередность не важна, будем опрашивать каналы просто по порядку, в соответствии с их номерами. С этим все понятно, а вот на параметре Sampling Time хотелось бы остановиться поподробнее.
ADC в STM32 работает в общих чертах так... Все входные каналы АЦП подключаются к аналоговому мультиплексору, на выходе которого установлен конденсатор. При измерении напряжения N-го канала мультиплексор переключается в соответствующее состояние и входной сигнал начинает заряжать конденсатор. Собственно, напряжение на этом конденсаторе и будет подвергаться измерению. А параметр Sampling Time это ни что иное, как время в тактах, которое выделяется на заряд этого внутреннего конденсатора.
Возникает вполне резонный вопрос - почему бы не делать всегда это время минимальным? Меньшее время измерения - лучше?
Тонкость заключается в том, что если ток входного сигнала мал, то конденсатор просто не успеет зарядиться. Поэтому измеренное значения напряжения будет неточным. В таком случае необходимо увеличить Sampling Time этого канала. Общее же время преобразования вычисляется следующим образом:
T_{conv} = Sampling Time + 12.5\medspace cycles
Значение 12.5 соответствует контроллерам серии STM32F10x. Для STM32F4, например, это значение составляет 12 тактов. В принципе, разница невелика.
Возвращаемся к STM32CubeMx, с АЦП мы разобрались, переходим на вкладку настроек DMA:
Здесь у нас:
- Increment Address - адрес регистра данных в периферии - не инкрементируем, адрес в памяти - инкрементируем
- Data Width - Half Word - 16 бит данных.
Пример использования ADC и DMA.
С настройками на этом заканчиваем, генерируем проект и вносим в него наши изменения. Первым делом определим переменные, при этом не забывайте о том, чтобы помещать весь пользовательский код в секции USER_CODE
. Код, помещенный в такие секции не будет удален/изменен/перемещен при перегенерации проекта. Добавим прямо в файл main.c:
/* Private define ------------------------------------------------------------*/ /* USER CODE BEGIN PD */ #define ADC_CHANNELS_NUM 6 /* USER CODE END PD */ /* USER CODE BEGIN PV */ uint16_t adcData[ADC_CHANNELS_NUM]; float adcVoltage[ADC_CHANNELS_NUM]; /* USER CODE END PV */
В буфере adcData[]
будем сохранять сырые данные с АЦП, а в adcVoltage[]
- пересчитанные в Вольты значения. В функции main()
добавляем запуск наших последовательных преобразований функцией HAL_ADC_Start_DMA()
:
int main(void) { /* USER CODE BEGIN 1 */ /* USER CODE END 1 */ /* MCU Configuration--------------------------------------------------------*/ /* Reset of all peripherals, Initializes the Flash interface and the Systick. */ HAL_Init(); /* USER CODE BEGIN Init */ /* USER CODE END Init */ /* Configure the system clock */ SystemClock_Config(); /* USER CODE BEGIN SysInit */ /* USER CODE END SysInit */ /* Initialize all configured peripherals */ MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); /* USER CODE BEGIN 2 */ HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adcData, ADC_CHANNELS_NUM); /* USER CODE END 2 */ /* Infinite loop */ /* USER CODE BEGIN WHILE */ while (1) { /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } /* USER CODE END 3 */ }
Запустили АЦП и DMA и ожидаем результат. По окончании преобразования будет вызвана callback-функция HAL_ADC_ConvCpltCallback()
. Для использования этой функции нам нужно просто переопределить ее и добавить туда любые необходимые нам операции:
/* USER CODE BEGIN 4 */ void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if(hadc->Instance == ADC1) { for (uint8_t i = 0; i < ADC_CHANNELS_NUM; i++) { adcVoltage[i] = adcData[i] * 3.3 / 4095; } } } /* USER CODE END 4 */
Здесь мы проверяем, что источником вызова функции является именно модуль ADC1, и спокойно обрабатываем принятые данные. В нашем случае мы пересчитываем сырые значения в значения напряжения в Вольтах. Вот и все!
Собираем проект, прошиваем микроконтроллер и теперь мы можем под отладчиком проверить измеренные значения, которые в точности соответствуют напряжению, которое мы подаем на входы. Для того, чтобы запустить еще одно преобразование просто вызываем снова:
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adcData, ADC_CHANNELS_NUM);
Но! Следует отметить, что не стоит запускать преобразование непосредственно из callback-функции. Это может привести к тому, что новое преобразование завершится настолько быстро, что программа просто не успеет перейти к выполнению другого кода и будет постоянно крутиться в callback-функции.
На этом на сегодня заканчиваем, спасибо за внимание и до новых встреч на нашем сайте 🤝
Проект для этой статьи - ссылка.
Архив битый
Проверил Chrome/Firefox - скачалось, разархивировалось.
Выкладываю дополнительно на всякий - https://disk.yandex.ru/d/6g-Wp53WFZRXhA
Благодарю, теперь все отлично.
очепятка " * 3.3 / 4095 "
Благодарю)
Добрый день! не совсем понятно почему в HAL_ADC_Start_DMA передается указатель на uint32_t в то время как из ADC мы получаем 16-битное значение и массив, в котором хранятся полученные значения тоже из 16-битных элементов
Хороший вопрос)
Доброго времени! Функция uint32_t* принимает.
пример не рабочий
Добрый день, какие симптомы, что именно не работает?
Добрый день. Пробовал этот пример на stm32L151. Записывает в буфер только первый вход АЦП.
А можете проект скинуть?
Добрый день
а как записать напримр 512 отсчетов с одного входа АЦП . с помощью ДМА
const int adcChunksize =512;
uint16_t ADC_buff1 [adcChunksize];
uint16_t ADC_buff2 [adcChunksize];
при этом ацп драйвится от timer 4 trigger out event
(каждые 2 мксек на отсчет по настройкам таймера )
потом когда запишется буфер 1 я анализирую и дучаю что с ним делать
( на это есть целая милисекунда)в то же время пишется буфер 2
.когда запишется буфер 2 я думаю что с ним делать
при этом пишется снова буфер 1
Ошибка тут:
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adcData, ADC_CHANNELS_NUM);
куда данные лягут? Может все же так - (uint32_t*)&adcData
Имя массива соответствует указателю на первый элемент массива, поэтому так неправильно:
Можно так:
или так:
Проверил, в железе все варианты рабочие, на Proteus adc stm32 врет, особенно при опросе нескольких регулярных/инжектированных каналов на одном ацп
Я proteus'ом сам особо не пользуюсь, но вроде как всплывают периодически какие-то нюансы, которые при симуляции не так работают.
доброго времени суток, та же проблема, cube указывает что такая форма команды - синтаксическая ошибка. Перепробовал все предложенные варианты, куда копать? Где?? версия workspace 1.10.1
Доброго! А какой сейчас синтаксис и какой текст ошибки?
Могу не корректно ответить, опыта мало еще. Что в итоге, так как этот проект первоначально создавал на плате от Дискавери, у которого на борту была выстроена своя инфраструктура, видимо был конфликт. Сделал проект абсолютно с белого листа, без указания что это Дискавери, все работает. Подскажите, а как можно посмотреть получаемые данные из под отладчика?? Или где посмотреть? Задача банальная, считать данные с 3 датчиков тока и вывести их на 2004 в реальном времени, )) бьюсь второй день)))))
Под отладчиком окна Watch / Live Watch, туда добавляешь нужные переменные. Названия окон в зависимости от IDE могут немного отличаться, но суть такая) Watch - значения обновятся при остановке программы, Live Watch - будут обновляться в реальном времени.
Весьма интересная и полезная статья. Толково написано - все понятно и без лишней воды. Но вот я столкнулся с несколько более сложной задачей и не могу придумать как ее грамотно решить, не заставляя ядро МК заниматься перебором данных.
В примере 6 каналов и с каждого по 1 значению, а в моем случае каналов 8 и с каждого по 4096 значений. Оцифровать все в буфер на 64 кБайта - не проблема, а вот что с ними дальше делать? Мне после преобразований нужно выдать содержимое каналов по очереди в USART. Через DMA нельзя - там каша. Или можно что то придумать с DMA? Кроме цикла по всему буферу с перекладыванием нужных данных в другой буфер под один канал ничего в голову не приходит. А это долго (очень) и память не резиновая, т.к. нужно будет буфер на 64к для оцифровки и еще столько же памяти под 8 буферов по 8к.
Может подскажете что то?
Пара мыслей навскидку - организовать так, чтобы данные с каждого из каналов в отдельности изначально попадали в свои отдельные буферы, чтобы потом обработку не делать с перекладыванием. И можно рассмотреть вариант пост-обработки, например, если измеряемое значение не меняется (изменение не превышает порог), то его не хранить, а хранить информацию о том, что такое-то количество времени значение не менялось.
Еще в плане экономии памяти - упаковать, чтобы хранить только по 12 бит на измерение.
PS
Пока пишу вариант с циклом и раскладыванием - посмотрю сколько реально времени это будет занимать.
Хм, может по завершению преобразования (однократного) в прерывании по-быстрому результаты (8 значений) раскидывать в 8 буферов? На выходе будем иметь эти 8 буферов для отправки и обработки.
А вообще интересная мысль... Сделать преобразования не через DMA а через прерывания и сразу обрабатывать. Пока полного комплекта данных нет, ядру все равно почти нечем заниматься, а это кололо 100 миллисекунд. Частота дискретизации не высокая, должен успевать. А дальше уже постобработка и отправка в UART через DMA нужного буфера. Спасибо, буду пробовать.
Решил написать, что получилось в итоге, вдруг кому то пригодиться:
Запускаем таймер и ждем когда все данные будут готовы.
FlagADCEnd=0;
GlobalCountADC=0;
HAL_TIM_Base_Start_IT(&htim13);
// Запускаем таймер оцифровки
while(FlagADCEnd==0) {} // Ждем окончанеия считывания
Запуск АЦП с ДМА идет по прерываниям от таймера через каждые 18мкс.
Оцифровываются 8 каналов через DMA (по одному значению с каждого канала). При прерывании от таймера проверяем счетчик оцифровок.
Если оцифрованы и разложены все данные - останавливаем таймер, устанавливаем флаг окончания всех преобразований.
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim->Instance == TIM13) // Если прерывнаие от таймера 13 (1Гц)
{
if (GlobalCountADC==ADC_CH_SAMPLES) // Если выполнили всю оцифровку
{
HAL_TIM_Base_Stop_IT(&htim13); // Отсаналиваем таймер
FlagADCEnd = 1; // Установить флаг - завершена оцифровка
}
else // Иначе снова оцифровываем 8 каналов по DMA
{
HAL_ADC_Start_DMA(&hadc1, (uint16_t*) ArrayADC_uint16, ADC_CH_COUNT);
}
}
}
В прерывании по готовности DMA увеличиваем глобальный счетчик отсчетов и раскладываем данные по буферам, заодно переводя их в напряжение. На все уходит 2мкс. И еще 16 в запасе. Данные с каналов 7 и 8 пока игнорирую.
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc)
{
if(hadc->Instance == ADC1) // Если АЦП заверширо работу DMA - готовы 8 значений
{
// Раскладываем значения по буферам и переводим в вольты
// Время на раскладывание и умножение 1,8мкс лимит еще примерно 16мс всего 18мкс
ArrayADCCH_float1[GlobalCountADC]=(float)ArrayADC_uint16[0]*0.000805664f;
ArrayADCCH_float2[GlobalCountADC]=(float)ArrayADC_uint16[1]*0.000805664f;
ArrayADCCH_float3[GlobalCountADC]=(float)ArrayADC_uint16[2]*0.000805664f;
ArrayADCCH_float4[GlobalCountADC]=(float)ArrayADC_uint16[3]*0.000805664f;
ArrayADCCH_float5[GlobalCountADC]=(float)ArrayADC_uint16[4]*0.000805664f;
ArrayADCCH_float6[GlobalCountADC]=(float)ArrayADC_uint16[5]*0.000805664f;
GlobalCountADC++; // Увеличиваем глобальный счетчик отсчетов
}
}
Суммарно по 8192 отсчета с каждого канала накапливаются за 150мс и лежат по своим буферами, преобразованные в напряжение.
Затем можно например передать в ПК нужный канал:
FlagTXEnd=0; // Передаем данные в ПК
HAL_UART_Transmit_DMA(&huart1, (uint8_t *)ArrayADCCH_float1, ADC_CH_SAMPLES*4);
while(FlagTXEnd==0) {}
В общем, большое спасибо за идею!!!
По идее можно еще так. Запускаем однократно:
И в прерывании раскладываем по буферам, запускать АЦП не надо:
Но, важно, чтобы код в прерывании выполнялся быстрее, чем наступит новое прерывание по окончанию преобразования.
В прерывании считаем, сколько собрали данных, когда собрали полный набор, выставляем флаг и в основном цикле или из отдельной задачи обрабатываем дальше.
Вы читаете результаты преобразования из массива в функции HAL_ADC_ConvCpltCallback. Но, если я правильно понимаю, эта функция вызывается после завершения именно преобразования, а не после того, как DMA завершит копирование данных. DMA конечно быстро работает, но по-моему корректнее читать массив в callback-фунции самого DMA. Или я что-то путаю?
HAL_ADC_ConvCpltCallback при работе через DMA вызывается по колбэку уже самого DMA.