Abstract

I²C è un bus di comunicazione seriale sincrono che permette a un microcontrollore di scambiare dati con più dispositivi utilizzando due sole linee di segnale: SDA per i dati e SCL per il segnale di temporizzazione. Ogni dispositivo viene selezionato mediante un indirizzo e può condividere il bus con altri componenti, purché non siano presenti conflitti di indirizzamento.

Sull’ATmega328P le linee I²C corrispondono a PC4 e PC5, normalmente disponibili come A4 e A5 sulle schede con disposizione compatibile con UNO R3. Il collegamento richiede inoltre una massa comune, tensioni compatibili e resistenze di pull-up sulle linee SDA e SCL.

L’applicazione pratica utilizza un display LCD 16×2 dotato di interfaccia I²C. Un primo programma individua l’indirizzo effettivo del modulo. Il programma finale verifica la presenza del display, lo inizializza e mostra il tempo trascorso dall’avvio, aggiornandolo con millis() senza bloccare l’esecuzione.

Un bus composto da due linee

I²C, acronimo di Inter-Integrated Circuit, è stato progettato per collegare circuiti integrati presenti sulla stessa scheda o comunque a breve distanza.

Le due linee condivise sono:

  • SDA, Serial Data, utilizzata per trasferire i dati;
  • SCL, Serial Clock, utilizzata per sincronizzare il trasferimento.

A queste si aggiungono l’alimentazione e la massa comune. Più dispositivi possono essere collegati alle stesse linee SDA e SCL. La selezione avviene mediante un indirizzo trasmesso all’inizio della comunicazione.

Il dispositivo di controllo genera il segnale di clock, invia l’indirizzo e stabilisce se l’operazione consiste in una scrittura o in una lettura. Il componente selezionato risponde soltanto quando riconosce il proprio indirizzo.

Nel datasheet dell’ATmega328P il blocco hardware compatibile con I²C viene indicato con il nome TWI, Two-Wire Interface. Nell’ambiente Wiring l’accesso avviene normalmente attraverso la libreria Wire.

SDA e SCL sull’ATmega328P

Nell’ATmega328P le funzioni del bus sono associate a:

  • PC4: SDA;
  • PC5: SCL.

Sulle schede con disposizione compatibile con UNO R3 corrispondono a:

  • A4: SDA;
  • A5: SCL.

Alcune schede espongono anche due terminali separati denominati SDA e SCL. Non costituiscono un secondo bus: sono collegati elettricamente alle stesse linee A4 e A5.

Schema di collegamento tra ATmega328P o UNO R3 e display LCD 16×2 I2C
Collegamento tra ATmega328P o UNO R3 e display LCD 16×2 I²C. Fare clic sull’immagine per ingrandirla.

L’alimentazione indicata è quella normalmente utilizzata dai comuni moduli LCD 16×2 per sistemi a 5 V. Le caratteristiche del modulo effettivamente impiegato devono comunque essere verificate prima del collegamento.

Resistenze di pull-up

I dispositivi collegati al bus possono forzare SDA e SCL verso massa, ma non portano direttamente le linee allo stato alto. Il ritorno al livello alto è affidato alle resistenze di pull-up collegate alla tensione logica del bus.

VCC
 |
[R]
 |
SDA

La stessa configurazione viene utilizzata su SCL. Senza pull-up adeguati le linee possono rimanere instabili e la comunicazione può non iniziare oppure produrre errori intermittenti.

Molti moduli LCD I²C possiedono già le resistenze necessarie. Aggiungere altre resistenze senza controllare il circuito può ridurre eccessivamente il valore equivalente, perché le resistenze collegate in parallelo si sommano elettricamente come un unico valore inferiore.

Nei collegamenti brevi si incontra frequentemente il valore di 4,7 kΩ, ma il dimensionamento dipende dalla tensione, dalla capacità complessiva del bus, dalla velocità e dal numero di moduli collegati.

Indirizzamento

Ogni dispositivo I²C viene selezionato mediante un indirizzo. Nelle applicazioni comuni viene utilizzato un indirizzo a 7 bit.

Il display LCD 16×2 non gestisce direttamente I²C. Il piccolo modulo montato sul retro riceve i dati seriali e li converte nei segnali paralleli richiesti dal controllore del display. L’indirizzo appartiene quindi al modulo di interfaccia.

Gli indirizzi frequentemente incontrati sono 0x27 e 0x3F, ma non devono essere dati per certi. Il valore reale deve essere individuato con lo scanner.

Due dispositivi con lo stesso indirizzo non possono essere selezionati separatamente sul medesimo bus senza modificare la configurazione hardware o introdurre un dispositivo di commutazione.

START, dati, conferma e STOP

Una comunicazione I²C comprende alcuni elementi fondamentali:

  • START: indica l’inizio della comunicazione;
  • indirizzo: seleziona il dispositivo;
  • lettura o scrittura: stabilisce la direzione dello scambio;
  • ACK: conferma la ricezione del byte;
  • NACK: segnala la mancata conferma;
  • dati: uno o più byte trasferiti;
  • STOP: termina la comunicazione e libera il bus.

La libreria Wire genera automaticamente queste sequenze. Il valore restituito da Wire.endTransmission() permette però di verificare se il dispositivo ha risposto correttamente.

La libreria Wire

L’inizializzazione del bus avviene con:

#include <Wire.h>

void setup()
{
  Wire.begin();
}

Le funzioni principali sono:

  • Wire.beginTransmission(indirizzo): apre una trasmissione verso il dispositivo;
  • Wire.write(valore): inserisce un byte da trasmettere;
  • Wire.endTransmission(): conclude la trasmissione e restituisce l’esito;
  • Wire.requestFrom(indirizzo, quantita): richiede uno o più byte;
  • Wire.available(): indica quanti byte sono disponibili;
  • Wire.read(): legge un byte ricevuto.

Per verificare soltanto la presenza di un dispositivo è sufficiente aprire e chiudere una trasmissione:

Wire.beginTransmission(indirizzo);
uint8_t risultato = Wire.endTransmission();

Se risultato vale zero, un dispositivo ha confermato la comunicazione a quell’indirizzo.

Primo programma: scanner I²C

Lo scanner prova gli indirizzi ordinariamente utilizzabili e stampa sul monitor seriale quelli che rispondono. Il programma esegue una sola scansione all’avvio.

Download: i2c_scanner.ino

#include <Wire.h>

/*
  Scanner I2C per ATmega328P / UNO R3.

  Collegamenti:
  SDA -> A4
  SCL -> A5
  GND comune
  Alimentazione conforme al modulo collegato
*/

void eseguiScansione()
{
  uint8_t dispositiviTrovati = 0;

  Serial.println();
  Serial.println("Scansione bus I2C");

  for (uint8_t indirizzo = 0x08; indirizzo <= 0x77; indirizzo++)
  {
    Wire.beginTransmission(indirizzo);
    uint8_t risultato = Wire.endTransmission();

    if (risultato == 0)
    {
      Serial.print("Dispositivo rilevato: 0x");

      if (indirizzo < 0x10)
      {
        Serial.print('0');
      }

      Serial.println(indirizzo, HEX);
      dispositiviTrovati++;
    }
  }

  if (dispositiviTrovati == 0)
  {
    Serial.println("Nessun dispositivo rilevato");
  }
  else
  {
    Serial.print("Dispositivi trovati: ");
    Serial.println(dispositiviTrovati);
  }
}

void setup()
{
  Serial.begin(115200);
  Wire.begin();

  eseguiScansione();
}

void loop()
{
}

Con il display collegato correttamente dovrebbe comparire un risultato simile a:

Scansione bus I2C
Dispositivo rilevato: 0x27
Dispositivi trovati: 1

L’indirizzo rilevato deve essere riportato nel programma finale. Lo scanner conferma la presenza di un dispositivo, ma non può stabilire automaticamente quale componente abbia risposto.

Display LCD 16×2 con interfaccia I²C

Un normale display LCD 16×2 richiede più linee digitali quando viene collegato direttamente. Il modulo I²C montato sul retro riduce il collegamento a quattro terminali:

  • GND;
  • VCC;
  • SDA;
  • SCL.

Il modulo converte i byte ricevuti sul bus nei segnali necessari al display e può gestire anche la retroilluminazione.

Sul modulo è normalmente presente un piccolo trimmer. Regola il contrasto dei caratteri, non la luminosità della retroilluminazione. Se il display viene rilevato ma non mostra testo, il contrasto è uno dei primi elementi da controllare.

Libreria LiquidCrystal_I2C

Il programma finale utilizza una libreria LiquidCrystal_I2C che fornisce il costruttore:

LiquidCrystal_I2C lcd(indirizzo, 16, 2);

e i metodi:

  • lcd.init();
  • lcd.backlight();
  • lcd.clear();
  • lcd.setCursor(colonna, riga);
  • lcd.print(testo).

Esistono più librerie con lo stesso nome. Il codice richiede una versione compatibile con il costruttore a tre argomenti e con il metodo init().

Programma finale

Il programma finale:

  • inizializza il bus I²C;
  • verifica che il display risponda all’indirizzo configurato;
  • inizializza il display e la retroilluminazione;
  • scrive LCD 16x2 I2C sulla prima riga;
  • mostra sulla seconda riga i secondi trascorsi dall’avvio;
  • aggiorna il tempo ogni secondo mediante millis();
  • controlla ogni cinque secondi che il display continui a rispondere;
  • reinizializza il display dopo un eventuale scollegamento e ricollegamento;
  • non utilizza delay().

Il valore di INDIRIZZO_LCD deve coincidere con quello individuato dallo scanner.

Download: i2c_lcd.ino

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

/*
  Display LCD 16x2 con interfaccia I2C.

  Prima di caricare questo programma:
  1. eseguire i2c_scanner.ino;
  2. leggere l'indirizzo rilevato;
  3. riportarlo in INDIRIZZO_LCD.

  Collegamenti ATmega328P / UNO R3:
  SDA -> A4
  SCL -> A5
  GND -> GND
  VCC -> alimentazione conforme al modulo
*/

constexpr uint8_t INDIRIZZO_LCD = 0x27;
constexpr uint8_t COLONNE_LCD = 16;
constexpr uint8_t RIGHE_LCD = 2;

constexpr unsigned long INTERVALLO_AGGIORNAMENTO_MS = 1000UL;
constexpr unsigned long INTERVALLO_CONTROLLO_MS = 5000UL;

LiquidCrystal_I2C lcd(
  INDIRIZZO_LCD,
  COLONNE_LCD,
  RIGHE_LCD
);

bool displayDisponibile = false;

unsigned long ultimoAggiornamento = 0;
unsigned long ultimoControllo = 0;

bool dispositivoPresente(uint8_t indirizzo)
{
  Wire.beginTransmission(indirizzo);
  uint8_t risultato = Wire.endTransmission();

  return risultato == 0;
}

void stampaIndirizzo(uint8_t indirizzo)
{
  if (indirizzo < 0x10)
  {
    Serial.print('0');
  }

  Serial.print(indirizzo, HEX);
}

void inizializzaDisplay()
{
  lcd.init();
  lcd.backlight();
  lcd.clear();

  lcd.setCursor(0, 0);
  lcd.print("LCD 16x2 I2C");

  lcd.setCursor(0, 1);
  lcd.print("Avvio completato");

  displayDisponibile = true;

  Serial.print("Display inizializzato all'indirizzo 0x");
  stampaIndirizzo(INDIRIZZO_LCD);
  Serial.println();
}

void controllaDisplay()
{
  bool presente = dispositivoPresente(INDIRIZZO_LCD);

  if (presente && !displayDisponibile)
  {
    Serial.println("Display rilevato");
    inizializzaDisplay();
  }
  else if (!presente && displayDisponibile)
  {
    displayDisponibile = false;
    Serial.println("Display non raggiungibile");
  }
}

void aggiornaDisplay(unsigned long tempoCorrente)
{
  if (!displayDisponibile)
  {
    return;
  }

  unsigned long secondi = tempoCorrente / 1000UL;

  char secondaRiga[17];

  snprintf(
    secondaRiga,
    sizeof(secondaRiga),
    "Tempo:%8lus",
    secondi
  );

  lcd.setCursor(0, 0);
  lcd.print("LCD 16x2 I2C   ");

  lcd.setCursor(0, 1);
  lcd.print(secondaRiga);
}

void setup()
{
  Serial.begin(115200);
  Wire.begin();

  Serial.println("Avvio controllo LCD I2C");

  controllaDisplay();

  if (!displayDisponibile)
  {
    Serial.print("Nessuna risposta all'indirizzo 0x");
    stampaIndirizzo(INDIRIZZO_LCD);
    Serial.println();
  }
}

void loop()
{
  unsigned long tempoCorrente = millis();

  if (
    tempoCorrente - ultimoControllo
    >= INTERVALLO_CONTROLLO_MS
  )
  {
    ultimoControllo = tempoCorrente;
    controllaDisplay();
  }

  if (
    tempoCorrente - ultimoAggiornamento
    >= INTERVALLO_AGGIORNAMENTO_MS
  )
  {
    ultimoAggiornamento = tempoCorrente;
    aggiornaDisplay(tempoCorrente);
  }
}

Temporizzazioni indipendenti

Nel loop() vengono gestite due temporizzazioni. La prima aggiorna il contenuto del display ogni secondo; la seconda controlla la presenza del modulo ogni cinque secondi.

Entrambe utilizzano il valore restituito da millis(), ma conservano separatamente il momento dell’ultima operazione. Il microcontrollore può quindi eseguire altre attività tra un aggiornamento e il successivo.

La forma:

tempoCorrente - ultimoAggiornamento >= intervallo

rimane valida anche quando il contatore di millis() supera il proprio valore massimo e riparte da zero.

Aggiornamento delle righe

Un display LCD non cancella automaticamente i caratteri rimasti da una scrittura precedente. Per questo il programma riscrive una riga di lunghezza controllata.

L’array:

char secondaRiga[17];

contiene sedici caratteri visibili più il terminatore finale richiesto dalle stringhe C. La funzione snprintf() costruisce il testo senza superare la dimensione disponibile.

Errori comuni

Nessun indirizzo rilevato

Le cause più frequenti sono SDA e SCL invertiti, massa non collegata, alimentazione assente, contatto instabile, modulo danneggiato oppure linee mantenute permanentemente a livello basso.

Lo scanner rileva il display, ma il programma finale no

L’indirizzo scritto in INDIRIZZO_LCD deve coincidere con quello rilevato. Se lo scanner restituisce 0x3F, il valore 0x27 deve essere sostituito.

Il display è illuminato ma non mostra caratteri

Occorre controllare il trimmer del contrasto, l’indirizzo, la compatibilità della libreria, il numero di colonne e righe e le saldature tra display e modulo di interfaccia.

Compaiono quadrati sulla prima riga

Il pannello è alimentato, ma non è stato inizializzato correttamente. Le cause possono essere un indirizzo errato, una libreria incompatibile o una configurazione interna del modulo diversa da quella prevista.

Comunicazione intermittente

Occorre verificare la lunghezza dei collegamenti, la stabilità dell’alimentazione, le resistenze di pull-up già presenti, i contatti della breadboard e la vicinanza a relè, motori o altri carichi commutati.

Più dispositivi sullo stesso bus

Più dispositivi possono condividere SDA e SCL, purché utilizzino indirizzi differenti e siano elettricamente compatibili.

Schema del bus I2C con linee SDA e SCL condivise, resistenze di pull-up e dispositivi con indirizzi differenti
Bus I²C con SDA e SCL condivise, resistenze di pull-up e dispositivi con indirizzi differenti. Fare clic sull’immagine per ingrandirla.

Quando vengono collegati più moduli occorre anche verificare il valore complessivo delle resistenze di pull-up presenti in parallelo.

Limiti pratici

I²C è destinato a collegamenti brevi. Aumentando la lunghezza dei conduttori cresce la capacità elettrica delle linee, i fronti di salita diventano più lenti e aumenta la probabilità di errori.

Su breadboard è opportuno mantenere SDA e SCL corte, utilizzare una massa stabile, evitare percorsi vicini a relè e motori e non aggiungere cavi volanti inutilmente lunghi.

Ridurre la frequenza può migliorare un collegamento marginale, ma non trasforma I²C in un’interfaccia adatta a lunghe distanze.

Conclusioni

I²C permette di collegare dispositivi differenti utilizzando due sole linee di segnale. SDA trasferisce i dati, mentre SCL sincronizza la comunicazione.

Sull’ATmega328P le linee corrispondono a PC4 e PC5, normalmente disponibili come A4 e A5 sulle schede con disposizione compatibile con UNO R3.

Il bus richiede resistenze di pull-up, una massa comune, tensioni compatibili e indirizzi non duplicati. Lo scanner permette di individuare l’indirizzo effettivo del modulo LCD; il programma finale utilizza poi quell’indirizzo per inizializzare il display e aggiornare il contenuto senza bloccare l’esecuzione.

Il display rende immediatamente visibile il risultato della comunicazione, ma il principio rimane generale: selezione dell’indirizzo, trasferimento dei byte, conferma della ricezione e gestione degli errori.

Fonti principali

NXP Semiconductors — UM10204, I²C-bus specification and user manual.

Microchip Technology — ATmega328P: 8-bit AVR Microcontroller with 32K Bytes In-System Programmable Flash, datasheet.

Arduino — documentazione della libreria Wire.

Hitachi — HD44780U Dot Matrix Liquid Crystal Display Controller/Driver, datasheet.

Documentazione della libreria LiquidCrystal_I2C utilizzata per la compilazione.