Добрый день, уважаемый читатель! Сегодня поговорим о таймерах, которые предоставляет нам платформа ESP32.
Наверное я не сильно погрешу против истины, если скажу что в практически всех прошивках используются таймеры для отсчета необходимых временных интервалов. В том или ином виде. Это могут быть временные интервалы между считыванием данных с сенсоров, включение нагрузки на заданное время и так далее. Давайте посмотрим, какие средства предоставляет нам ESP32:
- Функция ets_delay_us()
- Функция sys_delay_ms()
- Функции FreeRTOS – vTaskDelay() и vTaskDelayUntil()
- Аппаратные таймеры 64-bit General Purpose Timer – 4 шт.
- Программные таймеры (много)
Возможно, этот список не полный, я пока знаком далеко не со всеми внутренностями ESP-IDF. Если вы знакомы с ещё какими-либо способами – пожалуйста, прошу в комментарии. Начнем с самого простого способа.
Функция 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() и vTaskDelayUntil()
И eще два “не таймера“. Эти функции предоставляет нам даже не ESP32, а FreeRTOS и предназначены они для приостановки выполнения текущей задачи на заданное количество тиков. В отличие от ets_delay_us()
не нагружают процессор, а наоборот – освобождают его от части работы, что гораздо полезнее. А значит могут с успехом применяться при создании довольно точных временных интервалов, например для периодического выполнения той или иной полезной работы.
Задержка задачи на заданное количество тиков:
void vTaskDelay(const TickType_t xTicksToDelay)
Тик – это один “шаг” операционной системы, который в конечном итоге привязан к “кварцованной” таковой частоте процессора, а значит отсчитываемый интервал должен быть достаточно точным. Конечно, это не полноценный таймер, но для отсчета 30 секунд между изменениями с сенсоров вполне подойдет. Пересчитать необходимую длительность в миллисекундах в тики достаточно легко. Для этого у нас есть два макроса:
- время в тиках = время в мс /
portTICK_PERIOD_MS
- время в тиках =
pdMS_TO_TICKS ( время в мс )
portTICK_PERIOD_MS
и pdMS_TO_TICKS
– это макросы, привязанные к частоте операционной системы, вы можете использовать любой способ (я предпочитаю второй). Но vTaskDelay()
не обеспечивает идеального контроля частоты периодической задачи, поскольку время, затраченное на выполнение этого самого периодического кода, а также другие задачи и прерывания будут влиять на частоту, с которой вызывается vTaskDelay()
, и, следовательно, на время, при котором выполняется следующая итерация задачи. Чтобы уменьшить влияние этих эффектов, существует немного более продвинутая функция:
BaseType_t xTaskDelayUntil (TickType_t * const pxPreviousWakeTime, const TickType_t xTimeIncrement)
Как видите, здесь добавился ещё один параметр – указатель на переменную pxPreviousWakeTime, где будет хранится время последнего вызова этой функции. И при следующем вызове xTaskDelayUntil()
заданное количество тиков будет скорректировано с учетом этих данных. Таким образом легко организовать равномерный интервал выполнения какой-либо задачи. Пример использования vTaskDelay()
мы уже рассматривали в одной из предыдущих статей, его можно найти на GitHub.
Аппаратные таймеры
Пожалуй, это самый точный тип таймеров на ESP32, хотя и не самый простой в использовании. Вы можете использовать их, когда требуется очень высокая точность измеряемых интервалов.
Для работы с аппаратными таймерами потребуется использование прерываний – если вы избегаете их, то вам следует обратиться к программным таймерам. Хотя, в принципе, можно использовать аппаратные таймеры без прерываний, “вручную” проверяя регистр тревоги, но я не вижу в этом никакого смысла.
Чип ESP32 содержит четыре аппаратных таймера общего назначения – две группы по два таймера. Все они являются 64-битными универсальными таймерами, основанными на 16-битных масштабаторах (их ещё называют предделители) и 64-битных счетчиках увеличения/уменьшения, которые могут перезапускаться автоматически.
Для работы с аппаратными таймерами подключите библиотеку:#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_t timer_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)
, других вариантов пока просто нет, можно не заполнять.
Если вы заметили, в данной структуре нет начального и конечного значения счетчиков таймера. Их мы должны установить отдельно, с помощью специальных функций. Для задания начального значения счетчика необходимо вызывать функцию:
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)
Впрочем, для изменения параметров таймера есть ещё целая кучка функций:
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.
Простейшая функция обработки прерываний
Затем необходимо подключить её к нашему таймеру с помощью
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)
А если у нас насколько таймеров? Можно использовать два пути:
- Написать один обработчик для всех таймеров, но передавать в него какие-либо данные через void *arg, которые будут однозначно идентифицировать таймер, с которого поступило прерывание.
- А можно по простому – написать несколько разных обработчиков и подключить их отдельно.
Управление hardware таймером
Все готово к запуску нашего космического корабля таймера. Делается это совсем просто:
esp_err_t timer_start(timer_group_t group_num, timer_idx_t timer_num)
Кстати, если Вам по какой-либо причине необходимо временно приостановить таймер, вы можете сделать это с помощью функции timer_pause()
.
Примеры приложений с hardware таймерами
Официальный пример работы с аппаратными таймерами вы можете посмотреть по ссылке: esp-idf/timer_group_example_main.c В данном примере используется сразу два таймера в двух группах (с разными режимами работы соответственно) – этот пример в полной мере демонстрирует возможности работы с ними.
Но, на мой взгляд, новичку сравнительно непросто будет разобраться в указанном выше примере, поэтому я создал ещё более простой пример всего с одним таймером без всяких “хитростей”: dzen/timer_hardware. В примере всего 1 таймер, настроенный на повтор через каждые 3 секунды. А через каждый 5 секунд работает основной цикл, используя vTaskDelay(). Все предельно просто. Если прошить данный пример в микроконтроллер, по получим следующую картинку:
Как видите, всё работает “как часы”. Так это и есть часы!
В моем примере для просты нет никакого контроля ошибок и возвращаемых значений – сделайте это самостоятельно. Нужна в этом помощь – пишите в комментариях, запилим ещё одну статью.
Конечно, этим библиотека для работы с аппаратными таймерами не огранивается, там есть еще несколько интересных функций, но мы их пока опустим.
Программные таймеры
Аппаратные таймеры – это хорошо, но их только четыре! А в реальном приложении количество требуемых таймеров может легко исчисляться десятками. Для решения этой проблемы в Espressif придумали способ решения – программные таймеры, привязанные к одному из аппаратных таймеров.
Программные таймеры обладают несколько худшими характеристиками, чем аппаратные таймеры общего назначения, но, тем не менее, прекрасно подходят для решения прикладных задач в подавляющем большинстве случаев. Программные таймеры имеют несколько ограничений:
- Максимальное разрешение (то есть минимальный интервал таймера) равно периоду тика FreeRTOS.
- Обратные вызовы таймера отправляются из задачи с низким приоритетом
Аппаратные таймеры свободны от обоих ограничений, но зачастую они менее удобны в использовании. Например, компонентам приложения может потребоваться запуск событий таймера в определенное время в будущем, но аппаратный таймер содержит только одно значение «сравнения», используемое для генерации прерывания. Это означает, что поверх аппаратного таймера необходимо создать какое-то средство для управления списком ожидающих событий, которое может отправлять обратные вызовы для этих событий по мере возникновения соответствующих аппаратных прерываний. Внутри все программные таймеры используют всего один 64-битный аппаратный LAC-таймер.
Для работы с программными таймерами используется немного другая библиотека: #include "esp_timer.h"
. Общая схема работы с программными таймерами похожа на аппаратные:
- Создайте функцию – обработчик событий таймера
- Инициализируйте таймер с помощью функции
esp_timer_create()
- Запустите счетчик таймера с помощью функции
esp_timer_start_once()
(однократно) илиesp_timer_start_periodic()
(постоянно)
Как видите, процесс запуска программного таймера немного короче. Да и вообще использование программных таймеров, на мой взгляд, проще, чем аппаратных. Настраивать меньше, а для обработки событий используется обычная функция обратного вызова, а не обработчик прерываний (поэтому можно немного порезвиться). Рассмотрим все эти этапы поподробнее.
Создание функции обратного вызова
Как я уже написал, в программных таймерах используется обычная функция обратного вызова, поэтому к ней не предъявляется никаких особых требований. Можно сделать это, например, так:
Создание (инициализация) таймера
Далее нужно создать таймер с помощью функции
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
, то периодический таймер, который истекал несколько раз, не имея возможности вызвать обратный вызов, по-прежнему будет приводить только к одному событию обратного вызова после того, как только станет возможной обработка.
Я заполнил эту структуру так:
Обязательно проверьте хендл таймера на 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()
. Чтобы перезапустить работающий таймер, сначала остановите его, а только затем вызовите одну из функций запуска.
В итоге весь процесс запуска таймера будет выглядеть как-то так:
Как видите, всё довольно просто.
Результаты работы примера
Пример для работы с программными таймерами вы найдете на GitHub по ссылке: dzen/timer_software