FreeRTOS уже внутри
В отличие от AVR, ESP32 поставляется с FreeRTOS — маленькой операционной системой реального времени. Каждый ваш скетч уже работает в ней: setup() и loop() — это задача loopTask на ядре 1. Wi-Fi, Bluetooth, TCP-стек — отдельные задачи на ядре 0.
Это значит: вы можете создавать свои задачи, которые работают параллельно — на разных ядрах, с приоритетами, с настоящим вытеснением. Долгая обработка не мешает опросу кнопок, веб-сервер не тормозит логику.
Создание задачи
void sensorTask(void* param) {
while (true) {
readSensors();
vTaskDelay(pdMS_TO_TICKS(1000)); // неблокирующий delay для задачи
}
}
void setup() {
xTaskCreate(
sensorTask, // функция задачи
"sensors", // имя (для отладки)
4096, // размер стека в байтах
nullptr, // параметры
1, // приоритет (больше — важнее)
nullptr // хэндл (если нужен)
);
}
vTaskDelay — это не delay: он «отдаёт» процессор другим задачам на время сна. Задача, которая никогда не спит, забьёт ядро — всегда делайте vTaskDelay или ждите события.
Два ядра: параллельность по-настоящему
xTaskCreatePinnedToCore(
heavyTask, "heavy", 8192, nullptr, 1, nullptr,
0); // ядро 0 — тяжёлая обработка
// loop и Wi-Fi остаются на ядре 1
Тяжёлую обработку (DSP, видео, вычисления) выносят на ядро 0 — Wi-Fi и системные задачи там уже крутятся, но планировщик разруливает. На ядре 1 — ваш loop и лёгкие задачи.
Очереди: данные между задачами
Глобальные переменные между задачами — race condition. Правильный способ — очереди FreeRTOS:
QueueHandle_t q;
void producerTask(void*) {
int v = 0;
while (true) {
xQueueSend(q, &v, portMAX_DELAY); // положить (ждать места бесконечно)
v++;
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void consumerTask(void*) {
int v;
while (true) {
if (xQueueReceive(q, &v, portMAX_DELAY) == pdTRUE) {
Serial.println(v); // спит до появления данных
}
}
}
void setup() {
q = xQueueCreate(10, sizeof(int)); // очередь на 10 int
xTaskCreate(producerTask, "prod", 2048, nullptr, 1, nullptr);
xTaskCreate(consumerTask, "cons", 2048, nullptr, 1, nullptr);
}
Очередь — безопасный канал: producer не ждёт чтения, consumer не крутит пустой цикл.
Мьютексы и семафоры
Для защиты общего ресурса (Serial, шина I2C, дисплей):
SemaphoreHandle_t i2cMutex;
void setup() {
i2cMutex = xSemaphoreCreateMutex();
}
void i2cSafeRead() {
if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(100))) {
// безопасно работаем с I2C
xSemaphoreGive(i2cMutex);
}
}
Семафоры также используются как «сигнал»: задача ждёт xSemaphoreTake — прерывание или другая задача даёт xSemaphoreGive — просыпается мгновенно.
Прерывания и задачи: ISR-to-task
Из прерывания нельзя делать долгую работу — но можно «пнуть» задачу:
SemaphoreHandle_t btnSem;
void IRAM_ATTR isr() {
BaseType_t woken = pdFALSE;
xSemaphoreGiveFromISR(btnSem, &woken); // из прерывания
if (woken) portYIELD_FROM_ISR();
}
void buttonTask(void*) {
while (true) {
xSemaphoreTake(btnSem, portMAX_DELAY); // спит до прерывания
handleButton();
}
}
ISR делает минимум — отдаёт семафор; вся обработка в задаче с нормальным стеком и приоритетом.
Стек задачи и мониторинг
Каждая задача имеет свой стек — при переполнении плата перезагружается. Размер указывается в байтах (не в словах, как у классической FreeRTOS):
uxTaskGetStackHighWaterMark(NULL); // минимум свободного стека вашей задачи
xTaskCreate(task, "t", 8192, ...); // для задач с Serial/строками — не жадничайте
Задача с Serial.println, String и вложенными вызовами легко ест 4–8 КБ. Маленький стек — причина загадочных перезагрузок.
Типичные ошибки
delayв задаче — блокирует планировщик;vTaskDelay— правильно.- Маленький стек — переполнение и ребут без сообщения.
- Гонки на глобальных переменных — используйте очереди/мьютексы или
volatile+ атомики. - Вызов не-ISR API из прерывания — только
*FromISRверсии. while(1)без сна — монополизация ядра, watchdog или выдавливание задач.
Итог
FreeRTOS на ESP32 — полноценная ОС: задачи на двух ядрах, xTaskCreate и vTaskDelay вместо delay, очереди для данных, семафоры для событий и защиты ресурсов. Схема: setup создаёт задачи, каждая живёт своей жизнью — loop превращается в одну из задач, а не в единственный цикл.
Полный пример: датчик + дисплей + лог
QueueHandle_t tempQueue;
void sensorTask(void*) {
while (true) {
float t = dht.readTemperature();
xQueueSend(tempQueue, &t, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(2000));
}
}
void displayTask(void*) {
float t;
while (true) {
if (xQueueReceive(tempQueue, &t, portMAX_DELAY)) {
display.setCursor(0, 0);
display.printf("T=%.1f", t);
display.display();
}
}
}
void logTask(void*) {
while (true) {
vTaskDelay(pdMS_TO_TICKS(60000));
// log to SD / MQTT
}
}
void setup() {
tempQueue = xQueueCreate(5, sizeof(float));
xTaskCreatePinnedToCore(sensorTask, "sens", 4096, nullptr, 1, nullptr, 0);
xTaskCreatePinnedToCore(displayTask, "disp", 4096, nullptr, 1, nullptr, 1);
xTaskCreatePinnedToCore(logTask, "log", 4096, nullptr, 1, nullptr, 0);
}
Event groups: флаги событий
Для «жди несколько условий» — EventGroup:
EventGroupHandle_t events;
const int BIT_WIFI = 1, BIT_MQTT = 2, BIT_NTP = 4;
// в задаче ждём всё:
xEventGroupWaitBits(events, BIT_WIFI | BIT_MQTT | BIT_NTP,
pdFALSE, pdTRUE, portMAX_DELAY);
// теперь все три готовы
Сколько стека дать задаче
Зависит от того, что она делает:
- Лёгкий GPIO-полинг: 1024–2048 байт.
- Serial/String/lcd: 4096–8192 байт.
- JSON/HTTP: 8192–16384 байта.
- TLS/SSL: 16384+ байт.
Проверка uxTaskGetStackHighWaterMark() показывает фактический минимум свободного стека — оптимизируйте под него.
Приоритеты
- 0 — idle-задачи (фон, не важно).
- 1 — обычные задачи (датчики, UI).
- 5–10 — важные (логика, безопасность).
- 20+ — real-time (PID-регулятор, аудио).
Wi-Fi и Bluetooth — высокий приоритет на ядре 0; ваши тяжёлые задачи не должны их вытеснять.
Частые ошибки
delayв задаче —vTaskDelayправильно.- Маленький стек — таинственные ребуты.
- Гонки на глобалах — очереди/мьютексы или
volatile. - ISR вызывает не-ISR API — только
*FromISRверсии. while(1)без сна — монополизация ядра.