Добрый день, уважаемый читатель! Продолжим знакомство с “внутренностями” ESP-IDF. В этой статье я расскажу, что такое циклы событий, и нафига козе баян зачем они нужны в нашей программе.
Что вы обычно делаете в своей программе, когда вам требуется узнать о наступлении того или иного события в устройстве, например о подключении к WiFi, и предпринять какие-либо действия? Правильно! Добавляете callback-функцию! Но тут возникают потенциальные проблемы:
- Чтобы добавить callback функцию из одного библиотечного модуля на событие другого библиотечного модуля нам нужно явно подключить (include) файл источника события в файл подписчика. В итоге получается мешанина кросс-ссылок с одной библиотеки на другую.
- Что вы будете делать, если вам нужно среагировать на событие не в одном модуле, а сразу в нескольких? Единственное, что мне приходит на ум – это организовывать связанный список callback-ов и вызывать их по цепочке. Когда у вас эти callback-и в разных и не связанных друг с другом библиотеках, это превращается в кошмар.
- FreeRTOS это многозадачная система. И callback будет вызван в контексте той задачи, которая генерирует исходное событие. Иногда это неприменимо – среагировать на событие должен совершенно другой поток / задача.
Чтобы решить эти проблемы, FreeRTOS предоставляет нам встроенный механизм: циклы событий – Event Loop. Но не следует путать их с EventGroups, которые мы обсуждали в одной из предыдущих статей.
Сразу оговорюсь – это архиудобно в большинстве случаев, но иногда удобнее всё-же использовать старый способ (callbacks), всё зависит от ситации.
Попытаюсь объяснить своими словами, что такое 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, NarodMon; системный светодиод меняет режим работы на “Подключено к WiFi“RE_WIFI_STA_DISCONNECTED
– подключение к WiFi потеряно. При получении этого сигнала: приостанавливаются все сетевые службы, а системный светодиод переводится в режим мигания “Нет WiFi“
// Forwarded WIFI events static const char* RE_WIFI_EVENTS = "REVT_WIFI"; typedef enum { RE_WIFI_STA_INIT = 0, RE_WIFI_STA_STARTED, RE_WIFI_STA_STOPPED, RE_WIFI_STA_DISCONNECTED, RE_WIFI_STA_GOT_IP, RE_WIFI_STA_PING_OK, RE_WIFI_STA_PING_FAILED } re_wifi_event_id_t;
Передавать через циклы событий можно события разных самых типов:
- Временные интервалы (начало каждой минуты, часа, дня, недели…)
- Ошибки и сбои сенсоров или устройств
- Получение внешних команд через telegram
- Получение OTA-обновления прошивки
- Подключение и отключение к WiFi, MQTT и другим сервисам
- Потеря доступа в интернет (проверка посредством пинга)
- Обновление системного времени через SNTP
- Нажатия на различные кнопки или изменение уровня на GPIO
- Изменение внутренних параметров
- Получение сигнала с приемника 433 MHz
- используйте свою фантазию…
При этом “получатель” события ничего не знает о том, какой поток / задача вызвали это событие.
Удобно? На мой взгляд – очень. Используя этот механизм, мы можем связать в единую систему сборище разнородных задач с разными функциями.
Использование цикла событий
Общая схема работы с циклом событий выглядит так:
- Запустите цикл событий. Это может быть пользовательский (произвольный) или системный (предопределенный) цикл событий. Не обязательно для произвольных (ваших) событий использовать собственный экземпляр цикла событий – вполне можно обойтись системным циклом для всех задач – и системных (wifi к примеру) и ваших собственных прикладных. Но можно и выделенный экземпляр запустить, если свободная память позволяет. Запустить цикл событий при запуске устройства нужно как можно раньше, чтобы потом не возникало проблем при регистрации обработчиков.
- Заинтересованные задачи должны зарегистрировать обработчики событий, на которые они должны реагировать. Обработчики событий – это те же самые callback-функции, но выполняются они из контекста цикла событий. Поэтому, как и в случае с прерываниями, не стоит включать в обработчики событий слишком “тяжелый” и медленный код, так как это повлияет на диспетчеризацию других событий. Обратите внимание: обработчики событий “пользуются” стеком задачи-цикла событий, а не задачи, генерирующей событие.
- Отправляем событие в цикл. Задача цикла событий принимает его из входящей очереди и начинает проверку зарегистрированных на втором этапе обработчиков. Если таковой находится, он выполняется.
Вот и всё, ничего сложного. Как видите – цикл событий это просто централизованный способ регистрации и вызова callback-ов, значительно повышающих их удобство для программиста. Вам не требуется заботится, сколько ещё библиотек или модулей ждут этого же события. Вам не требуется включать библиотеку-источник события в список include. Вы можете в любой момент времени отменять подсписку на события и возобновлять её.
Создание цикла событий
Цикл событий – это обычная задача FreeRTOS с входящей очередью, которая при появлении в входящем потоке каких-либо событий перенаправляет эти данные всем заинтересованным “подписчикам”, то есть вызывает зарегистрированные обработчики событий.
Как я уже упоминал, вы можете запустить системный цикл событий или свой собственный. Разницы по функционалу между ними нет никакой, разве что для функций отправки события добавляется суффикс “_to” и требуется указать хэндл цикла. Системный цикл событий используется некоторыми встроенными библиотеками ESP-IDF, в частности “esp_wifi”, и вам придется запускать его в любом случае, если вы будете подключать ESP32 к WiFi.
Нужно ли создавать дополнительный, прикладной, цикл? Не уверен. Вообще у меня в прошивке такая возможность заложена и используется, чтобы не тормозить системные события. Но однажды, после очередной правки кода я допустил ошибку (макрос, управляющий этим, был случайно отключен) и дополнительный “прикладной” цикл не создавался, и абсолютно все события “пошли” через системный цикл событий. И всё прекрасно работало около месяца, без сбоев, почти на всех устройствах (кроме охранно-пожданой сигнализации, где генерировалось очень много внешних событий, и системная очередь изредка просто переполнялась).
Для создания системного цикла событий используйте функцию:
esp_err_t esp_event_loop_create_default(void)
Ей не нужно передавать никаких параметров – всё, что нужно (размер стека, например), конфигурируется через SdkConfig. Проверьте код возврата на предмет ошибок и всё, больше ничего делать не требуется.
Если вы посмотрите примеры для работы с WiFi, то в каждом из них вы найдете эту самую функцию, так как для работы WiFi системный цикл событий требуется обязательно.
Для создания пользовательского цикла событий используйте другую функцию:
esp_err_t esp_event_loop_create(const esp_event_loop_args_t *event_loop_args, esp_event_loop_handle_t *event_loop)
Здесь уже немного сложнее. Во первых вам придется передать в функцию указатель на структуру, в которой вы сами определяете параметры задачи цикла: ядро, приоритет, наименование задачи, а так же размер стека и очереди. А во вторых – передать указатель на хендл цикла событий.
Пример создания такого цикла, взятый из моего “рабочего” кода:
// Create the event loop esp_event_loop_args_t _loopCfg; memset(&_loopCfg, 0, sizeof(_loopCfg)); _loopCfg.queue_size = CONFIG_EVENT_LOOP_QUEUE_SIZE; _loopCfg.task_name = eventLoopTaskName; _loopCfg.task_priority = CONFIG_EVENT_LOOP_PRIORITY; _loopCfg.task_stack_size = CONFIG_EVENT_LOOP_STACK_SIZE; _loopCfg.task_core_id = CONFIG_EVENT_LOOP_CORE; esp_err_t err = esp_event_loop_create(&_loopCfg, &_eventLoop); if (err == ESP_OK) { rloga_i("Dedicated event loop created successfully"); return true; } else { rloga_e("Failed to create event loop!"); };
Так-с, очередь создали, теперь нужно настроить подписки….
Регистрация обработчиков событий
Для создания обработчика вам нужно подготовить функцию следующего прототипа:
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
на соответствие требуемому. Ниже приведён пример обработчика, который при подключении к MQTT генерирует топики сенсоров, а при отключении – удаляет их из памяти. Здесь же используется и пересылаемая структура, в которой содержаться все необходимые данные:
static void sensorsMqttEventHandler(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) { if (event_id == RE_MQTT_CONNECTED) { re_mqtt_event_data_t* data = (re_mqtt_event_data_t*)event_data; sensorsMqttTopicsCreate(data->primary); } else if ((event_id == RE_MQTT_CONN_LOST) || (event_id == RE_MQTT_CONN_FAILED)) { sensorsMqttTopicsFree(); } }
В обработчике вам не нужно заботиться об удалении данных event_data
– библиотека сделает это сама после завершения всех обработчиков. Только если вы не передаете в event_data
“вручную” выделенную память – тогда сами ломайте голову, как и когда её освобождать.
Теперь осталось его только зарегистрировать. Делается это с помощью функций
esp_err_t esp_event_handler_register(esp_event_base_t event_base, int32_t event_id, esp_event_handler_t event_handler, void *event_handler_arg)
для системного цикла или
esp_err_t esp_event_handler_register_with(esp_event_loop_handle_t event_loop, esp_event_base_t event_base, int32_t event_id, esp_event_handler_t event_handler, void *event_handler_arg)
для произвольного цикла.
Функции очень похожи, только в втором случае дополнительно нужно передать хендл цикла, который мы получили на этапе создания цикла событий.
Параметры:
- 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] данные, помимо данных события, которые передаются обработчику при его вызове. Вот эти данные и будут переданы в обработчик при его вызове в качестве первого аргумента.
Приведу пример из моего рабочего кода:
bool eventHandlerRegister(esp_event_base_t event_base, int32_t event_id, esp_event_handler_t event_handler, void* event_handler_arg) { #if CONFIG_EVENT_LOOP_DEDICATED // Dedicated event loop if (_eventLoop) { rlog_d(logTAG, "Register dedicated event handler for %s #%d", event_base, event_id); esp_err_t err = esp_event_handler_register_with(_eventLoop, event_base, event_id, event_handler, event_handler_arg); if (err != ESP_OK) { rlog_e(logTAG, "Failed to register event handler for %s #%d", event_base, event_id); return false; }; } else { return false; }; #else // Default event loop rlog_d(logTAG, "Register default event handler for %s #%d", event_base, event_id); esp_err_t err = esp_event_handler_register(event_base, event_id, event_handler, event_handler_arg); if (err != ESP_OK) { rlog_e(logTAG, "Failed to register event handler for %s #%d", event_base, event_id); return false; }; #endif // CONFIG_EVENT_LOOP_DEDICATED return true; }
В зависимости от значения CONFIG_EVENT_LOOP_DEDICATED = 1
или 0
будет зарегистрирован обработчик либо для выделенного цикла, либо для системного.
И так повторяем для “всех заинтересованных сторон“…
Генерация событий
Всё готово, можно начинать отправлять события в цикл. Для этого используйте функцию esp_event_post()
для системного цикла или esp_event_post_to()
для произвольного цикла. Опять здесь разница только в суффиксе “_to
” и необходимости указания хендла цикла, куда отправляем известие о событии.
esp_err_t esp_event_post_to(esp_event_loop_handle_t event_loop, esp_event_base_t event_base, int32_t event_id, const void *event_data, size_t event_data_size, TickType_t ticks_to_wait)
Параметры:
- event_loop — [in] цикл событий, к который пуляем извещение. Только для
esp_event_post_to()
- event_base — [in] базовый идентификатор события
- event_id – [in] идентификатор события
- event_data — [in] данные, относящиеся к событию, которое передается обработчику. Сюда проще всего передать указатель на обычную, не динамическую переменную (то есть не размещенную вручную в куче). Цикл событий снимет с нее копию и передаст всем обработчикам, поэтому даже если эта переменная уйдет из зоны видимости, и будет уничтожена компилятором – ничего страшного не произойдет. Но вот передавать здесь указатели на строки или массивы в куче – гораздо хлопотнее – ведь цикл клонирует только собственно указатель и вы сами должны позаботиться об освобождении памяти, иначе будет утечка памяти. А обработчиков теоретически может быть несколько…
- event_data_size — [in] размер данных события. С этим всё должно быть предельно ясно –
sizeof(...)
. - ticks_to_wait — [in] количество тиков для блокировки в полной очереди событий. В принципе, можно просто передать
portMAX_DELAY
(ждать “до победного”) и не париться. Но в таком случае можно нарваться на непонятные зависания, когда очередь вдруг переполнится.
bool eventLoopPost(esp_event_base_t event_base, int32_t event_id, void* event_data, size_t event_data_size, TickType_t ticks_to_wait) { esp_err_t err = ESP_OK; #if CONFIG_EVENT_LOOP_DEDICATED // Dedicated event loop if (!_eventLoop) return false; do { esp_err_t err = esp_event_post_to(_eventLoop, event_base, event_id, event_data, event_data_size, ticks_to_wait == portMAX_DELAY ? CONFIG_EVENT_LOOP_POST_DELAY : ticks_to_wait); if (err != ESP_OK) { rlog_e(logTAG, "Failed to post event to \"%s\" #%d: %d (%s)", event_base, event_id, err, esp_err_to_name(err)); if (ticks_to_wait == portMAX_DELAY) { vTaskDelay(CONFIG_EVENT_LOOP_POST_DELAY); }; }; } while ((ticks_to_wait == portMAX_DELAY) && (err != ESP_OK)); #else // Default event loop do { esp_err_t err = esp_event_post(event_base, event_id, event_data, event_data_size, ticks_to_wait == portMAX_DELAY ? CONFIG_EVENT_LOOP_POST_DELAY : ticks_to_wait); if (err != ESP_OK) { rlog_e(logTAG, "Failed to post event to \"%s\" #%d: %d (%s)", event_base, event_id, err, esp_err_to_name(err)); if (ticks_to_wait == portMAX_DELAY) { vTaskDelay(CONFIG_EVENT_LOOP_POST_DELAY); }; }; } while ((ticks_to_wait == portMAX_DELAY) && (err != ESP_OK)); #endif // CONFIG_EVENT_LOOP_DEDICATED return err == ESP_OK; }
Генерация событий из прерываний
Ну и напоследок стоит упомянуть об особенностях отправки событий из прерываний. Указанные выше функции 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
(бесконечно), но если при этом очередь цикла событий окажется переполненной, поток, отправляющий событие зависнет на определенное время до появления свободного “окна”. И вы не сразу поймете в чем дело.
Полезные ссылки