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.
Сторожевой таймер