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.

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:
brew install libgcrypt glib pixman sdl2 libslirp dfu-util cmake python
brew tap espressif/eim
brew install eim
eim installSu 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:
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:
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_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:
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.

Lo sketch Arduino di partenza
#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:
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
endmenuNel 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
#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_taskaspetta gli eventi del pulsante. Nessuno dei due controlla il tempo in continuazione:vTaskDelay()exQueueReceive()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.
PRIu32stampa unuint32_tin modo portabile. Dalla versione 6.0 ESP-IDF tratta gli avvisi del compilatore come errori: un formato sbagliato inprintfblocca la build.
Compilare, programmare e leggere i log
Dalla cartella del progetto, con l’ambiente attivo:
idf.py set-target esp32c6
idf.py menuconfig
idf.py build
idf.py -p PORT flash monitorset-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+].

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:
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 messaggioTask 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 conuxTaskGetStackHighWaterMark(). - Log dentro un interrupt.
ESP_LOGI()eprintf()non vanno chiamate in un interrupt: invia un evento a un task, come nell’esempio. - Configurazione persa.
idf.py set-targetrigenerasdkconfig. Le impostazioni che devono sopravvivere vanno insdkconfig.defaults(osdkconfig.defaults.esp32c6per un solo chip), da tenere nel controllo versione. - Percorsi con spazi. Progetti in cartelle come
Documenti/Progetti ESPnon 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
- ESP-IDF Programming Guide v6.1: Get Started, Espressif Systems
- Installazione su macOS con EIM, Espressif Systems
- Build System, Espressif Systems
- Migrazione da ESP-IDF 5.5 a 6.0: Build System, Espressif Systems
- Esempi ESP-IDF: blink e generic_gpio, Espressif Systems
- Copertina: die di un ESP32-S3, foto di John McMaster, licenza CC BY 4.0, ritagliata e composta con il titolo.