Realizza una comunicazione I2C tra prototipi Arduino

In questo tutorial vedremo come realizzare un semplice sistema master/slave, utilizzando due schede Arduino.



Il circuito completo del prototipo mostrato nel video è riportato nella figura.

Lo sketch per implementare il dispositivo slave è riportato nella finestra che segue.

// Carico la libreria Wire
#include <Wire.h>

// Definizione MACRO per il PIN dei led colorati
#define ROSSO 8
#define GIALLO 9
#define VERDE 10

void interpretaComando(int numeroByte) {
  if (Wire.available()) { 
    char comando = Wire.read(); 
    if (comando == 'r') {
      digitalWrite(ROSSO,HIGH);
      digitalWrite(GIALLO,LOW);
      digitalWrite(VERDE,LOW);
    }
   if (comando == 'g') {
      digitalWrite(ROSSO,LOW);
      digitalWrite(GIALLO,HIGH);
      digitalWrite(VERDE,LOW);
    } 
   if (comando == 'v') {
      digitalWrite(ROSSO,LOW);
      digitalWrite(GIALLO,LOW);
      digitalWrite(VERDE,HIGH);
    } 
  }
}

void setup() {
  // configurazione in modalità output per i PIN dei led colorati
  pinMode(ROSSO, OUTPUT);
  pinMode(GIALLO, OUTPUT);
  pinMode(VERDE, OUTPUT);
  
  // inizializzazione della libreria Wire e assegnazione dell'indirizzo I2C
  Wire.begin(42);

  // Impostazione della funzione 'handler'
  Wire.onReceive(interpretaComando);
  
  // Reset dei led
  digitalWrite(ROSSO,LOW);
  digitalWrite(GIALLO, LOW);
  digitalWrite(VERDE, LOW);
}

void loop() {
  // Il loop può rimanere vuoto
}

Il programma è abbastanza intuitivo e non è difficile comprenderne il funzionamento.

Puoi utilizzare gli sketch presentati in questo corso basandoti su una comprensione intuitiva e potresti adattarli alle tue esigenze, anche senza approfondire il significato di ogni singola linea di codice.

Se però preferisci analizzare in dettaglio il codice, di seguito riportiamo una spiegazione dettagliata dello sketch e con l’occasione forniremo qualche nozione più tecnica di programmazione.

Partiamo dalla prima linea del programma.

// Carico la libreria Wire
#include <Wire.h>

Questa istruzione inizia con il carattere ‘#’.

Questa notazione indica che non si tratta di vere istruzioni del linguaggio che saranno quindi tradotte ed eseguite dal microcontrollore di Arduino, ma di istruzioni, chiamate direttive, che saranno eseguite direttamente dal compilatore prima di effettuare il suo lavoro di traduttore in linguaggio macchina.

La direttiva #include contenuta in questa riga, chiede al compilatore Arduino di trovare il file Wire.h e di inserirlo esattamente in questa posizione all’interno del tuo programma, rendendo tutte le istruzioni contenute nel file, parte del tuo programma.

I file con estensione .h, detti file header (intestazione) sono utilizzati dagli sviluppatori di librerie in linguaggio C per raggruppare tutte le definizioni di tipi di dato, variabili e funzioni che compongono la libreria, in modo che il tuo programma possa richiamare le funzioni definite nella libreria.

Se dimentichi di inserire in testa al tuo sketch la direttiva include del file di header della libreria, otterrai un errore di compilazione nel momento in cui nel tuo codice compare una funzione di quella libreria.

Nel caso specifico, la libreria Wire e il suo file di header sono sicuramente presenti sul tuo PC in quanto questa libreria è parte dell’installazione standard dell’IDE Arduino.

// Definizione MACRO per il PIN dei led colorati
#define ROSSO 8
#define GIALLO 9
#define VERDE 10

Le tre righe successive, contengono un’altra direttiva al compilatore Arduino, molto utilizzata: la direttiva #define.

La direttiva #define permette di creare degli ‘alias’, chiamati MACRO, che saranno automaticamente sostituiti nel codice, ovunque compaiano, con il valore indicato nella riga di definizione.

In altre parole, a seguito della definizione delle tre MACRO chiamate ROSSO, GIALLO e VERDE, il compilatore Arduino, prima di procedere alla effettiva compilazione dello sketch, sostituirà tutte le occorrenze della parola ROSSO con il corrispondente valore 8, sostituirà tutte le occorrenze di GIALLO con il valore 9 e tutte le occorrenze di VERDE con il valore 10.

L’utilizzo della definizione di MACRO attraverso la direttiva #define consente di parametrizzare il codice.

Se in seguito vorremo modificare l’assegnazione del numero di PIN su cui collegare i tre LED, sarà sufficiente modificare la definizione delle MACRO senza cercare tali numeri in tutto il codice.

In un precedente tutorial hai visto che era stata utilizzata una variabile dichiarata ‘const’ per fare la stessa cosa.

Ai fini pratici i due metodi sono alternativi, per cui avremmo anche potuto parametrizzare il collegamento dei tre LED in questo modo:

const int rosso = 8;
const int giallo = 9;
const int verde = 10;

e poi utilizzare le tre variabili rosso, giallo e verde nel resto del codice.

Cosa cambia tra i due metodi?

Nel caso delle macro, è il compilatore che modifica il file sorgente prima di compilarlo e poi compila il codice modificato.

Ad esempio la successiva istruzione pinMode(ROSSO,OUTPUT); verrenne prima modificata in pinMode(8,OUTPUT); e poi tradotta.

Nel caso delle variabili di tipo costante, il compilatore compilerebbe subito il codice e poi sarebbe il microcontrollore, durante l’esecuzione del codice, ad assegnare il valore ad una locazione di memoria opportuna che verrebbe utilizzata ogni volta che la variabile viene referenziata in una istruzione.

L’uso delle macro potrebbe quindi essere considerato migliore poiché non causa la generazione di istruzioni in più nel codice in linguaggio macchina.

In realtà il compilatore è molto efficiente e probabilmente ottimizzerebbe comunque il codice generato qualunque sia il metodo scelto dal programmatore.

Il vero motivo per cui abbiamo utilizzato una volta il metodo della definizione di variabili const e questa volta abbiamo definito delle MACRO è che in questo modo abbiamo presentato entrambe le diverse possibili varianti.

Come noterai in tutti gli sketch in questo corso, i nomi delle MACRO sono sempre scritti a lettere maiuscole. Questa non è una regola del linguaggio, infatti potresti anche definire il nome di una macro con lettere minuscole, ma una convenzione usata dai programmatori C/C++.

Quando si incontra in un programma C/C++ un identificatore scritto con lettere tutte maiuscole, al 99,99% si tratta di una MACRO.

Le librerie standard di Arduino definiscono molte MACRO che fanno parte del suo linguaggio semplificato. Gli identificatori INPUT, OUTPUT, LOW e HIGH che hai già incontrato, sono in realtà delle MACRO predefinite da Arduino.

Queste MACRO vengono automaticamente sostituite con i valori numerici corrispondenti prima di effettuare la compilazione. Ad esempio, dovresti aver già intuito che LOW è in realtà il valore 0 e HIGH è il valore 1.

Proseguiamo nell’analisi del codice.

void interpretaComando(int numeroByte) {

Prima di definire le due funzioni obbligatorie di Arduino setup e loop, abbiamo definito una nostra funzione chiamata interpretaComando.

Una funzione è una sorta di sotto-programma a cui viene dato un nome, a cui possono essere passati dei parametri e che può restituire un valore.

Attraverso la definizione di funzioni e possibile raggruppare segmenti di codice che possono essere richiamati in più punti all’interno di altre funzioni. Gli sviluppatori software utilizzano le funzioni per rendere più modulare il programma, per favorire il riutilizzo del software e per eliminare qualunque forma di ridondanza.

Nel nostro caso, tuttavia, la definizione della funzione che abbiamo scelto di denominare interpretaComando è stata una necessità dettata dal particolare metodo di funzionamento della modalità slave prevista dalla libreria Wire.

Quando il microcontrollore di Arduino viene configurato per gestire il protocollo I2C, i due PIN etichettati A4 e A5 vengono internamente connessi ad un dispositivo hardware, integrato nel chip del microcontrollore, che gestisce tutti gli aspetti di basso livello del protocollo di comunicazione.

Questo vuol dire che non dobbiamo preoccuparci di come leggere e gestire i segnali elettrici che sono presenti su questi PIN, collegati al BUS I2C.

Il microcontrollore si preoccuperà di gestire a livello hardware il protocollo. Se il microcontrollore è stato configurato in modalità slave, il suo hardware interno rileverà tutti i segnali presenti sulla linea SDA e li interpreterà. Qualunque messaggio indirizzato ad altri dispositivi verrà automaticamente ignorato.

Nel momento in cui, sulla linea SDA, perviene un messaggio destinato al proprio indirizzo, il microcontrollore conserverà i dati ricevuti in un’area di memoria interna, chiamata buffer di comunicazione I2C, e interromperà l’esecuzione del programma, che nel frattempo era in esecuzione sulla sua CPU, e invocherà una opportuna funzione passandole un numero intero che rappresenta il numero di byte ricevuti e presenti nel buffer.

Questo schema di azione è tipico delle comunicazioni elettroniche.

Una versione di sistema slave molto semplice potrebbe essere quella di un programma che è fermo in attesa di ricevere un messaggio. Quando riceve un messaggio esegue le elaborazioni relative e poi ritorna in attesa di un nuovo messaggio.

Nei sistemi più complessi, questo schema non è molto efficiente, poiché il sistema sarebbe fermo e in attesa per la maggior parte del suo tempo.

Per questo motivo è stato inventato il meccanismo degli interrupt. Secondo questo schema, il dispositivo è libero di eseguire un programma e di svolgere altre attività ed operazioni. Ad attendere l’eventuale arrivo di un messaggio ci pensa un componente hardware apposito e separato dalla CPU del microcontrollore. Qualora pervenga un messaggio, l’esecuzione del programma ordinario viene interrotta, viene richiamato il programma che gestisce il messaggio e al suo termine si riprende l’esecuzione del programma principale che era stato interrotto.

Tradotto nel nostro caso, il programma principale è la funzione loop. Quando realizziamo uno slave I2C, siamo liberi di scrivere una funzione loop che faccia qualunque cosa. Non dobbiamo inserire alcuna istruzione di ascolto e ricezione dei messaggi I2C nella funzione loop.

Quello che invece dobbiamo fare è definire una apposita funzione che verrà automaticamente chiamata dal gestore hardware del protocollo, qualora pervenga un messaggio.

Possiamo chiamare questa funzione come vogliamo, a patto che preveda un solo parametro in ingresso di tipo intero. La funzione non deve restituire alcun valore.

Nel nostro caso abbiamo scelto di chiamare questa funzione, che sarà automaticamente chiamata dal gestore hardware del protocollo I2C, interpretaComando. Come si vede dalla riga riportata, si aspetta un parametro che abbiamo chiamato numeroByte di tipo int e non ha alcun tipo di dato come ritorno (void).

Vediamo cosa fa questa funzione.

void interpretaComando(int numeroByte) {
  if (Wire.available()) { 
       char comando = Wire.read(); 
       if (comando == 'r') {
         digitalWrite(ROSSO,HIGH);
         digitalWrite(GIALLO,LOW);
         digitalWrite(VERDE,LOW);
       }
      if (comando == 'g') {
         digitalWrite(ROSSO,LOW);
         digitalWrite(GIALLO,HIGH);
         digitalWrite(VERDE,LOW);
       } 
      if (comando == 'v') {
         digitalWrite(ROSSO,LOW);
         digitalWrite(GIALLO,LOW);
         digitalWrite(VERDE,HIGH);
    } 
  }

Sebbene riceva come parametro il numero di byte presenti nel buffer I2C, la funzione deve prevederlo perché sarà invocata in questo modo, nel nostro caso tale parametro viene ignorato.

Come prima istruzione troviamo una if, che verifica se nel buffer di ricezione è presente almeno un dato. Wire.available() è la chiamata ad una funzione della libreria Wire che restituisce FALSO se il buffer è vuoto e VERO se il buffer contiene qualcosa.

Se il valore di Wire.available() è FALSO, non viene fatto nulla e la funzione termina.

Se il valore di Wire.available() è VERO, l’esecuzione del programma entra nel blocco della prima istruzione if e come prima cosa viene creata una variabile di nome c di tipo char e viene inizializzata con il valore restituito dalla funzione Wire.read(). Come si intuisce dal nome Wire.read() restituisce il primo dato, ancora non letto, presente nel buffer di ricezione.

Nel nostro prototipo ciò che viene trasmesso da un punto di vista logico sono caratteri alfabetici, e in particolare i caratteri ‘r’, ‘g’ e ‘v’. Quello che in realtà viene trasmesso sono numeri, rappresentati in binario da 8 bit, cioè 1 byte, che corrispondono ai codici ASCII dei caratteri.

Il tipo char corrisponde ad una variabile che ha dimensione pari ad 1 byte, idonea quindi a contenere codici ASCII relativi a caratteri.

A questo punto la variabile c contiene il carattere ricevuto.

Seguono tre istruzioni if in sequenza. Qualora il carattere ricevuto sia uno dei tre caratteri che abbiamo scelto di usare come comandi, viene eseguito il blocco di istruzioni corrispondente, che semplicemente commuta in stato acceso il LED di colore corrispondente al comando e commuta in stato spento gli altri due.

Dopo aver definito la funzione che gestisce i messaggi in arrivo, possiamo procedere a definire la funzione setup, all’interno della quale devono essere effettuate le inizializzazioni.

void setup() {
  // configurazione in modalità output per i PIN dei led colorati
  pinMode(ROSSO, OUTPUT);
  pinMode(GIALLO, OUTPUT);
  pinMode(VERDE, OUTPUT);

Per prima cosa vengono impostati in modalità OUTPUT i tre PIN scelti per pilotare i LED colorati.

Passiamo ora ad impostare il sistema come slave I2C.

// inizializzazione della libreria Wire e assegnazione dell'indirizzo I2C
  Wire.begin(42);

// Impostazione della funzione 'handler'
  Wire.onReceive(interpretaComando);

Attraverso l’istruzione Wire.begin(42) inizializziamo la libreria Wire e impostiamo l’indirizzo I2C del nostro Arduino al valore 42.

Quando viene eseguita questa istruzione, il microcontrollore di Arduino attiva il suo gestore interno del protocollo I2C e configura di conseguenza i PIN A4 e A5 come SDA e SCL.

L’istruzione successiva Wire.onReceive(interpretaComando) segnala al microcontrollore che la funzione da chiamare in caso di ricezione di messaggi destinati al proprio indirizzo (42) è appunto la interpretaComando .

Dopo aver eseguito queste due istruzioni, il microcontrollore è in ascolto sul bus I2C, in attesa di messaggi destinati all’indirizzo che abbiamo configurato.

Ma perché abbiamo scelto proprio 42 come indirizzo? Potevamo scegliere un qualunque numero intero tra 1 e 254, per cui non avendo altri dispositivi collegati un numero vale l’altro, per cui abbiamo scelto “la risposta alla domanda fondamentale sulla vita, l’universo e tutto quanto.“

  // Reset dei led
  digitalWrite(ROSSO,LOW);
  digitalWrite(GIALLO, LOW);
  digitalWrite(VERDE, LOW);
}

L’ultimo blocco di codice della funzione setup imposta su LOW i tre PIN a cui sono collegati i LED colorati in modo da far partire il dispositivo con i LED tutti spenti.

A questo punto possiamo definire la funzione loop.

Come abbiamo spiegato, non dobbiamo preoccuparci di gestire il protocollo fisico I2C e non dobbiamo preoccuparci di richiamare esplicitamente la funzione interpretaComando, poiché questa viene automaticamente richiamata dal meccanismo di interrupt.

Nella funzione loop possiamo eseguire qualunque attività, come leggere dei sensori, elaborare dati, ecc…, qualora dovesse pervenire un messaggio I2C, il programma sarebbe automaticamente interrotto e la funzione interpretaComando sarebbe automaticamente richiamata.

Nel nostro esempio, molto semplice, non abbiamo nulla da fare, per cui la funzione loop è stata lasciata vuota.

void loop() {
  // Il loop può rimanere vuoto
}

Per quanto riguarda il dispositivo slave abbiamo finito.

Il codice relativo al dispositivo master è riportato di seguito.

#include <Wire.h>

void setup() {
  Wire.begin(); // Inizia I2C come master, non serve l'indirizzo
  Serial.begin(9600); // Inizia la comunicazione seriale a 9600 baud
  Serial.println("Inserisci comando ('r' per accendere il rosso, 'g' per il giallo, 'v' per il verde):");
}

void loop() {
  if (Serial.available() > 0) { // Controlla se ci sono dati disponibili nella seriale
    char command = Serial.read(); // Leggi il carattere inviato dall'utente

    // Ignora il carattere di newline
    if (command == 'n' || command == 'r') {
      return;
    }

    Serial.print("Invio comando: ");
    Serial.println(command);

    Wire.beginTransmission(42); // Inizia trasmissione I2C verso il dispositivo 42
    Wire.write(command); // Invia il comando al dispositivo slave
    Wire.endTransmission(); // Termina la trasmissione

    delay(1000); // Ritardo prima del prossimo comando
  }
}

Il dispositivo master dovrà interagire con il PC, comunicando attraverso la porta seriale, con le stesse modalità che abbiamo già visto nel tutorial relativo alle comunicazioni seriali RS232.

Anche in questo caso, in testa al programma, troviamo la direttiva #include <Wire.h>, necessaria per poter usare le funzioni della libreria Wire.

La funzione setup non ha elementi particolari da segnalare.

void setup() {
  Wire.begin(); // Inizia I2C come master, non serve l'indirizzo
  Serial.begin(9600); // Inizia la comunicazione seriale a 9600 baud
  Serial.println("Inserisci comando ('r' per accendere il rosso, 'g' per il giallo, 'v' per il verde):");
}

L’istruzione Wire.begin() inizializza la libreria ma non viene passato nessun parametro. Poiché questo sketch si riferisce al master, cioè al dispositivo che inizia la comunicazione e invia i messaggi, non è necessario impostare alcun indirizzo I2C.

Avremmo potuto anche impostarne uno, ma sarebbe stato superfluo poiché questo dispositivo non è previsto che riceva messaggi.

Ricorda che master e slave sono due ruoli relativi ad una sessione di comunicazione. Un dispositivo potrebbe essere programmato sia per ricevere messaggi che per trasmetterli. In questo caso il dispositivo si comporterebbe a volte da master (quando trasmette) e a volte da slave (quando riceve).

Nel nostro caso, per semplificare la comprensione del software abbiamo costruito un esempio con un dispositivo che sarà sempre solo uno slave e un dispositivo che sarà sempre soltanto un master.

Puoi facilmente combinare le istruzioni che stai imparando attraverso questo tutorial per creare dispositivi più complessi, in grado di trasmettere e ricevere.

Le due istruzioni restanti nel blocco setup inizializzano la connessione seriale verso il PC e stampano un messaggio di istruzioni.

Il primo blocco della funzione loop non dovrebbe presentare elementi che non conosci già. Usa il meccanismo già visto in un precedente tutorial per acquisire i caratteri dalla linea di prompt del terminale seriale sul PC.

void loop() {
  if (Serial.available() > 0) { // Controlla se ci sono dati disponibili nella seriale
    char command = Serial.read(); // Leggi il carattere inviato dall'utente

    // Ignora il carattere di newline
    if (command == 'n' || command == 'r') {
      return;
    }

Il test if (command == ‘n’ || command == ‘r’) controlla se tra i caratteri ricevuti c’è il carattere speciale ‘newline’ indicato con ‘n’ oppure il carattere speciale di ritorno carrello ‘r’, e in questo caso semplicemente vengono ignorati. L’istruzione return presente nel blocco della if causa l’uscita dal ciclo corrente della funzione loop. Come sappiamo, la funzione loop ogni volta che termina, sia a seguito di una esplicita istruzione return, sia perché il flusso del programma è giunto alla fine, viene sempre, nuovamente richiamata, all’infinito.

I caratteri speciali ‘n’ e ‘r’ corrispondono alla pressione del tasto ENTER della tastiera. Su un PC Windows, quando si preme ENTER sulla tastiera viene generata la sequenza di due caratteri “nr”, mentre sui PC con S.O. Linux o MacOs viene generato il solo carattere ‘n’.

In entrambi i casi è superfluo inoltrare questi caratteri sulla comunicazione I2C, per cui viene effettuato subito il ‘return‘ dalla funzione loop.

Qualora il carattere letto fosse diverso dal newline o dal ritorno carrello, l’esecuzione della funzione loop proseguirebbe con le istruzioni che seguono.

    Serial.print("Invio comando: ");
    Serial.println(command);

    Wire.beginTransmission(42); // Inizia trasmissione I2C verso il dispositivo 42
    Wire.write(command); // Invia il comando al dispositivo slave
    Wire.endTransmission(); // Termina la trasmissione

Le prime due istruzioni stampano semplicemente un messaggio informativo sulla finestra del terminale seriale.

La comunicazione dei dati su I2C è svolta dalle tre successive istruzioni.

Wire.beginTransmission(42) inizia la sequenza di comunicazione master/slave. Quando questa istruzione viene eseguita, il microcontrollore avvia il segnale di clock sulla linea SCL e trasmette il pacchetto dati di richiesta messaggio verso l’indirizzo 42. L’indirizzo è passato come parametro nella funzione beginTransmission.

L’istruzione Wire.write(command) trasmette sul bus I2C il carattere contenuto nella variabile command. La variabile command era stata valorizzata acquisendo il carattere dalla linea seriale proveniente dal PC.

L’istruzione Wire.endTransmission() termina la sessione di collegamento. Il microcontrollore smette di mandare sul PIN SCL il segnale di clock.