Apr 282018
 

Quest’oggi vedremo come collegare direttamente due schede Arduino insieme attraverso la porta seriale. Quello che apparentemente è un compito facile in realtà nasconde numerose insidie che andremo ad analizzare in questo articolo.

L’idea nasce da una richiesta di aiuto da parte di un amico che doveva connettere due schede Arduino all’interno del progetto Archimede che abbiamo già visto in passato.  Cominciamo con il vedere la bozza di software che il mio amico aveva preparato così da poterne descrivere i problemi che lo affliggono dopodiché andremo a studiare quali possono essere le soluzioni al problema. In realtà il software non è quello da lui scritto, ma è una mia rivisitazione fatta apposta per il blog e per far emergere le problematiche che ci stanno sotto. Prima ancora però andiamo a vedere l’hardware che utilizzeremo per questo nuovo progetto.

Si tratta di un Arduino Mega ed un arduino Uno connessi direttamente attraverso la porta seriale e con la massa in comune come potete vedere nello schema qui vicino. E’ sufficiente collegare il pin RX di un Arduino al TX dell’altro e viceversa, senza scordarsi di connettere i gnd delle due schede fra di loro. Si noti che nell’Arduino Mega ho utilizzato la porta seriale 1 e non la zero, questo perchè la zero è condivisa con la porta USB e quindi con il monitor seriale per cui la lasciamo per il debug. Se la parte di collegamento è davvero banale, ciò che invece è più complicato è il versante software che cercherò di affrontare per gradi.

Software Arduino UNO:

void setup()
{
 Serial.begin(9600); // initialize serial:
}
String str;

void loop() 
{
if (Serial.available() > 0) 
 {
 str=Serial.readString();
 Serial.println("Received data (Arduino UNO): ");
 Serial.println(str);
 }
 else Serial.println("I'm Arduino UNO");
 delay(1200);
}

Software Arduino Mega

String str;
void setup() 
{
 Serial1.begin(9600); // connessione con Arduino UNO
 Serial.begin(9600); // connessione con il monitor seriale
}

void loop() 
{
 if (Serial1.available() > 0)  // Arduino UNO ha trasmesso...
 {
 str=Serial1.readString();
 Serial.println("Received remote data");
 Serial.println(str);
 }
 
 if(Serial.available() > 0) // hai scritto sul monitor seriale
 {
 str=Serial.readString();
 Serial.println("send data...");
 Serial1.println(str);
 }
 //delay(200);
}


Il software di per se è banale. L’Arduino UNO invia ripetutamente la scritta I’m Arduino all’Arduino Mega che la riceve attraverso la porta seriale uno e sfrutta invece la porta zero (quella connessa alla porta USB) per stamparla sul monitor seriale in modo da avere un riscontro oggettivo di cosa succede. A sua volta l’Arduino Mega riceve dati dal monitor seriale per cui se scriviamo qualcosa e lo inviamo, l’Arduino Mega trasmette il contenuto all’UNO che a sua volta lo rispedisce indietro per essere visualizzato sul monitor seriale e avere un riscontro che tutto funziona a dovere. In alternativa avrei potuto usare due Arduino Mega, ma dato che nel progetto finale una delle schede non avrà la seconda porta seriale, ho appositamente evitato questa soluzione.

Cosa non va?

Ci sono tanti problemi in questi che sembrano due listati semplici e apparentemente funzionanti. Intanto parliamo di ottimizzazione delle risorse. Qui facciamo uso della classe String. Ne abbiamo proprio bisogno o la usiamo solo per rendere semplice la programmazione? Peraltro nel listato primordiale originale c’erano anche dei confronti fra la stringa ricevuta ed alcune stringhe pre registrate per far compiere ad Arduino dei compiti in base al tipo di stringa richiesta. Vi do solo due dati per farvi capire che non siamo sulla strada giusta. Il primo è la semplice compilazione dello sketch. Su Arduino UNO la compilazione mi restituisce:

Lo sketch usa 3530 byte (10%) dello spazio disponibile per i programmi. Il massimo è 32256 byte.
Le variabili globali usano 250 byte (12%) di memoria dinamica, lasciando altri 1798 byte liberi per le variabili locali. Il massimo è 2048 byte.

Ossia per la ricezione ed invio di alcune stringhe abbiamo occupato ben il 10%  dello spazio disponibile ed il 20% della memoria è utilizzato da variabili globali. Per fare un confronto lo sketch che vedremo alla fine e che è decisamente più complesso e si occupa della ricezione di dati differenti oltre che all’invio di messaggi di debug, mi da:

Lo sketch usa 2516 byte (7%) dello spazio disponibile per i programmi. Il massimo è 32256 byte.
Le variabili globali usano 209 byte (10%) di memoria dinamica, lasciando altri 1839 byte liberi per le variabili locali. Il massimo è 2048 byte.

Ossia incrementiamo il numero di compiti con maggior velocità esecutiva riducendo del 30% lo spazio disponibile e del 50% l’occupazione da parte delle variabili globali.

Secondo punto. Lo avete notato il delay(1200) messo su Arduino UNO. Ho dovuto inserirlo perchè l’Arduino Mega è talmente lento ad elaborare le stringhe ricevute che si satura velocemente il buffer seriale andando a bloccare tutto il sistema. Per fare un raffronto sul software definitivo ho lasciato un delay (20) giusto per riuscire con un colpo d’occhio a capire se qualcosa non funziona nei messaggi di debug, ma che comunque può essere eliminato. Ci sono altri problemi. Si eccome, ne rimane uno bello grosso che è legato a quello precedente. In una comunicazione UART (Universal Asynchronous Receiver Transmitter) è vero che entrambi i dispositivi possono trasmettere in contemporanea, ma è anche vero che i dati ricevuti vanno a finire in un buffer di ricezione che sui microcontrollori montati da Arduino è molto piccolo, partiamo da quantitativi di 16 o 64 bytes. Facciamo finta per un secondo di inviare la stringa “luce accesa”, sono 11 bytes, se il nostro buffer fosse di 11 bytes non sarebbe in grado di ricevere lo stesso messaggio prima che il buffer venga svuotato dal  nostro sketch, per cui ci rendiamo conto come sia facile saturare il buffer mandando in blocco tutto il sistema. E’ perciò meglio trasformare la trasmissione da asincrona a sincrona, ossia facciamo in modo che solo una scheda alla volta possa trasmettere mentre l’altra si mette in ricezione. Deve perciò esserci una decisione a priori su qual’è la scheda che può trasmettere e che eventualmente fornisce alla scheda remota degli spazi ben definiti per inviare eventuali risposte. E non basta. Cosa succde se riavviamo la scheda remota mentre la Uno invia un messaggio? Probabilmente viene ricevuta solo parte del messaggio per cui dobbiamo far capire al ricevente che il messaggio non è completo e deve essere scartato in attesa di uno nuovo. Avete capito quanto è complesso?

Da dove cominciamo?

Prima di tutto eliminiamo le stringhe, non hanno alcun senso. Nel progetto finale i dati che saranno trasferiti sono valori di temperatura, velocità e comandi di attivazione dei vari relè, tutte cose che possono essere fatte con pochi bytes. Non ho inventato nulla, ho ripreso parte della mia McMajan Library Pack, in particolare il protocollo di comuncazione da me ideato per le trasmissioni NRF24 e l’ho riproposto adeguatamente modificato per l’utilizzo con la porta seriale. Di fatto abbiamo un buffer di 6 bytes, i primi due indicano un raggruppamento ed un elemento che vanno ad identificare la tipologia di dato trasmesso, poi seguono 4 bytes in grado di contenere 4 char, oppure 2 int o ancora un float a seconda di cosa dobbiamo trasmettere. Benchè sia possibile sfruttare i puntatori ed accedere in modo diretto al buffer, mi rendo conto che la gran parte delle persone che si avvicina ad Arduino in realtà non conosce molto a fondo la programmazione C per cui ho strutturato la memoria in modo da poter accedere facilmente alle varie parti che vi ho appena descritto:

#define McAirData RefData.IO.Data
#define McAirGroup RefData.IO.Group
#define McAirElement RefData.IO.Element
#define McAirDataChar (char(McAirData.datachar[0]))
#define McAirDataInt (int(McAirData.dataint[0]))
#define McAirDataFloat (float(McAirData.datafloat))
#define McAirDataLong (long(McAirData.datalong))

bool modality; //0=trasmissione, 1=ricezione
unsigned int i;

union DataMix
{
 char datachar[4];
 int dataint[2];
 float datafloat;
 unsigned long datalong;
};
 
struct McMajanData
{
 char Group;
 char Element;
 union DataMix Data;
};

union dataUART
{ 
 char databuffer[6];
 struct McMajanData IO;
};
 
dataUART RefData;

Ciò che dobbiamo sapere è che RefData conterrà tutti i dati letti o da trasmettere e che tramite l’accesso diretto o tramite i vari #define posti all’inizio, possiamo leggere o scrivere i dati di cui abbiamo bisogno. Anche per chi non comprende il codice qui sopra, è comunque sufficiente un semplice copia e incolla senza alcun tipo di modifica. Ovviamente dobbiamo creare anche delle funzioni per leggere e scrivere i dati in questa tipologia di organizzazione della memoria.

void SetGE(uint8_t group,uint8_t element)
{
 RefData.IO.Group=group; 
 RefData.IO.Element=element; 
}

void SendUART(Stream &ser,uint8_t group,uint8_t element,char dato)
{
 SetGE(group,element);
 RefData.IO.Data.datachar[0]=dato;
 SendData(ser);
}
void SendUART(Stream &ser,uint8_t group,uint8_t element,int dato)
{
 SetGE(group,element);
 RefData.IO.Data.dataint[0]=dato;
 SendData(ser);
}
void SendUART(Stream &ser,uint8_t group,uint8_t element,float dato)
{
 SetGE(group,element);
 RefData.IO.Data.datafloat=dato;
 SendData(ser);
}
void SendUART(Stream &ser,uint8_t group,uint8_t element,unsigned long dato)
{
 SetGE(group,element);
 RefData.IO.Data.datalong=dato;
 SendData(ser);
}
void SendData(Stream &ser)
{
 ser.write('@');
 ser.write(RefData.databuffer,6); 
}

La prima funzione non fa che settare il gruppo / elemento del dato da inviare, funzione peraltro richiamata dalle successive che pemettono l’invio di un dato a nostra scelta. La funzione però più importante è quella che leggerà i dati dalla porta seriale:

int GetData(Stream &ser)
{
 int pos = 0;
 int max_len=6;
 for(;;)
 {
  if (ser.available() > 0) 
  {
   int data = ser.read();
   if(data=='@') break; 
  } 
 }
 for(pos=0;pos<max_len;)
 {
  if (ser.available() > 0) 
  {
   int data = ser.read();
   if(data!=-1) RefData.databuffer[pos++] = data;
  }
 }
 return pos;
}

In se non è una funzione particolarmente complessa ma risulta utile soffermarsi su alcuni dettagli. Ad esempio la prima parte del ciclo for attende la lettura del carattere @, che guarda caso è il primo carattere che inoltra la funzione di invio SendData visto sopra. Questo perchè come dicevamo se l’Arduino ricevente viene acceso, riavviato o quant’altro, potrà ritrovarsi a leggere una parte di un messaggio con predita delle informazioni precedenti, cosa che gli impedisce di comprendere il messaggio corrente ma anche i successivi visto che mancherebbe un sistema di sincronizzazione fra mittente e destinatario. Il simbolo @ viene perciò usato come “inizio trasmissione” per cui il suo riconoscimento ci fa comprendere che siamo all’inzio di un nuovo messaggio ed eventuali caratteri precedenti verranno scartati. 

Solo una volta identificato il carattere @ la funzione comincia a ricevere i dati dalla porta seriale, in particolare attende la lettura di sei caratteri che verranno impilati uno ad uno nel buffer prima predisposto. A questo punto avete tutti gli strumenti a disposizione per far colloquiare le due schede Arduino, anche se dovrete ancora decidere quale sarà la logica di funzionamento. Ad esempio si potrebbe decidere che una scheda tiene il comando di chi trasmette / riceve dati e quando, oppure si potrebbe cambiare in maniera dinamica questo ruolo in base alle esigenze. Nel nostro caso il “capo” della trasmissione è l’Arduino  contenuto nel volante dell’auto che vedete nella foto. In questo modo quando premete un pulsante potete inviare i dati rellativi alla scheda remota e quando invece volete aggiornare il display richiedete i dati dalla scheda remota, vi ponete in modalità ricezione ed attendete la risposta per poi rimettervi in modalità trasmissione. Ora, ricordando che le porzioni di software viste sopra sono comuni e devono essere presenti in entrambe le schede, vediamo come procedere con le porzioni restanti del codice.

Arduino Mega

L’arduino Mega è la scheda remota, quella collegata a tutti i relè e ai sensori che gestiscono la logica di funzionamento di tutta l’auto. Di base non può comunicare sulla porta seriale ma deve attendere determinati comandi dalla scheda master sul volante prima di poter inviare dei dati.

float faketemp1=23.69;
float faketemp2=21.54;
float fakespeed=51.48;

void setup() 
{
 Serial1.begin(9600);
 Serial.begin(9600);
 Serial.println("Avvio Arduino Mega....");
}

void loop() 
{
 if (Serial1.available() > 0) // ricezione remota
 { 
  GetData(Serial1);
  Serial.print(McAirGroup);
  Serial.print(" - ");
  Serial.println(McAirElement);
 
  if(McAirGroup=='R') // get (R)emote data: ora posso trasmettere
   {
    //L'arduino Master si è messo in attesa della risposta per cui rispondi prima possibile per restituirgli il controllo del volante.
    if(McAirElement=='1') {SendUART(Serial1,'S','1',fakespeed); Serial.println("speed sent");}
    else if(McAirElement=='2') {SendUART(Serial1,'T','1',faketemp1); Serial.println("T1 sent");} 
    else if(McAirElement=='3') {SendUART(Serial1,'T','2',faketemp2); Serial.println("T2 sent");} 
   }
   else if(McAirGroup=='D') // ONLY FOR DEBUG PURPOSE 
   {
    //l'arduino master NON è in modalità ricezione per cui qui non puoi trasmettere nulla
    if(McAirElement=='1') Serial.print("remote Arduino received fake speed: ");
    else if(McAirElement=='2') Serial.print("remote Arduino received fake temp 1: ");
    else if(McAirElement=='3') Serial.print("remote Arduino received fake temp 2: "); 
    Serial.println(McAirDataFloat); 
   }
   else Serial.flush();
  }
}

Come prima cosa vegono inizializzate le porte seriali 0 ed 1, la prima collegata al monitor seriale che usiamo per il debug, la seconda connessa all’Arduino Master sul volante. Prima ancora ho definito tre variabili float per tenere due temperature e la velocità dell’auto, ovviamente sono dati fissi messi li solo per debug, nella realtà ci sono dei sensori e le necessarie routines di lettura dei dati che non sono però lo scopo di questo articolo. Nel loop principale con la Serial.available() controlliamo se abbiamo dei dati da leggere sulla porta seriale, in alternativa possiamo dedicarci ad altri compiti come appunto la lettura dei sensori fisici, il controllo della carica delle batterie, etc. Se ci sono dei dati usiamo la GetData che come parametro richiede solamente il nome della porta seriale aperta. Essa come abbiamo visto prima si occupa di tutta la ricezione del dato per cui una volta terminata questa funzione abbiamo già tutti i dati pronti per la lettura. Infatti subito dopo possiamo controllare gruppo ed elemento con McAirGroup e McAirElement per conoscere il tipo di dato che ci è stato inviato. Ad esempio ho stabilito che il gruppo R contiene richieste di invio dati che saranno, in base all’elemento 1, 2, o 3 la velocità, la temperatura 1 o la temperatura 2. Alla richiesta di invio dati rispondiamo con la SendUART che come parametri prende il nome della porta seriale, il gruppo, l’elemento ed il dato vero e proprio. Quando l’Arduino Master riceve questi dati, li re-invia all’arduino Mega con il gruppo “D”, come “Debug”, in questo modo possiamo verificare attraverso il monitor seriale che la trasmissione si è chiusa in maniera corretta.

Arduino UNO

In questo caso il software è un po’ più complesso perchè deve prevedere qualche funzione in più, inoltre dobbiamo prevedere la presenza sia di una modalità di inoltro dei dati che una modalità di ricezione.

unsigned long time_start;
float fakespeed,faketemp1,faketemp2;

void setup() 
{
 Serial.begin(9600);
 modality=0;
 i=0;
}

void loop() 
{
 if(modality==1) // sono in modalità RICEZIONE (aspetto una risposta dal remoto)
 {
  if (Serial.available() > 0) 
  {
   GetData(Serial); // tutti i comandi sono lunghi 6 bytes, altrimenti c'è un errore.
   if(McAirGroup=='S') // S come Speed
   {
    if(McAirElement=='1') fakespeed=McAirDataFloat; // lettura della velocità
    SetModality(0); // ritorno in modalità trasmissione
   }
   else if(McAirGroup=='T') // T come Temperature
   {
    if(McAirElement=='1') faketemp1=McAirDataFloat; // lettura della temperatura 1
    if(McAirElement=='2') faketemp2=McAirDataFloat; // lettura della temperatura 2
   }
   SetModality(0); // ritorno in modalità trasmissione (se per qualche motivo aspetti più dati uno dietro l'altro, aspetterai l'arrivo dell'ultimo prima di cambiare modalità
  } // Serial.available() > 0
  else // sono in modalità ricezione ma non ho dati in arrivo...
  {
   if(millis()-time_start>2000) SetModality(0); // se non ricevo nulla per due secondi riorno in modo autonomo alla modalità di trasmissione
  }
 }
 else //sono in modalità TRASMISSIONE
 {
 if(i%100==0) // ogni 100 cicli
 {
  SendUART(Serial,'R','1',0); // richiedo la velocità dell'auto
  SetModality(1); // passa in modalità ricezione per leggere la risposta...da questo momento non posso trasmettere se non ricevo la risposta
 }
  if(i%110==0) SendUART(Serial,'D','1',fakespeed); //rimando indietro il dato come debug in modo da vederlo sul monitor seriale
  if(i%250==0) // ogni 250 cicli
 {
  SendUART(Serial,'R','2',0); // richiedo la temperatura 1
  SetModality(1); // passa in modalità ricezione per leggere la risposta...da questo momento non posso trasmettere se non ricevo la risposta
 }
 if(i%260==0) SendUART(Serial,'D','2',faketemp1); //rimando indietro il dato come debug in modo da vederlo sul monitor seriale 
 if(i%350==0) // ogni 350 cicli
 {
  SendUART(Serial,'R','3',0); // richiedo la temperatura 2
  SetModality(1); // passa in modalità ricezione per leggere la risposta...da questo momento non posso trasmettere se non ricevo la risposta
 }
 if(i%360==0) SendUART(Serial,'D','3',faketemp2); //rimando indietro il dato come debug in modo da vederlo sul monitor seriale 
 }

delay(20);
i++;
}

Anche qui abbiamo le variabili per mantenere in memoria le temperature e velocità lette, inoltre abbiamo una variabile modality che ricorda se siamo in modalità trasmissione (0) o ricezione(1). Quando è in modalità trasmissione, seconda parte del listato, ogni tot cicli invia qualche comando, in particolare ogni 100 cicli richiede la velocità alla scheda remota, ogni 110 invia alla scheda remota la temperatura ricevuta precedentemente, ogni 250 richiede la temperatura 1 e così via. Si noti che quando vengono inviati comandi di debug (‘D’) non ci attendiamo nessuna risposta, mentre quando ci attendiamo una risposta usiamo anche la funzione SetModality(1) che mette Arduino in modalità “lettura” finchè non riceve la risposta voluta. L’ultimo dettaglio di questo listato è la if(millis()-time_start>2000) SetModality(0); inserita nel ciclo di ricezione. Questa fa si che se entro 2 secondi non viene ricevuta la risposta attesa, che normalmente giunge nel giro di pochi millisecondi, ripristina la “modalità master” per evitare di bloccare il sistema in caso si sia verificata un’anomalia, ad esempio un cavo staccato, un riavvio dell’Arduino Mega o un suo blocco, etc.

Conclusioni

Lo scopo di questo articolo era sottolineare come un problema apparentemente molto semplice può nascondere molte insidie che non balzano subito all’occhio ma che necessitano di una attenta valutazione in quanto errori anche banali o la semplice mancanza di ottimizzazione del codice porterebbero al blocco di tutto il sistema o a malfunzionamenti che ne renderebbero impossibile l’utilizzo.Spero chel’articolo vi sia stato utile se non altro per chi era alla ricerca di una risposta alla domanda “perchè a me non funziona?”.




Salva