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

Создаем задачу FreeRTOS: динамический и статический способ

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

В этой статье я расскажу, как можно создать и запустить на выполнение задачу FreeRTOS применительно к ESP32 и ESP-IDF.

Все сказанное в данной статье справедливо не только для Espressiff IoT Development Framework (ESP-IDF), но и для Arduino Freamework for ESP32 (Arduino32). В том числе это должно полностью работать и в Arduino IDE, однако я лично не проверял.

FreeRTOS – это многозадачная, мульти‑платформенная, бесплатная операционная система жесткого реального времени с открытым исходным кодом, условно говоря, “встроенная” производителем (Espressif) в чип ESP32. На микроконтроллерах ESP32 практически вся полезная работа выполняется в рамках задач FreeRTOS, которые либо созданы “системой”, либо создаете вы сами. Исключения – это прерывания и таймеры (но и в программных таймерах ваш код выполняется в системной задаче, “занимающейся” обслуживанием таймеров).

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

  1. FreeRTOS— практическое применение. Управление задачами.
  2. Андрей Курниц “FreeRTOS — операционная система для микроконтроллеров”
  3. ESP32— FreeRTOS

Это своего рода “классика”, я вряд ли напишу подробнее и лучше. Но я всё-таки попытаюсь рассказать об этом своими словами.

 


Создаем функцию задачи

Задача FreeRTOS – по сути это маленькая программа, которая выполняется отдельно от других (задачи могут взаимодействовать друг с другом, но при этом нужно всегда помнить, что процессы внутри разных задач не синхронны). Задачи реализованы как функции на языке C. Для каждой запускаемой задачи вы должны создать функцию задачи (Task Function). Есть только одна особенность такой функции – она должна возвращать void и принимать в качестве параметра указатель на void:

void TaskFunction( void *pvParameters );

Чисто теоретически, вы можете создать “одноразовую” задачу, которая будет выполнена только один раз, а затем остановлена. Но на практике, как правило, внутри Task Function организуется бесконечный цикл, например так:

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

// Функция задачи 1
void task1_exec(void *pvParameters)
{
  // Организуем бесконечный цикл
  while(1) {
    // Выводим сообщение в терминал
    ESP_LOGI("TASK1", "Task 1 executed");
    // Пауза 10 секунд
    vTaskDelay(pdMS_TO_TICKS(100000));
  };
  // Сюда мы не должны добраться никогда. Но если "что-то пошло не так" - нужно всё-таки удалить задачу из памяти
  vTaskDelete(NULL);
}

Как видите, мы создали функцию задачи void task1_exec(void *pvParameters), давайте рассмотрим её поподробнее:

  • Цикл while(1) – это бесконечный цикл, который, в принципе, не должен завершиться никогда. Задачи FreeRTOS не должны делать возврат (выход) из своей функции – она не должна содержать оператор  Если в задаче больше нет надобности, вместо выхода из неё нужно явно удалить запущенную задачу с помощью vTaskDelete(NULL).
  • Внутри цикла мы просто выводим текстовое сообщение в лог / терминал, используя стандартную библиотеку “esp_log.h” (её мы рассмотрим подробнее позже).
  • Далее с помощью строчки vTaskDelay(pdMS_TO_TICKS(100000)) мы приостанавливаем задачу на 10 секунд. Макрос pdMS_TO_TICKS() преобразовывает время в миллисекундах в количество тиков операционной системы. Необходимость приостановок я подробнее объясню чуть ниже, пока пропустим.
  • После выхода из цикла – явно удаляем задачу из памяти с помощью функции vTaskDelete(NULL). Зачем? Ведь у нас бесконечный цикл! Потому что в цикле иногда может “что-то пойти не так”. В этом случае нужно не забыть удалить завершенную задачу из памяти. Вызов vTaskDelete() с пустым параметром как раз и удаляет задачу, из которой был сделан вызов.

 


Приостановка задачи

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

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

Кроме того, если позволить нашей задаче выполняться бесконечно долго, без приостановок, то очень скоро специальный сторожевой таймер WDT (Watch Dog Timer) обнаружит “зависание” задачи и выдаст сообщение об ошибке или вообще перезагрузит устройство (в зависимости от настроек в SdkConfig). Кстати, забегая вперед, если в вашей программе вдруг начинает “ругаться” WDT – добавьте vTaskDelay(1) в тело цикла – и это наверняка решит проблему. С чего я решил, что там будет цикл? Да потому что наверняка он там будет.

Существует достаточно много способов приостановить задачу “изнутри” самой задачи, приведу лишь некоторые из них:

  • vTaskDelay(count) – приостановить выполнение на count количество тиков (тик – это один шаг операционной системы, минимальный интервал времени). Версия ардуиновской функции delay() для ESP-IDF содержит вызов тот же самой vTaskDelay(). Самый простой способ, но иногда бывает неудобен, так как время исполнения одного цикла задачи может изменяться. Чтобы получить равномерные интервалы, используйте следующую функцию.
  • vTaskDelayUntil(&prev, count) – работает аналогично vTaskDelay(), но отсчитывает интервал от предыдущего времени вызова prev. Таким образом мы получаем равномерный интервал выполнения задачи.
  • xQueueReceive(…) – ожидание внешних данных из очереди задачи. Для каждой задачи мы можем создать очередь входящих событий. Например, это актуально для сервисных задач – например для отправки уведомлений или данных на внешние сервисы. Пока входящая очередь пуста – задача находится в режиме приостановки, как только в очереди появляется какой-либо объект – задача активируется и приступает к работе
  • xEventGroupWaitBits(…) – ожидание установки одного или нескольких бит (флагов) в группе событий. Например это может быть бит, указывающий на подключение к WiFi сети или к MQTT серверу. Пока подключения нет (бит не установлен) – ждем. Как только подключение появилось – начинаем какую-либо работу.
  • Существуют ещё средства синхронизации задач между собой – семафоры, мьютексы и т.д. С помощью этих средств задача также приостанавливается – но это скорее средства для синхронизации доступа нескольких разных задач к одним и тем же ресурсам.

Ещё раз повторюсь, что любая практическая задача должна содержать какой-либо из методов самоприостановки (можно использовать их сочетания), чтобы не вызывать зависаний устройства. Если вы не очень поняли смысл из моих объяснений, рекомендую почитать эту статью (но в ней не всё подходит для ESP32 – например не требуется запускать vTaskStartScheduler()).

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

  • vTaskSuspend() – приостановить выполнение задачи
  • vTaskResume() – продолжить выполнение задачи

 


Выбор приоритетов задач

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

Вариант 1. Допустим, у нас есть две задачи: основная, в которой выполняется получение данных с сенсоров и вспомогательная с более низким приоритетом – которая отправляет уведомление, когда “что-то пошло не так”. Измерение выполняется один раз в 30 секунд, что-то делается ещё, далее пауза. Допустим, значение какого-либо параметра вышло за допустимые пределы, и основная задача отправляет сообщение вспомогательной задаче, которая в свою очередь, должна отправить его вам на смартфон. Но оно не отправиться немедленно! Вспомогательная задача сможет отправить сообщение только тогда, когда очередная итерация цикла основной задачи будет завершена и основная задача “встанет на паузу”, то есть это может быть через несколько секунд после возникновения события.

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

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

 


Создание задачи

Как создать функцию задачи – разобрались, с приоритетами – тоже. Теперь обсудим главную тему статьи – создадим задачу и запустим её на выполнение. ESP-IDF предоставляет нам сразу два способа запуска задачи:

  • обычный, с размещением самой задачи и её стека (а также входящей очереди, если она необходима) в динамической памяти (куче), и…
  • статический, с выделением памяти под всё это барахло в секции BSS ещё на этапе компиляции

Я очень активно использую динамическую память (кучу), в основном для хранения динамически генерируемых строк; но для постоянно работающих задач, буферов и прочих “массивных” объектов предпочитаю выделять память статически.

Поясню почему. Как я уже писал в статье про использование памяти, секция BSS и куча “делят” один и тот же объем памяти. Что называется “на троих” (там еще сегмент инициализированных данных участвует). И, на первый взгляд, никакой разницы нет. В числовом выражении, действительно, разницы никакой нет. Вся соль в том, что при статическом выделении компилятор заранее обозначит место под задачу и ее стек в памяти, и это место не может быть занято ничем другим. Сколько бы раз мы не создавали и удаляли задачу – мы будем уверены, что память для нее есть всегда. А вот при обычном (динамическом) запуске память под стек и задачу будет выделяться из общей кучи всегда заново, и почти наверняка – по новому адресу. А перед этой достаточно большой занятой областью в куче почти наверняка успеют втиснуться сравнительно небольшие переменные, которые потом будут успешно удалены – и это может привести к заметной фрагментации кучи (так как сравнительно огромный блок памяти “выше” не будет освобожден еще долго).

Поэтому, прежде чем создавать задачу – подумайте, как она будет работать.

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

 


Как определиться с размером стека задачи

Я уже писал про это в предыдущих статьях, но чуть-чуть повторюсь. Подобрать оптимальный размер стека можно только опытным путем. Обычно я выделяю заведомо больший размер (например 4КБ), затем включаю в SdkConfig “отладочные” функции типа uxTaskGetSystemState() или vTaskList(), с помощью которых можно определить, какой минимальный размер стека оставался на всем протяжении жизни задачи. Исходя из этих данных, разумеется после того, как задача отработает все возможные сценарии действий, можно принимать какое-то решение по поводу приемлемого размера стека для нее. Исходя из своей практики могу порекомендовать такие значения:

  • мигание светодиодом (использование стека минимально) – 1024 байт, да и то место останется
  • HTTP (не HTTPS !) запросы – вполне можно обойтись 2048 байтами
  • HTTPS запросы – нужно уже не менее 3072, а лучше 3584 байт (mbedTLS жрёт много стека и кучи)
  • прикладные задачи вроде чтения данных с сенсоров и формирования JSON – не менее 4 килобайт

Выше по тексту я рассказал, как создать исполняемую функцию задачи. С размером стека тоже определились… Пора и задачу запускать, собственно.

 


Обычный метод запуска

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

static inline BaseType_t xTaskCreate(TaskFunction_t pxTaskCode, const char *const pcName, const configSTACK_DEPTH_TYPE usStackDepth, void *const pvParameters, UBaseType_t uxPriority, TaskHandle_t *const pxCreatedTask)

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

Параметры функции:

  • pvTaskCode – указатель на функцию задачи (которую мы создали выше).
  • pcName – условное имя задачи, ни на что не влияет, нужно только для отладки в vTaskList(). Максимальная длина не может превышать 16 символов.
  • usStackDepth – длина стека задачи в байтах. Именно в байтах, а не словах (для ESP32). Я стараюсь выравнивать это значение хотя бы до 256 или 512 байт, хотя это и не обязательно.
  • pvParameters – указатель на параметры, которые можно передать в запускаемую программу. Этот указатель будет передан в функцию задачи через параметр Не обязателен к использованию.
  • uxPriority – приоритет, с которым должна выполняться задача.
  • pvCreatedTask – этот параметр может содержать указатель на только что созданную задачу, который может быть использован для “внешнего” управления задачей (приостановки, возобновления, удаления). Не обязателен к использованию.
  • xCoreID – ядро процессора. Значения 0 или 1 указывают порядковый номер процессора, к которому должна быть привязана задача. Если значение равно tskNO_AFFINITY, созданная задача не привязана ни к какому процессору, и планировщик может запустить ее на любом доступном ядре.

Теперь добавим код запуска задачи в наш примерчик:

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

// Функция задачи 1
void task1_exec(void *pvParameters)
{
  // Организуем бесконечный цикл
  while(1) {
    // Выводим сообщение в терминал
    ESP_LOGI("TASK1", "Task 1 executed");
    // Пауза 10 секунд
    vTaskDelay(pdMS_TO_TICKS(100000));
  };
  // Сюда мы не должны добраться никогда. Но если "что-то пошло не так" - нужно всё-таки удалить задачу из памяти
  vTaskDelete(NULL);
}

void app_main()
{
  // Запуск задачи с динамическим выделением памяти в куче, с размером стека 4кБ, приоритетом 5 и на втором ядре (№1)
  xTaskCreatePinnedToCore(task1_exec, "task1", 4096, NULL, 5, NULL, NULL, 1);
}

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

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

// Функция задачи 1
void task1_exec(void *pvParameters)
{
  // Организуем бесконечный цикл
  while(1) {
    // Выводим сообщение в терминал
    ESP_LOGI("TASK1", "Task 1 executed");
    // Пауза 10 секунд
    vTaskDelay(pdMS_TO_TICKS(100000));
  };
  // Сюда мы не должны добраться никогда. Но если "что-то пошло не так" - нужно всё-таки удалить задачу из памяти
  vTaskDelete(NULL);
}

void app_main()
{
  TaskHandle_t xHandle = NULL;
  // Запуск задачи с динамическим выделением памяти в куче, с размером стека 4кБ, приоритетом 5 и на втором ядре (№1)
  xTaskCreatePinnedToCore(task1_exec, "task1", 4096, NULL, 5, NULL, &xHandle, 1);
  // Проверим, создалась ли задача
  if (xHandle == NULL) {
    ESP_LOGE("TASK1", "Failed to task create");
  };
}

 


Статический метод запуска

Кроме обычного метода, FreeRTOS предоставляет специальную функцию для создания задачи со статическим выделением памяти под буфер и стек. Это позволяет гарантировать, что задача будет создана, и заодно снизить фрагментацию кучи. Но такая задача не может быть выгружена из памяти никогда.

Функция xTaskCreateStaticPinnedToCore() имеет больше параметров, чем в предыдущих случаях:

  • pvTaskCode – указатель на функцию задачи (которую мы создали выше).
  • pcName – условное имя задачи, ни на что не влияет, нужно только для отладки в vTaskList(). Максимальная длина не может превышать 16 символов.
  • usStackDepth – длина стека задачи в байтах. Я стараюсь выравнивать это значение хотя бы до 256 или 512 байт.
  • pvParameters – указатель на параметры, которые можно передать в запускаемую программу. Этот указатель будет передан в функцию задачи через параметр Не обязателен к использованию.
  • uxPriority – приоритет, с которым должна выполняться задача.
  • pxStackBuffer – должен указывать на массив StackType_t, который имеет длину ulStackDepth — массив затем будет использоваться в качестве стека задачи, устраняя необходимость в динамическом выделении стека.
  • pxTaskBuffer – Должен указывать на переменную типа StaticTask_t, которая затем будет использоваться для хранения внутренних данных задачи, устраняя необходимость в динамическом выделении памяти.
  • xCoreID – ядро процессора. Значения 0 или 1 указывают порядковый номер процессора, к которому должна быть привязана задача. Если значение равно tskNO_AFFINITY, созданная задача не привязана ни к какому процессору, и планировщик может запустить ее на любом доступном ядре.

В отличие от предыдущих функций, xTaskCreateStaticPinnedToCore() вернет в качестве результата указатель на хендл только что созданной задачи.

Модифицируем наш пример под статическое выделение памяти для задачи:

#define STACK_SIZE 4096

void app_main() 
{
  // Буфер под служебные данные задачи
  static StaticTask_t xTaskBuffer;
  // Буфер под стек задачи
  static StackType_t xStack[STACK_SIZE];

  // Запуск задачи с статическим выделением памяти
  TaskHandle_t xHandle = xTaskCreateStaticPinnedToCore(task1_exec, "task1", STACK_SIZE, NULL, 5, xStack, &xTaskBuffer, 1);
  // Проверим, создалась ли задача
  if (xHandle == NULL) {
    ESP_LOGE("TASK1", "Failed to task create");
  };
}

Необходимые пояснения к примеру:

  • Поскольку я объявил xTaskBuffer и xStack внутри app_main() (я специально так сделал в данном случае), их нужно пометить как static – иначе компилятор может удалить их после их выхода из области видимости, и задача “пойдет вразнос”. Если вынести объявление буферов вне app_main() – они автоматически станут глобальными переменными и static добавлять уже не обязательно.
  • При передаче указателей на эти буферы в функцию запуска обратите внимание: xStack передается как обычный параметр (поскольку это массив и в xStack содержится указатель на первый элемент массива); а вот к xTaskBuffer нужно обязательно добавить символ &.

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

 


Контекст задачи

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

 


Использование app_main()

Если вы создадите новый проект Espressiff IoT Development Framework (как я ранее описывал здесь), то в каталоге src вы найдете файл main.c с единственной функцией app_main():

Задача main является одной из нескольких задач, которые автоматически создаются ESP-IDF во время запуска.

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

Эта функция выполняется в задаче “main”, которая будет автоматически создана FreeRTOS при запуске микроконтроллера (то есть app_main() это и есть “функция задачи” “main”, но немного “нестандартная”). Параметры этой “основной” задачи можно настроить через SdkConfig ESP System Settings → Main task stack size.

Если вы решите использовать эту задачу как “прикладную”, вам необходимо будет организовать здесь бесконечный цикл, как было изложено выше в разделе “Создание задачи“.

Если цикла не создать, то после того как функция app_main() завершит свою работу, задача “main” будет завершена и удалена из памяти, её не нужно удалять явным образом, как это было описано выше. Таким образом app_main() можно использовать только для запуска прикладных задач – например, если вы хотите создавать свои задачи статически.

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

 


Полезные ссылки

  1. Практические примеры создания задач FreeRTOS
  2. FreeRTOS практическое применение. Управление задачами.
  3. Андрей Курниц “FreeRTOS — операционная система для микроконтроллеров”
  4. Пример кода для данной статьи вы можете скачать тут

 

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


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

10 комментариев для “Создаем задачу FreeRTOS: динамический и статический способ”

    1. Имеется в виду то, что все “стандартные” платформы: и ESP-IDF и Arduino 32 и даже сторонние (типа MicroPython или LUA) – все работают с использованием FreeRTOS. Слово “встроенная” не случайно в кавычках. Конечно физически она никуда не встроена, но встроена во все API от производителя.
      Хотите от нее избавиться – напишите свою платформу с 0, полностью

      1. … Нет, нет что Вы, избавлятся точно не недо.
        … Я просто подумал что в ЕСП32 где то внутри уже “крутится” встроенный “кэрнел”.
        Работать с ЕСП32 только начинаю, до этого STM32.
        … А насчёт платформы, то я столько не проживу.

        1. ?

          Обычно вначале запускается загрузчик, который, насколько я понимаю, запускает FreeRTOS и все служебные “кэрнел”-ы. Затем запускается уже ваш скетч или задача app_main. Если написать “свой” загрузчик, то теоретически можно сделать как хочется. Вопрос – нужно ли?

  1. … Может чего то недопонял, но так и не увидел старта Шедулер”а. Нужен ли он здесь ?

    1. Константин

      Здравствуйте, я нашёл опечатку.

      static inline BaseType_t xTaskCreate(TaskFunction_t pxTaskCode /*тут должно быть pvTaskCode*/, const char *const pcName, const configSTACK_DEPTH_TYPE usStackDepth, void *const pvParameters, UBaseType_t uxPriority, TaskHandle_t *const pxCreatedTask)

  2. Спасибо, кое в чём просветили. Значит в ЕСП все-таки сидит свой “фирменный” загрузчик с поддержкой ЮСБ. Буду дальше читать Ваши статьи, и появиться больше вопросов. Спасибо за статью.

    1. Да нет же, ничего там изначально не сидит…
      При компиляции любой программы или скетча создается сразу два файла прошивки – bootloader.bin и firmware.bin
      Они записываются в разные разделы “диска” / flash памяти.
      bootloader.bin запускается первым и выполняет всю “служебную” работу – запускает планировщик, читает разделы памяти и т.д.
      Затем он запускает вторую часть.
      Причем это справедливо и для ESP-IDF и для Arduino 32

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

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