Перейти к содержимому

Временные интервалы и таймеры на ESP32

Метки:

Добрый день, уважаемые читатели! В данной статье поговорим о таймерах и задержках, которые предоставляет нам микроконтроллер ESP32. В статье я привожу ссылки на документацию и тестовые примеры для платформы ESP-IDF. Но, скорее всего, все описанные методы будут доступны и для платформы Arduino32 ( не проверял, зуб не даю! – самому мало ).

Обновления и дополнения:
2023-12-19: Статья переработана и отредактирована. Добавлено описание нового API GPTimer, которое появилось в ESP-IDF на версии 5.0 и выше.
2023-12-21: Добавлено описание таймеров FreeRTOS

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

 


Функции для формирования временных задержек

Функция ets_delay_us()

Есть простая системная функция, которая выполняет задержку на время us в микросекундах с полной блокировкой программы. Аналог delay() для Arduino, но с большим разрешением.

void ets_delay_us(uint32_t us)

Строго говоря, это не совсем таймер. Точнее – совсем не таймер. CPU тупо выполняет цикл while в течение заданного времени. Можно применять в некоторых случаях (например для формирования небольших временных интервалов при обмене с внешними сенсорами и устройствами). Но я бы настоятельно не рекомендовал её применять.

 

Функция sys_delay_ms()

Еще один “не таймер“. Аналог функции delay(), которую мы все помним еще по Arduino.

void sys_delay_ms(uint32_t ms)

Внутри эта функция просто вызывает vTaskDelay(ms / portTICK_PERIOD_MS) – см. ниже. Поэтому данную функцию можно применять в точно таких же случаях, что и vTaskDelay().

 


Функции vTaskDelay() и vTaskDelayUntil()

Эти функции предоставляет нам даже не ESP32 и ESP-IDF, а FreeRTOS и предназначены они для приостановки выполнения текущей задачи на заданное количество тиков. Вместе с FreeRTOS они доступны в том числе и из платформы Arduino32. В отличие от ets_delay_us() не нагружают процессор, а наоборот – освобождают его от части работы, что гораздо полезнее. А значит могут с успехом применяться при создании довольно точных временных интервалов, например для периодического выполнения той или иной полезной работы.

Задержка задачи на заданное количество тиков:

void vTaskDelay(const TickType_t xTicksToDelay)

Тик – это один “шаг” операционной системы, который в конечном итоге привязан к “кварцованной” таковой частоте процессора, а значит отсчитываемый интервал должен быть достаточно точным. Конечно, это не полноценный таймер, но для отсчета 30 секунд между изменениями с сенсоров вполне подойдет. Пересчитать необходимую длительность в миллисекундах в тики достаточно легко. Для этого у нас есть два макроса:

  • время в тиках = время в мс / portTICK_PERIOD_MS
  • время в тиках = pdMS_TO_TICKS ( время в мс )

portTICK_PERIOD_MS и pdMS_TO_TICKS – это макросы, привязанные к частоте операционной системы, вы можете использовать любой способ (я предпочитаю второй).

void app_task_exec(void *pvParameters)
{
  TickType_t prevWakeup = 0;
  // Бесконечный цикл задачи
  while(1) {
    ...
    // Пауза 10 секунд
   vTaskDelay(10000 / portTICK_PERIOD_MS);
  };
  vTaskDelete(NULL);
}

или что тоже самое:

void app_task_exec(void *pvParameters)
{
  TickType_t prevWakeup = 0;
  // Бесконечный цикл задачи
  while(1) {
    ...
    // Пауза 10 секунд
    vTaskDelay(pdMS_TO_TICKS(10000));
  };
  vTaskDelete(NULL);
}

Но vTaskDelay() не обеспечивает идеального контроля частоты повторения периодической задачи, поскольку время, затраченное на выполнение этого самого периодического кода, а также другие задачи и прерывания будут влиять на частоту, с которой вызывается vTaskDelay().  Следовательно, время, при котором выполняется следующая итерация задачи может быть отличным от  заданного. Дабы уменьшить влияние этих эффектов, существует немного более продвинутая функция:

BaseType_t xTaskDelayUntil(TickType_t * const pxPreviousWakeTime, const TickType_t xTimeIncrement)

Как видите, здесь добавился ещё один параметр – указатель на переменную pxPreviousWakeTime, где будет хранится время последнего вызова этой функции. И при следующем вызове xTaskDelayUntil() заданное количество тиков будет скорректировано с учетом этих данных. Таким образом легко организовать равномерный интервал выполнения какой-либо задачи.

void app_task_exec(void *pvParameters)
{
  TickType_t prevWakeup = 0;
  // Бесконечный цикл задачи
  while(1) {
    ...
    // Пауза 10 секунд
    vTaskDelayUntil(&prevWakeup, pdMS_TO_TICKS(10000));
  };
  vTaskDelete(NULL);
}

Пример использования vTaskDelay() мы уже рассматривали в одной из предыдущих статей, его можно найти на GitHub

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

 


Аппаратные таймеры General Purpose Timer

Пожалуй, не ошибусь, если скажу, что это самый точный тип таймеров на ESP32, хотя и не самый простой в использовании. Поэтому они и называются – таймеры высокого разрешения. Вы можете использовать их, когда требуется очень высокая точность измеряемых интервалов. Аппаратный таймер отличается высоким разрешением и гибкой настройкой действий по тревоге (поведение, когда внутренний счетчик таймера достигает определенного целевого значения, называется сигналом тревоги таймера). При срабатывании таймера будет вызван зарегистрированный пользователем обратный вызов для каждого таймера.

Классическая линейка чипов ESP32 содержит четыре аппаратных таймера общего назначения – две группы по два таймера. Новые линейки ESP32-S*, ESP32-C* могут иметь другое количество таймеров, доступных пользователю. Общего назначения они называются потому, что только эти таймеры доступны пользователю для использования в программах, а вообще аппаратных таймеров на борту ESP может быть больше.

GP times являются 64-битными универсальными таймерами-счетчиками, основанными на 16-битных масштабаторах (их ещё называют предделители) и 64-битных счетчиках увеличения/уменьшения, которые могут перезапускаться автоматически. Счет таймера выполняется по сигналам тактового генератора, а не процессора, поэтому счетчик таймера продолжает считать, даже если CPU “завис” или находится в режиме “легкого сна” – но при этом функции обратного вызова не вызываются. После пробуждения система получает разницу между счетчиками и вызывает функцию, которая увеличивает счетчик тревог. Поскольку счетчик тревог был увеличен, система начнёт генерировать прерывания, которые не вызывались во время сна. Количество обратных вызовов зависит от продолжительности сна и периода таймеров. Это может привести к переполнению некоторых очередей. Но все это применимо только к периодическим таймерам, поскольку однократные таймеры в любом случае будут обработаны только один раз.

Важное замечание: начиная с версии ESP-IDF 5.0 и выше, разработчики заменили API General Purpose Timers на новое API – GPTimer. Вроде бы почти то же самое название, но логика управления аппаратными таймерами отличается от привычной в ESP-IDF 4.x. Но тем не менее, старый вариант API по прежнему остался доступен для разработчика (по крайней мере на момент написания данной статьи). Поэтому в данной статье я отставил две версии текста (вдруг кто-то захочет использовать старые версии).

 


General Purpose Timer для версий ESP-IDF 4.4.6 и ниже

Ссылка на описание данного API: ESP32 – General Purpose Timer. Но данное API пока доступно и из ESP-IDF 5.1.1.

Для работы с аппаратными таймерами подключите необходимую API-библиотеку: #include "driver/timer.h"

Общая схема работы с аппаратными таймерами такова:

  • Инициализируйте таймер с помощью функции timer_init(). Здесь вы задаете коэффициент деления входящей частоты (то есть минимальный интервал таймера), направление счета и параметры автоматического аппаратного перезапуска.
  • Задайте начальное и конечное значение счетчика таймера с помощью timer_set_counter_value() и timer_set_alarm_value().
  • Создайте функцию – обработчик прерывания и подключите его к таймеру – timer_isr_callback_add() и timer_enable_intr().
  • Запустите счетчик таймера с помощью функции timer_start().

Рассмотрим все эти этапы поподробнее.

Инициализация таймера и настройка параметров

Для инициализации таймера необходимо вызывать функцию:

esp_err_t timer_init(timer_group_t group_num, timer_idx_t timer_num, const timer_config_t *config)

где:

  • timer_group_t group_num – номер группы таймеров, может принимать всего два значения:TIMER_GROUP_0 илиTIMER_GROUP_1 (нумерация начинается с головы поезда нуля)
  • timer_idx_ttimer_num – номер таймера в группе, может принимать всего два значения:TIMER_0 илиTIMER_1
  • const timer_config_t *config – указатель на параметры таймера, которые выглядят так:

Рассмотрим эту структуру подробнее:

  • uint32_t divider – Делитель тактовой частоты, или масштабатор. Диапазон делителя может быть от 2 до 65536. Да, да, делитель не может быть равным 0 или 1, то есть максимальная частота таймера 40MHz, что соответствует минимальному интервалу 0,025 микросекунды.
  • timer_count_dir_t counter_dir – направление счета: TIMER_COUNT_DOWN – вниз или TIMER_COUNT_UP – вверх
  • timer_autoreload_t auto_reload – разрешить автоматический аппаратный рестарт таймера после генерации прерывания; может принимать значения TIMER_AUTORELOAD_DIS или TIMER_AUTORELOAD_EN. При TIMER_AUTORELOAD_EN получится периодический таймер, при TIMER_AUTORELOAD_DIS – однократный.
  • timer_alarm_t alarm_en – разрешить или нет сигнал “будильника” для таймера; может принимать значения TIMER_ALARM_DIS или TIMER_ALARM_EN
  • timer_start_t counter_en – разрешить счет (работу таймера) или нет; может принимать два значения: TIMER_PAUSE или TIMER_START. При TIMER_PAUSE необходимо будет запустить таймер “вручную” после завершения всех настроек, иначе таймер будет запущен немедленно
  • timer_intr_mode_t intr_type – всегда TIMER_INTR_LEVEL (0), других вариантов пока просто нет, можно не заполнять.
// Настраиваем параметры таймера
timer_config_t config = {
  .divider = 80,                      // Задаем параметры предделителя (80 = минимальный интервал 1 микросекунда)
  .counter_dir = TIMER_COUNT_UP,      // Нормальное направление счета (вверх / на увеличение)
  .counter_en = TIMER_PAUSE,          // При создании таймера он будет "поставлен на паузу", его нужно позже запустить вручную
  .alarm_en = TIMER_ALARM_EN,         // Разрешить генерацию тревожного события (прерывания) по переполнению счетчика
  .auto_reload = TIMER_AUTORELOAD_EN, // Включить автоматическую перезагрузку: чип перезапустит счетчик после тревожного события
}; // default clock source is APB
timer_init(TIMER_GROUP_0, TIMER_0, &config);

Если вы заметили, в данной структуре нет начального и конечного значения счетчиков таймера. Их мы должны установить отдельно, с помощью специальных функций. Для задания начального значения счетчика необходимо вызывать функцию:

esp_err_t timer_set_counter_value(timer_group_t group_num, timer_idx_t timer_num, uint64_t load_val)

Здесь также необходимо задать идентификаторы группы и таймера, а также 64-битное начальное значение счетчика

Для задания конечного значения счетчика (по которому будет сгенерировано прерывание) необходимо вызывать другую похожую функцию:

esp_err_t timer_set_alarm_value(timer_group_t group_num, timer_idx_t timer_num, uint64_t alarm_value)

// Настраиваем НАЧАЛЬНОЕ значение счетчика
// Счетчик таймера начнёт свой счет со значения, указанного ниже. 
// Если установлено auto_reload, это значение будет также автоматически устанавливаться при тревоге
timer_set_counter_value(TIMER_GROUP_0, TIMER_0, 0);

// Настраиваем ПОРОГОВОЕ значение счетчика, при достижении которого будет сгенерировано событие тревоги
// Значение задается в TIMER_BASE_CLK / TIMER_DIVIDER, то есть в нашем случае это: 80 MHz / 80 = 1MHz или 1 микросекунда
// В нашем случае это три секунды
timer_set_alarm_value(TIMER_GROUP_0, TIMER_0, 3*1000*1000);

Впрочем, для изменения параметров таймера есть ещё целая кучка функций:

  • timer_set_divider() – для изменения значения делителя (во избежание неопределенности рекомендуется остановить таймер перед изменением этого значения)
  • timer_set_counter_mode() – для изменения направления счета
  • timer_set_auto_reload() – для изменения режима аппаратного перезапуска
  • timer_set_alarm() – для включения или отключения “будильника”

 

Создание обработчика прерываний

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

Обработчики прерывания – не совсем простые функции. Прерывание приостанавливает выполнение всех потоков вашей программы, поэтому к обработчикам прерываний предъявляются определенные требования:

  • Обработчик прерывания должен выполняться как можно меньше по времени, иначе сработает WDT для прерываний и устройство будет аварийно перезагружено.
  • Обработчик прерывания должен постоянно находится в быстрой памяти IRAM, поэтому его следует пометить соответствующим атрибутом IRAM_ATTR.
  • В обработчиках прерываний не допускается использование библиотечных функций ESP_LOGx, но можно использовать специальную облегченную версию ESP_DRAM_LOGx.
  • Следует всегда помнить, что обработчики прерываний выполняются вне контекста прикладных задач. Дабы соблюдать потокобезопасность из обработчика прерываний лучше всего отправить какие-либо данные в очередь другой задачи, включить флаг в EventGroup и т.д. Да, в принципе можно просто изменить значение какой-либо глобальной статической переменной bool или int, которая будет управлять потоком в другой задаче, но это не приветствуется.

Вначале нужно создать callback-функцию, которая будет вызвана при генерации прерывания, называется она обработчик прерывания (ISR handler или ISR callback). Создается она по следующем шаблону:

Ничего сверхсложного, но стоит обратить внимание на два существенных момента:

  • Функция эта должна быть обязательно помечена как IRAM_ATTR, чтобы заставить компилятор постоянно держать её в быстрой памяти.
  • Обратите внимание на возвращаемое значение: какое значение должна вернуть эта функция (кстати, в других ISR-обработчиках возвращаемые значения обычно не используются). Если вы вызывали функции FreeRTOS (например xQueueSendFromISR()) из обработчика прерываний, то вам необходимо вернуть значение true или false на основе возвращаемого значения аргумента pxHigherPriorityTaskWoken. Если возвращаемое значение pxHigherPriorityTaskWoken любых вызовов FreeRTOS равно pdTRUE, то вы должны вернуть true; в противном случае вернуть false.
// Обработчик прерываний таймера
static bool IRAM_ATTR timer_isr_callback(void *args)
{
  // В обработчиках нельзя использовать ESP_LOGx(), вместо этого следует использовать ESP_DRAM_LOGx()!!!
  ESP_DRAM_LOGW("timer0", "Hardware timer alarm!");

  // Если вы вызывали функции FreeRTOS (например `xQueueSendFromISR`) из обработчика прерываний, 
  // вам необходимо вернуть значение true или false на основе возвращаемого значения аргумента pxHigherPriorityTaskWoken. 
  // Если возвращаемое значение `pxHigherPriorityTaskWoken` любых вызовов FreeRTOS равно pdTRUE, то вы должны вернуть true; в противном случае вернуть false.
  // ---
  // В данном простейшем случае мы не отправляли ничего в очереди, поэтому можно вернуть false
  return false;
}

Затем необходимо подключить её к нашему таймеру с помощью

esp_err_t timer_isr_callback_add(timer_group_t group_num, timer_idx_t timer_num, timer_isr_t isr_handler, void *arg, int intr_alloc_flags)

где:

  • timer_group_t group_num – номер группы таймеров
  • timer_idx_t timer_num – номер таймера в группе
  • timer_isr_t isr_handler – указатель на функцию обработчик
  • void *arg – указатель на какие-либо данные, которые можно передать в обработчик для идентификации таймера
  • int intr_alloc_flags – просто поставьте 0

После этого необходимо разрешить прерывания:

esp_err_t timer_enable_intr(timer_group_t group_num, timer_idx_t timer_num)

// Разрешаем прерывания для данного таймера
timer_isr_callback_add(TIMER_GROUP_0, TIMER_0, timer_isr_callback, NULL, 0);
timer_enable_intr(TIMER_GROUP_0, TIMER_0);

А если у нас насколько таймеров? Можно использовать два пути:

  • Написать один обработчик для всех таймеров, но передавать в него какие-либо данные через void *arg, которые будут однозначно идентифицировать таймер, с которого поступило прерывание.
  • А можно по простому – написать несколько разных обработчиков и подключить их отдельно.

Управление hardware таймером

Все готово к запуску нашего космического корабля таймера. Делается это совсем просто:

esp_err_t timer_start(timer_group_t group_num, timer_idx_t timer_num)

// Запускаем таймер
timer_start(TIMER_GROUP_0, TIMER_0);
ESP_LOGI("main", "Hardware timer stated");

Кстати, если Вам по какой-либо причине необходимо временно приостановить таймер, вы можете сделать это с помощью функции timer_pause().

Примеры приложений с hardware таймерами

Официальный пример работы с аппаратными таймерами вы можете посмотреть по ссылке: esp-idf/timer_group_example_main.c В данном примере используется сразу два таймера в двух группах (с разными режимами работы соответственно) – этот пример в полной мере демонстрирует возможности работы с ними.

Но, на мой взгляд, новичку сравнительно непросто будет разобраться в указанном выше примере, поэтому я создал ещё более простой пример всего с одним таймером без всяких “хитростей”: dzen/timer_hardware.

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/timer.h"
#include "esp_log.h"

// Обработчик прерываний таймера
static bool IRAM_ATTR timer_isr_callback(void *args)
{
  // В обработчиках нельзя использовать ESP_LOGx(), вместо этого следует использовать ESP_DRAM_LOGx()!!!
  ESP_DRAM_LOGW("timer0", "Hardware timer alarm!");

  // Если вы вызывали функции FreeRTOS (например `xQueueSendFromISR`) из обработчика прерываний, 
  // вам необходимо вернуть значение true или false на основе возвращаемого значения аргумента pxHigherPriorityTaskWoken. 
  // Если возвращаемое значение `pxHigherPriorityTaskWoken` любых вызовов FreeRTOS равно pdTRUE, то вы должны вернуть true; в противном случае вернуть false.
  // ---
  // В данном простейшем случае мы не отправляли ничего в очереди, поэтому можно вернуть false
  return false;
}

void app_main() 
{
  // Настраиваем параметры таймера
  timer_config_t config = {
    .divider = 80,                      // Задаем параметры предделителя (80 = минимальный интервал 1 микросекунда)
    .counter_dir = TIMER_COUNT_UP,      // Нормальное направление счета (вверх / на увеличение)
    .counter_en = TIMER_PAUSE,          // При создании таймера он будет "поставлен на паузу", его нужно позже запустить вручную
    .alarm_en = TIMER_ALARM_EN,         // Разрешить генерацию тревожного события (прерывания) по переполнению счетчика
    .auto_reload = TIMER_AUTORELOAD_EN, // Включить автоматическую перезагрузку: чип перезапустит счетчик после тревожного события
  }; // default clock source is APB
  timer_init(TIMER_GROUP_0, TIMER_0, &config);

  // Настраиваем НАЧАЛЬНОЕ значение счетчика
  // Счетчик таймера начнёт свой счет со значения, указанного ниже. 
  // Если установлено auto_reload, это значение будет также автоматически устанавливаться при тревоге
  timer_set_counter_value(TIMER_GROUP_0, TIMER_0, 0);

  // Настраиваем ПОРОГОВОЕ значение счетчика, при достижении которого будет сгенерировано событие тревоги
  // Значение задается в TIMER_BASE_CLK / TIMER_DIVIDER, то есть в нашем случае это: 80 MHz / 80 = 1MHz или 1 микросекунда
  // В нашаем случае это три секунды
  timer_set_alarm_value(TIMER_GROUP_0, TIMER_0, 3*1000*1000);

  // Разрешаем прерывания для данного таймера
  timer_isr_callback_add(TIMER_GROUP_0, TIMER_0, timer_isr_callback, NULL, 0);
  timer_enable_intr(TIMER_GROUP_0, TIMER_0);

  // Запускаем таймер
  timer_start(TIMER_GROUP_0, TIMER_0);
  ESP_LOGI("main", "Hardware timer stated");

  // Основной цикл
  while (1) {
    // Просто выводим сообщение в лог через каждые 5 секунд
    vTaskDelay(pdMS_TO_TICKS(5000));
    ESP_LOGI("main", "vTaskDelay(5000) timeout");
  }
}

В примере всего 1 таймер, настроенный на повтор через каждые 3 секунды. А через каждый 5 секунд работает основной цикл, используя vTaskDelay(). Все предельно просто. Если прошить данный пример в микроконтроллер, по получим следующую картинку:

Как видите, всё работает "как часы". Так это и есть часы!

Как видите, всё работает “как часы”. Так это и есть часы!

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

 


General Purpose Timer для версий ESP-IDF 5.0.0 и выше

Ссылка на описание данного API: ESP32 – GPTimer.

GPTimer (General Purpose Timer) — это новый драйвер для аппаратных таймеров ESP32. С переходом на ESP-IDF пятого поколения существенно изменились многие API, в том числе и General Purpose Timer. Зачем нужно было кардинально переделывать API? Мне сложно сказать. Судя по всему, разработчики попытались универсализировать и упростить работу с GP-таймерами, так как сейчас выпускается много линеек ESP32 с различными характеристиками. 

Для работы с данной версией аппаратных таймеров подключите новую API-библиотеку: #include "driver/gptimer.h"

Общая схема работы с GP-таймерами похожа на предыдущий способ:

  • Инициализируйте дескриптор таймера с помощью функции gptimer_new_timer(). Здесь вы задаете источник тактового сигнала для счетчика, частоту счета (минимальный интервал времени) и направление счета (вверх или вниз). 
  • Настройте параметры счетчика таймера с помощью gptimer_set_alarm_action().
  • Создайте функцию – обработчик прерывания и подключите его к таймеру – gptimer_register_event_callbacks().
  • Запустите счетчик таймера с помощью функции gptimer_enable() и gptimer_start()

Рассмотрим все эти этапы поподробнее.

Инициализация таймера и настройка его параметров

Для инициализации таймера необходимо вызывать функцию gptimer_new_timer:

esp_err_t gptimer_new_timer(const gptimer_config_t *configgptimer_handle_tret_timer)

Функция принимает два параметра – указатель на параметры конфигурации аппаратного таймера и указатель на переменную, в которую будет помещен дескриптор таймера при успешном его создании. Тут есть тонкий момент: аппаратных таймеров всего 4, но вы можете попытаться вызвать эту функцию, скажем, 5 или 7 раз – и если все таймеры уже заняты, будет возвращена ошибка – всегда проверяйте коды ошибок.

Параметры конфигурации GP-таймера выглядят следующим образом:

Здесь необходимо указать:

  • источник тактового сигнала для таймера
  • направление счета – вверх или вниз
  • рабочую частоту таймера в герцах

В моем примере это выглядит так:

gptimer_handle_t gptimer = NULL;

// Настраиваем параметры таймера
gptimer_config_t timer_config = {
    .clk_src = GPTIMER_CLK_SRC_DEFAULT,            // Выбираем источник тактового сигнала для счетчиков
    .direction = GPTIMER_COUNT_UP,                 // Устанавливаем направление счета
    .resolution_hz = 1000000, // 1MHz, 1 tick=1us  // Устанавливаем частоту счета, то есть минимальный интервал времени на 1 тик
};

// Создаем дескриптор GP-таймера с указанными параметрами
gptimer_new_timer(&timer_config, &gptimer);

 

Создаем функцию обратного вызова

В терминологии GPTimer API обработчики “тревог” таймеров называются уже не обработчиками прерываний, а callback-функциями. Наверное это потому, что формально обработчик прерываний у всех таймеров, задействованных через GPTimer API один и тот же, и именно он вызывает в свою очередь настроенную callback-функцию. Хотя эти callback-и должны удовлетворять всем тем же самым требованиями, которые предъявляются и обработчикам прерываний – не расслабляйтесь.

Прототип callback-а выглядит уже несколько сложнее – в качестве аргументов передается указатель на сработавший таймер и дополнительные данные:

static bool IRAM_ATTR gptimer_alarm_callback(gptimer_handle_t timer, const gptimer_alarm_event_data_t * edata, void * user_data)

А вот подцепить этот callback к таймеру можно с помощью функции gptimer_register_event_callbacks:

esp_err_t gptimer_register_event_callbacks(gptimer_handle_t timerconst gptimer_event_callbacks_t *cbsvoid *user_data)

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

Важно! Сделать это нужно до вызова gptimer_enable()! Оно и понятно – gptimer_enable по всей видимости разрешает прерывания для таймера.

Что ж, сделаем как просили:

// Подключаем функцию обратного вызова
gptimer_event_callbacks_t cb_config = {
    .on_alarm = gptimer_alarm_callback,
};
gptimer_register_event_callbacks(gptimer, &cb_config, NULL);

 

Настраиваем счетчик таймера и параметры перезапуска

После этого необходимо настроить параметры счетчика таймера. Сделать это можно с помощью функции gptimer_set_alarm_action:

esp_err_t gptimer_set_alarm_action(gptimer_handle_t timerconst gptimer_alarm_config_t *config)

Опять конфиги! Что ж, смотрим что там:

Что здесь:

  • alarm_count – здесь указываем конечное значение счетчика, при котором будет вызвана “тревога”
  • reload_count – для цикличных (повторяющихся) таймеров указываем начальное значение счетчика, которое будет установлено сразу после генерации прерывания
  • flags.auto_reload_on_alarm – этот флаг отвечает за автоперезапуск таймера – при 0 это будет одноразовый таймер, при 1 – цикличный
// Задаем начальное значение счетчика таймера (не обязательно)
gptimer_set_raw_count(gptimer, 0);

// Задаем параметры счетчика таймера
gptimer_alarm_config_t alarm_config = {
    .alarm_count = 3000000,                   // Конечное значение счетчика = 3 секунды
    .reload_count = 0,                        // Значение счетчика при автосбросе
    .flags.auto_reload_on_alarm = true,       // Автоперезапуск счетчика таймера разрешен
};
gptimer_set_alarm_action(gptimer, &alarm_config);

На этом подготовку ракеты к запуску можно считать завершенной.

Запуск!

Для запуска таймера нужно сделать две вещи: разрешить прерывания через gptimer_enable и собственно выполнить запуск посредством gptimer_start:

esp_err_t gptimer_enable(gptimer_handle_t timer)esp_err_t gptimer_start(gptimer_handle_t timer)

Ничего особо хитрого в них нет, поехали:

// Разрешаем прерывания для данного таймера
gptimer_enable(gptimer);

// Запускаем таймер
gptimer_start(gptimer);
ESP_LOGI("main", "Hardware timer stated");

Если все в порядке и не наделали ошибок, то компилируем, загружаем в ESP32 и получаем результат:

Как видим, все работает корректно. Пример для работы с этим API вы найдете по ссылке: github.com/kotyara12/dzen/tree/master/timer_gptimer

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gptimer.h"
#include "esp_log.h"

// Функция обратного вызова таймера
static bool IRAM_ATTR gptimer_alarm_callback(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *user_data)
{
  // В обработчиках нельзя использовать ESP_LOGx(), вместо этого следует использовать ESP_DRAM_LOGx()!!!
  ESP_DRAM_LOGW("timer0", "Hardware timer alarm!");

  // Если вы вызывали функции FreeRTOS (например `xQueueSendFromISR`) из обработчика прерываний, 
  // вам необходимо вернуть значение true или false на основе возвращаемого значения аргумента pxHigherPriorityTaskWoken. 
  // Если возвращаемое значение `pxHigherPriorityTaskWoken` любых вызовов FreeRTOS равно pdTRUE, то вы должны вернуть true; в противном случае вернуть false.
  // ---
  // В данном простейшем случае мы не отправляли ничего в очереди, поэтому можно вернуть false
  return false;
}

void app_main() 
{
  gptimer_handle_t gptimer = NULL;

  // Настраиваем параметры таймера
  gptimer_config_t timer_config = {
      .clk_src = GPTIMER_CLK_SRC_DEFAULT,            // Выбираем источник тактового сигнала для счетчиков
      .direction = GPTIMER_COUNT_UP,                 // Устанавливаем направление счета
      .resolution_hz = 1000000, // 1MHz, 1 tick=1us  // Устанавливаем частоту счета, то есть минимальный интервал времени на 1 тик
  };
  
  // Создаем дексриптор GP-таймера с указанными параметрами
  gptimer_new_timer(&timer_config, &gptimer);

  // Подключаем функцию обратного вызова
  gptimer_event_callbacks_t cb_config = {
      .on_alarm = gptimer_alarm_callback,
  };
  gptimer_register_event_callbacks(gptimer, &cb_config, NULL);

  // Задаем начальное значение счетчика таймера (не обязательно)
  gptimer_set_raw_count(gptimer, 0);

  // Задаем параметры счетчика таймера
  gptimer_alarm_config_t alarm_config = {
      .alarm_count = 3000000,                   // Конечное значение счетчика = 3 секунды
      .reload_count = 0,                        // Значение счетчика при автосбросе
      .flags.auto_reload_on_alarm = true,       // Автоперезапуск счетчика таймера разрешен
  };
  gptimer_set_alarm_action(gptimer, &alarm_config);

  // Разрешаем прерывания для данного таймера
  gptimer_enable(gptimer);

  // Запускаем таймер
  gptimer_start(gptimer);
  ESP_LOGI("main", "Hardware timer stated");

  // Основной цикл
  while (1) {
    // Просто выводим сообщение в лог через каждые 5 секунд
    vTaskDelay(pdMS_TO_TICKS(5000));
    ESP_LOGI("main", "vTaskDelay(5000) timeout");
  }
}

Конечно, API для работы с аппаратными таймерами описанными здесь функциями не огранивается, но мы их gjrf опустим, дабы не раздувать и так объемную статью. При желании вы можете ознакомиться с ними самостоятельно.

 


Программные таймеры

Аппаратные таймеры – это хорошо, но их только четыре (для классической ESP32)! А в реальном приложении количество требуемых таймеров может легко исчисляться десятками. Для решения этой проблемы во FreeRTOS были придуманы программные таймеры. Но в Espressif этого показалось мало, и они придумали свой способ решения – программные таймеры High Resolution Timers (ESP Timers), привязанные к одному из аппаратных таймеров. 

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

  • Максимальное разрешение (то есть минимальный интервал таймера) равно периоду тика FreeRTOS.
  • Обратные вызовы таймера могут отправляться из задачи с низким приоритетом. Задача таймера потенциально может быть вытеснена другими задачами, что приведет к снижению точности измерения временных интервалов.

Аппаратные таймеры свободны от обоих ограничений, но зачастую они менее удобны в использовании. Например, компонентам приложения может потребоваться запуск событий таймера в определенное время в будущем, но аппаратный таймер содержит только одно значение «сравнения», используемое для генерации прерывания. Это означает, что поверх аппаратного таймера необходимо создать какое-то средство для управления списком ожидающих событий, которое может отправлять обратные вызовы для этих событий по мере возникновения соответствующих аппаратных прерываний.

 

Особенности использования программных таймеров в ESP-IDF

Работают программные таймеры (и в случае FreeRTOS и ESP Timer) примерно одинаково – “обслуживанием” программных таймеров занимаются специально выделенные задачи, которые автоматически запускается при старте планировщика FreeRTOS. Главное отличие таймеров “стандартных” таймеров FreeRTOS от таймеров ESP-IDF заключается в источнике опорных сигналов: 

  • Таймеры FreeRTOS привязаны к тактовой частоте процессора и квантам времени FreeRTOS
  • Программные “высокоточные” таймеры ESP Timer используют один 64-битный аппаратный LAC-таймер ESP32 (но тем не менее, все равно минимальный интервал времени – один тик FreeRTOS)

И тут Espressif нам подложили небольшую свинью: дело в том, что используете вы программные таймеры или нет (любого типа), но задачи, которые занимается их обслуживанием, все равно запускаются при запуске ESP32 и FreeRTOS, что зачастую приводит к бесполезному расходованию памяти (стека).

Хотя в справочной системе декларируется, что соответствующие задачи для программных таймеров будут созданы только в том случае, если используются соответствующие им API, но… Список запущенных задач FreeRTOS однозначно показывает, что обе задачи таймеров работают всегда – хотя FreeRTOS таймеры я в своих программах никогда не использую (впрочем, их может использовать сама FreeRTOS для своих внутренних целей – я не знаю).

Как видно из вырезанного кусочка списка задач – обе задачи в наличии – хотя Tmr Svc и находится в заблокированном состоянии (не потребляет ресурсы процессора), но ей таки выделен свой стек и память под саму задачу, что есть не хорошечно. Отключить тот или иной тип таймеров через SDK Config на текущий момент нельзя, можно только ограничить размер стека, выделяемого той или иной задаче. Например для таймеров FreeRTOS:

На этом вступительную речь к теме программных таймеров можно завершить и плавно перейти к их практическому использованию. Начнем со специфичного для ESP32 API.

 


High Resolution Timer (ESP Timer)

Для работы с программными таймерами ESP Timer API используется немного другая библиотека: #include “esp_timer.h”.

Общая схема работы с программными таймерами:

  • Создайте функцию – обработчик событий таймера
  • Инициализируйте таймер с помощью функции esp_timer_create()
  • Запустите счетчик таймера с помощью функции esp_timer_start_once() (однократно) или esp_timer_start_periodic() (постоянно)

Как видите, процесс запуска программного таймера немного короче, чем аппаратного (да и вообще использование программных таймеров, на мой взгляд, проще, чем аппаратных). Настраивать меньше, а для обработки событий используется обычная функция обратного вызова, а не обработчик прерываний (поэтому можно немного порезвиться). Рассмотрим все эти этапы поподробнее.

Создание функции обратного вызова

Как я уже написал, в программных таймерах используется обычная функция обратного вызова, поэтому к ней не предъявляется никаких особых требований. Можно сделать это, например, так:

// Функция обратного вызова таймера
static void timer_callback(void *args)
{
  // Это не обработчик прерываний, поэтому можно использовать ESP_LOGx
  ESP_LOGW("timer", "Software timer alarm!");
}

 

Создание (инициализация) таймера

Далее нужно создать таймер с помощью функции

esp_err_t esp_timer_create(const esp_timer_create_args_t *create_args, esp_timer_handle_t *out_handle)

где:

  • const esp_timer_create_args_t *create_args – указатель на структуру, содержащую параметры создаваемого таймера
  • esp_timer_handle_t *out_handle – указатель на хендл таймера, который вы сможете потом использовать для управление этим таймером

Рассмотрим esp_timer_create_args_t поподробнее:

  • esp_timer_cb_t callback – функция обратного вызова
  • void* arg – аргументы для передачи в функцию обратного вызова
  • esp_timer_dispatch_t dispatch_method – метод обработки таймаута: из задачи или через прерывание
  • const char* name – имя таймера, используется только для отладки
  • bool skip_unhandled_events – пропустить обработку событий, если обработка события таймера по какой-либо причине была пропущена. Обратный вызов таймера будет вызван в любом случае, он не будет потерян. В худшем случае, когда таймер не обрабатывался более одного периода (для периодических таймеров), обратные вызовы будут вызываться один за другим, не дожидаясь установленного периода. Это может быть неприемлемо для некоторых приложений, и для устранения такого поведения была введена опция skip_unhandled_events. Если установлено значение skip_unhandled_events = true, то периодический таймер, который истекал несколько раз, не имея возможности вызвать обратный вызов, по-прежнему будет приводить только к одному событию обратного вызова после того, как только станет возможной обработка.

Я заполнил эту структуру так:

static esp_timer_handle_t _timer = NULL;

// Настраиваем параметры таймера
esp_timer_create_args_t config = {
  .name = "test_timer",              // Условное название таймера, ни на что не влияет
  .callback = timer_callback,        // Указатель на функцию обратного вызова для таймера
  .arg = NULL,                       // Какие-либо аргументы, которые можно передать в callback
  .dispatch_method = ESP_TIMER_TASK, // Обработчик будет вызыван из задачи (другой вариант - из ISR)
  .skip_unhandled_events = false     // Если какое-либо срабатывание таймера было пропущено, то обработать его в любом случае
}; 

// Создаем таймер
esp_timer_create(&config, &_timer);
if (_timer == NULL) {
  ESP_LOGE("main", "Failed to timer create");
  return;
};

Обязательно проверьте хендл таймера на NULL после вызова esp_timer_create(), чтобы избежать исключения и внеплановой перезагрузки MCU.

Запуск таймера

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

  • Чтобы запустить таймер в однократном режиме, вызовите esp_timer_start_once(esp_timer_handle_t timer, uint64_t timeout_us), передав временной интервал, через который должен быть вызван обратный вызов. При вызове обратного вызова таймер считается остановленным.
  • Чтобы запустить таймер в периодическом режиме, вызовите esp_timer_start_periodic(esp_timer_handle_t timer, uint64_t period), передав период, с которым должен быть вызван обратный вызов. Таймер продолжает работать до тех пор , пока не будет вызван esp_timer_stop().

Обратите внимание, что таймер не должен работать, когда вызывается esp_timer_start_once() или esp_timer_start_periodic(). Чтобы перезапустить работающий таймер, сначала остановите его, а только затем вызовите одну из функций запуска.

В итоге весь процесс запуска таймера будет выглядеть как-то так:

static esp_timer_handle_t _timer = NULL;

// Настраиваем параметры таймера
esp_timer_create_args_t config = {
  .name = "test_timer",              // Условное название таймера, ни на что не влияет
  .callback = timer_callback,        // Указатель на функцию обратного вызова для таймера
  .arg = NULL,                       // Какие-либо аргументы, которые можно передать в callback
  .dispatch_method = ESP_TIMER_TASK, // Обработчик будет вызван из задачи (другой вариант - из ISR)
  .skip_unhandled_events = false     // Если какое-либо срабатывание таймера было пропущено, то обработать его в любом случае
}; 

// Создаем таймер
esp_timer_create(&config, &_timer);
if (_timer == NULL) {
  ESP_LOGE("main", "Failed to timer create");
  return;
};

// Запускаем таймер в периодическом режиме с интервалом 3 секунды
esp_timer_start_periodic(_timer, 3*1000*1000);
ESP_LOGI("main", "Software timer stated");

Как видите, всё довольно просто.

Результаты работы примера

Результаты работы примера

Пример для работы с программными таймерами ESP Timers вы найдете на GitHub по ссылке: dzen/timer_software

 


Таймеры FreeRTOS (Timer API)

Таймеры FreeRTOS, как и следует из их названия, предоставляется операционной системой FreeRTOS, то есть это не “изобретение” Espressif. Они же, пожалуй, являются самыми простыми в использовании таймерами на ESP-IDF.

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

  • Создать таймер с помощью функции xTimerCreate(…) – здесь мы указываем все параметры таймера, в том числе и функцию обратного вызова.
  • Запустить его с помощью xTimerStart(…).

Создание программного таймера

Создать программный таймер можно двумя способами:

  • Динамически с помощью функции xTimerCreate(…) – в этом случае память, необходимая под таймер будет выделена из общей кучи (heap)
  • Статически с помощью функции xTimerCreateStatic(…) – в этом случае вы сами должны будете позаботиться о том, чтобы выделить необходимый буфер ещё на этапе программирования. Таким образом, xTimerCreateStatic() позволяет создавать программный таймер без использования какого-либо динамического выделения памяти.

В случае динамического создания таймера используйте функцию:

TimerHandle_t xTimerCreate(const char * const pcTimerName, const TickType_t xTimerPeriodInTicks, const UBaseType_t uxAutoReload, void * const pvTimerID, TimerCallbackFunction_t pxCallbackFunction)

где:

  • pcTimerName — текстовое имя, присвоенное таймеру. Нужно исключительно для облегчения отладки, ядро ​​всегда ссылается на таймер только по его дескриптору, а не по имени.
  • xTimerPeriodInTicks – период таймера  в тактовых периодах, поэтому константу portTICK_PERIOD_MS можно использовать для преобразования времени, указанного в миллисекундах. Например, если таймер должен истечь после 100 тактов, то xTimerPeriodInTicks должен быть установлен на 100. Но если таймер должен истечь через 500 миллисекунд, тогда xPeriod может быть установлен на ( 500 / portTICK_PERIOD_MS ). Период таймера должен быть больше 0.
  • uxAutoReload — если для uxAutoReload установлено значение pdTRUE, то таймер будет автоматически перезапущен с частотой, заданной параметром xTimerPeriodInTicks. Если для uxAutoReload установлено значение pdFALSE, то таймер будет одноразовым и перейдет в состояние покоя после истечения его срока действия.
  • pvTimerID — любой идентификатор, присваиваемый создаваемому таймеру. Обычно это используется в функции обратного вызова таймера, чтобы определить, какой таймер eё вызывал – в случае, если одна и та же функция обратного вызова назначается более чем одному таймеру.
  • pxCallbackFunction — функция, вызываемая по истечении времени таймера. Функции обратного вызова должны иметь прототип, определенный TimerCallbackFunction_t, то есть void vCallbackFunction( TimerHandle_t xTimer );

 

Если вы не хотите использовать динамическую память, вам следует обратиться к статической версии:

TimerHandle_t xTimerCreateStatic(const char *const pcTimerName, const TickType_t xTimerPeriodInTicks, const UBaseType_t uxAutoReload, void *const pvTimerID, TimerCallbackFunction_t pxCallbackFunction, StaticTimer_t *pxTimerBuffer)

Здесь вы должны будете дополнительно указать предварительно созданный статический буфер под данные таймера pxTimerBuffer, а в остальном она не отличается от предыдущей.

Запуск таймера

В любом случае таймер создается в неактивном состоянии и его нужно запустить. Делается это не просто, а очень просто:

BaseType_t xTimerStart(TimerHandle_t xTimer, TickType_t xTicksToWait);

где:

  • xTimer – дескриптор запускаемого/перезапускаемого таймера, полученный при его создании.
  • xTicksToWait — указывает время в тиках, в течение которого вызывающая задача должна удерживаться в состоянии «заблокировано», чтобы дождаться успешной отправки команды запуска в очередь команд таймера, если очередь задачи таймера уже заполнена на момент вызова xTimerStart(). Это не время таймера!

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

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/timers.h"
#include "esp_log.h"

TimerHandle_t _timer = NULL;

// Функция обратного вызова таймера
void timer_callback(TimerHandle_t pxTimer)
{
  // Это не обработчик прерываний, поэтому можно использовать ESP_LOGx
  ESP_LOGW("timer", "Software FreeRTOS timer alarm!");
}

void app_main() 
{
  // Создаем таймер
  _timer = xTimerCreate("Timer", // Просто текстовое имя для отладки
      pdMS_TO_TICKS(1000),       // Период таймера в тиках
      pdTRUE,                    // Повторяющийся таймер
      NULL,                      // Идентификатор, присваиваемый создаваемому таймеру
      timer_callback             // Функция обратного вызова
  );

  // Запускаем таймер
  if (xTimerStart( _timer, 0) == pdPASS) {
    ESP_LOGI("main", "Software FreeRTOS timer stated");
  };

  // Основной цикл
  while (1) {
    // Просто выводим сообщение в лог через каждые 5 секунд
    vTaskDelay(pdMS_TO_TICKS(5000));
    ESP_LOGI("main", "vTaskDelay(5000) timeout");
  };
}

Этот пример вы можете скачать или просто посмотреть по ссылке: github.com/…/timer_freertos.

Конечно же, Timer API не ограничен описанными тремя функциями, а включает в себя функции на все случаи жизни. Но для данной статьи этого вполне достаточно. Справочник по FreeRTOS timer API вы может найти здесь: https://freertos.org/FreeRTOS-Software-Timer-API-Functions.html

 


На этом пока всё, до встречи на сайте и на telegram-канале

💠 Полный архив статей вы найдете здесь


Пожалуйста, оцените статью:
[ 5 из 5, всего 2 оценок ]

4 комментария для “Временные интервалы и таймеры на ESP32”

  1. День добрый.
    Пытаюсь аккуратно решить следующую проблемку. Запись во внешний i2c eeprom длится 5 мс. portTICK_PERIOD_MS у меня равен 10 мс.
    У меня нет большой спешки и задержка даже на 20 мс проблемы не создаст, но хочется понять принципы работы задержек в том числе на будущее.
    Я написал небольшую функцию для измерения длительности задержки (400 раз вызывается vTaskDelay время считывается до и после esp_timer_get_time()) и получил следующие результаты в мкс:
    vTaskDelay(0) min time: 13, max time: 1641, aver time: 28
    vTaskDelay(1) min time: 5052, max time: 10462, aver time: 9986
    vTaskDelay(2) min time: 14883, max time: 20003, aver time: 19986

    запускал многократно, увеличивал количество вызовов до 40 000 времена кардинально не изменяются.
    В моём понимании вызов функции vTaskDelay(1) может дать задержку от 0 до 10 мс (в моём случае). Однако на “железе” я получил минимальное время 5 мс. Можете ли Вы подсказать, надёжно ли будет минимальное время 5 мс или лучше перестраховаться и увеличить задержку? Ну или может подскажете более правильный способ отсчёта задержки в 5мс который не будет занимать процессорное время.

    1. Добрый день.

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

      vTaskDelay(0) просто отдаст управление планировщику от текущей задачи немедленно, до истечения текущего кванта времени, ей выделенного. То есть интервал ожидания будет “остаток текущего тика” или в мс это от 0 до 10 мс.
      Но это только в том случае, если во время вызова vTaskDelay(0) не случится более высокоприоритетной задачи, которая на время перехватит управление.

      vTaskDelay(1) соответственно теоретически должен дать задержку “остаток текущего тика” + “1 тик”
      vTaskDelay(2) соответственно теоретически должен дать задержку “остаток текущего тика” + “2 тика”

      Это насколько я понимаю

  2. Провел эксперимент, но на этот раз до инициализации всех задач и прерываний результаты несколько изменились:
    vTaskDelay(0) min time: 13, max time: 34, aver time: 13
    vTaskDelay(1) min time: 9518, max time: 10000, aver time: 9995
    vTaskDelay(2) min time: 19786, max time: 20000, aver time: 19996
    т.е. когда процессор не занят, флуктуации значительно меньше
    ps. Комментарий просто для истории

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *