Инструкции

Два ядра ESP32 и FreeRTOS: задачи, очереди и мьютексы

13 мин чтения·Август 2026·RoboArm R1

У классического 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().

Что дальше

Рекомендуемые продукты

Связанные статьи