STM32 - DMA часть первая





Здравствуйте.

Про DMA с примерами на HAL.


В первую очередь нужно напомнить — что бы мы не делали в микроконтроллере, будь то работа с таймером, или приём байт по какой-либо шине данных (USART, I2C, SPI и т.д.), или управление GPIO, или любая другая операция, всё это ни что иное как запись единицы или ноля в определённую ячейку (регистр) памяти.

В stm32 память поделена на разные области, а область с регистрами отвечающими за перечисленную выше периферию, обозначена как Peripheral...



То есть, когда мы хотим подать «плюс» или «минус» на какую-то ножку МК, мы просто записываем единицу или ноль в определённый регистр в области Peripheral, и происходит соединение/разъединение этой ножки с электрической шиной. То же самое происходит с таймером — когда мы хотим его запустить, то записываем единичку в нужный регистр, таймер соединяется с тактирующей шиной и начинает тикать. Если хотим узнать сколько он натикал, то лезем в соответствующий регистр и смотрим что там лежит.

Когда отправляем данные по USART'у, то берём данные из какого-то нашего массива, и последовательно перекладываем их в регистр (USART_DR), а из него они улетают по проводкам. При приёме всё происходит в обратном порядке — байты прилетают в приёмный регистр (он так же называется USART_DR, но для приёма и отправки они разные), а мы обязаны быстренько их оттуда забирать и куда-то сохранять (если забрать не успели, то получим ошибку).



Так вот, к чему я это всё тут понаписал — все эти действия, запись в регистр, чтение, перекладывание из массива в регистр и обратно, производятся с помощью ЦПУ…


То есть ЦПУ тратит своё время на эти операции и само собой программа ничего другого в это время делать не может.




И тут мы подошли к основному вопросу — что такое DMA (Direct Memory Access) — прямой доступ к памяти (само название как-бы намекает ).


DMA это отдельный блок (или несколько блоков, в зависимости от «жирности» камня), подключённый через Bus Matrix, к различной периферии и различной памяти…



… который может самостоятельно, без участия ЦПУ, перегонять данные из регистров периферии в оперативную память и обратно, либо из периферии в периферию, либо из памяти в память. То есть мы можем сказать DMA, — «вот массив байт в ОЗУ, возьми их и отправь по USART'у». Либо наоборот, — «сиди и жди когда придут данные по USART'у, и сложи их в такой-то массив». А теперь самое главное — вся эта работа будет происходить без какого либо участия ЦПУ, всё будет делать само DMA…


То есть, мы даём команду DMA, и можем дальше выполнять код из программы. Таким образом программа распараллелится, DMA будет получать данные из USART'а, а ЦПУ делать что-то другое. Конечно же не всё так просто, а точнее вообще не просто, шины не резиновые , и надо учитывать пропускную способность, время доступа, и прочее, но об этом в следующей части.

Толстые стрелки на схеме, это шины по которым гоняются данные, а тоненькие (Request) служат для того, чтоб периферия могла подавать различные запросы блоку DMA. Подробнее про Request см. ниже.


Основные возможности

У каждого блока DMA имеется несколько каналов, которые связаны с определённым набором периферии, это нужно смотреть в мануале на конкретный камень, но если вы настраиваете через Куб, то он всё сделает за вас. У некоторых камней есть штуковина под названием DMAMUX (см. в мануале на свой МК), с помощью которой можно настраивать любой канал на работу с любой периферией. В общем это некий мультиплексор каналов.

Каждому каналу DMA можно назначить один из четырёх уровней приоритета (можно оставить одинаковыми). За исполнением приоритетов следит встроенный в блок арбитр (Arbiter). Всё это происходит аппаратно, разработчику нужно только назначить приоритет.

С помощью DMA можно обращаться к Flash, SRAM, периферии на шинах APB1, APB2 и AHB. Любое из устройств может быть как источником, так и приёмником данных. Подробнее нужно смотреть в мануале для конкретного камня.

DMA может вызывать три вида прерываний:

Первый — завершена передача половины данных. Второй — завершена передача всех данных. Прерывание по половинке сделано не спроста, это очень удобный механизм для организации некого подобия «кольцевого буфера». Например, вам нужно непрерывно отправлять какой-то большой объём данных по USART'у, тогда можно сделать так: как только первая половина массива будет отправлена и произойдёт прерывание по половинке, то пока отправляется вторая половина массива, можно заполнять первую половину новыми данными. Как только произойдёт прерывание по завершении передачи всех данных, можно заполнять новыми данными вторую половину массива, и т.д.

Третий вид прерывания, это какая-либо ошибка.

У каждого канала свои прерывания.


Функционал

Механизм работы с DMA очень прост: при копировании данных из памяти в память нужно передать в функцию указатель на буфер-источник, указатель на буфер-приёмник, и количество байт, которые нужно скопировать. Копировать можно байтами (8 бит), полусловами (16 бит), и словами (32 бита).

Если ведётся чтение/запись данных из/в периферийное устройство, то опять же, передаётся указатель на данные, указатель на адрес нужного регистра устройства, и количество данных.




Интерфейсы (I2C, SPI, и т.д.)

Теперь давайте для наглядности настроим USART на передачу массива с помощью DMA…


Тут ничего нового, обычная настройка.


Переходим на вкладку DMA Settings, нажимаем кнопку «Add», в выпадающем списке «Select» выбираем USART1_TX, и кликаем появившуюся строчку…



DMA Request — здесь указывается какая периферия будет работать с DMA. Нам нужно отправлять данные поэтому указываем USART1_ТX. Настраивается либо приём, либо отправка. Если нужно ещё и прием, тогда нужно ещё раз нажать кнопку «Add» и выбрать USART1_RX.

Запрос (Request), это ключевая деталь в работе DMA c любой периферией. В случае с USART'ом это выглядит примерно так: когда мы запустим процесс, DMA спросит у USART'а, — «ты готов?», USART ответит — «готов». После чего DMA отправит в USART один байт и будет ждать от него сообщения что он готов к отправке следующего байта, и так до тех пор пока не будет отправлен весь массив.

Если происходит приём данных, то USART сообщает блоку DMA, — «я получил очередной байт, забери его из регистра USART_DR и переложи в массив».

Любая периферия работает с DMA через запросы — запрос-операция, запрос-операция, и т.д. Если же происходит копирование память-память, то без запросов, просто дали команду и данные гонятся из одного места в другое.

Один запрос-операция называется транзакция. То есть приём/отправка одного байта — это одна транзакция.


Channel — канал настроился автоматически, тут нам не о чём беспокоится. В некоторых случаях появляется несколько каналов на выбор — указывайте любой.


Direction — здесь указывается откуда и куда нужно передавать данные. В данном случае мы будем пересылать данные из памяти, то есть из массива который создадим в программе, в регистр USART_DR, то есть в периферию. Если бы мы принимали данные, то указали бы наоборот — периферия-память.


Priority — тут указываем приоритет каналов. Сейчас у нас задействован всего один канал, поэтому неважно что там указанно. Когда будете использовать несколько каналов, тогда сами решайте какой важнее. Работа приоритетов заключается в том, что когда одновременно прилетают два или более запросов, арбитр решает какой канал запустить первым. Как я уже говорил, работа с DMA не такая простая как кажется на первый взгляд, это связано с доступом к шинам. Во второй части я попробую рассказать об этом чуть подробнее, а на первых порах это не сильно важно.

Если приоритеты будут одинаковые у всех каналов, то первым будет запущен канал с меньшим порядковым номером. То же самое относится и к блокам DMA — приоритет у блока с меньшим номером.


Mode — об этом чуть позже.


Increment Address — здесь указываем что нужно приращивать. Поскольку мы будем копировать массив из нескольких байт находящийся в памяти, то соответственно нам нужно чтобы указатель двигался по этому массиву после отправки очередного байта. Что же касается Peripheral, то естественно ничего прибавлять не нужно, байты последовательно кладутся в регистр USART_DR и улетают.


Data Width — тут указываем размерность данных. Мы передаём восьмибитные байты поэтому Byte. Этот параметр настраивается исходя из того с чем работаем — гоняем 16-ти битные данные, тогда указываем полслова, если 32-ух битные, тогда целое слово. Если указать источник 16 бит, а приёмник 8 бит, тогда DMA обрежет каждый 16-ти битный байт до 8. Если сделать наоборот, тогда DMA дополнит его нулями. В некоторых случаях можно настроить так, что в 32-ух битное слово будет записываться два разных 16-ти битных значения, в частности так делается в парном режиме АЦП, про это в следующей части.

Все эти пункты очень важные. Когда настраиваете, будьте внимательны.


В программе перед бесконечным циклом пишем и прошиваем…

/* USER CODE BEGIN 2 */
  char buff[8] = "istarik\n";
  HAL_UART_Transmit_DMA(&huart1, (uint8_t*)buff, 8);


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

Указатель на источник, указатель на приёмник, и кол-во данных.

Что же касается последнего аргумента — количество данных, то оно не должно превышать 65535, вне зависимости от размерности данных. Это связано с тем, что счётный регистр DMA (CNDTR) шестнадцатибитный.


Теперь вернёмся к настройке режима DMA — Mode ⇨ Normal. Сейчас у нас строка выводится на печать один раз, а если указать Mode ⇨ Circular, тогда DMA будет работать в циклическом режиме. То есть строчка будет печататься бесконечно. Этот режим очень пригодится когда нужно что-то делать постоянно, не только отправлять/принимать данные по USART'у, но и при работе с другой периферией или памятью — запустили и забыли — DMA занимается своим делом, а ЦПУ в вашем распоряжении. Если необходимо изменять данные, то делать это нужно в прерывании…


Прерывание от DMA включается автоматически и его нельзя отключить в Кубе…



Если всё же хочется отключить прерывание, то это можно сделать с помощью функции…

HAL_NVIC_DisableIRQ(DMA1_Channel4_IRQn); // укажите нужный канал

… но если отключите, то не узнаете закончило ли DMA работу.


Колбеки…

/* USER CODE BEGIN 0 */
void HAL_UART_TxHalfCpltCallback(UART_HandleTypeDef *huart)
{
	// передана половина данных
}

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
	// завершена передача всех данных
}
/* USER CODE END 0 */


Тут есть особенность, если включён режим Mode ⇨ Normal, то сработает только колбек по половинке, а если Mode ⇨ Circular, то будут срабатывать оба. Если нужно чтоб в режиме Mode ⇨ Normal сработали оба колбека, включите глобальное прерывание…




С другими интерфейсами (I2C, SPI, и т.д.) работа ведётся примерно так же.




Память-память

Чтобы копировать из памяти в память, нужно в Кубе в разделе Peripherals (левая колонка) тыкнуть DMA, и на вкладке MemToMem проделать то же самое что и с USART'ом — кнопка «Add» ⇨ Select ⇨ MEMTOMEM…



Канал можно выбрать любой (кроме четвёртого, он занят уартом). Копировать будем из буфера в буфер, поэтому инкремент и у источника, и у получателя. Размерность (Data Width) указываем исходя из того, что копируем. То есть если бы массив состоял из 16-ти битных значений, то указали бы Half Word, если из 32-ух битных, то Word.


В программе будем копировать существующий массив в массив-получатель…

/* USER CODE BEGIN 2 */
  char buff[] = "istarik\n";
  char dest[8] = {0,};

  HAL_DMA_Start(&hdma_memtomem_dma1_channel1, (uint32_t)buff, (uint32_t)dest, 8);

  HAL_UART_Transmit(&huart1, (uint8_t*)dest, 8, 1000);


В функцию DMA передаётся номер канала, указатели на буфер-источник и на буфер-получатель, и кол-во байт. Прерывания эта функция не вызывает. Далее выводим на печать буфер-получатель чтоб убедиться что данные скопировались.

Тут стоит обратить внимание на одну важную деталь. Функция DMA работает параллельно, то есть она неблокирующая, то есть получается, что мы запустили копирование и буквально тут же стали выводить на печать. То есть, по идее, у нас ничего не должно было напечататься, так как ещё не успело скопироваться. Но, поскольку для запуска функции вывода нужно какое-то время, то первые символы успевают скопироваться, а пока присходит их отправка (сам по себе вывод в USART довольно таки долгая операция), DMA успевает докопировать всё остальное. Однако такое может прокатить только с USART'ом или ещё где-то, но в других случаях может понадобиться дождаться окончания работы DMA. Для этого есть специальная функция ожидания…

HAL_DMA_PollForTransfer(DMA_HandleTypeDef *hdma, uint32_t CompleteLevel, uint32_t Timeout)


В нашем примере она бы выглядела так…

/* USER CODE BEGIN 2 */
  char buff[] = "istarik\n";
  char dest[8] = {0,};

  HAL_DMA_Start(&hdma_memtomem_dma1_channel1, (uint32_t)buff, (uint32_t)dest, 8);

  HAL_DMA_PollForTransfer(&hdma_memtomem_dma1_channel1, HAL_DMA_FULL_TRANSFER, 1000);

  HAL_UART_Transmit(&huart1, (uint8_t*)dest, 8, 1000);


Первый аргумент это номер канала, который нужно «подождать», второй означает что нужно дождаться копирования всего буфера (не половинки), а последний это таймаут, как в обычных халовских функциях. В циклическом режиме (Mode ⇨ Circular) эта функция не работает.

В целом конечно это абсолютно бессмысленно, так как теряется суть DMA. Правильней использовать прерывание.


Прерывание нужно включить в разделе NVIC…



Потом нужно зарегистрировать (создать) колбек. Для копирования память-память нету дефолтного колбека, поэтому его нужно создавать вручную с помощью функции…

HAL_DMA_RegisterCallback(&hdma_memtomem_dma1_channel1, HAL_DMA_XFER_CPLT_CB_ID, dma_m2m_callback);

Первый аргумент это канал для которого создаётся колбек.

Второй аргумент означает…


Последний аргумент это имя самого колбека, который нужно добавить в программу…

/* USER CODE BEGIN 0 */
void dma_m2m_callback(DMA_HandleTypeDef *hdma_memtomem_dma1_channel1)
{
	// что-то делаем
}


Колбек регистрируется один раз, до запуска функции…

/* USER CODE BEGIN 2 */
char buff[] = "istarik\n";
char dest[8] = {0,};

HAL_DMA_RegisterCallback(&hdma_memtomem_dma1_channel1, HAL_DMA_XFER_CPLT_CB_ID, dma_m2m_callback);

HAL_DMA_Start_IT(&hdma_memtomem_dma1_channel1, (uint32_t)buff, (uint32_t)dest, 8);



Если нужен колбек по половинке, тогда и его тоже надо зарегистрировать, и создать саму функцию…

HAL_DMA_RegisterCallback(&hdma_memtomem_dma1_channel1, HAL_DMA_XFER_HALFCPLT_CB_ID, dma_m2m_half_callback);


void dma_m2m_half_callback(DMA_HandleTypeDef *hdma_memtomem_dma1_channel1)
{
	// что-то делаем
}


То есть будет так…

/* USER CODE BEGIN 2 */
char buff[] = "istarik\n";
char dest[8] = {0,};

HAL_DMA_RegisterCallback(&hdma_memtomem_dma1_channel1, HAL_DMA_XFER_HALFCPLT_CB_ID, dma_m2m_half_callback);
HAL_DMA_RegisterCallback(&hdma_memtomem_dma1_channel1, HAL_DMA_XFER_CPLT_CB_ID, dma_m2m_callback);

HAL_DMA_Start_IT(&hdma_memtomem_dma1_channel1, (uint32_t)buff, (uint32_t)dest, 8);





Таймер

Далее поработаем с таймером. Однако перед тем как что-либо настраивать, отключите в Кубе все каналы DMA, так чтоб не осталось ничего связанного с DMA, и перегенерируйте проект. Позже я объясню зачем это нужно.

Теперь настроим первый канал таймера №1 в режиме ШИМа…


Предделитель указываем 7200, переполнение 30000, а длину импульса 1000, больше ничего трогать не надо. Таким образом, при условии что камень работает на частоте 72МГц, у нас получится ШИМ очень сильно растянутый во времени. То есть когда мы подключим лампочку к пину РА8, она через каждые три секунды будет загораться на 100мс, в общем моргать будет раз в три секунды. Это нужно чтоб наглядно продемонстрировать наши дальнейшие действия.


В программе, перед бесконечным циклом добавьте запуск таймера и прошейтесь…

HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);


Если лампочка моргает тогда продолжим. Предположим вам нужно изменять длину импульса, то есть менять значение сравнения (Pulse), например для плавного увеличения свечения. Если бы у нас не было DMA, тогда пришлось бы в прерывании или в цикле приращивать регистр CCR (Capture/Compare Register), однако у нас есть DMA, а значит всё будет происходить аппаратно. Суть очень простая, мы создаём буфер с несколькими различными значениями, которые DMA будет подсовывать в регистр сравнения после каждого очередного переполнения таймера.

Идём в настройки нашего таймера и добавляем DMA…



DMA Request — выбираем TIM1_CH1, так как работаем с первым каналом таймера. Запрос к блоку DMA будет посылаться в момент захвата/сравнения.

Если бы работали со вторым или третьим каналом, или со всеми вместе, то они были бы там. Четвёртый канал там уже есть, он ещё может работать как триггер (см. ниже). Это пункт есть только у самых «жирнючих» таймеров. TIM1_UP — запрос к DMA посылается в момент переполнения таймера (см. ниже).

Direction — здесь всё понятно — из буфера в регистр захвата/сравнения таймера.

Mode — указываем циклический режим, чтоб значения в регистре таймера менялись постоянно. Если указать Normal, то буфер прокрутится один раз.

Increment Address — инкрементируем только память, то бишь наш буфер.

Data Width — указываем полслова так как CCR регистр у таймера 16-ти битный.


В программе создаём буфер…

uint16_t buff[] = {1000, 10000, 20000};

Большой буфер нам не нужен, достаточно пары-тройки значений чтоб наглядно убедится в том, что значения меняются, но вы можете создавать буферы с любым количеством значений.

После запуска у нас будет следующее: сначала светик загорится на 100мс, потом на 1 сек, потом на 2 сек, потом всё повторится, и так бесконечно, у нас ведь DMA в циклическом режиме.

Добавляем запуск таймера, прошиваем, и смотрим…

/* USER CODE BEGIN 2 */
  uint16_t buff[] = {1000, 10000, 20000};

  HAL_TIM_PWM_Start_DMA(&htim1, TIM_CHANNEL_1, (uint32_t*)buff, 3);

… и скорее всего у вас ничего не работает, точнее светик то будет мигать, но только короткими вспышками, то есть таймер запустился, но DMA не подсовывает новые значения.

Дело вот в чём. Выше я намеренно попросил вас удалить всё что связано с DMA и настроить таймер в обычном режиме, сгенерировать проект, а потом добавить DMA. В результате получилось следующее: Куб сначала сгенерировал инициализацию таймера, а потом инициализацию DMA, то есть так…


В результате когда таймер инициализировался, он ничего не знал про DMA и работал в обычном режиме.

Неправильной работой Куба это нельзя назвать, просто если настраиваете какую-либо периферию на работу c DMA, то настраивайте DMA сразу же, тогда инициализация будет прописана в правильном порядке. Я специально акцентировал внимание на этой ситуации, чтоб облегчить вам жизнь в дальнейшем

Чтобы всё заработало, нужно либо удалить все настройки таймера, перегенерировать проект, а потом настроить всё заново, либо просто поменять местами инициализацию таймера и DMA…

Компилите и прошивайте, теперь всё будет окей. Однако если перегенерируете проект, всё опять поменяется местами.


Чтобы уж полностью убедится что всё работает, можно добавить колбеки и посмотреть что лежит в CCR…

void HAL_TIM_PWM_PulseFinishedHalfCpltCallback(TIM_HandleTypeDef *htim)
{
	uint32_t val = HAL_TIM_ReadCapturedValue(&htim1, TIM_CHANNEL_1);

	char str[16] = {0,};
	snprintf(str, 16, "HALF buf %lu\n", val);
	HAL_UART_Transmit(&huart1, (uint8_t*)str, strlen(str), 1000);
}

void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim)
{
	uint32_t val = HAL_TIM_ReadCapturedValue(&htim1, TIM_CHANNEL_1);

	char str[16] = {0,};
	snprintf(str, 16, "FULL buf %lu\n", val);
	HAL_UART_Transmit(&huart1, (uint8_t*)str, strlen(str), 1000);
}



Первого значения мы не увидим так как прерывания происходят, как вы помните, по половине и по полному буферу. Три на два не делятся по этому вот тебе один золотой Буратино © половина в большую сторону.

И в довершение начатого сделаем плавное включение/выключение лампочки. Создадим буфер на 2Кб и заполним его значениями…

/* USER CODE BEGIN 2 */
  uint16_t buff[2000] = {0,};

  uint16_t ccr = 0;
  uint16_t i = 0;

  for(; i < 1000; i++)
  {
	  ccr = ccr + 30;
	  buff[i] = ccr;
  }

  for(; i < 2000; i++)
  {
	  ccr = ccr - 30;
	  buff[i] = ccr;
  }

  HAL_TIM_PWM_Start_DMA(&htim1, TIM_CHANNEL_1, (uint32_t*)buff, 2000);


Предделитель таймера уменьшим до 7, можно это сделать прям в коде, чтоб не лазить в Куб…


Можно прошивать — светик будет плавно разгораться и угасать, а колбеки присылать соответствующие значения…




Всё то же самое, можно проделывать не только в режиме PWM, но и Output Compare, вызывая соответствующую функцию — HAL_TIM_OC_Start_DMA(...).



Теперь сделаем наоборот, будем захватывать сигнал. В Кубе нужно перенастроить канал на захват и изменить направление DMA, из периферии в память…



Будем подавать сигнал на вход канала таймера и записывать полученные значения в наш буфер с помощью DMA.

Буфер оставляем прежний, а функцию меняем на захват (то что в скобках остаётся неизменным)

/* USER CODE BEGIN 2 */
  uint16_t buff[3] = {0,};

  HAL_TIM_IC_Start_DMA(&htim1, TIM_CHANNEL_1, (uint32_t*)buff, 3);


В бесконечном цикле делаем импульсы, которые будем захватывать. Нужно настроить какой-нибудь пин как GPIO_Output и соединить его с РА8. Заодно выведем на печать результат…

while (1)
  {
	  HAL_GPIO_WritePin(pb12_GPIO_Port, pb12_Pin, GPIO_PIN_SET);
	  HAL_Delay(100);
	  HAL_GPIO_WritePin(pb12_GPIO_Port, pb12_Pin, GPIO_PIN_RESET);
	  HAL_Delay(100);

	  HAL_GPIO_WritePin(pb12_GPIO_Port, pb12_Pin, GPIO_PIN_SET);
	  HAL_Delay(200);
	  HAL_GPIO_WritePin(pb12_GPIO_Port, pb12_Pin, GPIO_PIN_RESET);
	  HAL_Delay(200);

	  HAL_GPIO_WritePin(pb12_GPIO_Port, pb12_Pin, GPIO_PIN_SET);
	  HAL_Delay(300);
	  HAL_GPIO_WritePin(pb12_GPIO_Port, pb12_Pin, GPIO_PIN_RESET);
	  HAL_Delay(300);

	  char str[64] = {0,};
	  snprintf(str, 64, "Val1 %d Val2 %d Val3 %d\n", buff[0], buff[1], buff[2]);
	  HAL_UART_Transmit(&huart1, (uint8_t*)str, strlen(str), 1000);
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
  }

Не забудьте про очерёдность инициализации.


Всё работает, но это какая-то чепуха. Чтоб результат был внятный нужно после каждого захвата обнулять счётчик таймера (Counter Period). Можно конечно это делать в прерывании по захвату, но тогда смысл DMA просто потеряется, поэтому мы сделаем так чтоб всё было аппаратно, ибо таймеры у stm32 офигительно крутые

Идём в настройки таймера и делаем его подчинённым самому себе, а триггером будет любой фронт сигнала на первом канале…



TI1_ED — триггером служит любой фронт.

Reset Mode — получая триггерный сигнал таймер обнуляет свой счётчик.

Про таймеры у меня есть отдельные статьи.

Прошиваемся и получаем искомое…


Вуаля, у нас получилось полностью аппаратное измерение длины импульса, ЦПУ не принимает вообще никакого участия в этом процессе.

Ну и само собой есть колбеки по заполнению половинки и полного буфера…

void HAL_TIM_IC_CaptureHalfCpltCallback(TIM_HandleTypeDef *htim)
{
}

void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
}




Да, я начал с каналов таймера и совсем забыл, что c помощью DMA можно менять и значение переполнения.

Настраиваем таймер просто как измеритель интервалов времени…


Включаем тактирование таймера и делаем так чтоб он переполнялся каждые 200мс.


DMA…



Запрос к блоку DMA будет происходить во время переполнения — TIM1_UP. Направление передачи из памяти в периферию — из массива в регистр переполнения (Counter Period, он же ARR). Размерность данных Half Word, так как регистр 16-ти битный. И включаем циклический режим.

В программе делаем массив из трёх значений, которые будут поочерёдно записываться в регистр переполнения после каждого переполнения, ну и запускаем таймер в соответствующем режиме…

/* USER CODE BEGIN 2 */
  uint16_t buff[3] = {3000, 4000, 5000};

  HAL_TIM_Base_Start_DMA(&htim1, (uint32_t*)buff, 3);



Колбеки по половинке и по полному буферу…

void HAL_TIM_PeriodElapsedHalfCpltCallback(TIM_HandleTypeDef *htim)
{
	HAL_UART_Transmit(&huart1, (uint8_t*)"Half\n", 5, 1000);
}

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
	HAL_UART_Transmit(&huart1, (uint8_t*)"Full\n", 5, 1000);
}





Timer DMA-burst

Есть ещё один режим работы таймера с DMA, он называется Timer DMA-burst. В этом режиме можно с помощью DMA менять значения в нескольких регистрах таймера за один запрос. До этого мы за один запрос меняли одно значение в одном регистре таймера (CCR).

Поскольку каждый запрос требует нескольких лишних тактов, то Timer DMA-burst работает быстрее чем простой режим DMA.


Например, если понадобится с помощью таймера создать какую-то хитровыдуманную синусойду, где необходимо очень быстро менять не только длину импульса, но и значение переполнения, и ещё какие-нибудь регистры, да ещё и у нескольких каналов сразу, да ещё и всё это делать аппаратно, то режим Timer DMA-burst это то что нужно.


Протестируем этот режим на практике. Удалите в Кубе всё что связанно с таймером, перегенерируйте проект, и настройте четыре канала как PWM Generation c DMA…



DMA Request — указываем TIM1_UP, то есть запрос к блоку DMA будет происходить не в момент сравнения как в прошлом примере, а в момент переполнения таймера. Можно было бы выбрать и сравнение, указав любой из каналов, но для разнообразия сделаем так.

Direction — из памяти в периферию.

Mode — Normal.


В разделе Parameter Settings меняем только один пункт — переполнение…


Всё остальное оставим по нулям. Заполнять эти значения будем с помощью DMA. Переполнение тоже будем изменять, но если его оставить равным нулю, то таймер не запустится.


Теперь давайте в бесконечном цикле запустим вывод значений регистров таймера…

/* USER CODE BEGIN WHILE */
  while (1)
  {
	uint16_t psk = TIM1->PSC;
	uint16_t arr = TIM1->ARR;
	uint16_t rcr = TIM1->RCR;

	uint16_t ccr1 = HAL_TIM_ReadCapturedValue(&htim1, TIM_CHANNEL_1);
	uint16_t ccr2 = HAL_TIM_ReadCapturedValue(&htim1, TIM_CHANNEL_2);
	uint16_t ccr3 = HAL_TIM_ReadCapturedValue(&htim1, TIM_CHANNEL_3);
	uint16_t ccr4 = HAL_TIM_ReadCapturedValue(&htim1, TIM_CHANNEL_4);

	char str[128] = {0,};
	snprintf(str, 128, "PSK %d ARR %d RCR %d CCR1 %d CCR2 %d CCR3 %d CCR4 %d\n", psk, arr, rcr, ccr1, ccr2, ccr3, ccr4);
	HAL_UART_Transmit(&huart1, (uint8_t*)str, strlen(str), 1000);

	HAL_Delay(500);


В результате мы получим что и ожидали, все регистры кроме ARR равны нулю…


PSK — предделитель (Prescaler). ARR — переполнение (Counter Period). RCR — Repetition Counter. CCRx — регистры захвата/сравнения (Pulse).


Список регистров, к которым можно обращаться с помощью Timer DMA-burst находится в файле stm32f1xx_hal_tim.c, перед функцией HAL_TIM_DMABurst_WriteStart(.....)это та самая функция, с помощью которой запускается режим Timer DMA-burst.


Всего 18 регистров. Тут они обозначены в том виде, в котором их нужно прописывать в качестве аргумента функции.

Ниже указаны параметры, которые тоже нужно прописывать в функцию, а означают они то же самое, что мы указывали в Кубе, в качестве DMA Request. То есть это событие, при котором будет происходить запрос к блоку DMA…



Теперь добавьте перед бесконечным циклом следующий код…

/* USER CODE BEGIN 2 */
  uint16_t buff[7] = {7200,30000,0, 1111,2222,3333,4444};
  
  HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);
  
  HAL_TIM_DMABurst_WriteStart(&htim1, TIM_DMABASE_PSC, TIM_DMA_UPDATE, (uint32_t*)buff, TIM_DMABURSTLENGTH_7TRANSFERS);


В буфере (buff) лежат семь значений, которые будут записываться в соответствующие регистры. 7200 — предделитель, 30000 — переполнение, 0 — Repetition Counter, последние четыре значения запишутся в регистры сравнения каналов.

Далее запускаем таймер в режиме PWM. Для демонстрации работы нам не обязательно запускать все каналы, достаточно одного. Собственно вообще не важно как мы запустим таймер, можно и так — HAL_TIM_Base_Start(), главное чтоб он работал и в момент переполнения «толкал» DMA.

И наконец запускаем Timer DMA-burst.

Второй аргумент этой функции означает с какого регистра начать запись, а последний аргумент говорит о том, что нужно переписать семь регистров. То есть первый элемент буфера будет записан в регистр PSC (TIM_DMABASE_PSC), второй в ARR (TIM_DMABASE_ARR), третий в RCR (TIM_DMABASE_RCR), и т.д., семь регистров…


Третий аргумент указывает что транзакция будет происходить во время переполнения таймера, а четвёртый указатель на буфер.

Прошиваем плату и смотрим на результат…


Все семь регистров перезаписались.


Если бы нам нужно было перезаписать только регистр ARR, тогда буфер будет таким…

uint16_t buff[1] = {30000};


… а функция такая…

HAL_TIM_DMABurst_WriteStart(&htim1, TIM_DMABASE_ARR, TIM_DMA_UPDATE, (uint32_t*)buff, TIM_DMABURSTLENGTH_1TRANSFER);

Запись начинается с регистра ARR, а количество перезаписываемых регистров равно одному, то есть перезаписываем только один регистр.




Если нужно переписать регистры начиная с ARR и заканчивая CCR2, тогда делаем буфер из четырёх элементов, и в функции указываем что нужно перезаписывать четыре регистра…

uint16_t buff[4] = {30000,0, 1111,2222};


HAL_TIM_DMABurst_WriteStart(&htim1, TIM_DMABASE_ARR, TIM_DMA_UPDATE, (uint32_t*)buff, TIM_DMABURSTLENGTH_4TRANSFERS);




Как вы уже наверно поняли, нельзя исключить перезапись каких-либо регистров находящихся в середине. То есть, если нам нужно перезаписывать регистр ARR и CCRx, то исключить RCR из этой операции не получится. Однако в этом нет ничего страшного так как никаких лишних ресурсов на перезапись «ненужного» регистра не тратится.


Сейчас у нас все значения записываются один раз, и особого смысла в этом нет, так как это можно сделать и вручную. А вот чтобы они изменялись динамически, нужно включить циклический режим DMA — Mode ⇨ Circular (сделайте сейчас), и внести изменения в код.


Воспользуемся последним примером и добавим в буфер ещё значений…

uint16_t buff[12] = {3000,0,1111,2222, 1000,0,5555,4444, 1234,0,7777,8888};

Здесь добавлено ещё два раза по четыре значения: при первом запросе будут отправлены первые четыре элемента, при втором следующие четыре, и при третьем последние четыре, после этого всё повторится.

С буфером всё понятно, а вот с функцией есть проблема. Складывается впечатление, что разработчики HAL'а упустили одну важную деталь. Суть заключается вот в чём: для работы с DMA у таймера есть специальный регистр DCR…



В биты DBL записывается количество регистров для перезаписи. То есть в эти биты записывается то, что мы передаём аргументом TIM_DMABURSTLENGTH_4TRANSFERS.

В биты DBA записывается адрес регистра с которого начинается запись. То есть это то, что мы передаём аргументом TIM_DMABASE_ARR.

Так же у DMA есть регистр CNDTR, в который записывается общее число транзакций. Когда мы работали с USART'ом, или одним каналом таймера, то передавали в функцию количество байт для отправки, вот в этот регистр и записывалось это число.

Так вот, функция HAL_TIM_DMABurst_WriteStart() написана так, что последний аргумент (TIM_DMABURSTLENGTH_4TRANSFERS) передаётся и в регистр DBL, и в CNDTR, то есть в оба регистра записывается число 4. Когда мы делаем одноразовую перезапись регистров, то всё работает правильно, сейчас же нам нужно в DBL передать число 4, а в CNDTR число 12, однако такой возможности у этой функции не предусмотрено. Чтоб исправить эту ситуацию, нужно сделать следующее…


Идем в файл stm32f1xx_hal_tim.c, находим функцию, и добавляем аргумент "uint32_t len" в самый конец…



Спускаемся ниже, и в "case TIM_DMA_UPDATE:", в функции HAL_DMA_Start_IT(...) меняем "((BurstLength) >> 8U) + 1U" на "len"…



Если в дальнейшем будете использовать другие источники запроса к DMA (TIM_DMA_CC1 — TIM_DMA_CC4, TIM_DMA_COM, TIM_DMA_TRIGGER), то в других "case" надо так же отредактировать функции HAL_DMA_Start_IT(...).

Да, ещё в хедере stm32f1xx_hal_tim.h тоже нужно добавить аргумент "uint32_t len". И помните что после перегенерации проекта это всё затрётся.


Ну и в функцию запуска надо дописать количество данных…

/* USER CODE BEGIN 2 */
  uint16_t buff[12] = {3000,0,1111,2222, 1000,0,5555,4444, 1234,0,7777,8888};

  HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);

  HAL_TIM_DMABurst_WriteStart(&htim1, TIM_DMABASE_ARR, TIM_DMA_UPDATE, (uint32_t*)buff, TIM_DMABURSTLENGTH_4TRANSFERS, 12);

Теперь в регистр CNDTR будет записана правильная длина буфера — 12.

Можно прошивать…


Всё как задумано, меняются значения в регистрах ARR, CCR1 и CCR2. В ARR чередуются значения — 3000, 1000, 1234. В CCR1 — 1111, 5555, 7777. В CCR2 — 2222, 4444, 8888. В RCR постоянно записывается 0.

Регистр RCR есть только у таймеров №1 и №8, но не смотря на это, работа с другими таймерами происходит так же, то есть как будто этот регистр есть.


спойлер
Всё выше сказанное про проблемы с функцией относится к семейству F1.

У F3 есть дополнительная функция HAL_TIM_DMABurst_MultiWriteStart(...) у которой есть аргумент с длиной буфера…



Работу на других семействах — F2, F4, F7, я не проверял, но просмотр кода говорит о том, что придётся вносить описанные исправления, так как функции HAL_TIM_DMABurst_MultiWriteStart(...) там нет.


В режиме Timer DMA-burst можно не только записывать в регистры, но читать из них. Это делается с помощью функции HAL_TIM_DMABurst_ReadStart(...). Всё то же самое что и запись, но только наоборот. Откровенно говоря не знаю какой практический смысл в этой функции. Разве что во время захвата на всех каналах таймера читать значения всех CCRx.

Чтобы остановить запись/чтение есть соответствующие функции…

HAL_TIM_DMABurst_WriteStop(&htim1, TIM_DMA_UPDATE);

HAL_TIM_DMABurst_ReadStop(&htim1, TIM_DMA_UPDATE);





На этом первая часть заканчивается, в следующей части будет описана работа с GPIO, АЦП, различные триггеры, и ещё много чего.



Всем спасибо


Телеграм-чат STM32


  • 0
  • 1890
Поддержать автора




Telegram-чат istarik

Задать вопрос по статье
Telegram-канал istarik

Известит Вас о новых публикациях






Комментарии (0)

Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.