Da Arduino a ESP-IDF: guida pratica al passaggio

Da Arduino a ESP-IDF: guida pratica al passaggio
Indice

Il core Arduino per ESP32 è costruito sopra ESP-IDF, il framework ufficiale di Espressif. Quando programmi un ESP32 con l’IDE Arduino stai già usando ESP-IDF, ma in una versione precompilata, con una configurazione decisa da altri e un livello di astrazione in più. Passare a ESP-IDF significa togliere quel livello: controlli ogni opzione del sistema, usi tutte le periferiche, aggiorni il firmware in modo sicuro e costruisci un progetto pronto per la produzione.

In questa guida installiamo ESP-IDF, vediamo come è fatto un progetto, confrontiamo le API più usate e convertiamo uno sketch Arduino completo in un’applicazione ESP-IDF. I comandi e il codice si riferiscono a ESP-IDF v6.1 e valgono per ESP32-C6 ed ESP32-S3, i due chip confrontati nel primo articolo della serie.

Confronto a livelli tra Arduino ed ESP-IDF: lo sketch Arduino passa dal core Arduino-ESP32 e da un ESP-IDF precompilato, l’applicazione ESP-IDF usa direttamente i componenti e la configurazione
Arduino ed ESP-IDF condividono gli stessi componenti: cambia quanto puoi configurarli.

Quando conviene passare

Arduino resta una buona scelta per prototipi veloci e progetti piccoli. ESP-IDF diventa necessario quando ti serve almeno una di queste cose:

  • Configurazione del sistema: dimensione della flash e delle partizioni, frequenza della CPU, livelli di log, gestione dei consumi, opzioni dello stack Wi-Fi e Bluetooth. Con Arduino sono fissate al momento della compilazione del core.
  • Aggiornamenti OTA affidabili, con rollback se il nuovo firmware non parte.
  • Sicurezza: secure boot, cifratura della flash, partizione NVS cifrata.
  • Periferiche complete: le API Arduino coprono i casi comuni, i driver ESP-IDF espongono tutte le funzioni dell’hardware.
  • Multitasking esplicito con FreeRTOS, invece di un unico loop().
  • Build riproducibili: versioni del framework e delle librerie dichiarate nel progetto, compilabili da riga di comando e in integrazione continua.

Il passaggio non è un salto nel vuoto: i concetti sono gli stessi e le API hanno quasi sempre un equivalente diretto.

Installare ESP-IDF

Dalla versione 6.0, il metodo consigliato da Espressif è l’ESP-IDF Installation Manager (EIM), disponibile con interfaccia grafica o da riga di comando. Su macOS, con Homebrew, installa prima i prerequisiti e poi EIM:

Terminale
brew install libgcrypt glib pixman sdl2 libslirp dfu-util cmake python
brew tap espressif/eim
brew install eim
eim install

Su Ubuntu e Debian EIM è disponibile anche come pacchetto APT, mentre su Windows c’è un installer grafico: i passaggi sono nella guida ufficiale indicata in fondo.

eim install scarica l’ultima versione stabile di ESP-IDF con il compilatore e gli strumenti. Al termine stampa il comando per attivare l’ambiente in un terminale, del tipo:

Terminale
source "$HOME/.espressif/tools/activate_idf_v6.1.sh"

Usa il percorso stampato da EIM sul tuo computer. Da quel momento il comando idf.py è disponibile nel terminale. Se preferisci un editor, l’estensione ESP-IDF per VS Code e l’Espressif-IDE basato su Eclipse usano le installazioni create da EIM.

Due attenzioni: ESP-IDF non supporta spazi nel percorso del framework o del progetto, e richiede Python 3.10 o successivo.

Com’è fatto un progetto

Un progetto minimo ha questa struttura:

Testo
lampeggio-pulsante/
├── CMakeLists.txt          # dichiarazione del progetto
├── sdkconfig.defaults      # opzioni di configurazione iniziali
└── main/
    ├── CMakeLists.txt      # registrazione del componente main
    ├── Kconfig.projbuild   # opzioni dell'applicazione in menuconfig
    └── main.c              # codice, con la funzione app_main()

Il CMakeLists.txt principale contiene sempre le stesse righe. MINIMAL_BUILD compila solo i componenti effettivamente usati, riducendo i tempi:

CMake
cmake_minimum_required(VERSION 3.22)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
idf_build_set_property(MINIMAL_BUILD ON)
project(lampeggio_pulsante)

In ESP-IDF tutto è un componente: il tuo codice in main, i driver, FreeRTOS, lo stack Wi-Fi. Ogni componente dichiara i sorgenti e da quali altri componenti dipende:

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

Dichiarare le dipendenze con REQUIRES rende esplicito cosa usa il codice e, insieme a MINIMAL_BUILD, evita di compilare ciò che non serve.

Le API equivalenti

Arduino ESP-IDF
setup(), loop() app_main() e task FreeRTOS
delay(ms) vTaskDelay(pdMS_TO_TICKS(ms))
millis() esp_timer_get_time(), in microsecondi
Serial.printf() ESP_LOGI(TAG, ...)
pinMode(), digitalWrite() gpio_config(), gpio_set_level()
attachInterrupt() gpio_isr_handler_add()
analogRead() adc_oneshot_read()
analogWrite() driver LEDC: ledc_set_duty()
Wire (I2C) i2c_master_transmit()
Preferences NVS: nvs_get_u32(), nvs_set_u32()
WiFi.begin() esp_wifi_start() con esp_netif
Library Manager idf.py add-dependency

Ogni funzione appartiene a un componente da dichiarare in REQUIRES: esp_driver_gpio per i GPIO, esp_adc per l’ADC, esp_driver_ledc per il PWM, esp_driver_i2c per l’I2C, nvs_flash per la memoria non volatile, esp_wifi per il Wi-Fi. FreeRTOS e il log sono sempre disponibili.

Le differenze sono soprattutto di stile: in ESP-IDF ogni funzione restituisce un codice esp_err_t da controllare, le periferiche si configurano con una struttura e la temporizzazione passa da FreeRTOS.

Un esempio completo: dallo sketch al progetto

Convertiamo uno sketch tipico: un LED lampeggia, un pulsante mette in pausa il lampeggio tramite interrupt, e il numero di pressioni viene salvato in memoria non volatile, quindi sopravvive al riavvio.

Schema elettrico: GPIO10 collegato a una resistenza da 330 ohm e a un LED verso massa; GPIO9 collegato al pulsante BOOT verso massa con pull-up interno a 3,3 V
Il circuito: un LED esterno e il tasto BOOT già presente sulla scheda.

Lo sketch Arduino di partenza

Arduino
#include <Preferences.h>

const int LED_PIN = 10;
const int BUTTON_PIN = 9;           // Tasto BOOT su ESP32-C6 (GPIO0 su ESP32-S3)
const uint32_t BLINK_MS = 500;
const uint32_t DEBOUNCE_MS = 200;

Preferences prefs;
volatile bool buttonEvent = false;
bool blinking = true;
uint32_t presses = 0;

void IRAM_ATTR onButton() {
  buttonEvent = true;
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), onButton, FALLING);

  prefs.begin("app", false);
  presses = prefs.getUInt("presses", 0);
  Serial.printf("Avvio: %lu pressioni salvate\n", presses);
}

void loop() {
  static uint32_t lastBlink = 0, lastPress = 0;
  static bool ledOn = false;
  uint32_t now = millis();

  if (buttonEvent) {
    buttonEvent = false;
    if (now - lastPress > DEBOUNCE_MS) {
      lastPress = now;
      blinking = !blinking;
      prefs.putUInt("presses", ++presses);
      Serial.printf("Pulsante: lampeggio %s, totale %lu\n", blinking ? "attivo" : "fermo", presses);
    }
  }

  if (blinking && now - lastBlink >= BLINK_MS) {
    lastBlink = now;
    ledOn = !ledOn;
    digitalWrite(LED_PIN, ledOn);
  }
}

Le opzioni in menuconfig

Invece di costanti nel codice, in ESP-IDF i parametri dell’applicazione diventano opzioni di configurazione. Il file main/Kconfig.projbuild le aggiunge a menuconfig, con valori predefiniti diversi per ciascun chip:

Configurazione
menu "Applicazione"

    config APP_LED_GPIO
        int "GPIO del LED"
        default 10

    config APP_BUTTON_GPIO
        int "GPIO del pulsante"
        default 9 if IDF_TARGET_ESP32C6
        default 0
        help
            Pulsante verso massa. Il valore predefinito è il tasto BOOT della scheda.

    config APP_BLINK_MS
        int "Semiperiodo di lampeggio (ms)"
        range 50 5000
        default 500

endmenu

Nel codice diventano macro: CONFIG_APP_LED_GPIO, CONFIG_APP_BUTTON_GPIO, CONFIG_APP_BLINK_MS. Puoi cambiarle con idf.py menuconfig senza toccare i sorgenti.

L’applicazione ESP-IDF

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

#define LED_GPIO     CONFIG_APP_LED_GPIO
#define BUTTON_GPIO  CONFIG_APP_BUTTON_GPIO
#define BLINK_MS     CONFIG_APP_BLINK_MS
#define DEBOUNCE_US  (200 * 1000)

static const char *TAG = "app";
static QueueHandle_t button_queue;
static volatile bool blinking = true;

// L'interrupt fa il minimo indispensabile: segnala l'evento al task.
static void IRAM_ATTR button_isr(void *arg)
{
    uint8_t event = 1;
    xQueueSendFromISR(button_queue, &event, NULL);
}

static void button_task(void *arg)
{
    nvs_handle_t nvs;
    ESP_ERROR_CHECK(nvs_open("app", NVS_READWRITE, &nvs));

    uint32_t presses = 0;
    esp_err_t err = nvs_get_u32(nvs, "presses", &presses);
    if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) {
        ESP_LOGE(TAG, "Lettura NVS non riuscita: %s", esp_err_to_name(err));
    }
    ESP_LOGI(TAG, "Avvio: %" PRIu32 " pressioni salvate", presses);

    int64_t last_press = 0;
    uint8_t event;
    while (true) {
        xQueueReceive(button_queue, &event, portMAX_DELAY);
        int64_t now = esp_timer_get_time();
        if (now - last_press < DEBOUNCE_US) {
            continue;  // Rimbalzo del contatto: ignora
        }
        last_press = now;
        blinking = !blinking;
        presses++;
        ESP_ERROR_CHECK(nvs_set_u32(nvs, "presses", presses));
        ESP_ERROR_CHECK(nvs_commit(nvs));
        ESP_LOGI(TAG, "Pulsante: lampeggio %s, totale %" PRIu32,
                 blinking ? "attivo" : "fermo", presses);
    }
}

void app_main(void)
{
    // La partizione NVS va inizializzata prima dell'uso.
    esp_err_t err = nvs_flash_init();
    if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        err = nvs_flash_init();
    }
    ESP_ERROR_CHECK(err);

    const gpio_config_t led = {
        .pin_bit_mask = 1ULL << LED_GPIO,
        .mode = GPIO_MODE_OUTPUT,
    };
    ESP_ERROR_CHECK(gpio_config(&led));

    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));

    button_queue = xQueueCreate(8, sizeof(uint8_t));
    xTaskCreate(button_task, "button", 3072, NULL, 5, NULL);

    ESP_ERROR_CHECK(gpio_install_isr_service(0));
    ESP_ERROR_CHECK(gpio_isr_handler_add(BUTTON_GPIO, button_isr, NULL));

    bool led_on = false;
    while (true) {
        if (blinking) {
            led_on = !led_on;
            gpio_set_level(LED_GPIO, led_on);
        }
        vTaskDelay(pdMS_TO_TICKS(BLINK_MS));
    }
}

Cosa è cambiato

  • Il loop() è diventato due task. app_main() gestisce il lampeggio e poi resta in esecuzione come task; button_task aspetta gli eventi del pulsante. Nessuno dei due controlla il tempo in continuazione: vTaskDelay() e xQueueReceive() sospendono il task e lasciano la CPU libera, anche per il risparmio energetico.
  • L’interrupt non modifica lo stato. Invia un evento su una coda e il task fa il lavoro, incluse le scritture in flash, che non sono ammesse dentro un interrupt.
  • Gli errori sono espliciti. ESP_ERROR_CHECK() interrompe l’esecuzione con un messaggio chiaro se una chiamata fallisce, invece di proseguire in uno stato incoerente.
  • I tipi sono precisi. PRIu32 stampa un uint32_t in modo portabile. Dalla versione 6.0 ESP-IDF tratta gli avvisi del compilatore come errori: un formato sbagliato in printf blocca la build.

Compilare, programmare e leggere i log

Dalla cartella del progetto, con l’ambiente attivo:

Terminale
idf.py set-target esp32c6
idf.py menuconfig
idf.py build
idf.py -p PORT flash monitor

set-target va eseguito una volta, o quando cambi chip: reinizializza la configurazione. Sostituisci PORT con la porta della scheda, per esempio /dev/cu.usbmodem1101 su macOS o /dev/ttyACM0 su Linux; se la ometti, idf.py prova a trovarla da solo. Per uscire dal monitor premi Ctrl+].

Flusso di build di ESP-IDF: codice e configurazione entrano in idf.py build, che produce bootloader, tabella delle partizioni e applicazione scritti in flash agli indirizzi 0x0, 0x8000 e 0x10000
La build produce tre binari: bootloader, tabella delle partizioni e applicazione.

A differenza di Arduino, che carica un unico file, idf.py flash scrive tre binari: il bootloader, la tabella delle partizioni e l’applicazione. Sono separati perché puoi modificarli in modo indipendente, per esempio aggiungendo una partizione per i dati o una seconda partizione applicativa per gli aggiornamenti OTA.

Il monitor decodifica automaticamente gli errori: se il firmware va in crash, mostra la funzione e la riga del sorgente invece dei soli indirizzi di memoria.

Le librerie: il Component Registry

Al posto del Library Manager, ESP-IDF usa il Component Registry di Espressif. Per esempio, per pilotare il LED RGB indirizzabile presente su molte schede di sviluppo:

Terminale
idf.py add-dependency "espressif/led_strip^3"

Il comando crea o aggiorna main/idf_component.yml con il nome e la versione richiesta. Alla build successiva il componente viene scaricato in managed_components/ e la versione esatta viene registrata in dependencies.lock. Inserisci nel controllo versione idf_component.yml e dependencies.lock, non managed_components/: chiunque compili il progetto otterrà le stesse versioni.

Se un progetto dipende da librerie Arduino che non vuoi riscrivere, puoi anche usare il core Arduino come componente di ESP-IDF. È una soluzione di transizione utile, ma lega il progetto alle versioni di ESP-IDF supportate dal core Arduino.

Errori comuni nei primi progetti

  • Task watchdog. Un ciclo che non chiama mai vTaskDelay() o una funzione bloccante impedisce agli altri task di girare, e il watchdog riavvia il chip con il messaggio Task watchdog got triggered.
  • Stack troppo piccolo. In ESP-IDF la dimensione dello stack in xTaskCreate() è espressa in byte. Se un task va in overflow, aumentala e controlla il margine con uxTaskGetStackHighWaterMark().
  • Log dentro un interrupt. ESP_LOGI() e printf() non vanno chiamate in un interrupt: invia un evento a un task, come nell’esempio.
  • Configurazione persa. idf.py set-target rigenera sdkconfig. Le impostazioni che devono sopravvivere vanno in sdkconfig.defaults (o sdkconfig.defaults.esp32c6 per un solo chip), da tenere nel controllo versione.
  • Percorsi con spazi. Progetti in cartelle come Documenti/Progetti ESP non compilano: usa percorsi senza spazi.

Nel prossimo articolo della serie entriamo in FreeRTOS: task, code, semafori e priorità, con gli errori di concorrenza più frequenti e come evitarli.

Fonti