Allenamento per l’esame di maturità
Percorso di laboratorio con Arduino per studenti di quinta ITIS
Obiettivo didattico
Organizzare il programma come scheduler cooperativo con tre task indipendenti: acquisizione analogica, lampeggio di stato e trasmissione seriale periodica. L’attività mostra come il loop() possa diventare un piccolo supervisore software.
Materiali suggeriti
- Arduino UNO R3 o UNO R4;
- 1 potenziometro;
- 2 LED;
- 2 resitori (per i LED);
- breadboard;
- cavetti jumper.
Schema di collegamento
Richiamo teorico
In un sistema embedded semplice non si usa un vero sistema operativo, ma si può costruire uno scheduler cooperativo con millis(). Ogni task possiede il proprio intervallo e il proprio istante dell’ultima esecuzione. Il loop() controlla se ciascun task è pronto e lo richiama.
Schema logico dell’attività
Il programma inizializza i timer dei tre task. Nel loop legge il tempo corrente e verifica uno dopo l’altro se è il momento di eseguire il task di acquisizione, quello di segnalazione e quello di stampa seriale. Se il tempo non è scaduto, passa al controllo successivo.
Diagramma di flusso
Diagramma di flusso Mermaid
flowchart TD
A[Inizio] --> B[Configura pin, seriale e timer]
B --> C[Leggi tempo attuale]
C --> D{Task acquisizione pronto?}
D -- Sì --> E[Esegui acquisizione]
D -- No --> F{Task LED pronto?}
E --> F
F -- Sì --> G[Commuta LED di stato]
F -- No --> H{Task seriale pronto?}
G --> H
H -- Sì --> I[Invia dati in seriale]
H -- No --> J[Ritorna al loop]
I --> J
J --> C
Programma
/*
Prof. Maffucci Michele
Esercizio 3: scheduler cooperativo con tre task indipendenti
*/
const int PIN_SENSORE = A0;
const int PIN_LED_STATO = 8;
const int PIN_LED_SOGLIA = 9;
// ---------------------------
// Intervalli dei tre task
// ---------------------------
const unsigned long PERIODO_ACQUISIZIONE = 50;
const unsigned long PERIODO_LED = 300;
const unsigned long PERIODO_SERIALE = 500;
// ---------------------------
// Istanti ultima esecuzione
// ---------------------------
unsigned long ultimoTaskAcquisizione = 0;
unsigned long ultimoTaskLed = 0;
unsigned long ultimoTaskSeriale = 0;
// ---------------------------
// Variabili condivise
// ---------------------------
int valoreGrezzo = 0;
float tensione = 0.0;
bool statoLed = false;
void setup() {
pinMode(PIN_LED_STATO, OUTPUT);
pinMode(PIN_LED_SOGLIA, OUTPUT);
Serial.begin(9600);
}
void loop() {
// Il loop assume il ruolo di piccolo scheduler.
unsigned long adesso = millis();
// ---------------------------
// Task 1: acquisizione
// ---------------------------
if ((adesso - ultimoTaskAcquisizione) >= PERIODO_ACQUISIZIONE) {
ultimoTaskAcquisizione = adesso;
taskAcquisizione();
}
// ---------------------------
// Task 2: LED di stato
// ---------------------------
if ((adesso - ultimoTaskLed) >= PERIODO_LED) {
ultimoTaskLed = adesso;
taskLed();
}
// ---------------------------
// Task 3: seriale
// ---------------------------
if ((adesso - ultimoTaskSeriale) >= PERIODO_SERIALE) {
ultimoTaskSeriale = adesso;
taskSeriale();
}
}
// ----------------------------------------------------------
// Legge il sensore e calcola una tensione equivalente.
// ----------------------------------------------------------
void taskAcquisizione() {
valoreGrezzo = analogRead(PIN_SENSORE);
// Conversione esplicita ADC -> tensione.
tensione = (valoreGrezzo * 5.0) / 1023.0;
// Uso della misura per attivare un LED di soglia.
if (tensione >= 2.50) {
digitalWrite(PIN_LED_SOGLIA, HIGH);
} else {
digitalWrite(PIN_LED_SOGLIA, LOW);
}
}
// ----------------------------------------------------------
// Task di segnalazione periodica.
// ----------------------------------------------------------
void taskLed() {
if (statoLed == false) {
statoLed = true;
digitalWrite(PIN_LED_STATO, HIGH);
} else {
statoLed = false;
digitalWrite(PIN_LED_STATO, LOW);
}
}
// ----------------------------------------------------------
// Task di comunicazione seriale.
// ----------------------------------------------------------
void taskSeriale() {
Serial.print("ADC = ");
Serial.print(valoreGrezzo);
Serial.print(" Tensione = ");
Serial.print(tensione, 2);
Serial.println(" V");
}
Come funziona il codice
Nel nostro caso i task sono tre:
- acquisizione analogica del potenziometro;
- lampeggio del LED di stato;
- trasmissione seriale delle misure.
L’aspetto fondamentale è che questi tre compiti sono indipendenti: ognuno ha il proprio periodo di esecuzione e viene richiamato solo quando è il momento giusto.
Struttura generale del programma
Il programma è costruito attorno a quattro elementi fondamentali:
- i pin utilizzati;
- i periodi dei task;
- gli istanti dell’ultima esecuzione;
- le variabili condivise tra i task.
Il cuore del sistema è questo:
void loop() {
unsigned long adesso = millis();
if ((adesso - ultimoTaskAcquisizione) >= PERIODO_ACQUISIZIONE) {
ultimoTaskAcquisizione = adesso;
taskAcquisizione();
}
if ((adesso - ultimoTaskLed) >= PERIODO_LED) {
ultimoTaskLed = adesso;
taskLed();
}
if ((adesso - ultimoTaskSeriale) >= PERIODO_SERIALE) {
ultimoTaskSeriale = adesso;
taskSeriale();
}
}
Qui il loop() controlla continuamente il tempo attuale e verifica, uno dopo l’altro, se ciascun task deve essere eseguito.
01. Definizione dei pin
All’inizio troviamo:
const int PIN_SENSORE = A0; const int PIN_LED_STATO = 8; const int PIN_LED_SOGLIA = 9;
Queste costanti associano un nome significativo ai pin fisici.
PIN_SENSORE = A0indica il pin analogico a cui è collegato il potenziometro.PIN_LED_STATO = 8è il LED che lampeggia periodicamente per mostrare che il programma è attivo.PIN_LED_SOGLIA = 9è il LED che segnala il superamento di una soglia di tensione.
e come già sappiamo, usare nomi simbolici rende il codice più leggibile e più semplice da modificare.
02. Periodi dei task
Subito dopo vengono dichiarati gli intervalli temporali:
const unsigned long PERIODO_ACQUISIZIONE = 50; const unsigned long PERIODO_LED = 300; const unsigned long PERIODO_SERIALE = 500;
Questi valori sono espressi in millisecondi.
Significa che:
- il task di acquisizione viene eseguito ogni 50 ms;
- il task del LED di stato ogni 300 ms;
- il task seriale ogni 500 ms.
Questa scelta fa capire bene il principio dello scheduler: non tutti i task devono lavorare alla stessa velocità.
L’acquisizione analogica, ad esempio, deve essere abbastanza frequente; la stampa seriale può invece essere più lenta.
03. Istanti dell’ultima esecuzione
Il codice definisce poi tre variabili molto importanti:
unsigned long ultimoTaskAcquisizione = 0; unsigned long ultimoTaskLed = 0; unsigned long ultimoTaskSeriale = 0;
Ognuna di queste memorizza il tempo dell’ultima esecuzione del relativo task.
Per esempio:
ultimoTaskAcquisizionecontiene il valore dimillis()relativo all’ultima lettura del potenziometro;ultimoTaskLedricorda quando è stato commutato l’ultima volta il LED di stato;ultimoTaskSerialericorda quando sono stati inviati i dati sulla seriale.
L’idea è semplice: per sapere se un task è pronto, non confrontiamo un orario assoluto futuro, ma controlliamo quanto tempo è passato dall’ultima esecuzione.
04. Variabili condivise
Troviamo poi:
int valoreGrezzo = 0; float tensione = 0.0; bool statoLed = false;
Queste sono variabili condivise tra più task.
valoreGrezzocontiene il valore numerico letto dall’ADC.tensionecontiene la corrispondente tensione calcolata.statoLedmemorizza lo stato logico del LED di stato, utile per invertirlo a ogni esecuzione del task.
Il fatto che siano globali è coerente con la struttura dell’esercizio: il task di acquisizione aggiorna i dati, mentre il task seriale li legge e li stampa.
05. La funzione setup()
La funzione di inizializzazione è questa:
void setup() {
pinMode(PIN_LED_STATO, OUTPUT);
pinMode(PIN_LED_SOGLIA, OUTPUT);
Serial.begin(9600);
}
Qui il programma svolge tre operazioni:
- imposta il pin del LED di stato come uscita;
- imposta il pin del LED di soglia come uscita;
- avvia la comunicazione seriale a 9600 baud.
Come già sappiamo non è necessario configurare A0 come ingresso, perché i pin analogici vengono letti direttamente con analogRead().
06. Il ruolo del loop(): da ciclo principale a scheduler
Questa è la parte più importante dell’intero esercizio.
void loop() {
unsigned long adesso = millis();
La funzione millis() restituisce il numero di millisecondi trascorsi dall’avvio della scheda.
Il valore viene salvato nella variabile adesso, che rappresenta il riferimento temporale corrente.
A questo punto, il loop() controlla ciascun task separatamente.
Task 1: acquisizione
if ((adesso - ultimoTaskAcquisizione) >= PERIODO_ACQUISIZIONE) {
ultimoTaskAcquisizione = adesso;
taskAcquisizione();
}
significa:
“il tempo passato dall’ultima esecuzione è almeno pari al periodo richiesto?”
Se la risposta è sì:
- si aggiorna
ultimoTaskAcquisizione; - si esegue
taskAcquisizione().
Task 2: LED di stato
if ((adesso - ultimoTaskLed) >= PERIODO_LED) {
ultimoTaskLed = adesso;
taskLed();
}
Il principio è identico, ma il periodo è di 300 ms.
Task 3: seriale
if ((adesso - ultimoTaskSeriale) >= PERIODO_SERIALE) {
ultimoTaskSeriale = adesso;
taskSeriale();
}
Anche qui la logica è la stessa, ma il task seriale si attiva ogni 500 ms.
07. Perché si usa la sottrazione e non un confronto diretto
La forma:
(adesso - ultimoTaskX) >= periodo
è molto importante.
Non si tratta solo di una scelta stilistica: è il modo corretto per gestire il tempo con millis() anche quando, dopo molto tempo, il contatore va in overflow e ricomincia da zero.
Questa tecnica rende il codice robusto e affidabile.
È un dettaglio molto utile, perché mostra una buona pratica reale di programmazione embedded.
08. Task di acquisizione
La funzione è:
void taskAcquisizione() {
valoreGrezzo = analogRead(PIN_SENSORE);
tensione = (valoreGrezzo * 5.0) / 1023.0;
if (tensione >= 2.50) {
digitalWrite(PIN_LED_SOGLIA, HIGH);
} else {
digitalWrite(PIN_LED_SOGLIA, LOW);
}
}
Vediamola passo passo.
a) Lettura analogica
valoreGrezzo = analogRead(PIN_SENSORE);
Arduino legge il valore presente sul pin A0.
Con risoluzione a 10 bit, il valore ottenuto è compreso tra:
- 0 > tensione circa 0 V
- 1023 > tensione circa 5 V
b) Conversione in tensione
tensione = (valoreGrezzo * 5.0) / 1023.0;
Questa formula trasforma il valore grezzo dell’ADC in una tensione equivalente.
Il fattore 5.0 rappresenta la tensione di riferimento assunta dal codice.
Il valore 1023.0 corrisponde al massimo valore ottenibile con un ADC a 10 bit.
Usando 5.0 e 1023.0 come numeri con parte decimale, il calcolo viene eseguito in virgola mobile, quindi il risultato finisce correttamente nella variabile float tensione.
c) Controllo della soglia
if (tensione >= 2.50) {
digitalWrite(PIN_LED_SOGLIA, HIGH);
} else {
digitalWrite(PIN_LED_SOGLIA, LOW);
}
Il LED di soglia si accende quando la tensione è almeno pari a 2,50 V.
In questo modo la misura analogica non rimane solo un dato numerico, ma viene trasformata in un’azione visibile.
Secondo me didatticamente risulta molto utile, perché collega acquisizione, elaborazione e uscita digitale.
09. Task del LED di stato
La funzione è:
void taskLed() {
if (statoLed == false) {
statoLed = true;
digitalWrite(PIN_LED_STATO, HIGH);
} else {
statoLed = false;
digitalWrite(PIN_LED_STATO, LOW);
}
}
Questo task ha lo scopo di far lampeggiare periodicamente il LED di stato.
La variabile statoLed memorizza l’ultimo stato del LED:
- se era
false, viene portata a true e il LED si accende; - se era
true, viene riportata a false e il LED si spegne.
Quindi il task non si limita a “scrivere un valore”, ma commuta lo stato a ogni attivazione.
In pratica, ogni 300 ms il LED cambia stato.
Questo produce un lampeggio regolare che segnala che il sistema è in esecuzione.
Si potrebbe scrivere anche con:
statoLed = !statoLed; digitalWrite(PIN_LED_STATO, statoLed);
ma ritengo che la versione proposta è più esplicita e adatta a chi è alle prime esperienze.
10. Task seriale
La funzione è:
void taskSeriale() {
Serial.print("ADC = ");
Serial.print(valoreGrezzo);
Serial.print(" Tensione = ");
Serial.print(tensione, 2);
Serial.println(" V");
}
Questo task invia periodicamente i dati al monitor seriale.
Vediamo i punti chiave:
Serial.print("ADC = ");stampa un testo fisso;Serial.print(valoreGrezzo);stampa il valore grezzo letto dall’ADC;Serial.print(" Tensione = ");aggiunge un’etichetta descrittiva;Serial.print(tensione, 2);stampa la tensione con due cifre decimali;Serial.println(" V");completa la riga con l’unità di misura e va a capo.
Il task seriale non effettua una nuova lettura: usa i dati già aggiornati da taskAcquisizione().
Questo è un punto concettualmente importante: i task possono avere ruoli diversi.
- uno acquisisce;
- uno segnala;
- uno comunica.
11. Perché questo programma è “cooperativo”
Il termine cooperativo significa che i task non vengono interrotti da un sistema operativo, ma collaborano tra loro rispettando alcune regole implicite:
- ciascun task deve essere breve;
- nessun task deve bloccare il programma;
- il
loop()deve poter tornare rapidamente ai controlli temporali.
Se dentro uno dei task inserissimo un delay(2000), per esempio, tutto il sistema resterebbe fermo per 2 secondi e gli altri task perderebbero la loro regolarità.
Quindi la cooperazione dipende dal fatto che ogni funzione svolga il proprio compito rapidamente e restituisca il controllo al loop().
12. In che senso c’è “supervisione dei tempi”
La supervisione dei tempi, in questo esercizio, è affidata interamente al loop().
Il ciclo principale:
- legge il tempo corrente;
- confronta il tempo trascorso per ogni task;
- decide chi deve essere eseguito;
- rimanda gli altri controlli al giro successivo.
Il loop() quindi si comporta come un piccolo supervisore software.
Non esegue direttamente tutta la logica applicativa, ma coordina l’attivazione dei diversi moduli.
È una struttura molto utile perché introduce a una forma elementare di progettazione concorrente, pur restando dentro un programma semplice.
13. Esempio pratico di funzionamento nel tempo
Immaginiamo l’avvio del programma.
All’inizio tutti i valori ultimoTask... valgono 0.
Quando millis() raggiunge:
- circa 50 ms, parte il task di acquisizione;
- circa 300 ms, parte anche il task LED;
- circa 500 ms, parte anche il task seriale.
Da quel momento:
- l’acquisizione si ripete ogni 50 ms;
- il LED viene commutato ogni 300 ms;
- la seriale stampa ogni 500 ms.
Questo significa che i task si possono anche “incontrare” nello stesso giro di loop(): se più condizioni risultano vere nello stesso istante, vengono eseguiti uno dopo l’altro, in ordine di scrittura nel codice.
14. Nota tecnica utile
Nel codice la conversione da valore ADC a tensione usa la formula (valoreGrezzo * 5.0) / 1023.0, che assume una risoluzione a 10 bit e un riferimento di 5 V. Questa ipotesi è perfettamente coerente con Arduino UNO R3 e resta valida anche su UNO R4 se si lavora con la risoluzione standard compatibile. Se si modificasse la risoluzione dell’ADC o il riferimento analogico, la formula andrebbe aggiornata.
Esercizio aggiuntivo da svolgere in autonomia

Questo esercizio è basato sull’attività proposta nella lezione, ma richiede l’aggiunta di nuove funzionalità da sviluppare in autonomia. La soluzione verrà pubblicata in un post successivo, così da favorire il lavoro personale di analisi, progettazione e sperimentazione.
Consegna per lo studente
Aggiungi allo scheduler cooperativo spiegato nell’attività precedente le seguenti funzionalità:
- un quarto task di heartbeat;
- la possibilità di abilitare o disabilitare i task da seriale;
- un watchdog software che segnala se un task viene eseguito con un ritardo eccessivo.
Comandi seriali suggeriti: ON1, OFF1, ON2, OFF2, ALLON, ALLOFF.
Di seguito la sequenza logica utile per la realizzazione del diagramma di flusso:
- inizializzazione dei task con intervallo, abilitazione e tolleranza di ritardo;
- lettura dei comandi seriali che modificano lo stato dei task;
- controllo del tempo corrente e verifica di scadenza per ogni task;
- esecuzione del task se abilitato e aggiornamento del suo istante di attivazione
- segnalazione watchdog se il task è partito con un ritardo superiore alla tolleranza.
Buon Coding a tutti


