Top.Mail.Ru

STM32 ADC (АЦП) и DMA. Обзор, настройка и пример проекта.

Пришло время разобраться, что из себя представляет модуль ADC, он же АЦП, в микроконтроллерах STM32. Давайте по привычной схеме, сначала кратко пройдемся по теории, во второй части статьи - практический пример.

Теоретическая часть.

ADC в микроконтроллерах STM32.

И начнем с основных характеристик АЦП в STM32:

  • АЦП является 12-ти битным.
  • Возможна генерация прерывания по окончанию преобразования, по окончанию преобразования с инжектированного канала, а также возможно прерывание от Analog Watchdog (что это такое расскажу чуть ниже).
  • Возможно одиночное преобразование и преобразование в непрерывном режиме.
  • В наличии присутствует самокалибровка.
  • Доступен запуск преобразования от внешнего события.
  • Может работать в связке с DMA.

Структурная схема из даташита:

Структурная схема АЦП в STM32.

Пока не забыл про 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:

Продолжаем... Наверно никого не удивлю выбором контроллера для реализации этого проекта - использовать буду 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 входа для подключения отладчика:

Настройка АЦП в STM32.

Далее привычным нам образом настраиваем тактирование всего и вся:

Настройки тактирования в STM32CubeMx.

Задаем все по максимуму - 72 МГц для APB2, 36 МГц - APB1, на модули ADC1, 2, 3 отправляем 12 МГц. В качестве источника тактирования выбираем внешний кварцевый резонатор, который мы уже активировали на вкладке Pinout&Configuration ранее.

Базовую подготовку и настройку проекта на этом заканчиваем и переходим непосредственно к интересующим нас ADC и DMA. Окно настроек АЦП:

Конфигурация АЦП в STM32CubeMx.

Параметры конфигурируем следующим образом:

STM32 АЦП (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:

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-функции.

На этом на сегодня заканчиваем, спасибо за внимание и до новых встреч на нашем сайте 🤝

Проект для этой статьи - ссылка.

Подписаться
Уведомить о
guest

28 комментариев
Старые
Новые
Межтекстовые Отзывы
Посмотреть все комментарии
LexResident
LexResident
3 лет назад

Архив битый

LexResident
LexResident
Ответ на комментарий  Aveal
3 лет назад

Благодарю, теперь все отлично.

sid
sid
2 лет назад

очепятка " * 3.3 / 4095 "

Алексей
Алексей
1 год назад

Добрый день! не совсем понятно почему в HAL_ADC_Start_DMA передается указатель на uint32_t в то время как из ADC мы получаем 16-битное значение и массив, в котором хранятся полученные значения тоже из 16-битных элементов

Илья
Илья
Ответ на комментарий  Алексей
1 год назад

Хороший вопрос)

Александр
Александр
10 месяцев назад

пример не рабочий

Александр
Александр
Ответ на комментарий  Aveal
10 месяцев назад

Добрый день. Пробовал этот пример на stm32L151. Записывает в буфер только первый вход АЦП.

12val12
12val12
8 месяцев назад

Добрый день
а как записать напримр 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

Александр
Александр
4 месяцев назад

Ошибка тут:
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adcData, ADC_CHANNELS_NUM);
куда данные лягут? Может все же так - (uint32_t*)&adcData

Александр
Александр
Ответ на комментарий  Aveal
4 месяцев назад

Проверил, в железе все варианты рабочие, на Proteus adc stm32 врет, особенно при опросе нескольких регулярных/инжектированных каналов на одном ацп

Сергей
Сергей
Ответ на комментарий  Александр
2 месяцев назад

доброго времени суток, та же проблема, cube указывает что такая форма команды - синтаксическая ошибка. Перепробовал все предложенные варианты, куда копать? Где?? версия workspace 1.10.1

Сергей
Сергей
Ответ на комментарий  Aveal
2 месяцев назад

Могу не корректно ответить, опыта мало еще. Что в итоге, так как этот проект первоначально создавал на плате от Дискавери, у которого на борту была выстроена своя инфраструктура, видимо был конфликт. Сделал проект абсолютно с белого листа, без указания что это Дискавери, все работает. Подскажите, а как можно посмотреть получаемые данные из под отладчика?? Или где посмотреть? Задача банальная, считать данные с 3 датчиков тока и вывести их на 2004 в реальном времени, )) бьюсь второй день)))))

Сергей
Сергей
2 месяцев назад

Весьма интересная и полезная статья. Толково написано - все понятно и без лишней воды. Но вот я столкнулся с несколько более сложной задачей и не могу придумать как ее грамотно решить, не заставляя ядро МК заниматься перебором данных.
В примере 6 каналов и с каждого по 1 значению, а в моем случае каналов 8 и с каждого по 4096 значений. Оцифровать все в буфер на 64 кБайта - не проблема, а вот что с ними дальше делать? Мне после преобразований нужно выдать содержимое каналов по очереди в USART. Через DMA нельзя - там каша. Или можно что то придумать с DMA? Кроме цикла по всему буферу с перекладыванием нужных данных в другой буфер под один канал ничего в голову не приходит. А это долго (очень) и память не резиновая, т.к. нужно будет буфер на 64к для оцифровки и еще столько же памяти под 8 буферов по 8к.
Может подскажете что то?

Сергей
Сергей
Ответ на комментарий  Aveal
2 месяцев назад
  1. "в свои отдельные буферы" - идеальный вариант, вопрос в том - как? Можно ли заставить DMA укладывать не по прядку, а например со смещением? Тогда можно было бы потом просто играть с адресами, указывая на начало нужного участка в памяти.
  2. "пост-обработка" тут и так по сути пост обработка. Кроме передачи в уарт я еще над этими данными работаю. Хранить часть значений никак - я захватываю сигнал и обрабатываю его, нельзя выкинуть часть отсчетов. Просто время на пост обработку ограничено, поэтому жалко терять ресурс на прогон цикла по раскладыванию данных.
  3. Резерв памяти пока есть - всего 256кБ. Но еще не писал обработку, а там тоже буфера нужны будут.

PS
Пока пишу вариант с циклом и раскладыванием - посмотрю сколько реально времени это будет занимать.

Сергей
Сергей
Ответ на комментарий  Aveal
2 месяцев назад

А вообще интересная мысль... Сделать преобразования не через DMA а через прерывания и сразу обрабатывать. Пока полного комплекта данных нет, ядру все равно почти нечем заниматься, а это кололо 100 миллисекунд. Частота дискретизации не высокая, должен успевать. А дальше уже постобработка и отправка в UART через DMA нужного буфера. Спасибо, буду пробовать.

Сергей
Сергей
Ответ на комментарий  Сергей
2 месяцев назад

Решил написать, что получилось в итоге, вдруг кому то пригодиться:
Запускаем таймер и ждем когда все данные будут готовы.

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) {}
В общем, большое спасибо за идею!!!

28
0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x