FreeRTOS на ESP32: задачи, очереди и мьютексы на живых примерах

Как устроен планировщик под Arduino-скетчем и чем передавать данные между задачами, чтобы они не ломали друг друга

Под каждым скетчем для ESP32 уже работает FreeRTOS, а loop() — одна из его задач. Разбираем, как завести свои задачи, раздать их по ядрам, передать данные очередью и не перемешать вывод в порт.

Скетч мигает светодиодом, читает датчик и отвечает по WiFi. Пока задач две, их удаётся развести через millis() в одном loop(). На пятой задаче код превращается в клубок флагов и таймеров, а одна медленная операция задерживает все остальные. Операционная система реального времени решает это иначе: каждая задача пишет свой цикл так, будто она одна, а планировщик сам делит процессор.

На ESP32 для этого ничего не нужно подключать. Ядро Arduino для ESP32 построено на ESP-IDF, внутри которого уже работает FreeRTOS. Функции setup() и loop() выполняются в задаче loopTask, которую ядро создаёт при старте. Всё, что ниже, — про то, как добавить к ней свои задачи и как им не мешать друг другу.

Задача — это функция с бесконечным циклом

Задача FreeRTOS — обычная функция с параметром void *, которая никогда не возвращается. Создаётся она вызовом xTaskCreate: функция, имя для отладки, размер стека, параметр, приоритет и указатель для дескриптора.

void blinkTask(void *param) {
  int pin = (int)(intptr_t)param;
  pinMode(pin, OUTPUT);
  int period = pin == 2 ? 200 : 700;
  for (;;) {
    digitalWrite(pin, !digitalRead(pin));
    vTaskDelay(pdMS_TO_TICKS(period));
  }
}

xTaskCreate(blinkTask, "blink2", 2048, (void *)2, 1, NULL);
xTaskCreate(blinkTask, "blink4", 2048, (void *)4, 1, NULL);

Одна функция обслуживает две задачи: номер вывода приходит через параметр. В примере с двумя задачами светодиод на D2 переключается каждые 200 мс, на D4 — каждые 700 мс, и они не мешают друг другу. loop() тем временем раз в три секунды печатает объём свободной кучи: задачи берут стек оттуда, и число это покажет.

Три числа в вызове требуют пояснения.

  • Размер стека на ESP32 задаётся в байтах, а не в словах, как в классической документации FreeRTOS. 2048 — это 2 КБ.
  • Приоритет — чем больше, тем важнее. Задачи с равным приоритетом получают процессор по очереди, квантами по одному тику. У loopTask приоритет 1.
  • Тик в сборке Arduino для ESP32 равен 1 мс. pdMS_TO_TICKS(200) переводит миллисекунды в тики, и код останется верным, если частоту тиков когда-нибудь изменят.

Функция задачи не должна завершаться. Если работа закончена, задача удаляет себя вызовом vTaskDelete(NULL), иначе выход из функции приводит к аварии.

Ожидание, которое отдаёт процессор

vTaskDelay переводит задачу в состояние «заблокирована» на заданное число тиков. Всё это время процессор выполняет другие задачи, а если все спят — служебную задачу простоя. В Arduino для ESP32 delay() внутри вызывает тот же vTaskDelay, поэтому и он процессор отдаёт.

Не отдаёт его цикл ожидания без задержки:

while (millis() - start < 500) { }   // задача занимает ядро целиком

Задача с таким циклом не пропускает на своё ядро задачи с меньшим приоритетом, а равноприоритетные получают только свои кванты. Хуже всего, если голодает задача простоя: на ней держится сторожевой таймер задач, и через несколько секунд он сообщит о зависании. Пример сторожевой таймер показывает, как сторож с таймаутом 5 с перезагружает плату, когда его перестают сбрасывать.

Два ядра и привязка задач

У ESP32 два ядра. xTaskCreate позволяет планировщику запускать задачу на любом, xTaskCreatePinnedToCore прибивает её к конкретному последним аргументом:

xTaskCreatePinnedToCore(worker, "на ядре 0", 2048, (void *)"задача A", 1, NULL, 0);
xTaskCreatePinnedToCore(worker, "на ядре 1", 2048, (void *)"задача B", 1, NULL, 1);

Каждая задача раз в секунду печатает номер ядра из xPortGetCoreID(). В мониторе порта примера с ядрами видно «задача A работает на ядре 0», «задача B работает на ядре 1», а loop() сообщает, что идёт на ядре 1: так настроено ядро Arduino.

На ядре 0 по умолчанию работает стек WiFi и Bluetooth. Долгие вычисления туда класть не стоит: задача с высоким приоритетом, которая редко засыпает, будет тормозить сеть. Обычная раскладка — сеть на ядре 0, прикладной код на ядре 1, и два ядра превращаются в два независимых конвейера.

Очередь: данные из задачи в задачу

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

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

struct Reading { unsigned long ms; int value; };
queue = xQueueCreate(8, sizeof(Reading));

// производитель
if (xQueueSend(queue, &r, pdMS_TO_TICKS(100)) != pdTRUE) {
  Serial.println("очередь переполнена");
}

// потребитель
if (xQueueReceive(queue, &r, portMAX_DELAY) == pdTRUE) {
  Serial.printf("[%lu] значение %d\n", r.ms, r.value);
}

Посчитаем память. Reading — это unsigned long и int, по 4 байта, итого 8. Очередь на 8 элементов держит 64 байта данных плюс служебную структуру. Элементы копируются по значению, поэтому производитель может тут же переиспользовать свою переменную r.

В примере с очередью производитель кладёт случайное число с отметкой времени каждые 300 мс, а потребитель печатает его сразу, как только оно появилось. Два таймаута стоит понимать. portMAX_DELAY у потребителя — ждать сколько угодно, не тратя процессор. 100 мс у производителя — если очередь полна, подождать немного, а потом отказаться и сообщить об этом, вместо того чтобы зависнуть.

Если потребитель станет медленнее производителя, очередь заполнится. При 300 мс на элемент и потребителе, который тратит по 500 мс, в очереди прибавляется примерно один элемент на каждые 750 мс, и восемь мест кончатся через шесть секунд. Дальше начнутся сообщения о переполнении. Очередь сглаживает всплески, но не исправляет устойчивую разницу скоростей.

Мьютекс: один ресурс на несколько задач

Serial — общий ресурс. Ядро Arduino защищает только отдельный вызов записи: одна строка из printf уходит целиком, а строка, собранная из нескольких print, у двух задач может перемешаться. Мьютекс — это замок: взявшая его задача работает с ресурсом одна, остальные ждут у xSemaphoreTake.

xSemaphoreTake(lock, portMAX_DELAY);
Serial.print(who);
Serial.print(": измерено ");
Serial.print(millis());
Serial.println(" мс");
xSemaphoreGive(lock);

FreeRTOS: мьютекс

Запустите: строки задач A и B идут целиком. Закомментируйте xSemaphoreTake и xSemaphoreGive и уменьшите задержку с 200 до 5 мс: строки начнут сливаться — «задача A: измерено 4 мсзадача B: …», потому что каждая печатается четырьмя вызовами. Верните замок — строки снова целые.

Открыть пример

От двоичного семафора мьютекс отличается двумя вещами. Отпустить его может только та задача, которая взяла. И он наследует приоритет: если замок держит задача с приоритетом 1, а ждёт задача с приоритетом 3, держатель временно поднимается до 3, чтобы его не вытеснила задача с приоритетом 2. Без этого получается инверсия приоритетов, когда важная задача ждёт неважную бесконечно.

Мьютекс нельзя отдавать из прерывания. Сигнал из обработчика передают двоичным семафором через xSemaphoreGiveFromISR, как в примере сигнал из прерывания.

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

Типичные ошибки

СимптомПричинаЧто делать
Плата перезагружается, в порту «stack overflow» или мусорМал стек задачиИзмерить запас, увеличить размер в xTaskCreate
Строки в порту перемешаныДве задачи пишут в Serial без замкаМьютекс или одна задача-писатель с очередью
Сторож задач срабатывает через несколько секундЦикл без задержки не пускает задачу простояvTaskDelay в каждом проходе цикла
Авария сразу после запуска задачиФункция задачи вернуласьБесконечный цикл или vTaskDelete(NULL)
Иногда читается испорченная структураОбщая переменная без защитыОчередь или мьютекс

Про стек стоит сказать отдельно. Локальные переменные задачи, включая массивы, живут на её стеке, и туда же кладут свои буферы printf и snprintf. В примере с запасом стека задача держит массив на 256 байт и раз в секунду печатает минимальный остаток стека за всю свою жизнь, полученный от uxTaskGetStackHighWaterMark(NULL). В ESP-IDF эта функция, как и размер стека, считает в байтах. Если запас близок к нулю, следующая правка кода, добавившая пару переменных, закончится перезагрузкой, и искать причину будет трудно. Запас в несколько сотен байт — нормальный ориентир.

Итог

FreeRTOS на ESP32 уже работает под любым скетчем, и loop() — одна из его задач на ядре 1. Свою задачу создаёт xTaskCreate или xTaskCreatePinnedToCore: бесконечный цикл, vTaskDelay в каждом проходе, стек в байтах с измеренным запасом. Данные между задачами передают очередью, общий ресурс защищают мьютексом, сигнал из прерывания отдают семафором через FromISR. В симуляторе PinPort каждое из этих правил видно в мониторе порта: номера ядер, запас стека, целые или перемешанные строки.

Попробуйте в симуляторе

FreeRTOS: две задачи

Поменяйте периоды 200 и 700 мс на 100 и 1000 и убедитесь, что светодиоды на D2 и D4 мигают независимо. Затем добавьте третью задачу для вывода 5 со своим периодом, не трогая остальные.

Две задачи

FreeRTOS: задачи по ядрам

Прибейте обе задачи к ядру 1 и посмотрите в монитор порта, как изменится номер ядра в строках. Затем создайте задачу через xTaskCreate без привязки и узнайте, на каком ядре она окажется.

Задачи по ядрам

FreeRTOS: запас стека задачи

Увеличьте buffer с 256 до 512 байт: «запас стека» упадёт с 440 до 184 байт, ровно на добавленные 256. При 1500 байт задача переполнит стек и плата перезагрузится с сообщением о переполнении — увеличьте стек в xTaskCreate до 4096 и проверьте запас снова.

Запас стека

FreeRTOS: сигнал из прерывания

Нажмите кнопку и убедитесь, что задача-обработчик отвечает сразу, хотя loop() спит по секунде. Поменяйте приоритет задачи с 2 на 1: разницы не будет, потому что loop() спит и соперничать за ядро некому.

Сигнал из прерывания

Сторожевой таймер

Замените условие i < 8 на i < 3 и посмотрите в мониторе порта, через сколько секунд после последнего сброса сторож перезагрузит плату. Сравните с TIMEOUT_S.

Сторожевой таймер

← Все статьи