Comunicazione seriale asincrona (UART)

Valuta questo articolo 1 Voti

Autori: Lorenzo Maiorfi e Gianluca Ruta

I moduli UART presenti all'interno del microcontrollore sovrintendono alla gestione delle comunicazioni seriali asincrone, quelle in cui cioè non è previsto un segnale di clock separato dal segnale dati, ma in cui la sincronizzazione avviene seguendo un timing condiviso tra i due interlocutori. In moltissime applicazioni, questo tipo di comunicazione serve per coordinare l'azione di più dispositivi separati che interagiscono al fine di realizzare una funzione  generale più complessa e articolata. Non di rado, uno dei due interlocutori in una comunicazione seriale con il microcontrollore è il PC, che, con un'interfaccia RS-232 implementata direttamente nel chipset della scheda madre o tramite un "bridge" USB (tipicamente basato su chip FTDI, Prolific o Silicon Labs), può diventare parte di una soluzione embedded.

Per familiarizzare con le classi del .NET Micro Framework dedicate alla comunicazione seriale/UART, abbiamo realizzato un firmware che implementa un protocollo personalizzato per la gestione di una porta I/O. Come hardware di riferimento abbiamo utilizzato come già fatto nella scorsa puntata la scheda di sviluppo USBizi prodotta da GHIElectronics, ma, a parte la mappatura del Pin relativo alla porta da controllare e la porta UART da utilizzare (in un microcontrollore ne è presente molto spesso più di una), il firmware è compatibile con tutte le piattaforme hardware che implementano il .NET Micro Framework.

Dal menù "Nuovo->Progetto" di Visual Studio, scegliamo di creare un nuovo progetto di tipo .NET Micro Framework Console Application. L'ambiente di sviluppo creerà per noi uno "scheletro" di applicazione, rappresentato dalla dichiarazione di una classe, denominata "Program", che definisce il contenitore logico all'interno del quale viene effettivamente eseguito il firmware che svilupperemo. La principale responsabilità della classe Program è senz'altro quella di esporre il metodo "Main()" (statico, ossia non legato alle singole istanze di questa classe, ma alla classe stessa), che rappresenta il punto di ingresso nell'esecuzione del firmware.

La struttura dell'applicazione "scheletro" è pertanto rappresentata dal frammento di codice 1 (in cui sono omesse le dichiarazioni "using" per brevità):

namespace UARTTest
{
   public static class Program
   {
       public static void Main()
       {
           // Corpo del metodo Main()
       }
   }

}

Le funzionalità relative alla gestione delle porte UART sono implementate dalla classe SerialPort, definita all'interno dell'assembly "Microsoft.SPOT.hardware.SerialPort", che dovremo referenziare come riferimento esterno del nostro progetto attraverso la funzionalità "Aggiungi Riferimento" ("Add Reference", nella versione in inglese di Visual Studio). Una volta referenziato l'assembly in questione, possiamo dichiarare il riferimento ad una istanza di SerialPort mediante la definizione di un "field", ossia una variabile incapsulata all'interno dell'oggetto contenitore che ne rappresenta parte dello stato (come visto nella prima puntata del corso), con la riga riportata nel frammento di codice 2.

static SerialPort _uart = new SerialPort("COM1", 9600); 

Si noti che il riferimento in questione viene valorizzato contestualmente alla dichiarazione, utilizzando l'operatore "new" del C#. L'esecuzione di tale operatore provoca due effetti: la runtime del framework dapprima alloca una quantità di memoria sufficiente per contenere l'oggetto da istanziare (quantità rappresentata dalla somma dei field in esso contenuti) e successivamente invoca un suo particolare metodo denominato "costruttore", di cui possono essere definite più forme differenziate dal numero e dal tipo di parametri accettati. E' facile evincere poi il significato dei due parametri passati al costruttore: il primo indica il nome della porta UART da utilizzare (tipicamente alla porta COM1 è associata la UART0 del microcontrollore), mentre il secondo indica il baud rate da utilizzare.

Una volta creato l'oggetto SerialPort, la UART "sottostante" non è ancora pronta per la comunicazione, fino a che non viene espressamente "aperta", mediante il metodo Open(). Una volta aperta la porta, la nostra applicazione (rappresentata dalla classe Program) dovrà "iscriversi" alle notifiche di ricezione dati provenienti da essa mediante la sottoscrizione all'evento denominato "DataReceived", mediante il comando illustrato nel seguente frammento di codice 3:

_uart.DataReceived += new SerialDataReceivedEventHandler(_uart_DataReceived); 

L'identificativo "_uart_DataReceived" fa riferimento ad un metodo della classe Program che assumerà quindi la funzione di gestore di evento relativamente all'evento "DataReceived". L’implementazione specifica di tale metodo nel nostro firmware di esempio è riportata nel frammento di codice 4:  

static void _uart_DataReceived(object sender, SerialDataReceivedEventArgs e) 

{ 

  if(e.EventType!=SerialData.Chars) return; 


  int buflen=_uart.BytesToRead; 


  byte[] rxdata=new byte[buflen]; 


  _uart.Read(rxdata,0,buflen); 


  byte[] buf = new byte[rxdata.Length]; 

  int i = 0; 


  foreach (byte b in rxdata) 

  {   

      if (b >= 48 && b <= 57 || b >= 65 && b <= 90 || b >= 97 && b <= 122 || b==10 || b==13 || b==32) 

      { 

          buf[i++] = b;    

      } 

  } 


  lock (_syncLock) 

  { 

      _inBuffer+=new string(Encoding.UTF8.GetChars(buf)); 


      parseCommands(); 

  } 
} 

Dietro alla apparente complessità del metodo è possibile individuare poche semplici operazioni svolte:

  • con la prima istruzione ‘if’ se l'evento non si riferisce alla ricezione di caratteri (ma di fine flusso, ossia EOF) il metodo non svolge alcuna operazione, ignorando di fatto la notifica
  • viene predisposto un buffer, rappresentato da un array di variabili di tipo "byte", avente la capacità sufficiente a contenere la quantità di dati effettivamente ricevuta, rappresentata dal valore della proprietà "BytesToRead" dell'oggetto SerialPort utilizzato
  • viene popolato il buffer allocato con i dati provenienti dalla porta UART
  • viene allocato un secondo array che conterrà una copia "ripulita" del buffer precedente
  • viene scorso l'intero contenuto dell'array contenente i dati "grezzi" provenienti dalla porta UART, al fine di conservare soltanto quelli permessi dal protocollo che si desidera implementare. Nel nostro caso conserveremo solo i caratteri alfanumerici, maiuscoli e minuscoli, gli spazi e i due caratteri di fine riga, ossia il "carriage return" ed il "line feed"
  • i byte ricevuti e ripuliti vengono "trasformati" in una stringa, mediante la codifica UTF8 (che coincide con la ASCII per i caratteri standard) e concatenati alla stringa "_inBuffer" (definita come field della classe Program), che rappresenta il testo ancora da "interpretare" da parte del nostro processore di comandi, invocato contestualmente mediante il metodo "parseCommands()"

E' importante notare il frammento incluso all'interno di una sezione "lock". Tale costrutto ha come scopo di impedire che più "thread" (ossia percorsi paralleli di esecuzione) possano eseguire delle operazioni critiche che devono essere necessariamente eseguite in maniera non concorrente. In questo caso, poiché il thread che esegue il gestore di evento "_uart_DataReceived" è effettivamente diverso da quello che esegue il metodo Main() del nostro oggetto "applicazione" (ossia Program), il rischio di concorrenza di lettura/scrittura della stringa "_inBuffer" è molto concreto. Una operazione di lettura che si dovesse sovrapporre con una di scrittura potrebbe portare a problemi che vanno dalla mancata acquisizione di dati in ingresso alla porta UART alla scrittura di memoria protetta, con conseguenze che porterebbero molto probabilmente al crash del firmware.

L’interpretazione dei comandi implementati dal nostro protocollo avviene all'interno del metodo parseCommands(), riportato nel frammento di codice che segue:

private static void parseCommands() 

{ 

  string[] tokens=_inBuffer.Split('\r'); 


  if (tokens.Length == 1) return; 


  for (int i=0;i<tokens.Length-1;i++) 

  { 

      _commandQueue.Enqueue(tokens[i]); 

  } 


  _inBuffer=tokens[tokens.Length-1]; 

} 

  Nel dettaglio tale metodo esegue le seguenti operazioni:

  • viene definito un array di stringhe attraverso la separazione del buffer ricevuto in "token", ossia in frasi separate dal carattere "\r" (carriage return), utilizzato come delimitatore di fine comando nel nostro protocollo
  • se non è stato individuato almeno un terminatore comando, l’interpretazione non può essere effettuata, quindi il metodo esce senza fare altro
  • vengono accodate le stringhe complete all'interno di un oggetto di tipo "Queue", un contenitore che implementa il tipico comportamento di una coda (ossia il cosiddetto paradigma "fifo": first-in-first-out)
  • il buffer parziale viene aggiornato con l'ultimo comando non terminato

I comandi accumulati nella coda vengono continuamente estratti ed eseguiti dal ciclo contenuto all'interno del metodo Main(), in cui il firmware entra una volta esaurita la fase di inizializzazione. Il ciclo in questione è riportato nel frammento di codice che segue:

bool exit = false; 


while (!exit) 

{ 

     // Matching dei comandi riconsciuti 

     switch (getLastCommandLocked()) 

     { 

         case "led on": 

             _ledPort.Write(true); 

             writeToUart(OK); 

             break; 


         case "led off": 

             _ledPort.Write(false); 

             writeToUart(OK); 

             break; 


         case "led toggle": 

             _ledPort.Write(!_ledPort.Read()); 

             writeToUart(OK); 

             break; 


         case "exit": 

             exit = true; 

             break; 


         case null: 

             break; 


         default: 

             writeToUart(ERR); 

             break; 

     } 

} 

Nel dettaglio possiamo notare che:

  • viene definita una variabile locale di tipo booleano (true/false) che contiene la condizione di fine ciclo (relativa al comando "exit")
  • viene eseguita l'azione corrispondente al comando ricevuto, con il seguente insieme di comandi previsti: "led on", "led off", "led toggle", "exit", il cui significato è facilmente deducibile
  • vengono gestiti i due casi "anomali" relativi rispettivamente al comando "nullo" e non previsto

L'accesso al primo comando della coda viene effettuato in maniera sincronizzata attraverso il metodo riportato nel frammento di codice seguente:

private static string getLastCommandLocked() 

{ 

     lock (_syncLock) 

     { 

         if (_commandQueue.Count == 0) return null; 


         return _commandQueue.Dequeue() as string; 

     } 

} 

La scrittura sulla porta UART da parte del microcontrollore avviene tramite un metodo che utilizza il metodo Write() dell'oggetto SerialPort, riportato nel frammento di codice 8:

 

private static void writeToUart(string txt) 

{ 

     _uart.Write(Encoding.UTF8.GetBytes(txt), 0, txt.Length); 

} 

Veniamo dunque ad illustrare il semplice circuito di test implementato (lo vedete illustrato nella figura seguente):

Come detto utilizziamo la scheda USBizi prodotta da GHIElectronics, un convertitore USB/seriale con a bordo un chip SiLabs (ne esistono molti in commercio, tutti praticamente equivalenti) e un pc con 2 porte USB (una la utilizziamo per programmare il chip NXP della scheda USBizi, mentre l’altra la utilizziamo per il dialogo seriale oggetto del test). Oltre all’ambiente di sviluppo consigliamo di utilizzare il software TeraTerm, un programma di comunicazione in emulazione terminale gratuito e ben fatto: lo trovate all’indirizzo http://www.tinyclr.com/dl. Una volta realizzato il circuito e scritto il firmware nel chip della USBizi potete far partire TeraTerm e aprire una finestra terminale sulla porta COM che è stata aggiunta al sistema operativo una volta connessa la porta USB al convertitore USB/seriale SiLabs. Il protocollo di comunicazione che utilizziamo è semplicissimo:

  • ‘led on’ accende il led
  • ‘led off’ spegne il led
  • ‘led toggle’ cambia lo stato del led
  • ‘exit’ chiude la comunicazione

Alla ricezione di un comando riconosciuto il microcontrollore risponde con ‘ok’, mentre ad ogni altro comando risponde ‘err’. Una finestra TeraTerm di esempio è riportata nella figura seguente: