Использование таймерных прерываний в Esp32 [2024-11-03]

Данный материал опирается на документацию ESPRESSIF по таймерам https://docs.espressif.com/projects/arduino-esp32/en/latest/api/timer.html.

В контроллерах ESP32 содержится от 2 до 4 аппаратных таймеров. Все они представляют собой 64-битные (54-битные для ESP32-C3) универсальные таймеры на основе 16-битных предварительных масштабирующих устройств и 64-битных (54-битных для ESP32-C3) счётчиков с возможностью автоматической перезагрузки.

Контроллер ESP32       Количество таймеров
------------------------------------------
ESP32                  4
ESP32-S2               4
ESP32-S3               4
ESP32-C3               2
ESP32-C6               2
ESP32-H2               2

Что такое “спин-блокировки”?

В представленном далее примере обработчик прерывания от таймера содержит критическую секцию, связанную со “спин-блокировкой” процессора (ядра).

При использовании обычных блокировок (мьютексов) операционная система переводит ваш поток в состояние WAIT и прерывает его (“вытесняет задачу”), планируя другие потоки на том же ядре. Это снижает производительность, когда время ожидания очень короткое, потому что теперь ваш поток должен ждать прерывания, чтобы снова получить процессорное время. Кроме этого затрачиваются накладные расходы на переключение задач.

Спин-блокировки (спинлоки) не вызывают вытеснения потока, а заставляют его ждать в цикле (“спине”), пока ядро не снимет блокировку. То есть задача продолжает работать в кванте, выделенном ей операционной системой.

Cпин-блокировки полезны только в тех местах, где время ожидания меньше кванта (читай: миллисекунд). Если время ожидания неизвестно, то спин-блокировки неэффективны, так как потребляется 100% процессорного времени на ожидающем ядре при выполнении проверки, доступна ли спин-блокировка. Это не позволяет другим потокам работать на этом ядре до истечения кванта времени.

Критические разделы и отключение прерываний

В стандартной версии FreeRTOS критические секции реализованы с помощью taskENTER_CRITICAL() и portDISABLE_INTERRUPTS() вызовов. Это предотвращает принудительное переключение контекста и обслуживание ISR во время критической секции. Таким образом, критические секции используются в стандартной версии FreeRTOS в качестве надёжного метода защиты от одновременного доступа.

Но в ESP32/8266 нет аппаратного метода, с помощью которого ядра могли бы отключать прерывания друг друга. Вызов portDISABLE_INTERRUPTS() не повлияет на прерывания другого ядра. Таким образом, отключение прерываний НЕ является эффективным методом защиты от одновременного доступа к общим данным, поскольку другое ядро может свободно обращаться к данным, даже если текущее ядро отключило собственные прерывания.

По этой причине в ESP FreeRTOS критические секции реализуются с помощью специальных мьютексов, на которые ссылаются объекты portMUX_Type. Они реализованы на основе специального компонента спин-блокировки. При вызове taskENTER_CRITICAL или taskEXIT_CRITICAL в качестве аргумента передаётся объект спин-блокировки, который связан с общим ресурсом, требующим защиты доступа.

При входе в критическую секцию в ESP вызывающее ядро отключает прерывания, как и в стандартной реализации FreeRTOS, а затем получает спин-блокировку и входит в критическую секцию. На этом этапе другое ядро не затронуто, если только оно не входит в свою собственную критическую секцию и не пытается получить ту же спин-блокировку. В этом случае оно будет ожидать освобождения блокировки. Таким образом, реализация критических секций в ESP32/8266 FreeRTOS позволяет ядру получить защищённый доступ к общему ресурсу, не отключая другое ядро. Другое ядро будет затронуто только в том случае, если оно попытается одновременно получить доступ к тому же ресурсу.

Для обслуживания критического раздела ESP-IDF FreeRTOS отделяются функции для обработчиков прерываний и в составе задач, и выделяются в группы следующим образом:

taskENTER_CRITICAL(mux), taskENTER_CRITICAL_ISR(mux), portENTER_CRITICAL(mux), portENTER_CRITICAL_ISR(mux) определены для вызова внутренней функции vPortEnterCritical()

taskEXIT_CRITICAL(mux), taskEXIT_CRITICAL_ISR(mux), portEXIT_CRITICAL(mux), portEXIT_CRITICAL_ISR(mux) определены для вызова внутренней функции vPortExitCritical()

portENTER_CRITICAL_SAFE(mux), portEXIT_CRITICAL_SAFE(mux) определяют контекст выполнения, то есть ISR или Non-ISR и вызывают соответствующие функции критического раздела (port*_CRITICAL в Non-ISR и port*_CRITICAL_ISR в ISR).

Следует отметить, что при изменении стандартного кода FreeRTOS для обеспечения совместимости с ESP-IDF FreeRTOS можно легко изменить тип вызываемого критического раздела, поскольку все они определены для вызова одной и той же функции. Пока при входе и выходе используется одна и та же спин-блокировка, точный макрос или функция, используемые для вызова, не имеют значения.

Пример прерывания от таймера повтора

/** Arduino-Esp32-CAM                                   *** RepeatTimer.ino ***
 * 
 *                                        Пример прерывания от таймера повтора.
 *                                  
 *            В скетче запускается таймерное прерывание каждую секунду, которое 
 *    освобождает семафор для основного цикла. В основном цикле при обнаружении 
 *      свободного семафора в последовательный порт выводится значение счётчика 
 *                                    прерываний и время очередного прерывания.  
 *                 Работу таймера можно остановить замкнув контакт IO0 на землю
 *                                       (на контроллере AI-Thinker ESP32-CAM).
 * 
 * v1.0, 02.11.2024                                   Автор:      Труфанов В.Е.
 * Copyright © 2024 tve                               Дата создания: 02.11.2024
**/


// Назначаем 0 пин на остановку таймера
#define BTN_STOP_ALARM 0
// Определяем заголовок для объекта таймера
hw_timer_t *timer = NULL;
// Определяем заголовок семафора, который будет указывать на срабатывание таймера
volatile SemaphoreHandle_t timerSemaphore;
// Инициируем спинлок критической секции в обработчике таймерного прерывания
portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED;
// Определяем защищенные переменные: счетчик прерываний и время последнего
volatile uint32_t isrCounter = 0;
volatile uint32_t lastIsrAt = 0;

void ARDUINO_ISR_ATTR onTimer() 
{
   // Инкрементируем счетчик прерываний и фиксируем время текущего прерывания
   portENTER_CRITICAL_ISR(&timerMux);
   isrCounter = isrCounter + 1;
   lastIsrAt = millis();
   portEXIT_CRITICAL_ISR(&timerMux);
   // Освобождаем семафора, который будем проверять в основном цикле
   xSemaphoreGiveFromISR(timerSemaphore, NULL);
}

void setup() 
{
   Serial.begin(115200);
   // Переводим нулевой пин в режим ввода
   pinMode(BTN_STOP_ALARM, INPUT);
   // Создаём бинарный семафор, сообщающий о срабатывании таймера
   timerSemaphore = xSemaphoreCreateBinary();
   // Создаём объект таймера, устанавливаем его частоту отсчёта (1Mhz)
   timer = timerBegin(1000000);
   // Подключаем функцию обработчика прерывания от таймера - onTimer
   timerAttachInterrupt(timer, &onTimer);
   // Настраиваем таймер: интервал перезапуска - 1 секунда (1000000 микросекунд),
   // всегда повторяем перезапуск (третий параметр = true), неограниченное число 
   // раз (четвертый параметр = 0) 
   timerAlarm(timer, 1000000, true, 0);
}

void loop() 
{
   // Если семафор свободен, выполняем обработку ситуации
   // (после завершения обработки семафор будет снова занят)
   if (xSemaphoreTake(timerSemaphore, 0) == pdTRUE) 
   {
      uint32_t isrCount = 0, isrTime = 0;
      // Выбираем "текущие" значения счетчика прерываний и времени прерывания
      portENTER_CRITICAL(&timerMux);
      isrCount = isrCounter;
      isrTime = lastIsrAt;
      portEXIT_CRITICAL(&timerMux);
      // Распечатываем
      Serial.print("Счетчик прерываний: ");
      Serial.print(isrCount);
      Serial.print(" Время: ");
      Serial.print(isrTime);
      Serial.println(" ms");
   }
   // Если IO0 замкнут GND, то останавливаем таймер
   if (digitalRead(BTN_STOP_ALARM) == LOW) 
   {
      if (timer) 
      {
         timerEnd(timer);
         timer = NULL;
      }
   }
}

// ******************************************************** RepeatTimer.ino ***

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

Работу таймера можно остановить, замкнув контакт IO0 на землю (GND).

Пример прерывания и управления программным сторожевым таймером

/** Arduino-Esp32-CAM                                 *** WatchdogTimer.ino ***
 * 
 *              Пример прерывания и управления программным сторожевым таймером.
 *                          
 *    В скетче задается время сторожевому таймеру для перезагрузки контроллера, 
 *   равное 3 секундам. Но пока контакт IO0 не замкнут на GND, скетч работает 1
 *            секунду и сбрасывает сторожевой таймер, предотвращая перезагрузку 
 *          контроллера. После замыкания контакта скетч переходит в бесконечный 
 *    цикл, что вызывает прерывание через 3 секунды и перезагрузку контроллера.
 *                                        (на контроллере AI-Thinker ESP32-CAM)
 * 
 * v1.1, 03.11.2024                                   Автор:      Труфанов В.Е.
 * Copyright © 2024 tve                               Дата создания: 03.11.2024
**/


// #include "esp_system.h"
// #include "rom/ets_sys.h"

// Назначаем 0 пин на остановку таймера
const int button = 0;
// Назначаем время срабатывания прерывания сторожевого таймера 3 секунды         
const int wdtTimeout = 3000;  
// Формируем заголовок таймера
hw_timer_t *timer = NULL;
// Определяем счетчик перезапусков главного цикла
int icounter;

void ARDUINO_ISR_ATTR resetModule() 
{
   ets_printf("\nПерезагрузка!\n");
   esp_restart();
}

void setup() 
{
   Serial.begin(115200);
   Serial.println();
   icounter = 1;
   Serial.println("Запускаются настройки таймера в setup");
   pinMode(button, INPUT_PULLUP);                   // init control pin
   timer = timerBegin(1000000);                     // timer 1Mhz resolution
   timerAttachInterrupt(timer, &resetModule);       // attach callback
   timerAlarm(timer, wdtTimeout * 1000, false, 0);  // set time in us
}

void loop() 
{
   ets_printf("Запускается главный цикл %d раз\n",icounter++);
   // Фиксируем время начала цикла
   long loopTime = millis();
   // Выполняем основную работу цикла
   delay(1000);  
   loopTime = millis() - loopTime;
   ets_printf("Отработано в цикле %d миллисекунд.\n",loopTime);
   // Сбрасываем WatchdogTimer - "Кормим сторожевого пса"
   timerWrite(timer, 0); 
   Serial.println(" ");

   // Пока IO0 замкнут на GND, с интервалом в полсекунды выводим сообщение
   // до тех пор, пока не произойдет перезагрузка контроллера
  while (!digitalRead(button)) 
  {
    Serial.println("IO0 замкнут на GND");
    delay(500);
  }
}

// ****************************************************** WatchdogTimer.ino ***

В скетче задается время сторожевому таймеру для перезагрузки контроллера, равное 3 секундам. Но пока контакт IO0 не замкнут на GND, скетч работает 1 секунду и сбрасывает сторожевой таймер, предотвращая перезагрузку контроллера. После замыкания контакта скетч переходит в бесконечный цикл, что вызывает прерывание через 3 секунды и перезагрузку контроллера.