FreeRTOS in pratica: task, code e semafori su ESP32

FreeRTOS in pratica: task, code e semafori su ESP32
Indice

Su ESP32 FreeRTOS è sempre presente: ESP-IDF lo avvia prima di app_main() e lo usa per il Wi-Fi, il Bluetooth e i timer. Usarlo bene significa dividere il firmware in task indipendenti, farli comunicare con code e notifiche, proteggere i dati condivisi con un mutex e scegliere le priorità in modo che i lavori urgenti non aspettino quelli lenti.

In questo articolo vediamo come funziona lo scheduler di ESP-IDF, quali strumenti usare e quando, e costruiamo un esempio completo che campiona il sensore di temperatura interno del chip, elabora i dati e stampa un riepilogo alla pressione di un tasto. Il codice si riferisce a ESP-IDF v6.1 e funziona senza componenti esterni su ESP32-C6 ed ESP32-S3. Se arrivi da Arduino, parti dal secondo articolo della serie, dove abbiamo già usato una coda tra un interrupt e un task.

Come ragiona lo scheduler

Ogni task è una funzione con il proprio stack, che non termina mai. In ogni istante un task si trova in uno di questi stati:

  • In esecuzione: sta usando la CPU.
  • Pronto: potrebbe girare, ma la CPU è occupata da un task con priorità uguale o più alta.
  • Bloccato: aspetta un evento (un ritardo, un dato in una coda, un semaforo, una notifica) e non consuma CPU.
  • Sospeso: fermato esplicitamente con vTaskSuspend().

La regola dello scheduler è semplice: gira sempre il task pronto con la priorità più alta. Se un task più importante si sblocca, quello in esecuzione viene interrotto subito (preemption). Task con la stessa priorità si alternano a ogni tick, che in ESP-IDF vale 10 ms con l’impostazione predefinita di 100 Hz.

Diagramma temporale di quattro task su un solo core: quando sensor_task con priorità 6 si sveglia, interrompe report_task con priorità 4, che riprende dopo che sensor_task e process_task si sono bloccati; quando nessun task è pronto gira il task idle
Il task a priorità più alta che è pronto ottiene la CPU; quando tutti sono bloccati gira il task idle.

Quando nessun task è pronto gira il task idle, con priorità 0. È il momento in cui il sistema può entrare in light sleep se la gestione dei consumi è attiva. Per questo il buon firmware passa la maggior parte del tempo con tutti i task bloccati: un ciclo che controlla una variabile in continuazione tiene la CPU occupata, scalda il chip e impedisce il risparmio energetico.

Le particolarità di ESP-IDF

La versione di FreeRTOS inclusa in ESP-IDF è stata adattata da Espressif per i chip a due core. Le differenze rispetto alla documentazione di FreeRTOS che troverai online sono poche, ma importanti:

  • Lo stack si indica in byte, non in word. xTaskCreate(..., 4096, ...) assegna 4 KB.
  • Su ESP32-S3 i core sono due e lo scheduler è simmetrico: un task senza affinità può girare su entrambi. Con xTaskCreatePinnedToCore() lo leghi a un core specifico. ESP32-C6 ha un solo core ad alte prestazioni, quindi il codice resta identico ma i task non girano mai in parallelo.
  • Le priorità vanno da 0 a 24. Il task di app_main() ha priorità 1; i task di sistema usano le priorità alte: lwIP 18, ciclo degli eventi 20, esp_timer 22, Wi-Fi e controller Bluetooth 23.
  • Le sezioni critiche richiedono uno spinlock, perché disabilitare gli interrupt su un core non ferma l’altro. Ne parliamo più avanti.

Creare un task

Un task riceve un puntatore come parametro e contiene un ciclo infinito che, a ogni giro, si blocca su qualcosa:

C
static void blink_task(void *arg)
{
    const gpio_num_t led = (gpio_num_t)(intptr_t)arg;
    bool on = false;          // GPIO già configurato come uscita
    while (true) {
        on = !on;
        gpio_set_level(led, on);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

void app_main(void)
{
    // funzione, nome, stack in byte, parametro, priorità, handle
    xTaskCreate(blink_task, "blink", 2048, (void *)(intptr_t)GPIO_NUM_10, 3, NULL);
}

Se una funzione di task deve terminare, chiama vTaskDelete(NULL): non deve mai arrivare al return, altrimenti il sistema si interrompe con un errore. app_main() è l’eccezione: quando ritorna, ESP-IDF elimina il task principale e i task creati continuano a girare.

Per le attività periodiche usa xTaskDelayUntil() invece di vTaskDelay(). Il primo calcola il risveglio a partire dal precedente, quindi il periodo resta costante anche se il lavoro dura un tempo variabile; il secondo aspetta sempre lo stesso intervallo dopo la fine del lavoro, e il periodo reale si allunga.

Come scegliere le priorità

Assegna la priorità in base a quanto presto deve rispondere il task, non a quanto è importante la sua funzione. Un task che legge un sensore a intervalli precisi va sopra quello che scrive i dati in flash, anche se il salvataggio sembra più importante. Per un’applicazione tipica bastano tre o quattro livelli tra 2 e 10, ben sotto i task di sistema: un task applicativo con priorità superiore a 18 può rallentare lo stack TCP/IP e il Wi-Fi.

Gli strumenti per comunicare

Due task non dovrebbero mai scambiarsi dati con variabili globali senza protezione. FreeRTOS offre strumenti diversi per esigenze diverse:

Strumento Quando usarlo API principali
Coda Passare dati da un produttore a un consumatore, nell’ordine xQueueSend(), xQueueReceive()
Notifica Svegliare un task specifico, anche da un interrupt; la scelta più leggera xTaskNotifyGive(), ulTaskNotifyTake()
Mutex Proteggere una risorsa condivisa: una struttura dati, un bus, una periferica xSemaphoreTake(), xSemaphoreGive()
Semaforo binario Segnalare un evento quando più task possono aspettarlo xSemaphoreGiveFromISR()
Semaforo contatore Contare eventi o risorse disponibili xSemaphoreCreateCounting()
Event group Aspettare più condizioni insieme, come «Wi-Fi connesso e ora sincronizzata» xEventGroupWaitBits()

Code

Una coda copia i dati al suo interno: il produttore può riutilizzare subito la propria variabile e il consumatore riceve una copia indipendente. Per dati grandi, come un buffer audio, conviene inviare un puntatore e stabilire chiaramente chi libera la memoria.

Il timeout decide cosa succede se la coda è piena o vuota: 0 restituisce subito un errore, portMAX_DELAY aspetta per sempre, un valore in tick aspetta al massimo quel tempo. Chi produce dati a ritmo fisso di solito non deve bloccarsi: meglio perdere un campione e registrarlo che ritardare tutti i successivi.

Mutex e semafori

Un mutex protegge una risorsa: lo prende un task, lo restituisce lo stesso task. Ha l’ereditarietà della priorità: se un task a bassa priorità tiene il mutex mentre uno ad alta priorità lo aspetta, il primo viene temporaneamente promosso, così finisce in fretta e lo rilascia. Senza questo meccanismo un task a priorità intermedia potrebbe bloccare entrambi indefinitamente: è l’inversione di priorità.

Un semaforo binario invece segnala un evento: un task o un interrupt lo dà, un altro task lo prende. Non ha ereditarietà della priorità e non va usato per proteggere dati. Per segnalare un evento a un solo task, le notifiche fanno lo stesso lavoro con meno memoria e sono più veloci, secondo la documentazione di FreeRTOS fino al 45%.

Dagli interrupt ai task

Una routine di interrupt deve essere breve e non può bloccarsi. Usa sempre le versioni FromISR delle funzioni e, alla fine, chiedi un cambio di contesto se hai sbloccato un task più importante di quello interrotto:

C
static void IRAM_ATTR button_isr(void *arg)
{
    BaseType_t higher_priority_woken = pdFALSE;
    vTaskNotifyGiveFromISR(report_task_handle, &higher_priority_woken);
    portYIELD_FROM_ISR(higher_priority_woken);
}

Così il task sbloccato parte appena l’interrupt termina, invece di aspettare il tick successivo.

Un esempio completo

L’applicazione ha tre task, come nello schema:

  • sensor_task legge la temperatura del chip ogni secondo e mette il campione in una coda.
  • process_task riceve i campioni e aggiorna minimo, massimo e media in una struttura condivisa.
  • report_task aspetta la pressione del tasto BOOT, copia e azzera le statistiche e le stampa.
Schema dell’esempio: il sensore di temperatura interno alimenta sensor_task, che invia i campioni nella coda sample_queue letta da process_task; process_task aggiorna la struttura stats protetta da un mutex; l’interrupt del tasto BOOT notifica report_task, che copia e azzera stats
Il sensore interno e il tasto BOOT sono già sulla scheda: non serve altro hardware.

Il sensore misura la temperatura del die, non quella dell’ambiente: sale con il carico della CPU e con l’uso della radio. Per questo esempio è perfetto, perché è disponibile su entrambi i chip e non richiede collegamenti.

Il main/CMakeLists.txt dichiara i componenti usati:

CMake
idf_component_register(SRCS "main.c"
                       INCLUDE_DIRS "."
                       REQUIRES esp_driver_gpio esp_driver_tsens esp_timer)

E questo è main/main.c:

C
#include <inttypes.h>
#include <stdbool.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "driver/gpio.h"
#include "driver/temperature_sensor.h"
#include "esp_timer.h"
#include "esp_log.h"

#if CONFIG_IDF_TARGET_ESP32C6
#define BUTTON_GPIO 9      // Tasto BOOT su ESP32-C6-DevKitC-1
#else
#define BUTTON_GPIO 0      // Tasto BOOT su ESP32-S3-DevKitC-1
#endif
#define SAMPLE_PERIOD_MS 1000
#define DEBOUNCE_MS      200

typedef struct {
    int64_t time_us;
    float celsius;
} sample_t;

typedef struct {
    float min;
    float max;
    float sum;
    uint32_t count;
} stats_t;

static const char *TAG = "rtos";
static const stats_t STATS_EMPTY = { .min = 1000.0f, .max = -1000.0f };

static QueueHandle_t sample_queue;
static SemaphoreHandle_t stats_mutex;
static TaskHandle_t report_task_handle;
static stats_t stats;  // Condivisa: accesso solo con stats_mutex

// Produttore: campiona a periodo fisso, non si blocca mai sulla coda.
static void sensor_task(void *arg)
{
    temperature_sensor_handle_t tsens = arg;
    TickType_t last_wake = xTaskGetTickCount();

    while (true) {
        sample_t sample = { .time_us = esp_timer_get_time() };
        if (temperature_sensor_get_celsius(tsens, &sample.celsius) == ESP_OK &&
            xQueueSend(sample_queue, &sample, 0) != pdTRUE) {
            ESP_LOGW(TAG, "Coda piena: campione perso");
        }
        xTaskDelayUntil(&last_wake, pdMS_TO_TICKS(SAMPLE_PERIOD_MS));
    }
}

// Consumatore: dorme finché non arriva un campione.
static void process_task(void *arg)
{
    sample_t sample;

    while (true) {
        xQueueReceive(sample_queue, &sample, portMAX_DELAY);

        xSemaphoreTake(stats_mutex, portMAX_DELAY);
        if (sample.celsius < stats.min) stats.min = sample.celsius;
        if (sample.celsius > stats.max) stats.max = sample.celsius;
        stats.sum += sample.celsius;
        stats.count++;
        xSemaphoreGive(stats_mutex);
    }
}

static void IRAM_ATTR button_isr(void *arg)
{
    BaseType_t higher_priority_woken = pdFALSE;
    vTaskNotifyGiveFromISR(report_task_handle, &higher_priority_woken);
    portYIELD_FROM_ISR(higher_priority_woken);
}

static void report_task(void *arg)
{
    while (true) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);

        // Tiene il mutex il minimo indispensabile: copia e azzera.
        xSemaphoreTake(stats_mutex, portMAX_DELAY);
        stats_t snapshot = stats;
        stats = STATS_EMPTY;
        xSemaphoreGive(stats_mutex);

        if (snapshot.count == 0) {
            ESP_LOGI(TAG, "Nessun campione dall'ultimo riepilogo");
        } else {
            ESP_LOGI(TAG, "%" PRIu32 " campioni: min %.1f °C, media %.1f °C, max %.1f °C",
                     snapshot.count, snapshot.min,
                     snapshot.sum / snapshot.count, snapshot.max);
        }
        ESP_LOGI(TAG, "Stack mai usato da report_task: %u byte",
                 (unsigned)uxTaskGetStackHighWaterMark(NULL));

        // Antirimbalzo: scarta le notifiche arrivate durante il rimbalzo del tasto.
        vTaskDelay(pdMS_TO_TICKS(DEBOUNCE_MS));
        ulTaskNotifyTake(pdTRUE, 0);
    }
}

void app_main(void)
{
    temperature_sensor_handle_t tsens = NULL;
    temperature_sensor_config_t tsens_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80);
    ESP_ERROR_CHECK(temperature_sensor_install(&tsens_config, &tsens));
    ESP_ERROR_CHECK(temperature_sensor_enable(tsens));

    stats = STATS_EMPTY;
    sample_queue = xQueueCreate(16, sizeof(sample_t));
    stats_mutex = xSemaphoreCreateMutex();
    configASSERT(sample_queue != NULL && stats_mutex != NULL);

    // Priorità: chi deve rispondere prima sta più in alto.
    xTaskCreate(sensor_task, "sensor", 3072, tsens, 6, NULL);
    xTaskCreate(process_task, "process", 3072, NULL, 5, NULL);
    xTaskCreate(report_task, "report", 4096, NULL, 4, &report_task_handle);

    const gpio_config_t button = {
        .pin_bit_mask = 1ULL << BUTTON_GPIO,
        .mode = GPIO_MODE_INPUT,
        .pull_up_en = GPIO_PULLUP_ENABLE,
        .intr_type = GPIO_INTR_NEGEDGE,
    };
    ESP_ERROR_CHECK(gpio_config(&button));
    ESP_ERROR_CHECK(gpio_install_isr_service(0));
    ESP_ERROR_CHECK(gpio_isr_handler_add(BUTTON_GPIO, button_isr, NULL));

    // app_main() può terminare: i task creati continuano a girare.
}

Compila e programma come nell’articolo precedente, con idf.py set-target esp32c6 (o esp32s3) e idf.py -p PORT flash monitor. Attendi qualche secondo e premi il tasto BOOT: il monitor stampa il numero di campioni raccolti, i valori minimo, medio e massimo e lo spazio di stack mai utilizzato da report_task.

Le scelte del codice

  • Il produttore non aspetta. xQueueSend() con timeout 0 scarta il campione se la coda è piena e lo segnala nel log. La coda da 16 elementi copre 16 secondi di ritardo del consumatore, molto più del necessario.
  • Il periodo è stabile. xTaskDelayUntil() mantiene il campionamento a un secondo esatto, indipendentemente dal tempo di lettura del sensore.
  • Il mutex dura poco. report_task copia la struttura e la rilascia subito; il log, che è lento, avviene sulla copia. Tenere un mutex durante una stampa o una scrittura in flash blocca tutti gli altri task che ne hanno bisogno.
  • L’interrupt usa una notifica. Basta un contatore, non serve trasportare dati: la notifica è la scelta più leggera. ulTaskNotifyTake(pdTRUE, ...) azzera il contatore, quindi più pressioni ravvicinate producono un solo riepilogo.
  • Le creazioni sono controllate. configASSERT() interrompe l’avvio se manca la memoria per la coda o il mutex, invece di far fallire il primo utilizzo in un punto lontano.

Su ESP32-S3 puoi provare a legare process_task al secondo core sostituendo xTaskCreate() con xTaskCreatePinnedToCore(process_task, "process", 3072, NULL, 5, NULL, 1). Il comportamento non cambia, perché il codice è già protetto dal mutex: è proprio questo il vantaggio di sincronizzare correttamente i task.

Gli errori di concorrenza più comuni

Una variabile condivisa senza protezione

counter++ sembra un’operazione unica, ma il processore legge il valore, lo incrementa e lo riscrive. Se un altro task scrive nello stesso momento, uno dei due incrementi si perde. Su ESP32-S3 succede anche con due task a pari priorità che girano sui due core contemporaneamente, e volatile non risolve il problema: impedisce al compilatore di tenere il valore in un registro, ma non rende l’operazione indivisibile.

Per un contatore o un flag basta un tipo atomico di C11:

C
#include <stdatomic.h>

static atomic_uint_fast32_t events;

atomic_fetch_add(&events, 1);              // Da qualsiasi task
uint32_t total = atomic_load(&events);

Per strutture più grandi usa un mutex, come nell’esempio.

Sezioni critiche senza spinlock

Le sezioni critiche servono quando un dato è condiviso tra un interrupt e un task e non basta un tipo atomico. In ESP-IDF richiedono uno spinlock, che blocca anche l’altro core:

C
static portMUX_TYPE lock = portMUX_INITIALIZER_UNLOCKED;

portENTER_CRITICAL(&lock);
shared_value = new_value;
portEXIT_CRITICAL(&lock);

Dentro un interrupt si usano portENTER_CRITICAL_ISR() e portEXIT_CRITICAL_ISR(). Durante una sezione critica gli interrupt sono disabilitati sul core corrente: deve contenere poche istruzioni, mai una chiamata bloccante o un log.

Ritardi più brevi di un tick

Con il tick predefinito di 10 ms, pdMS_TO_TICKS(5) vale 0 e vTaskDelay(0) non aspetta affatto. Per ritardi di pochi microsecondi, come la temporizzazione di un sensore, usa esp_rom_delay_us(), che però tiene occupata la CPU. Se l’applicazione ha davvero bisogno di una risoluzione più fine, porta CONFIG_FREERTOS_HZ a 1000 in menuconfig, sapendo che lo scheduler interverrà dieci volte più spesso.

Deadlock tra due mutex

Se il task A prende il mutex del bus I2C e poi quello del display, mentre il task B li prende in ordine inverso, ciascuno può restare bloccato per sempre in attesa dell’altro. La regola è prendere i mutex sempre nello stesso ordine in tutto il codice. Durante lo sviluppo, un timeout finito al posto di portMAX_DELAY, con un errore nel log, rende visibile il problema invece di bloccare il dispositivo in silenzio.

Stack troppo piccolo

Un overflow dello stack sovrascrive la memoria di altri task e produce errori casuali difficili da riprodurre. ESP-IDF controlla per impostazione predefinita una zona di guardia alla fine dello stack e, al cambio di contesto, interrompe l’esecuzione con il messaggio A stack overflow in task ... has been detected. Le cause più comuni sono printf con valori in virgola mobile, buffer grandi dichiarati come variabili locali e ricorsione.

Durante lo sviluppo stampa il margine con uxTaskGetStackHighWaterMark(), che in ESP-IDF restituisce byte, nelle condizioni di lavoro più pesanti. Lascia almeno qualche centinaio di byte liberi; se il margine è di diversi kilobyte, puoi ridurre lo stack e recuperare RAM.

Task che non si bloccano mai

Un task che non chiama mai una funzione bloccante tiene la CPU per sé: i task a priorità inferiore e il task idle non girano più, e dopo 5 secondi il task watchdog lo segnala nel log con Task watchdog got triggered. La correzione non è aggiungere un vTaskDelay(1) qua e là, ma ripensare il task perché aspetti un evento: un dato in coda, una notifica dall’interrupt della periferica, un timer.

Vedere cosa succede nel sistema

Abilitando CONFIG_FREERTOS_USE_TRACE_FACILITY e CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS in menuconfig, vTaskList() scrive in un buffer una tabella con nome, stato, priorità, margine di stack e numero di ogni task. Con CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS, vTaskGetRunTimeStats() mostra la percentuale di CPU usata da ciascun task: se il task idle ha una percentuale alta, il sistema ha margine; se è vicina a zero, qualche task sta lavorando più del previsto.

Nel prossimo articolo della serie useremo questa struttura per gli aggiornamenti OTA: partizioni, rollback se il nuovo firmware non parte e firma dell’immagine.

Fonti