Табло в мастерской с бегущей строкой на матрице ожило с первого скетча — и в первый же вечер я поймал изъян: раз в минуту табло ходило за курсом валюты по HTTPS, и строка замирала на добрую секунду. Я грешил на дисплей, перепаивал питание, выкидывал библиотеку — а трассировка Serial показала скучную правду: HTTPS-рукопожатие съедало под секунду процессорного времени, и весь этот груз лежал на loop(). Анимация и сеть жили в одном loopTask, а он у платы один, и очередь дел у него тоже одна. На Uno так и живёт вся жизнь — по очереди; но ESP32 не Uno, у него два ядра, и я о втором напрочь забыл.
В этом уроке разберём, куда система прячет WiFi-стек, почему loop() сам по себе задача с именем loopTask и как xTaskCreatePinnedToCore прибивает вашу функцию к конкретному ядру. Соберём скетч, где сеть живёт в отдельной задаче, а логика на соседнем ядре не замечает ни подключения, ни запросов. Проверим идею в симуляторе — и сразу честно: FreeRTOS-механику движок не эмулирует, там модель на millis-таймерах, которая показывает, зачем всё это нужно.
Два ядра: кто где живёт
Внутри ESP32 два одинаковых 32-битных ядра Xtensa LX6, по 240 МГц каждое — расклад из урока про первый запуск, только теперь он заработает на нас. Система делит ядра не поровну, а по ролям: ядро 0 (PRO_CPU) забирает WiFi и Bluetooth-стек, ядро 1 (APP_CPU) отдаётся вашему скетчу. И loop() в Arduino-скетче — не голый цикл, а задача FreeRTOS с именем loopTask: приоритет 1, стек 8192 байт, прибита к ядру 1. То есть любой ваш скетч уже многозадачный: радио крутит одно ядро, код — другое, и они друг другу не наступают на пятки.
Отсюда факт, который на Uno не имел смысла, а тут решает: delay() в loop() на ESP32 не молотит пустой цикл, как на AVR, а внутри вызывает vTaskDelay и отдаёт ядро планировщику. Пока задача спит, соседние задачи и системный код работают без конкурента. Поэтому «жадный» скетч на ESP32 — это не цикл без delay, а задача, у которой нет ни delay, ни vTaskDelay: о ней отдельное слово ниже.
| Кто | Ядро | Роль и параметры | Ловушка |
|---|---|---|---|
| loopTask (ваш скетч) | 1 | приоритет 1, стек 8192 байт | делит ядро с вашими задачами по тикам RTOS |
| WiFi/BT стек | 0 | радио, прерывания, TCP/IP | системный дом: ничего не просит у вас |
| Ваша задача на ядре 0 | 0 | то, что вы попросите | цикл без пауз — голодный idle, watchdog, перезагрузка |
| Ваша задача на ядре 1 | 1 | логика, датчики, экраны | равный приоритет с loopTask = честная очередь по тикам |
xTaskCreatePinnedToCore: семь аргументов без воды
Сигнатура выглядит страшнее сути: xTaskCreatePinnedToCore(функция, имя, стек, аргумент, приоритет, хэндл, ядро). Функция задачи — void с параметром void*, внутри вечный цикл for(;;) и обязательно пауза. Имя — любая строка до 16 символов: всплывёт в логах паники, и вы будете рады, что назвали задачу net, а не task1. Стек — главный подводный камень: на ESP32 размер задаётся в байтах, хотя классическая документация FreeRTOS меряет его словами — 8192 значит 8 килобайт, а не 32. Приоритет — число, больше значит важнее: для логики хватает 1-2, выше поднимают только то, что обязано успевать. Хэндл — указатель на TaskHandle_t: нужен, если задачей собираетесь управлять через vTaskDelete, иначе смело NULL. Последний аргумент — номер ядра, 0 или 1.
Сколько стека давать? Мой старт — 4096 байт для простых задач и 8192, если внутри Serial, String или HTTP. Мало — проявится не сразу: скетч живёт, а через минуты падает со строкой про stack canary, и без имени задачи вы его не найдёте. Два приоритета-близнеца на одном ядре делят время по тикам RTOS — тик по умолчанию 1 мс, — так что мигалку и логику можно селить рядом спокойно: кооперативный планировщик на millis из урока про millis вместо delay делает то же самое руками, RTOS — за вас.
#include <WiFi.h> const char* ssid = "DomaSetka";const char* password = "superParol"; TaskHandle_t netTask = NULL; // хэндл пригодится, если задачу придётся убить void taskNetwork(void* param) { // живёт на ядре 0, рядом со стеком WiFi for (;;) { // задача никогда не возвращается WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { vTaskDelay(pdMS_TO_TICKS(500)); // пауза обязательна: отдаём ядро системе } Serial.println("net: gotovo, sydu v seti"); for (int i = 0; i < 10; i++) { // десять минут тишины vTaskDelay(pdMS_TO_TICKS(60000)); } if (WiFi.status() != WL_CONNECTED) { Serial.println("net: svyaz poteryana, peresozdayu"); WiFi.disconnect(); } }} void setup() { Serial.begin(115200); pinMode(2, OUTPUT); // GPIO 2 — встроенный светодиод платы xTaskCreatePinnedToCore(taskNetwork, "net", 8192, NULL, 1, &netTask, 0);} void loop() { // loopTask: ядро 1, приоритет 1 digitalWrite(2, !digitalRead(2)); // ритм 300 мс: не собьётся никогда delay(300);}Разбор. taskNetwork создана с ядром 0, приоритетом 1 и стеком 8192 байт — цикл вечный, пауза vTaskDelay(pdMS_TO_TICKS(500)) вместо delay: макрос pdMS_TO_TICKS переводит миллисекунды в тики, и без него число 500 будет означать 500 тиков, то есть полсекунды только если тик миллисекундный. Пока связь жива, задача спит минутными интервалами и проверяет WiFi.status() — тот же автомат восстановления из урока про автореконнект, только в собственном потоке. loop() остался на ядре 1 и мигает каждые 300 мс: при подключении, потере связи и переподключении ритм не сдвигается ни на миллисекунду — сетевые паузы физически не доезжают до логики.
Заметьте: Serial теперь печатает из двух миров — задача на ядре 0 и loopTask на ядре 1. UART у платы один, и при одновременной печати из двух ядер строки слипаются посреди слова: я однажды полчаса искал «глюк датчика», который был перемешанным логом. Лечится печатью одной строкой println или мьютексом — о нём ровно в следующем уроке. А когда в сетевой задаче появится реальная работа — запросы из урока про телеметрию — мигалка даже не узнает об этом.
- 1. Что такое loop() в Arduino-скетче для ESP32?
- 2. В xTaskCreatePinnedToCore вы передали размер стека 8192. Сколько это на ESP32?
- 3. Задача на ядре 0 крутится в цикле без vTaskDelay и delay. Что произойдёт?
- 4. Где живёт WiFi-стек ESP32?
- 5. Две ваши задачи с одинаковым приоритетом 1 живут на ядре 1. Как они делят ядро?
- Ядро 0 — радио-дом: WiFi/BT стек; ядро 1 — ваш скетч: loop() это задача loopTask, приоритет 1, стек 8192 байт
- xTaskCreatePinnedToCore(функция, имя, стек, аргумент, приоритет, хэндл, ядро); стек на ESP32 в байтах
- Функция задачи — вечный цикл: return из неё перезагружает плату, а цикл без vTaskDelay кормит watchdog
- Мой расклад: всё своё на ядро 1 с приоритетами 1-2; ядро 0 не трогаю; Serial из двух ядер перемешивает строки
- Стек: 4096 байт для простых задач, 8192 с Serial/String/HTTP; мало — stack canary panic через минуты работы
Что видно в логе: мигалка держит ровные 400 мс, но каждые шесть секунд в ней дыра — сетевая задача на 1.2 секунды заморозила общий loop, и LED застыл. Счётчик blinks за это время недобирает три-четыре такта: millis-планировщик догоняет, но такты, выпавшие внутри блока, уже не вернуть. На живой плате в этом месте вступает xTaskCreatePinnedToCore: сетевой код уезжает на ядро 0, loop остаётся на ядре 1, и дыра исчезает — ядра ходят параллельно, а не по очереди.
Моё правило ролей за три года с платой: всё своё — на ядро 1. Задачи датчиков, экранов, логики — туда же, рядом с loopTask, с приоритетами 1-2 и паузами внутри. Ядро 0 не трогаю вовсе: WiFi-стеку конкуренты не нужны, а пустить туда тяжёлый цикл без пауз — верный способ познакомиться с watchdog из предыдущей заметки. Единственный мой аргумент за задачу на ядре 0 — когда логики на ядре 1 уже так много, что сетевые таймауты начинают её задевать; но до этого доживает один проект из десяти. Дальше по разделу два урока, которые растут прямо из этого: в уроке про очереди и семафоры задачи научатся обмениваться данными без гонок — очередь-почтовый ящик и мьютекс на Serial закрывают обе грабли, упомянутые выше. А урок про IoT-датчик собирает полный путь показания — от чтения DHT до строки в облачном графике, и там сетевая работа в отдельной задаче перестаёт быть теорией.
Пока же зафиксируйте жёсткое: return из функции задачи — это перезагрузка, цикл без пауз — это watchdog, стек в байтах, минимум 4096. С этими тремя правилами первая же ваша двухядерная задача заработает с первого раза — а не с третьего, как у меня.
Частые вопросы по теме урока
- Что такое loop() в Arduino-скетче для ESP32?
- Arduino-ядро ESP32 оборачивает loop() в задачу loopTask: приоритет 1, стек 8192 байт, прибита к ядру 1. Поэтому скетч уже многозадачный, даже если вы этого не заметили. Задача loopTask на ядре 1: приоритет 1, стек 8192 байт
- В xTaskCreatePinnedToCore вы передали размер стека 8192. Сколько это на ESP32?
- На ESP32 размер стека задаётся в байтах, хотя классический FreeRTOS меряет словами — частая путаница. 8192 здесь значит ровно 8 килобайт. 8192 байта, то есть 8 килобайт
- Задача на ядре 0 крутится в цикле без vTaskDelay и delay. Что произойдёт?
- Idle-задаче нужны крохи процессорного времени; без пауз в вашем цикле она их не получает, watchdog это видит и перезагружает плату. Любой цикл задачи дышит vTaskDelay. Сработает task watchdog: idle-задача ядра голодает, плата перезагрузится
- Где живёт WiFi-стек ESP32?
- Ядро 0 — радио-дом: WiFi и Bluetooth стек живут там. Ядро 1 отдаётся вашему скетчу. Моё правило: всё своё — на ядро 1. На ядре 0 (PRO_CPU) — туда его поселила система
- Две ваши задачи с одинаковым приоритетом 1 живут на ядре 1. Как они делят ядро?
- Равные приоритеты делят ядро по тикам — примерно 1 мс каждому в зависимости от версии ядра. Это тот же принцип, что у millis-планировщика, только честный на уровне системы. По тикам RTOS: поочерёдно, тик по умолчанию 1 мс