Добрый день, уважаемый читатель! Продолжим знакомство с "внутренностями" ESP-IDF. В этой статье я расскажу, что такое циклы событий, и нафига козе баян зачем они нужны в нашей программе.
Что вы обычно делаете в своей программе, когда вам требуется узнать о наступлении того или иного события в устройстве, например о подключении к WiFi, и предпринять какие-либо действия? Правильно! Добавляете callback-функцию! Но тут возникают потенциальные проблемы:
- Чтобы добавить callback функцию из одного библиотечного модуля на событие другого библиотечного модуля нам нужно явно подключить (include) файл источника события в файл подписчика. В итоге получается мешанина кросс-ссылок с одной библиотеки на другую.
- Что вы будете делать, если вам нужно среагировать на событие не в одном модуле, а сразу в нескольких? Единственное, что мне приходит на ум - это организовывать связанный список callback-ов и вызывать их по цепочке. Когда у вас эти callback-и в разных и не связанных друг с другом библиотеках, это превращается в кошмар.
- FreeRTOS это многозадачная система. И callback будет вызван в контексте той задачи, которая генерирует исходное событие. Иногда это неприменимо - среагировать на событие должен совершенно другой поток / задача.
Чтобы решить эти проблемы, FreeRTOS предоставляет нам встроенный механизм: циклы событий - Event Loops.
Сразу оговорюсь - это архиудобно в большинстве случаев, но иногда удобнее всё-же использовать старый способ.
Попытаюсь объяснить своими словами, что такое Event Loop. Если вы знакомы с MQTT протоколом, то Event Loop - это такой махонький MQTT сервер внутри операционной системы. Компоненты прошивки и библиотеки могут генерировать и публиковать события-данные в определенные "топики"; а другие компоненты и библиотеки могут подписываться на интересующие их "топики" и получать своевременные уведомления, ничего не зная об источнике события.
Каждое событие идентифицируется двумя признаками: строковым const char* event_base (базовый идентификатор событий) и числовым int32_t event_id (номер события в группе). Кроме этого, к событию можно прикрепить данные произвольной длины void* event_data (важно! при отправке события из обработчиков исключений можно отправить не более 4 байт), например это может быть код ошибки или причина отключения от сети..
Практический пример - возьмем то же самое событие "подключение к WiFi". Точнее у меня это будет целая группа событий, но для примера нам достаточно пока двух:
- RE_WIFI_STA_GOT_IP - подключение к WiFi установлено и получен IP-адрес. При получении этого сигнала: запускается MQTT-клиент; запускается сервис PING-а; переводятся в рабочий режим клиенты Telegram, ThingSpeak, OpenMon, NarorMon; системный светодиод меняет режим работы на "Подключено к WiFi"
- RE_WIFI_STA_DISCONNECTED - подключение к WiFi потеряно. При получении этого сигнала: приостанавливаются все сетевые службы, а системный светодиод переводится в режим мигания "Нет WiFi"
Передавать через циклы событий можно события разных самых типов:
- Временные интервалы (начало каждой минуты, часа, дня, недели...)
- Ошибки и сбои сенсоров или устройств
- Получение внешних команд через telegram
- Получение OTA-обновления прошивки
- Подключение и отключение к WiFi, MQTT и другим сервисам
- Потеря доступа в интернет (проверка посредством пинга)
- Обновление системного времени через SNTP
- Нажатия на различные кнопки или изменение уровня на GPIO
- Изменение внутренних параметров
- Получение сигнала с приемника 433 MHz
- используйте свою фантазию...
При этом "получатель" события ничего не знает о том, какой поток / задача вызвали это событие. Удобно? На мой взгляд - очень.
Используя этот механизм, мы можем связать в единую систему сборище разнородных задач с разными функциями.
Использование цикла событий
Общая схема работы с циклом событий выглядит так:
- Запускается цикл событий. Это может быть пользовательский (произвольный) или системный (предопределенный) цикл событий. Не обязательно для произвольных (ваших) событий использовать собственный экземпляр цикла событий - вполне можно обойтись системным циклом для всех задач - и системных (wifi к примеру) и ваших собственных прикладных. Но можно и выделенный экземпляр запустить, если свободная память позволяет. Запустить цикл событий при запуске устройства нужно как можно раньше, чтобы потом не возникало проблем при регистрации обработчиков.
- Заинтересованные задачи должны зарегистрировать обработчики событий, на которые они должны реагировать. Обработчики событий - это те же самые callback-функции, но выполняются они из контекста цикла событий. Поэтому, как и в случае с прерываниями, не стоит включать в обработчики событий слишком "тяжелый" и медленный код, так как это повлияет на диспетчеризацию других событий. Обратите внимание: обработчики событий "пользуются" стеком задачи-цикла событий, а не задачи, генерирующей событие.
- Отправляем событие в цикл. Задача цикла событий принимает его из входящей очереди и начинает проверку зарегистрированных на втором этапе обработчиков. Если таковой находится, он выполняется.
Вот и всё, ничего сложного. Как видите - цикл событий это просто централизованный способ регистрации и вызова callback-ов, значительно повышающих их удобство для программиста. Вам не требуется заботится, сколько ещё библиотек или модулей ждут этого же события. Вам не требуется включать библиотеку-источник события в список include. Вы можете в любой момент времени отменять подсписку на события и возобновлять её.
Создание цикла событий
Цикл событий - это обычная задача FreeRTOS с входящей очередью, которая при появлении в входящем потоке каких-либо событий перенаправляет эти данные всем заинтересованным "подписчикам", то есть вызывает зарегистрированные обработчики событий.
Как я уже упоминал, вы можете запустить системный цикл событий или свой собственный. Разницы по функционалу между ними нет никакой, разве что для функций отправки события добавляется суффикс "_to" и требуется указать хэндл цикла.
Системный цикл событий используется некоторыми встроенными библиотеками ESP-IDF, в частности "esp_wifi", и вам придется запускать его в любом случае, если вы будете подключать ESP32 к WiFi.
Нужно ли создавать дополнительный, прикладной, цикл? Не уверен. Вообще у меня в прошивке такая возможность заложена и используется, чтобы не тормозить системные события. Но однажды, после очередной правки кода я допустил ошибку (макрос, управляющий этим, был случайно отключен) и дополнительный "прикладной" не создавался, и абсолютно все события "пошли" через системный цикл событий. И всё прекрасно работало около месяца, без сбоев, почти на всех устройствах (кроме ОПС сигнализации, где генерировалось очень много внешних событий, и системная очередь изредка просто переполнялась).
Для создания системного цикла событий используйте функцию esp_event_loop_create_default():
Ей не нужно передавать никаких параметров - всё, что нужно (размер стека, например), конфигурируется через SdkConfig.
Проверьте код возврата на предмет ошибок и всё, больше ничего делать не требуется.
Если вы посмотрите примеры для работы с WiFi, то в каждом из них вы найдете эту самую функцию, так как для работы WiFi системный цикл событий требуется обязательно.
Для создания дополнительного цикла событий используйте другую функцию esp_event_loop_create():
Здесь уже немного сложнее. Во первых вам придется передать в функцию указатель на структуру, в которой вы сами определяете параметры задачи цикла: ядро, приоритет, наименование задачи, а так же размер стека и очереди. А во вторых - передать указатель на хендл цикла событий. Пример создания такой очереди, взятый из моего "рабочего" кода смотрите ниже:
Так-с, очередь создали, теперь нужно настроить подписки....
Регистрация обработчиков событий
Для создания обработчика вам нужно подготовить функцию следующего прототипа:
void run_on_event(void* handler_arg, esp_event_base_t event_base, int32_t event_id, void* event_data);
C event_base, event_id и event_data у вас не должно быть вопросов - про это я уже упоминал выше. Но откуда тут вдруг взялся какой-то handler_arg? Скоро узнаем...
Обработчик можно создать один на несколько разных типов, тогда внутри его вам нужно проверить event_id на соответствие требуемому:
В обработчике вам не нужно заботиться об удалении данных event_data - библиотека сделает это сама после завершения всех обработчиков. Только если вы не передаете в event_data "вручную" выделенную память - тогда сами ломайте голову, как и когда её освобождать.
Теперь осталось его только зарегистрировать. Делается это с помощью функций esp_event_handler_instance_register() для системного цикла или esp_event_handler_instance_register_with() для произвольного цикла. Функции очень похожи, только в esp_event_..._with() дополнительно нужно передать хендл цикла, который мы получили на этапе создания задачи цикла.
Параметры:
- event_loop — [in] цикл событий, в котором регистрируется эта функция обработчика. Только для esp_event_handler_instance_register_with()
- event_base — [in] базовый идентификатор события. Здесь есть тонкость - если указать ESP_EVENT_ANY_BASE, то обработчик будет реагировать на вообще любые события.
- event_id – [in] идентификатор события. Здесь так же можно указать ESP_EVENT_ANY_ID в качестве идентификатора события, тогда обработчик будет вызван для любого события указанной выше группы.
- event_handler — [in] функция-обработчик, которая вызывается при отправке события
- event_handler_arg — [in] данные, помимо данных события, которые передаются обработчику при его вызове. Вот эти данные и будут переданы в обработчик при его вызове в качестве первого аргумента.
- instance – [out] Объект экземпляра обработчика событий, связанный с зарегистрированным обработчиком событий и данными (может иметь значение NULL). Нужон, только если конкретный экземпляр обратного вызова должен быть отменен в процессе работы прошивки. Возможна многократная регистрация одного и того же обработчика событий, в результате чего будут получены разные объекты-экземпляры. Данные могут быть одинаковыми для всех регистраций. Если отмена регистрации не требуется, instance может иметь значение NULL.
Приведу пример из моего рабочего кода:
И так повторяем для "всех заинтересованных сторон"...
Генерация событий
Всё готово, можно начинать отправлять события в цикл. Для этого используйте функцию esp_event_post() для системного цикла или esp_event_post_to() для произвольного цикла. Опять здесь разница только в суффиксе "_to" и необходимости указания хендла цикла, куда отправляем известие о событии.
Параметры:
- event_base — [in] базовый идентификатор события
- event_id – [in] идентификатор события
- event_data — [in] данные, относящиеся к событию, которое передается обработчику. Сюда проще всего передать указатель на обычную, не динамическую переменную (то есть не размещенную вручную в куче). Цикл событий снимет с нее копию и передаст всем обработчикам, поэтому даже если эта переменная уйдет из зоны видимости, и будет уничтожена компилятором - ничего страшного не произойдет. Но вот передавать здесь указатели на строки или массивы в куче - гораздо хлопотнее - ведь цикл клонирует только собственно указатель и вы сами должны позаботиться об освобождении памяти, иначе будет утечка памяти. А обработчиков теоретически может быть несколько...
- event_data_size — [in] размер данных события. С этим всё должно быть предельно ясно - sizeof(...).
- ticks_to_wait — [in] количество тиков для блокировки в полной очереди событий. В принципе, можно просто передать portMAX_DELAY (ждать "до победного") и не париться. Но в таком случае можно нарваться на непонятные зависания, когда очередь вдруг переполнится.
Мелко, конечно, но ничего не поделаешь. Дзен не хочет добавлять теги <pre> или <code> в редактор текста, хотя я просил, упрямые они. Но вы можете посмотреть всё своими глазами здесь.
Генерация событий из прерываний
Ну и напоследок стоит упомянуть об особенностях отправки событий из прерываний. Указанные выше функции esp_event_post() и esp_event_post_to() нельзя использовать из ISR (обработчиков прерываний), так как это гарантированно приведет к срабатыванию WDT таймера и немедленной перезагрузке процессора. Вместо них следует использовать функции esp_event_isr_post() или esp_event_isr_post_to(). Эти версии немногим отличаются от предыдущих версий, ознакомьтесь с ними самостоятельно, кликнув по ссылкам.
Я бы хотел только отметить одну особенность, на которую можно легко "нарваться с наскоку": размер пересылаемых данных через event_data в данном случае не может превышать 4 байт.
Грабли - наше всё!
Этого обычно нет в официальных инструкциях, и, по идее, должно быть понятно опытному программисту. Но для начинающих наступить на грабли - святое дело, сам так делал поначалу. Поэтому опишу свой опыт - возможно он поможет вам их миновать.
Как, надеюсь, вам стало понятно из статьи - обработчики событий всегда выполняются из контекста задачи - цикла событий. То есть обработчики располагаются в стеке этого самого цикла. И если микроконтроллер стал вдруг перезагружаться по причине переполнения стека, а адрес указывает на обработчик события - добавлять стек нужно в задачу цикла событий.
Во вторых, во время появления нового события обработчики вызываются последовательно, один за другим. Поэтому стоит обращать внимание на то, что зависание в одном из обработчиков вызовет полную блокировку всех остальных обработчиков, да и всего цикла в целом.
При отправке события в цикл можно указать время ожидания ticks_to_wait равным portMAX_DELAY (бесконечно), но если при этом очередь цикла событий окажется переполненной, поток, отправляющий событие зависнет на определенное время до появления свободного "окна". И вы не сразу поймете в чем дело.
На этом пока всё, до новых встреч на канале
Полезные ссылки
_______________
На этом пока всё, до встречи на сайте и на dzen-канале!
👍 Понравилась статья? Поддержите канал лайком или комментарием! Каналы на Дзене "живут" только за счет ваших лайков.
📌Подпишитесь на канал и вы всегда будете в курсе новых статей.
🔶 Полный архив статей вы найдете здесь
Благодарю за вашу поддержку! 🙏