FreeRTOS su ESP32: task, priorità e i due core al lavoro

Pubblicato il 13/07/2026 ESP32

Quando scrivi uno sketch Arduino per ESP32, in realtà setup() e loop() girano già come un task FreeRTOS gestito automaticamente dal core Arduino. Ma FreeRTOS è lì, esposto, e puoi usarlo direttamente per creare più task indipendenti, dare loro priorità diverse e sfruttare i due core fisici del chip (spesso chiamati PRO_CPU e APP_CPU) invece di far girare tutto in sequenza in un loop unico sempre più ingarbugliato.

Perché non basta un solo loop()

Finché il firmware fa una cosa alla volta, loop() va benissimo. Ma appena hai bisogno di leggere sensori a intervalli diversi, gestire una connessione di rete, aggiornare un display e rispondere a input utente senza che un'operazione lenta blocchi le altre, il codice a singolo loop diventa un incubo di flag e millis() sparsi ovunque. Con FreeRTOS puoi spezzare queste responsabilità in task separati, ciascuno con il proprio ciclo e la propria cadenza, schedulati in modo preemptive dal kernel.

Creare task pinnati a un core: xTaskCreatePinnedToCore

La funzione chiave è xTaskCreatePinnedToCore, che in più rispetto alla classica xTaskCreate ti lascia scegliere su quale dei due core far girare il task. Per convenzione, nel core Arduino per ESP32 il core 0 gestisce già WiFi e Bluetooth (il task del loop Arduino di default gira sul core 1), quindi se hai un task pesante è spesso meglio pinnarlo sul core 1 per non contendere CPU con lo stack di rete, oppure valutare caso per caso in base al carico.

void taskSensore(void *parameter) {
  for (;;) {
    float valore = analogRead(34) * (3.3 / 4095.0);
    Serial.println("Lettura sensore: " + String(valore));
    vTaskDelay(pdMS_TO_TICKS(500)); // cede la CPU, non blocca il core
  }
}

void taskRete(void *parameter) {
  for (;;) {
    // qui, ad esempio, client.loop() di un client MQTT
    vTaskDelay(pdMS_TO_TICKS(50));
  }
}

void setup() {
  Serial.begin(115200);

  xTaskCreatePinnedToCore(
    taskSensore,    // funzione del task
    "TaskSensore",  // nome (per debug)
    4096,           // stack in byte
    NULL,           // parametro passato al task
    1,              // priorità
    NULL,           // handle (non ci serve qui)
    1               // core: 1 = APP_CPU
  );

  xTaskCreatePinnedToCore(
    taskRete, "TaskRete", 4096, NULL, 2, NULL, 0 // core 0
  );
}

void loop() {
  vTaskDelete(NULL); // non ci serve il loop Arduino: lo eliminiamo
}

Priorità: chi vince quando entrambi vogliono girare

Il parametro priorità (0 = più bassa) determina chi ottiene la CPU quando più task pronti competono sullo stesso core. Un task a priorità più alta prelaziona uno a priorità più bassa. Attenzione però: se scrivi un task ad alta priorità con un loop stretto senza mai cedere la CPU (niente vTaskDelay, niente operazioni bloccanti che rilascino il controllo), rischi di affamare gli altri task sullo stesso core, incluso il task idle che alimenta il watchdog — risultato tipico: reset per "Task watchdog got triggered". La regola pratica è: ogni task deve avere un punto in cui cede volontariamente la CPU, anche solo con vTaskDelay(1).

Far comunicare i task: le queue

Variabili globali condivise tra task funzionano ma sono una fonte facile di race condition. Il meccanismo idiomatico di FreeRTOS per passare dati tra task in modo sicuro è la queue: un task scrive con xQueueSend, un altro legge con xQueueReceive, e il kernel si occupa della sincronizzazione.

QueueHandle_t codaLetture;

typedef struct {
  float temperatura;
  unsigned long timestamp;
} Lettura;

void taskProduttore(void *parameter) {
  Lettura l;
  for (;;) {
    l.temperatura = leggiTemperatura();
    l.timestamp = millis();
    xQueueSend(codaLetture, &l, pdMS_TO_TICKS(100));
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

void taskConsumatore(void *parameter) {
  Lettura ricevuta;
  for (;;) {
    if (xQueueReceive(codaLetture, &ricevuta, portMAX_DELAY) == pdTRUE) {
      Serial.println("Temp: " + String(ricevuta.temperatura) +
                      " @ " + String(ricevuta.timestamp));
      // qui potresti pubblicarla via MQTT, scriverla su display, ecc.
    }
  }
}

void setup() {
  Serial.begin(115200);
  codaLetture = xQueueCreate(10, sizeof(Lettura));

  xTaskCreatePinnedToCore(taskProduttore, "Produttore", 4096, NULL, 1, NULL, 1);
  xTaskCreatePinnedToCore(taskConsumatore, "Consumatore", 4096, NULL, 1, NULL, 0);
}

void loop() { vTaskDelete(NULL); }

Nota pratica

Dimensiona lo stack di ogni task con un margine reale: 4096 byte bastano per task semplici, ma se dentro chiami librerie pesanti (TLS, JSON parsing, driver display) può servire il doppio o il triplo — uno stack overflow su FreeRTOS spesso si manifesta come crash apparentemente casuale in un punto del codice che non c'entra nulla. Usa uxTaskGetStackHighWaterMark() in fase di sviluppo per verificare quanto stack resta libero e ridimensionare di conseguenza.