Два ядра ESP32 и FreeRTOS: задачи, очереди и мьютексы
У классического ESP32 и ESP32-S3 два вычислительных ядра, и обычный скетч использует
только одно. Второе простаивает, пока вы боретесь с тем, чтобы loop() успевал и
опрашивать датчики, и отвечать по сети. Разбираемся, как раздать работу по ядрам —
и почему в половине случаев этого делать не нужно.
Что уже работает
Операционная система на ESP32 есть с самого начала: под Arduino-скетчем крутится FreeRTOS. Просто её обычно не замечают.
Ваш setup() и loop() выполняются внутри задачи, которую ядро Arduino создало за
вас. По умолчанию она живёт на ядре 1. Сетевой стек — Wi-Fi и Bluetooth —
работает на ядре 0. То есть второе ядро не простаивает полностью: когда вы
подключаетесь к сети, оно занято.
Проверить, где выполняется код, можно в одну строку:
void setup() {
Serial.begin(115200);
Serial.printf("setup() выполняется на ядре %d\n", xPortGetCoreID());
}
void loop() {
}
У одноядерных чипов — ESP32-C3, ESP32-C6, ESP32-H2, а также ESP32-S2 — ядро одно, и
там loop() автоматически едет на ядро 0. Задачи FreeRTOS работают точно так же, и
код с
xTaskCreatePinnedToCore компилируется, но номер ядра можно указывать только 0
или tskNO_AFFINITY — попытка привязать задачу к ядру 1 приведёт к аварийной
остановке.
Когда это нужно, а когда нет
Разделять работу по ядрам стоит, когда есть две независимые задачи с разными требованиями ко времени. Классический пример: одна часть программы должна непрерывно и равномерно управлять моторами, а вторая — общаться по сети, где задержки непредсказуемы.
А вот чего вторым ядром не решить:
- Медленный код быстрее не станет. Одна задача выполняется на одном ядре.
- Проблемы с
delay()не исчезнут. Если задача блокируется, она блокируется. - Простой скетч усложнится зря. Пока всё умещается в неблокирующий
loop()наmillis(), задачи только добавят способов выстрелить себе в ногу.
Хорошее правило: сначала перепишите loop() без delay(), и только если этого не
хватило — беритесь за задачи.
Создание задачи
void motorTask(void* parameters) {
for (;;) {
// управление моторами: выполняется на своём ядре
vTaskDelay(pdMS_TO_TICKS(10));
}
}
void setup() {
Serial.begin(115200);
xTaskCreatePinnedToCore(
motorTask, // функция задачи
"motor", // имя для отладки
4096, // размер стека в БАЙТАХ
nullptr, // параметр, передаваемый в функцию
1, // приоритет: чем больше, тем выше
nullptr, // сюда можно получить дескриптор задачи
0 // номер ядра: 0 или 1
);
}
void loop() {
// продолжает работать на ядре 1 как обычно
}
Три параметра требуют пояснения.
Размер стека в ESP-IDF задаётся в байтах, а не в словах, как в обычном
FreeRTOS. Это частая причина странных сбоев у тех, кто копирует примеры из книг по
FreeRTOS. Разумный старт — 2048 байт для простой задачи, 4096 если внутри есть
Serial.printf() или работа со строками, 8192 для сетевых операций.
Приоритет — число от 0 и выше; 0 занимает задача простоя, поэтому пользовательские начинают с 1. Задача с более высоким приоритетом вытесняет более низкую. Ставить всем подряд высокий приоритет — верный способ заблокировать систему.
Номер ядра — 0 или 1. Можно передать tskNO_AFFINITY, и тогда планировщик сам
решит, где выполнять.
Функция задачи не должна завершаться. Внутри обязателен бесконечный цикл; если
дойти до конца функции, FreeRTOS аварийно перезагрузит плату. Если задача больше не
нужна, её удаляют явно: vTaskDelete(nullptr).
Обязательно уступайте время
Внутри задачи нельзя занимать ядро непрерывно. Цикл вида
for (;;) {
readSensor(); // и ничего больше
}
не заблокирует задачи того же приоритета — планировщик FreeRTOS переключает их по
тику. А вот всё, что приоритетом ниже, включая задачу простоя, перестанет получать
процессор. Именно из-за голодающей задачи простоя срабатывает сторожевой таймер:
система решает, что программа зависла, и перезагружает плату с сообщением
Task watchdog got triggered ... IDLE0.
Уступать время нужно явно — vTaskDelay():
vTaskDelay(pdMS_TO_TICKS(10)); // отдать управление на 10 мс
Макрос pdMS_TO_TICKS() переводит миллисекунды в тики планировщика — так код не
зависит от настройки частоты тиков.
Распространённое заблуждение — что delay() внутри задачи «зависает» и надо
обязательно менять его на vTaskDelay(). На самом деле в Arduino-ESP32 delay()
реализован через vTaskDelay() и корректно отдаёт процессор. А вот
delayMicroseconds() — настоящий busy-wait, он крутится в цикле и не уступает
никому. Опасны именно он и собственные циклы вида while (!ready) {}.
Обмен данными: очереди
Соблазн — сделать общую глобальную переменную. Проблема в том, что задачи на разных ядрах могут читать и писать её одновременно, и результат окажется непредсказуемым. Правильный способ — очередь: FreeRTOS сам обеспечит безопасность.
QueueHandle_t commandQueue;
struct MotorCommand {
uint8_t axis;
int16_t angle;
};
void motorTask(void* parameters) {
MotorCommand command;
for (;;) {
// ждём команду; portMAX_DELAY — ждать бесконечно
if (xQueueReceive(commandQueue, &command, portMAX_DELAY) == pdTRUE) {
Serial.printf("Ось %u на угол %d\n", command.axis, command.angle);
}
}
}
void setup() {
Serial.begin(115200);
commandQueue = xQueueCreate(10, sizeof(MotorCommand)); // 10 элементов
xTaskCreatePinnedToCore(motorTask, "motor", 4096, nullptr, 1, nullptr, 0);
}
void loop() {
MotorCommand command = {0, 90};
xQueueSend(commandQueue, &command, 0); // 0 — не ждать, если очередь полна
delay(1000);
}
Очередь копирует данные, а не хранит указатель, поэтому передавать локальные
структуры безопасно. Задача-получатель при portMAX_DELAY не тратит процессорное
время: она спит, пока в очереди пусто, и просыпается сама.
Именно так удобно устроить робота: loop() принимает команды по Serial или сети и
кладёт их в очередь, а отдельная задача исполняет движение в своём темпе.
Защита общих данных: мьютекс
Когда без общей переменной не обойтись — например, две задачи пишут в один буфер, — доступ закрывают мьютексом:
SemaphoreHandle_t dataMutex;
float sharedTemperature = 0;
void sensorTask(void* parameters) {
for (;;) {
float value = readSensor();
if (xSemaphoreTake(dataMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
sharedTemperature = value;
xSemaphoreGive(dataMutex); // отдать обязательно, иначе всё встанет
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void setup() {
dataMutex = xSemaphoreCreateMutex();
xTaskCreatePinnedToCore(sensorTask, "sensor", 4096, nullptr, 1, nullptr, 0);
}
Правило простое: захватили — обязательно отдайте, и держите как можно короче. Внутри
критической секции не должно быть delay(), сетевых запросов и печати в порт.
Распространённый и опасный совет — «для простой переменной хватит volatile».
volatile запрещает компилятору кешировать значение в регистре, но не даёт ни
атомарности, ни порядка обращений к памяти, ни синхронизации между ядрами.
Без защиты допустим ровно один сценарий: один писатель, один читатель, одно
значение-флаг, где потеря или задержка обновления некритична. Всё остальное требует
защиты. В частности, counter++ — это чтение, увеличение и запись: два ядра легко
затрут инкремент друг друга, сколько бы volatile вы ни поставили. Для счётчиков
берите std::atomic, критическую секцию portENTER_CRITICAL или очередь.
Что нельзя делать
Обращаться к сети с ядра 0 без нужды. Там работает Wi-Fi-стек, и тяжёлая задача рядом с ним ухудшит связь. Для своей нагрузки берите ядро 1.
Занимать ядро без пауз. Сторожевой таймер перезагрузит плату.
Экономить на стеке. Переполнение стека задачи проявляется как случайная
перезагрузка с сообщением Stack canary watchpoint triggered. Узнать реальный расход
можно так:
Serial.printf("Свободно в стеке: %u байт\n",
uxTaskGetStackHighWaterMark(nullptr));
Функция возвращает минимальный остаток за всё время работы задачи. Если там сотня байт — стек надо увеличивать.
Вызывать функции задач из прерывания. Из ISR используют варианты с суффиксом
FromISR — например, xQueueSendFromISR().
Что дальше
- Сколько ОЗУ уходит на задачи — Память Arduino и ESP32.
- Подключение к сети, работающее на ядре 0 — Подключение ESP32 к Wi-Fi.
- Какой чип двухъядерный, а какой нет — Какую ESP32 выбрать.