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

ESP-IDF: а что под капотом? Обзор базовых объектов

Добрый день, уважаемый читатель!

На создание этой статьи меня натолкнул один из моих постоянных читателей и подписчиков. Эта статья ориентирована на начинающих разработчиков и представляет собой очень краткий обзор инструментов и средств, которые предоставляет ESP-IDF и FreeRTOS, для чего и в каких случаях они могут применяться. Как правило начинающим программистам, которые хотят освоить FreeRTOS, очень не хватает знаний о всех её возможностях и инструментах. Конечно, есть прекрасные публикации, например А. Курница и других авторов (список ссылок в конце статьи), но там “многа букав”, не всякий осилит сразу, а начать работу хочется уже сейчас. Данная статья ни в коем случае не замена более подробным источникам, а скорее – некое “оглавление” или “выжимка” для быстрого ознакомления, а в дальнейшем вы сможете изучить эти механизмы более подробно по официальной документации, другим статьям на этом канале, а также другим источникам.

Предупреждение! Перечень рассмотренных в статье объектов может быть не полным, я сам изучаю FreeRTOS и ESP-IDF всего несколько лет и всё время натыкаюсь на новые возможности. Если вы знакомы с чем-то, чему не нашлось пока места в данной статье – пожалуйста, напишите комментарий, постараюсь дополнить.

 


Почему для ESP32 используется именно FreeRTOS и ESP-IDF?

По сути ESP-IDF, которая расшифровывается как ESPressif Iot Development Framework – это адаптированная конкретно для микроконтроллеров ESP32 версия FreeRTOS. ESP-IDF FreeRTOS основана на Vanilla FreeRTOS v10.4.3. Поэтому в контексте данной статьи мы говорим Ленин, подразумеваем партия я буду использовать оба этих термина в равном значении.

Разве нельзя без нее обойтись? Нельзя! Просто потому, что разработчики чипов Espressif выбрали FreeRTOS в качестве основного API (Application Program Interface – программный интерфейс для приложений), то есть базового набора инструментов для программиста. И даже если вы пишете скетч для ESP32 в Arduino Studio и ничего не знаете об ESP-IDF, то все равно “где-то внутри” работает та же самая ESP-IDF, просто надежно скрытая надстройками Arduino. Но это не означает что из Arduino вы не можете “достать” до ESP-IDF! Наоборот, даже из Arduino вы можете использовать большинство базовых возможностей FreeRTOS. Поэтому эта статья может быть вам полезна, даже если вы совсем не собираетесь покидать старую добрую Arduino IDE.

 


Немного теории

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

Так получилось, что вступительная (теоретическая) часть получилась чуть ли не длиннее основной, так как здесь не ставилась задача детального рассмотрения каждого объекта. А без необходимой теоретической подготовки не совсем ясно-понятно, для чего эти самые объекты и нужны.

В системе FreeRTOS каждый поток выполнения называется задачей или task. Вообще, на взрослых компьютерах то же самое обычно принято называть потоком (thread), и лично мне так гораздо привычней. Нет абсолютного согласия в терминологии внутри встраиваемых систем, но в большинстве статей и инструкций для ESP используется термин задача (task), поэтому и я постараюсь использовать этот термин. Но если где-то я назову задачу потоком – не удивляйтесь, я имел в виду одно и то же.

Обработка задач (работа программ) на компьютере-десктопе может быть классифицирована как мягкий реалтайм (soft real time). Чтобы обеспечить наилучшее использование компьютера для пользователя, система должна отвечать на каждый ввод в течение минимального желаемого лимита времени, однако если незначительно выйти за пределы этого лимита, то компьютерная система всё равно останется для пользователя работоспособной. Например, нажатия на клавиши должны визуально регистрироваться в течение определенного времени после нажатия. Регистрирование нажатий вне этого времени выглядит как потеря отзывчивости системой, но её работоспособность в целом сохраняется.

Многозадачность встраиваемых систем (контроллер дисплея, стиральной машины, промышленного робота, бортовой компьютер автомобиля и так далее) устроена очень похоже на многозадачность десктопов, если рассматривать их с точки зрения запуска нескольких потоков выполнения (задач) на одном процессоре. Однако цель встраиваемых систем реального времени полностью отличается от десктопов – от встраиваемых систем ожидается обеспечение жесткого реалтайма (hard real time). Поэтому такие операционные системы и называются Real Time OS.

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

На рис. 1 показано истинно параллельное выполнение трех задач, но на реальных процессорах такое недостижимо. В реальном же процессоре при работе RTOS выполнение задач носит периодический характер: каждая задача выполняется определенное время, после чего процессор «переключается» на следующую задачу (рис. 2).

Источник: https://easyelectronics.ru/img/ARM_kurs/FreeRTOS/Kurniz.pdf

Источник: https://easyelectronics.ru/img/ARM_kurs/FreeRTOS/Kurniz.pdf

То есть на одном ядре процесcора cреди всех задач в один момент времени может выполняться только одна задача. Говорят, что она находится в состоянии выполнения. Остальные задачи в этот момент не выполняются, ожидая, когда планировщик выделит каждой из них процессорное время.

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

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

Источник: http://microsin.net/programming/arm/freertos-part1.html

Источник: http://microsin.net/programming/arm/freertos-part1.html

Кроме того, что выполнение задачи может быть приостановлено планировщиком принудительно, или задача может сама приостановить свое выполнение. Это происходит в двух случаях. Первый – это когда задача «хочет» задержать свое выполнение на определенный промежуток времени – в таком случае она переходит в состояние сна (sleep). Второй – когда задача ожидает освобождения какого-либо аппаратного ресурса (например, последовательного порта) или наступления какого-то события, в этом случае говорят, что задача блокирована (block). Блокированная или «спящая» задача не нуждается в процессорном времени до наступления соответствующего события или истечения определенного интервала времени, поэтому планировщик её некоторое время игнорирует.

То есть получается, что состояний у задачи вовсе не два, а целых четыре:

  • READY – задача запущена и готова принять на себя управление, когда подойдет её очередь в соответствии с приоритетом и планировщик выделит ей квант времени, после чего задача переходит в следующее состояние – RUN.
  • RUN – переключил управление на данную задачу, процессор выполняет ее код в данный момент. Только в этот момент задача живет, потребляет процессорное время и делает полезную работу ради которой она была создана.
  • WAIT (тоже самое что и SLEEP или BLOCK) – задача спит, но спит чутко, как кошка, в ожидании некоего события, например, пока заданное время не истекло, или пока что-нибудь в системе не случится, на что эта задача должна среагировать. При этом планировщик не тратит на неё процессорное время. Очень удобное состояние. Как только ожидаемое событие произойдет (например что-то упало во входящую очередь), то состояние задачи изменится на READY и она будет готова к выполнению.
  • SUSPEND – задача полностью остановлена. Не удалена и не выгружена из памяти, но она просто неактивна. Ни на какие события не реагирует и планировщик на неё внимания не обращает от слова совсем. Летаргический сон. Вывести ее из этого состояния можно только API командой, извне. Удобно применять в нештатных ситуациях, когда например “отвалилась” WiFi, и нужно приостановить все сетевые процессы – “а чо зря ресурсы жрать в бесплодных попытках подключиться к серверу”.
Источник: Яндекс.Картинки (https://easyelectronics.ru/freertos_manual.html)

Источник: Яндекс.Картинки (https://easyelectronics.ru/freertos_manual.html)

Возникает резонный вопрос: зачем приостанавливать (переводить в состояние wait) задачу? Ведь у нас же многозадачная система!? И, чисто теоретически, наша бесконечно выполняющаяся задача без пауз и приостановок не должна мешать другим задачам. На самом деле это не совсем так.

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

 

Планировщик задач

Планировщик задач (scheduler) – это одна из основных сущностей ядра FreeRTOS, именно планировщик распределяет процессорное время между вашими (и системными) задачами.

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

Планировщик в ESP32 по умолчанию работает на первом (нулевом) ядре процессора, а второе ядро обычно отдано под выполнение прикладных задач пользователя. Но с помощью SdkConfig это поведение можно и изменить.

Tick

Важным понятием FreeRTOS является квант времени работы планировщика (tick) — это жестко фиксированный отрезок времени, на который планировщик отдает управление работающей в данный момент задаче. По истечении кванта времени планировщик получает возможность приостановить текущую задачу и передать управление следующей, готовой к выполнению.

В статьях, посвященных FreeRTOS, квант времени так и называется – квантом времени, но в документации к ESP-IDF чаще встречается просто tick. Поэтому я буду использовать оба понятия в равном значении.

Для отсчета тиков в FreeRTOS используется прерывание от специального таймера/счетчика. Длительность этого периода зависит от настроек FreeRTOS, в ESP-IDF по умолчанию это 100 Гц или 10 миллисекунд.

Настройки FreeRTOS в SdkConfig. Просто для иллюстрации, не рекомендую здесь ничего менять, если вы не понимаете, что делаете.

Настройки FreeRTOS в SdkConfig. Просто для иллюстрации, не рекомендую здесь ничего менять, если вы не понимаете, что делаете.

Задача

Каждая задача – это маленькая программа. Основной объект любой операционной системы. Каждая задача имеет точку входа – функцию задачи, в которой выполняет свой бесконечный цикл, из которого никогда не делает выход, и свой личный стек, где хранятся локальные переменные и таблица вызовов локальных подфункций (если они есть). Задача может добровольно заснуть с помощью функций vTaskDelay() или для ожидания какого-либо события или быть приостановлена извне. Но задачи FreeRTOS не должны никоим образом делать возврат (выход) из своей функции – она не должна содержать оператор return, и выполнению не должно быть позволено доходить до конца функции. Если в функции задачи больше нет надобности, вместо выхода из неё нужно явно удалить запущенную задачу.

Когда задача выполняется, она, как и любая программа, использует регистры процессора, память программ и память данных. Вместе эти ресурсы (регистры, стек и др.) образуют контекст задачи (task execution context). Контекст задачи целиком и полностью описывает текущее состояние процессора: флаги процессора, какая инструкция сейчас выполняется, какие значения загружены в регистры процессора, где в памяти находится вершина стека и т.д. Задача «не знает», когда ядро RTOS приостановит ее выполнение или, наоборот, возобновит – это является одним из ключевых факторов, который необходимо учитывать при проектировании многозадачных программ. За исключением случаев, когда задача добровольно отдает неиспользованное время тика или засыпает в ожидании.

Про задачи, приоритеты, стеки и прочие премудрости я уже подробно писал в другой статье, рекомендую ознакомиться, если вы ещё этого не сделали: создаем задачу FreeRTOS: динамический и статический способ

Ещё более подробно про задачи и управление ими изложено в здесь: microsin.net/programming/arm/freertos-part1.html

Проблема совместного доступа к общим данным из разных задач

Задачи – это в общем то “вещь в себе”. Каждая это отдельная мини-программа, которая выполняется как бы “сама по себе” и “ничего не знает” о других задачах. Но на практике такое обычно не возможно – большинство задач вынуждены как-то “общаться” между собой, передавая друг другу необходимые для выполнения своих функций данные. Обмен данными между задачами (процессами) сравнительно сложен и почти всегда требует применения специальных средств межпроцессного взаимодействия. Зачем? Объясняю “на пальцах”…

Ну например: вы написали задачу telegtam_bot, которая должна отправлять сообщения в telegram. Возникает вопрос – как отправить задаче это самое сообщение? Простейшее решение – объявить глобальную переменную-буфер в которую будем записывать это самое сообщение. Наша задача будет проверять, есть ли там что-то и отправлять. На самом деле это самый ужасный вариант, что можно придумать. Почему? Допустим мы начали записывать в буфер из другой какой-то прикладной задачи, но тут…. кончился квант времени, выделенный прикладной задаче и сообщение оказалось в буфере лишь частично. А планировщик уже передал управление задаче telegtam_bot, которая успешно обнаружила данные в буфере и отправила их. Усложним проблему – нам нужно отправлять сообщения из нескольких прикладных задач… В этом случае в буфере может оказаться вообще “мусор” из обрывков сообщений от разных задач.

Эта проблема совместного доступа к глобальным переменным актуальна не только для “больших” переменных типа строк и массивов, но и для обычных многобайтовых чисел типа int16_t, int32_t.

Не подвержены такому риску только те операции, выполнение которых может быть произведено процессором за один такт (не путать с тиком ОС, за один тик процессор успевает выполнить много тактов), такие операции называются атомарными. На их основе и создано большинство объектов синхронизации.

 

Проблема совместного доступа к общим ресурсам из разных задач

Другая распространенная проблема в многозадачных системах – совместный доступ к одному и тому же физическому ресурсу (например к UART порту или какому-либо другому физическому интерфейсу) из разных задач.

Допустим, у нас есть одна шина I2C, к ней подключено 2-3 сенсора температуры и влажности. Пока чтение данных с этих сенсоров выполняется внутри одной и той же задачи – проблем не будет, всё будет гладко и хорошо. Добавим на эту же шину один или парочку расширителей портов, но вот только читать и писать в них должна будет уже другая задача. Как вы думаете, что произойдет, если обе задачи одновременно попытаются обменяться данными по шине? Точно ничего хорошего!

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

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

Более подробно теория FreeRTOS изложена в списке использованной литературы, ссылки на которую вы можете найти в конце статьи.

 

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

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

Итак, приступим к основной части марлезонского балета статьи…

 


Очереди

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

Наиболее часто встречающаяся схема применения: несколько “писателей” -> один “читатель”. Но можно одновременно иметь и несколько “читателей”, это не возбраняется (несколько “писателей” -> несколько “читателей”).

Пример применения: в рассмотренный выше пример с telegram ботом очереди “вписываются” практически идеально. Почти это потому, что длинные строки и массивы через очереди передавать все-таки “не комильфо”, гораздо лучше размещать строки в куче а в очередь помещать только указатель на них. Главное – не забыть освободить память после получения.

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

// Бесконечный цикл задачи
while(1) {
  // Ждем данные извне
  while (xQueueReceive(dataQueue, &text, portMAX_DELAY) == pdPASS) {
    if (text != NULL) {
      // Выводим сообщение в терминал
      ESP_LOGI(logTAG, "Text recieved: %s", text);
      // Отправляем данные на сервер
      sendHttpRequest(text);
      // И не забываем удалить текст из памяти
      free(text);
    };
  };
};

Подробнее про очереди читайте в другой статье: очереди FreeRTOS. Хорошая и подробная статья об очередях на другом ресурсе: microsin.net/programming/arm/freertos-part2.html

 


Потоковые буферы и буферы сообщений

Для передачи между задачами динамических строк и массивов переменной длины разработчики предусмотрели потоковые буферы и буферы сообщений. Работают по принципу “обычных” очередей, но в отличие от “обычных” очередей в них можно помещать данные переменной длины. Удобно?

Да только вот незадача какая – в отличие от “обычных” очередей у потоковых буферов может быть только один “писатель” и только один “читатель” в каждый момент времени. То есть один “писателей” -> один “читатель”.

Ну это вообще как, а?!

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

 


События и циклы событий

События и циклы событий – это архиудобный способ оповещения не связанных между собой задач о практически любых событиях в системе. В некоторых источниках их иногда называют сообщениями (по аналогии с системными сообщениями windows, видимо). Циклы событий чем-то очень похожи на классические функции обратного вызова ( callbacks ), широко используемые для асинхронного программирования; и одновременно на очереди, описанные выше. Да по сути это и есть дикая смесь задачи, связанной с ней очереди, и строго упорядоченного списка функций обратного вызова в одном флаконе.

Основная схема применения: один “издатель” -> много “подписчиков”.

Цикл событий можно иметь только один на всю программу, а вот наплодить классов и типов событий – великое множество, на все случаи цифровой жизни. К сообщениям / событиям можно прикреплять дополнительные данные произвольной длины “в нагрузку”, которые могут нести дополнительные сведения о событии.

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

Почему нельзя продолжать использовать обычные callback-и, зачем такие сложности?

  • Во первых, это позволяет подписаться одним callback-ом сразу на несколько модификаций событий (или даже на все события в системе).
  • Во вторых, это позволяет на одно событие подписаться сразу из нескольких разных модулей / задач – попробуйте такое провернуть в обычном режиме (можно, но сложно).
  • В третьих, это позволяет практически полностью избавиться от перекрестных #include внутри разных библиотек – подписчик не обязан ничего знать об источнике сообщения.

Пример применения: уведомления о подключениях и сбоях WiFi, подключении и потери соединения с сервером, синхронизации времени, начале каждой минуты (для работы расписаний), о длинном или коротком нажатии кнопки, о поступившем сигнале на приемнике 433MH и прочая, прочая, прочая. В общем, я активно использую события в своих проектах.

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

// События GPIO 
static const char* RE_GPIO_EVENTS = "REVT_GPIO";
typedef enum {
  RE_GPIO_CHANGE = 0,      // Изменения уровня
  RE_GPIO_BUTTON = 1,      // Нажатие кнопки (короткое)
  RE_GPIO_LONG_BUTTON = 2  // Нажатие кнопки (длинное)
} re_gpio_event_id_t;

// Структура, с помощью которой передаем данные о том, какая именно кнопка была нажата
typedef struct {
  uint8_t bus;             // I2C+1 шина для GPIO extenders, 0 для внутренних портов
  uint8_t address;         // I2C адрес для GPIO extenders, 0 для внутренних портов
  uint8_t pin;             // Номер вывода
  uint8_t value;           // Логический уровень: 0 or 1
} gpio_data_t;

Отправка события из обработчика прерывания будет выглядеть примерно так:

// Заполняем данные в структуре
gpio_data_t evt_data;
evt_data.bus = 0;     // Встроенный GPIO, no I2C
evt_data.address = 0; // Встроенный GPIO, no I2C
evt_data.pin = (uint8_t)_gpio_num;
evt_data.value = gpio_get_level(_gpio_num);

// Отправляем событие с прикрепленными данными в системный цикл событий
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
esp_err_t err = esp_event_isr_post(RE_GPIO_EVENTS, RE_GPIO_CHANGE, &evt_data, sizeof(evt_data), &xHigherPriorityTaskWoken);
if (err == ESP_OK) {
  portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
};

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

void sensorsGpioEventHandler(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data)
{
  if (event_data) {
    gpio_data_t* data = (gpio_data_t*)event_data;
    if (event_id == RE_GPIO_CHANGE) {
      ESP_LOGW("BTN", "Button changed: %d", data->pin);
    } else if (event_id == RE_GPIO_BUTTON) {
      ESP_LOGW("BTN", "Button pressed: %d", data->pin);
    };      
  };
}

...
// Подписываемся на событие
esp_err_t err = esp_event_handler_register(RE_GPIO_EVENTS, ESP_EVENT_ANY_ID, &sensorsGpioEventHandler, nullptr);
if (err != ESP_OK) {
  ESP_LOG(logTAG, "Failed to register event handler for %s #%d", RE_GPIO_EVENTS, ESP_EVENT_ANY_ID);
return false; 
};
...

Подробнее про события читайте в другой статье: библиотека циклов событий


Группы событий

Группы событий – ещё один достаточно удобный механизм синхронизации задач, который очень удобно применять в случаях, когда требуется проверка и ожидание того или иного события в системе. Но в этом случае в качестве основы выступает обычное целое 32-разрядное число, а рабочими элементами являются его составляющие – биты. То есть это многозадачный аналог обычных битовых флагов. Главным назначением данного объекта синхронизации является не столько передача информации (в одном бите много не передашь), сколько перевод задачи в состояние блокировки для ожидания какого-либо события в системе.

Механизм работы: много “писателей” -> много “ждунов”

Пример применения: у меня в системе есть задача – пингер, которая периодически, раз в минуту пингует гуголь и яндекс, с целью определить качество интернета (ну не стабильный у меня интернет на дачи и что? живу я тут). В случае очень плохого пинга снимается соответствующий бит, при хорошем времени ответа – устанавливается. Сетевые задачи (mqtt, telegram, data_send) проверяют этот бит и не пытаются почём зря отправить сообщение, а вместо этого послушно ждут нормального состояния. Это позволяет не занимать под неотправленные сообщения всю доступную оперативку в случае проблем.

Другой вариант: подключение к WiFi. Процессы подключения в драйвере занимают весьма продолжительное время и сильно несинхронны. Назначение соответствующего бита для каждого этапа подключения позволяет сильно упростить логику работу конечного автомата. Возьмем тот же пример с нажатием кнопки:

// Обработчик прерывания по нажатию кнопки
static void IRAM_ATTR isrButton1Press(void* arg)
{
  BaseType_t xHigherPriorityTaskWoken, xResult;
  xHigherPriorityTaskWoken = pdFALSE;
  xResult = xEventGroupSetBitsFromISR(xEventGroup, BIT_BUTTON_PRESSED, &xHigherPriorityTaskWoken);
  if (xResult == pdPASS) {
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
  };
}

Получение сигнала о нажатии кнопки в этом случае будет выглядеть примерно так:

// Ждем либо нажатия на кнопку, либо таймаута 10 секунд
uxBits = xEventGroupWaitBits(
  xEventGroup,               // Указатель на группу событий
  FLGS_BUTTONS,              // Какие биты мы ждем
  pdTRUE,                    // Сбросить установленные биты, после того как они были прочитаны
  pdTRUE,                    // Любой бит (даже один) приведет к выходу из ожидания
  portMAX_DELAY);            // Период ожидания в тиках

Подробнее про группы событий читайте в другой статье: EventGroup – группы событий

 


Семафоры и мьютексы

Перейдем к объектам блокировки ресурсов. Начать стоит с семафоров (semaphore) и мьютексов (mutex), на самом деле они очень похожи.

Иногда не требуется пересылать какие-либо данные между задачами. Несколько задач могут конфликтовать друг с другом за те или иные ресурсы (например, за оборудование – GPIO, шину или порт). Нельзя, например, из разных задач писать в один и тот же UART одновременно – вы просто получите “кашу”.

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

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

Mutual exclusion — система взаимного исключения, то есть механизм обеспечивающий уникальный доступ многих задач к единственному ресурсу. Он похож похож на самый примитивный флажок (boolean) при традиционном программировании (когда один процесс флажок поднимает, а другая задача этот флажок в цикле отслеживает). Но полностью потокобезопасный – тут цикл на себя берет диспетчер и будит задачу из спячки при необходимости.

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

Мне очень нравится пример с easyelectronics.ru, да простит меня его автор за небольшой плагиат, но лучше я вряд ли смогу придумать.

Сортир это уникальный ресурс. Если два человека вломятся в одноместный сортир случится конфуз. Ключ от сортира, лежащий у вахтерши, это и есть мьютекс. Прибежал, взял ключ, пошел в сортир. Пока ключ у тебя никто сортиром воспользоваться не сможет. Сходил — верни ключ на место. Кто ключ взял тот его и возвращает вахтеру. Нет, разумеется никто не запретит прийти и выбить дверь ногой, но это же неприлично, а RTOS только для приличных людей. Быдло его портит, случаются падения и глюки. Так что двигай к вахтеру и жди ключ. Ну и, разумеется, зажимать ключ дольше чем это реально требуется большое свинство.

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

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

Но на самом деле мьютекс – это особый вид двоичного (бинарного) семафора. То есть семафор, который может принимать только два состояния – занят и свободен. Иногда это может быть неудобно, например если сортир трехместный.

Разница лишь в том, что у счетного семафора состояние это не единичный флажок, а счетчик. Задача которая его освобождает (операция signal) счетчик увеличивает, а которая занимает (операция wait) – уменьшает. Досчитает счетчик семафора до нуля – и следующая в очереди задача заснет в состоянии WAIT. Семафор может быть как бинарным (мьютекс и по сути и есть вариант двоичного семафора), так и счетным.

Семафоры могут быть простыми и рекурсивными. Рекурсивный семафор означает, что одна и та же задача может занять его несколько раз, например дважды. Но при этом она обязана ровно столько же раз его и освободить. Если вы “взяли” семафор, а освободить “забыли” – произойдет утечка семафора.

Чем же отличаются бинарный семафор и мьютекс? Официальным ответом считается такой: “семафор – это сигнальный механизм, mutex – это механизм блокировки”. Хм, не лучше, один овощ ничего не понятно. Я понимают это так: мьютекс освободить может только тот кто первым занял тапки, семафор может “посчитать в минус” (операция signal) – кто угодно.

Подробнее про совместный доступ к ресурсам изложено тут: microsin.net/programming/arm/freertos-part4.html, но возможно, будет сложновато для начинающих.

 


Приостановка планировщика

Иногда требуется не просто заблокировать доступ к тому или иному объекту или переменной в вашей программе, но и вовсе запретить передачу управления другим задачам на некоторое время. То есть просто сказать всем остальным участникам процесса: “ша!!!! всем сидеть по местам и не рыпаться!” (потому что тихо должно быть в библиотеке). Например для измерения длительности импульса на входе GPIO:

// Turn off interrupts temporarily because the next sections are timing critical and we don't want any interruptions.
vTaskSuspendAll();

// Wait sensor response signal: DHT will keep the line low for 80 µs and then high for 80 µs
if (expectPulse(0) == DHT_TIMEOUT) {
  xTaskResumeAll();
  return SENSOR_STATUS_CONN_ERROR;
};
if (expectPulse(1) == DHT_TIMEOUT) {
  xTaskResumeAll();
  return SENSOR_STATUS_CONN_ERROR;
};

// Now read the 40 bits sent by the sensor. Each bit is sent as a 50 µs
// low pulse followed by a variable length high pulse.  If the
// high pulse is ~28 µs then it's a 0 and if it's ~70 µs then it's a 1. 
// We measure the cycle count of the initial 50 µs low pulse
// and use that to compare to the cycle count of the high pulse to determine
// if the bit is a 0 (high state cycle count < low state cycle count), or a
// 1 (high state cycle count > low state cycle count). Note that for speed
// all the pulses are read into a array and then examined in a later step
for (int i = 0; i < 80; i += 2) {
  cycles[i] = expectPulse(0);
  cycles[i + 1] = expectPulse(1);
};

// Timing critical code is now complete.
xTaskResumeAll();

Само собой, такое должно случаться как можно реже и на как можно более короткое время.


Критические секции

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

taskENTER_CRITICAL(); 
{
  // Код, выполнение которого не должно быть прервано
}
taskEXIT_CRITICAL(); 

Но критические секции требуют применения мьютекса, то есть исключается возможность вложенной приостановки планировщика из нескольких задач одновременно.

#define portENTER_CRITICAL(mux) vPortEnterCriticalCompliance(mux)
#define portEXIT_CRITICAL(mux) vPortExitCriticalCompliance(mux) 

static inline void __attribute__((always_inline)) vPortEnterCriticalCompliance(portMUX_TYPE *mux);

 


Прерывания

На самом деле прерывания вовсе не являются сущностью FreeRTOS, а “свои собственные” (как говаривал кот Матроскин). Прерывания имеются на всех микроконтроллерах по умолчанию, вне зависимости от того, можно там запустить операционную систему или нельзя.

Прерывания служат простой цели “срочно брось все дела и сделай то, что требуется” источнику прерывания.

Пример из жизни: ваш дверной звонок в доме. Без дверного звонка вам пришлось бы периодически проверять, нет ли кого у двери. Это приводит к бесполезной трате времени в большинстве случаев, когда за дверью никого нет; а также не гарантирует, что если за дверью кто-то есть, вы своевременно откроете её.

Работа с прерываниями из FreeRTOS имеет свои небольшие особенности (по сравнению с обычными Arduino, например), связанные с досрочной передачей управления задаче, которая ожидает события из обработчика прерывания, до истечения текущего тика.

Допустим, в текущий момент выполняется низкоприоритетная задача, а высокоприоритетная ожидает наступления некоторого прерывания. Далее происходит прерывание, но по окончании работы обработчика прерываний выполнение возвращается к текущей низкоприоритетной задаче, а высокоприоритетная ожидает, пока закончится текущий квант времени (тик), только после этого планировщик отдаст ей управление. Однако если после выполнения обработчика прерывания добровльно передать управление планировщику ( через вызов portYIELD_FROM_ISR ), то он передаст управление высокоприоритетной задаче, что позволяет значительно сократить время реакции системы на прерывание, связанное с внешним событием. Однако это может потребоваться далеко не всегда, а только когда вы пересылаете через объекты FreeRTOS уведомления о событии, да и то не в каждом случае:

static xQueueHandle button_queue = NULL;

// Обработчик прерывания по нажатию кнопки
static void IRAM_ATTR isrButtonPress(void* arg)
{
  // Переменные для переключения контекста
  BaseType_t xHigherPriorityTaskWoken, xResult;
  xHigherPriorityTaskWoken = pdFALSE;
  // Поскольку мы "подписались" только на GPIO_INTR_NEGEDGE, мы уверены что это именно момент нажатия на кнопку
  bool pressed = true;
  // Отправляем в очередь задачи событие "кнопка нажата"
  xResult = xQueueSendFromISR(button_queue, &pressed, &xHigherPriorityTaskWoken);
  // Если высокоприоритетная задача ждет этого события, переключаем управление
  if (xResult == pdPASS) {
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
  };
}

Подробнее про это и вообще про работу с прерываниями GPIO можно почитать тут: обработка перерываний GPIO на ESP-IDF. Другая статья при работу с прерываниями из FreeRTOS: microsin.net/programming/arm/freertos-part3.html

 


Таймеры

Таймеры, как с следует из названия, служат для измерения и отсчета временных интервалов. На ESP32 их можно выделить несколько типов:

  • Функция задержки ets_delay_us()
  • Функции перевода задачи в временный сон FreeRTOSvTaskDelay() и vTaskDelayUntil()
  • Аппаратные таймеры 64-bit General Purpose Timer
  • Программные таймеры ESP-IDF, разработанные Espressif; а также программные таймеры предоставляемые функциями FreeRTOS.

Пример запуска программного таймера (заранее созданного при запуске программы) по прерыванию от PIR-сенсора с изменением флагов в группе событий:

static void ventOnPirTimerEnd(void* arg)
{
  ESP_LOGI(logTAG, "PIR timer stopped");
  // Что-то делаем
}

static void ventOnPIRSignal()
{
  if (gpio_get_level((gpio_num_t)CONFIG_GPIO_PIR)) {
    ESP_LOGI(logTAG, "PIR signal");
    // Старт или продление (перезапуск) таймера
    esp_timer_stop(timerPir);
    ERR_CHECK(esp_timer_start_once(timerPir, ventPirDuration * 1000000), ERR_TIMER_START);
    ESP_LOGI(logTAG, "PIR timer (re)started");
    // Что-то делаем
  };
}

Подробнее про использование таймеров я рассказывал здесь: использование таймеров на ESP32


Ссылки

  1. ESP32FreeRTOS
  2. ESP32 – особенности реализации FreeRTOS на ESP32
  3. FreeRTOS.org – официальная страница проекта
  4. FreeRTOS для чайников
  5. Андрей Курниц “FreeRTOS – операционная система для микроконтроллеров”
  6. FreeRTOSпрактическое применение. Управление задачами.

 


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

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


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

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

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