Микроконтроллеры STM32: основы использования таймеров, прерываний и ШИМ

6 августа 2018

Вы могли обратить внимание, что до сих пор при изучении микроконтроллеров STM32 мы как-то обходились исключительно синхронным кодом. Это может неплохо работать для игрушечных примеров, но при попытке написать что-то действительно полезное без прерываний и таймеров далеко не уедешь. Так что, пришла пора научиться с ними работать. Должен предупредить, что статья вышла довольно длинной, поскольку тема непростая.

Немного матчасти

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

Если на пальцах, то прерывание (оно же Interrupt Request, или IRQ) — это какой-то сигнал, генерируемый по определенному событию, и обычно приводящий к выполнению некого отрывка кода. Соответствующий отрывок кода называется Interrupt Service Routine (ISR), или просто обработчиком прерывания. Например, прерывание может генерироваться при нажатии пользователем кнопки. Такие прерывания называют внешними, так как они генерируются внешними по отношению к МК событиями.

За доставку в МК внешних прерываний отвечают специальные куски кремния, называемые External Interrupt / Event Controller, или EXTI. EXTI0 отвечает за пины PA0, PB0, PC0, и так далее, EXTI1 — за пины PA1, PB1, PC1, и так далее, и по аналогии для остальных пинов. Некоторым EXTI назначается уникальный ISR, а некоторые делят его с другими EXTI. Подробности следует искать в даташите конкретного МК.

Соответствующая иллюстрация (источник [PDF]):

External Interrupt / Event Controller

Важное следствие из этой картинки заключается в том, что у микроконтроллера нет возможности отличить внешние прерывания, случающиеся на пинах, обслуживаемых одним EXTI. Например, нельзя отличить нажатие кнопки, подключенной к PA0, от нажатия кнопки, подключенной к PB0. Поэтому определять такие «конфликтующие» прерывания не представляется возможным (в частности, STM32CubeMX не позволит вам этого сделать). Но если одна кнопка подключена к PA0, а вторая к PB1, то пожалуйста.

Прерывания могут возникать и в самом МК. Например, прерывания генерируют аппаратные реализации протоколов I2C, SPI и всех остальных. Помимо прочего, это позволяет реализовать асинхронную передачу данных. Также в МК встроены специальные устройства, называемые таймерами, которые умеют генерировать прерывания раз в заданный интервал времени. Таймеры имеют имена вроде TIM1, TIM2, TIM3, и так далее.

Часть микроконтроллера, отвечающая за управление прерываниями, называется Nested Vectored Interrupt Controller, или NVIC, что переводится примерно как Контроллер Вложенных Векторных Прерываний. Название отражает сразу два факта об этом устройстве. Во-первых, адреса ISR хранятся в специально отведенном участке памяти, называемом vector table. Сам же адрес (то есть, элемент vector table) принято называть вектором. Во-вторых, прерывания имеют настраиваемые приоритеты. Если во время обработки прерывания придет более приоритетное прерывание, выполняемый в данный момент ISR будет приостановлен, и управление будет передано ISR более приоритетного прерывания. Когда он отработает, управление будет возвращено обратно в обработчик менее приоритетного прерывания. Отсюда и слово nested (вложенных) в названии устройства.

Сказанное выше можно изобразить в виде такой картинки (источник):

Объяснение вложенных прерываний

Соответственно, выполнение ISR прерываний с тем же или более низким приоритетом, чем приоритет прерывания, чей ISR сейчас выполняется, откладывается до завершения текущего ISR. Если одновременно происходит два или более одинаковых прерываний, они «схлопываются» в одно. Другими словами, прерывания не копятся в очереди. Также прерывания не являются re-entrant. То есть, если сейчас выполняется ISR некого прерывания, при возникновении того же прерывания NVIC дождется завершения ISR и только после этого вызовет его во второй раз.

Есть множество других занятных моментов, но в рамках данной статьи они нам не понадобятся. В этом контексте хотелось бы поблагодарить пользователей форума easyelectronics.ru, в особенности BusMaster, за то, что объяснили мне тонкости работы прерываний в разных граничных случаях. На этом теории, думаю, достаточно, и можно перейти к примерам. Примеры были проверены мной на плате Nucleo-F411RE, но с минимальными изменениями будут работать и ну других платах.

Пример использования таймера

В мире STM32 существует по крайней мере девять видов таймеров, из которых основными видами являются basic, general purpose (делятся на 16-и и 32-х битные) и advanced. Таймеры, находящиеся правее в приведенном списке включают в себя функции тех таймеров, что находятся в списке левее. Например, general purpose таймеры умеют все, что умеют basic таймеры, а также могут использоваться для генерации ШИМ-сигнала. Чтобы совсем не уходить в дебри, рассмотрим только типичный сценарий использования 16-и битного general purpose таймера, коим является TIM3 (cм табличку в AN4013, «STM32 cross-series timer overview» [PDF]).

Откроем STM32CubeMX и найдем TIM3 во вкладке Pinout. Изменим Clock Source на Internal Clock. Далее идем во вкладку Configuration и снова находим там TIM3. В поле Prescaler вводим 41999, в Counter Mode выбираем Up, в Counter Period вводим 1000. В тех же настройках TIM3 находим вкладку NVIC Settings и ставим галочку напротив TIM3 Global Interrupt, тем самым разрешая генерацию прерывания. Теперь давайте попробуем понять, что же означают все эти настройки.

Таймер TIM3 подключен к шине APB1, о чем мы можем узнать, например, из «Table 10. STM32F411xC/xE register boundary addresses» даташита нашего МК [PDF]. Также, открыв в STM32CubeMX вкладку Clock Configuration, мы можем узнать, что часы на этой шине работают на частоте 84 МГц:

Вкладка Clock Configuration в STM32CubeMX

Параметр Prescaler, указанный нами выше, используется для деления этой частоты. Чтобы случайно не получить деление на ноль, к этому параметру всегда прибавляется единица. Таким образом, часы будут работать на частоте 2 кГц:

>>> 84000000/(41999+1)
2000.0

Параметр Counter Period определяет, как часто будет выстреливать прерывание, связанное с часами. В нашем случае это будет происходить два раза в секунду:

>>> 2000 / 1000
2.0

Наконец, значение Up параметра Counter Mode говорит о том, что значение связанного с часами счетчика должно начинаться с нуля и увеличиваться на единицу 2000 раз в секунду (для нашей конфигурации) до тех пор, пока не достигнет значения Counter Period. В этот момент генерируется прерывание и счетчик обнуляется. Можно и наоборот, считать от Counter Period до нуля, указав в Counter Mode значение Down. Но насколько я могу судить, на практике от этого ничего не меняется.

Генерируем код. Затем в Src/main.c дописываем:

int main(void)
{
  // ... пропущено ...

  /* Initialize all configured peripherals */
  MX_GPIO_Init();
  MX_USART2_UART_Init();
  MX_TIM3_Init();

  /* USER CODE BEGIN 2 */

  // добавляем вот эту строчку, запускаем таймер:
  HAL_TIM_Base_Start_IT(&htim3);

  /* USER CODE END 2 */

  // ... пропущено ...
}

Также правим Src/stm32f4xx_it.c:

void TIM3_IRQHandler(void)
{
  /* USER CODE BEGIN TIM3_IRQn 0 */

  /* USER CODE END TIM3_IRQn 0 */
  HAL_TIM_IRQHandler(&htim3);
  /* USER CODE BEGIN TIM3_IRQn 1 */

  // дописываем вот эту строчку:
  HAL_GPIO_TogglePin(Led1_GPIO_Port, Led1_Pin);

  /* USER CODE END TIM3_IRQn 1 */
}

Как альтернативный вариант, вместо последнего шага можно определить такую процедуру:

/* USER CODE BEGIN 1 */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if(htim->Instance == TIM3) {
        HAL_GPIO_TogglePin(Led1_GPIO_Port, Led1_Pin);
    }
}
/* USER CODE END 1 */

Эффект будет аналогичным. Эта процедура объявлена с атрибутом __weak в коде HAL, в файле Src/stm32f4xx_hal_tim.c, поэтому наша реализация ее переопределяет. Тема с атрибутом __weak нам уже знакома по заметке Микроконтроллеры STM32: работа с внешним EEPROM. Если вас интересуют прочие колбэки, доступные для переопределения, то их можно найти в том же файле.

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

Использование внешних прерываний

Давайте повесим прерывание на нажатие кнопки. Для этого в STM32CubeMX во вкладке Pinout кликнем на пин PC13 и в появившемся списке выберем GPIO_EXTI13. Во вкладке Configuration → System → GPIO в GPIO Mode можно выбрать, когда будет приходить прерывание, например, по переднему фронту, заднему фронту, или по обоим. Я выбрал «External Interrupt Mode with Rising/Falling edge trigger detection». Там же нажимаем кнопку NVIC и включаем прерывание, поставив галочку Enabled напротив «EXTI line[15:10] interrupts».

Генерируем код. Затем правим Src/stm32f4xx_it.c так, чтобы светодиод мигал не все время, как это сделано сейчас, а только пока пользователь держит кнопку нажатой:

/* USER CODE BEGIN 0 */
#include <stdbool.h>

bool buttonPressed = false;
/* USER CODE END 0 */

// ... пропущено ...

// в данном случае EXTI10..EXTI15 имеют общий ISR
// но эту процедуру мы не трогаем
void EXTI15_10_IRQHandler(void) {
  /* USER CODE BEGIN EXTI15_10_IRQn 0 */

  /* USER CODE END EXTI15_10_IRQn 0 */
  HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_13);
  /* USER CODE BEGIN EXTI15_10_IRQn 1 */

  /* USER CODE END EXTI15_10_IRQn 1 */
}

/* USER CODE BEGIN 1 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    if (GPIO_Pin == GPIO_PIN_13) {
        buttonPressed = !buttonPressed;
    }  
}

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if(htim->Instance == TIM3) {
        if(buttonPressed) {
            HAL_GPIO_TogglePin(Led1_GPIO_Port, Led1_Pin);
        }
    }
}
/* USER CODE END 1 */

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

В контексте внешних прерывание не могу не напомнить о проблеме дребезга контактов. К счастью, бороться с ним довольно просто. Например, так:

volatile static uint32_t lastPress = 0;

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    uint32_t tstamp = HAL_GetTick();

    // если кнопка нажимается чаще одного раза в 200 мс,
    // считаем это дребезгом
    if(tstamp - lastPress < 200)
        return;

    if(GPIO_Pin == GPIO_PIN_0) {
        /* ... do something ... */

        lastPress = tstamp;
    }
}

Само собой разумеется, решения на базе конденсаторов и треггиров Шмитта также никто не отменял.

Генерация ШИМ-сигнала

Наконец, попробуем управлять яркостью светодиода при помощи ШИМ.

Поскольку свободные светодиоды на плате закончились, я воткнул светодиод в гнезда для подключения Arduino-шилда, катодом к земле, анодом к гнезду A0. Этому гнезду соответствует пин микроконтроллера PA0. Подключать резистор последовательно со светодиодом не требуется, так как микроконтроллер использует 3.3-вольтовую логику и не может выдавать большой ток.

В STM32CubeMX идем во вкладку Pinout, кликаем на пин PA0 и в выпадающем списке выбираем TIM2_CH1. В дереве слева находим таймер TIM2, в Clock Source выбираем Internal Clock, в выпадающем списке Channel 1 выбираем PWM Generation CH1. На данном этапе по количеству каналов, доступных у таймера, можно догадаться, что всего он может генерировать до четырех ШИМ-сигналов. Затем находим TIM2 во вкладке Configuration. Устанавливаем Prescaler в значение 40, а Counter Period — в значение 256.

Генерируем код и сразу правим Src/main.c. В процедуру main дописываем:

  /* USER CODE BEGIN 2 */
  HAL_TIM_Base_Start_IT(&htim3);

  // дописали вот эту строчку:
  HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
  /* USER CODE END 2 */

Основной цикл программы изменяем таким образом:

void loop() {
    for(int i = 0; i < 256; i++) {
        __HAL_TIM_SetCompare(&htim2, TIM_CHANNEL_1, i);
        HAL_Delay(10);
    }

    for(int i = 255; i >= 0; i--) {
        __HAL_TIM_SetCompare(&htim2, TIM_CHANNEL_1, i);
        HAL_Delay(10);
    }
}

Компилируем, запускаем, смотрим на светодиод. Если все было сделано правильно, он будет плавно загораться, а затем плавно гаснуть.

Если посмотреть на генерируемый сигнал осциллографом, то увидим:

Генерация ШИМ-сигнала микроконтроллером STM32

Вместе выбранные Prescaler и Counter Period определяют частоту сигнала:

>>> 84000000/(40+1)/256
8003.048780487805

Что примерно совпадает с 8 кГц, которые мы видим на осциллографе. Как и TIM3, использованный нами TIM2 подключен к шине APB1, поэтому в числителе фигурирует 84 МГц. Кроме того, параметр Counter Period определяет количество возможных значений коэффициента заполнения (duty cycle). В приведенном примере мы можем менять его от 0 до 255. На скриншоте используется значение около 150.

Заключение

Если вдруг вам показалось, что теперь-то вы знаете все о прерываниях и таймерах в STM32, боюсь вас огорчить. Как минимум, у прерываний еще есть приоритеты и сабприортитеты, которые можно менять динамически во время работы прошивки. Также прерывания можно временно отключать (маскировать). Таймеры могут использовать в качестве источника сигнала не только internal clock, но и другие таймеры, а также внешние источники. А еще у таймеров есть режимы работы, которые не были рассмотрены в данной статье, в частности input capture mode и output compare mode. Кроме того, таймеры в STM32 имеют специальные режимы для чтения роторных энкодеров и датчиков Холла.

Понятно, что все это невозможно рассмотреть в рамках одного поста. В качестве дополнительного источника информации можно порекомендовать книгу Mastering STM32 за авторством Carmine Noviello. Правда, даже она покрывает далеко не все, и отправляет за дополнительной информацией в даташиты и application notes.

Исходники к этому посту вы найдете на GitHub.

Дополнение: В статье Учимся передавать звук с использованием протокола I2S вы найдете пример асинхронной передачи данных на прерываниях. Хотя заметка и рассказывает об I2S, функции HAL_*_Transmit_IT и HAL_*_Receive_IT работают по тому же принципу для UART, I2C и SPI.

Метки: , .


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