Networking: Sockets

Valuta questo articolo 1 Voti

Autori: Lorenzo Maiorfi e Gianluca Ruta

Mettendo a confronto le possibilità offerte dalle diverse famiglie di microcontrollori disponibili sul mercato, si nota una divisione piuttosto netta nel supporto alle funzionalità di rete tra i microcontrollori a 8 bit e quelli a 16-32 bit. La principale motivazione risiede nella complessità dei protocolli che compongono il cosiddetto "stack" di rete. Ciascuno strato di tale "pila", il modello utilizzato per descrivere la struttura "a layer" definita dallo standard "ISO/OSI", è responsabile del supporto allo strato sovrastante e può utilizzare solamente i servizi forniti dallo strato sottostante.

Facendo riferimento al diagramma "OSI Model" riportato nella figura precedente, è possibile ad esempio individuare un livello "fisico" in cui il protocollo gestisce direttamente i segnali digitali veicolati dal mezzo utilizzato (il cavo bipolare, ad esempio), un livello "data link", in cui il protocollo gestisce l'informazione circa l'indirizzo dei nodi all'interno di una rete e così via, salendo di strato in strato, fino a raggiungere i layer relativi ai protocolli di alto livello, in grado di fornire servizi di comunicazione molto evoluti alle applicazioni che ne fanno uso. Sebbene il modello in questione possa rappresentare molti tipi diversi di "composizione" di protocolli, la sua implementazione più diffusa è senza dubbio quella relativa allo stack TCP/IP. Tale pila di protocolli definisce molte possibili varianti per ciascun livello, con l'indubbio vantaggio di svincolare il firmware dell'applicazione che stiamo sviluppando dal contesto richiesto da ciascuno specifico scenario.

In particolare, il modello noto come OSI/IP prevede le seguenti combinazioni di layer, elencati dal livello 1 (fisico) al livello 7 (applicativo):

1. Physical Layer: RS-232, V.35-V.90 (utilizzato nei modem), DSL, 802.11 PHY (utilizzato dal wi-fi), USB, Bluetooth, Ethernet, etc.

2. Data Link Layer: ARP (per la risoluzione degli indirizzi hardware dei nodi), PPP (utilizzato nel collegamento ad Internet via modem, tramite la mediazione di un provider), Framing Ethernet, ecc.

3. Network Layer: IP, ICMP (il protocollo reso famoso dal "ping", che utilizza a sua volta il sotto-protocollo ECHO), AppleTalk, IPX (disponibile  nelle vecchie reti Novell, oramai estinte)

4. Transport Layer: TCP, UDP, ecc.

5. Session Layer: Named Pipes, NetBIOS, ecc.

6. Presentation Layer: SSL, MIME, TLS, ecc.

7. Application Layer: HTTP, SMTP, Telnet, FTP, DNS, DHCP, ecc.

Nella nostra sperimentazione  con il .NET Micro Framework faremo riferimento ad uno degli scenari più comuni, in cui il dispositivo che programmeremo utilizzerà una connessione TCP/IP su rete Ethernet. La configurazione dei parametri di rete del dispositivo può essere effettuata tramite l'utility MFDeploy, installata dal setup del .NET Micro Framework SDK, come visibile nella figura seguente:

 
 
Socket

Il modello più utilizzato nella realizzazione di una comunicazione via rete tra dispositivi (o tra più applicazioni all'interno dello stesso dispositivo) è senza dubbio quello del "Socket". Questo oggetto rappresenta letteralmente un "connettore" logico che permette ad una applicazione di scambiare informazioni con un'altra, tipicamente in funzione all'interno di un altro dispositivo presente sulla rete. Vale subito la pena di notare che l'uso dei Socket permette la comunicazione tra due qualsiasi "nodi" all'interno di una rete IP, sia essa una rete locale LAN , una rete geografica WAN o anche Internet.

In una comunicazione via Socket si individuano due ruoli: il "client" è rappresentato dal nodo che prende l'iniziativa nello stabilire una nuova connessione; il "server" utilizza invece un Socket che viene aperto "in ascolto" di richieste di connessione da parte di un client. Ciascuno dei due estremi della comunicazione è in realtà rappresentato da una coppia di informazioni: un indirizzo IP, tipicamente espresso come quaterna di numeri 0-255 separati da un punto, come in "88.62.138.1" ed una porta IP, un numero che insieme all'indirizzo IP identifica univocamente un'istanza di Socket attiva. Tale coppia di informazioni prende nel .NET Micro Framework il nome di "IPEndPoint"; è importante notare che, mentre nel Socket server la porta dell'IPEndPoint in ascolto deve essere nota anche al client perché questo possa stabilire una connessione, la porta dell'IPEndPoint del client viene di solito scelta casualmente (tra quelle non riservate, tipicamente comprese nell'intervallo 0-1024) in fase di connessione. Una volta stabilita la connessione tra i due Socket, lo scambio di informazioni avviene in maniera full-duplex, utilizzando i metodi Send() e Receive().

Nel primo esempio che vediamo, prendiamo in esame la situazione in cui un dispositivo embedded effettua una connessione ad un servizio di rete esposto da una apposita applicazione Windows, realizzata come semplice applicazione "Console " (ossia in cui l'interfaccia utente è rappresentata da una semplice finestra terminale simile al prompt dei comandi di sistema), realizzata in questo caso con il Framework .NET "senior", ossia quello per PC.

Iniziamo proprio dal codice di quest'ultimo, riportato nel frammento di codice seguente:

namespace TCPSocketServer

{

     class Program

     {

         static void Main(string[] args)

         {

             TcpListener listener = new TcpListener(new IPEndPoint(IPAddress.Any, 1234));


             listener.Start();


             Console.WriteLine("Server started...");


             while (true)

             {

                 TcpClient client = listener.AcceptTcpClient();


                 Console.WriteLine(string.Format("{0} Connected!", client.Client.RemoteEndPoint));


                 using (StreamReader sr = new StreamReader(client.GetStream()))

                 {

                     string s = sr.ReadLine();


                     Console.WriteLine(string.Format("Received: {0}", s));


                     using (StreamWriter sw = new StreamWriter(client.GetStream()))

                     {

                         switch (s.ToLower())

                         {

                             case "exit":

                                 listener.Stop();

                                 break;


                             case "?":

                                 sw.WriteLine("TCPSocketServer 1.0");

                                 break;

                         }

                     }

                 }

             }

         }

     }

}

Possiamo notare che:

  • viene creata un'istanza della classe TcpListener, inizializzata con un IPEndPoint costituito dallo pseudo-indirizzo IP "Any", che consente di ricevere connessioni su uno qualsiasi degli indirizzi IP presenti sulla macchina, e dalla porta "1234". Le classi TcpListener e Tcpclient semplificano lo sviluppo rispettivamente di server TCP e client TCP nelle applicazioni basate sul Framework .NET "senior"; tali classi fanno da involucro ai Socket sottostanti, esposti comunque da apposite proprietà
  • la chiamata AcceptTcpClient() è bloccante e ritorna solo nel momento in cui un client stabilisce effettivamente una connessione; l'indirizzo e la porta del client sono visualizzate nella finestra terminale dell'applicazione
  • viene creato un oggetto di tipo StreamReader, inizializzato a partire dallo stream relativo al canale di comunicazione attivato. La classe Stream nel Framework .NET e tutte le sue classi derivate gestiscono il flusso di comunicazione nei vari contesti in cui questo è richiesto (ad es. per la lettura/scrittura di file su disco). Le classi StreamReader e StreamWriter consentono rispettivamente l'estrazione e l'immissione di dati all'interno di un canale rappresentato da un determinato Stream
  • viene letta dal client una stringa costituita da tutti i caratteri ricevuti prima di un terminatore di riga
  • utilizzando uno StreamWriter, viene inviata al client la risposta relativa al comando ricevuto; in questo esempio il comando "exit" chiude il Socket server, mentre il comando "?" restituisce al client la stringa "TCPSocketServer 1.0"

Per sviluppare un firmware in grado di fare da client per il server desktop appena illustrato, abbiamo utilizzato la scheda Tahoe II prodotta da Device Solutions; tale scheda integra infatti anche un transceiver ethernet 10 Mbit/s (il noto ENC28J60), connesso internamente al modulo SPI1.

Il codice relativo al client realizzato con il .NET Micro Framework  è simile alla controparte "server", sebbene non siano disponibili in questo contesto le classi di supporto TcpListener e TcpClient, ed è riportato nel frammento di codice che segue:

public static void Main()

{

    // Apertura connessione TCP al server "pip" su porta IP 1234

    using (Socket socket = Connect("pip.main.innovactive.it", 1234))

    {

        byte[] tx = Encoding.UTF8.GetBytes("?\r\n");


        // Invio al socket la stringa "?+<invio>"

        socket.Send(tx);


        // Attendo fino a che non arriva risposta...

        while (socket.Available == 0)

        {

            Thread.Sleep(100);

        }


        string rxstring = string.Empty;


        // fino a che il socket è leggibile...

        while(socket.Poll(-1,SelectMode.SelectRead))

        {

            byte[] rx=new byte[1024];


            // viene popolato il buffer...

            socket.Receive(rx);


            // concateniamo alla stringa ricevuta fino a qui...

            rxstring += new string(Encoding.UTF8.GetChars(rx));


            // se non ci sono dati disponibili in ingresso esco dal ciclo

            if (socket.Available == 0) break;

        }


        // La stringa ricevuta finora è...

        Debug.Print(rxstring);

    }

}


static Socket Connect(string server, int port)

{

     // Risolvo il nome DNS del server

IPHostEntry host = Dns.GetHostEntry(server);


     // Creo un socket TCP

     Socket sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);


// connetto il socket ad uno degli indirizzi del server (il primo), alla porta IP richiesta

sock.Connect(new IPEndPoint(host.AddressList[0],port));


// una volta connesso, restituisco il socket aperto al chiamante...

return sock;

}

 

Possiamo notare che:

  • attraverso la chiamata ad un metodo "Connect" viene stabilita la connessione al listener TCP in ascolto sulla porta 1234 dell'host identificato dal nome DNS "pip.main.innovactive.it"
  • viene inviata la stringa relativa all'unico comando riconosciuto dal server, ossia "?"+<invio>
  • dopo aver aspettato la disponibilità di dati dal socket, viene letta la risposta proveniente dal server, fino a che non ci sono più dati disponibili
  • la stringa ricevuta viene visualizzata nella finestra di debug di Visual Studio

Riguardo al metodo Connect(), a parte notare le righe relative alla creazione e connessione di un Socket in maniera simile a quanto visto negli altri esempi, vale la pena di soffermarci sul fatto che, attraverso l'esecuzione del metodo statico GetHostEntry() della classe Dns, viene valorizzato un oggetto di tipo IPHostEntry. La classe Dns è responsabile della fruizione del servizio DNS così come configurato nel dispositivo. Il servizio DNS utilizza il protocollo omonimo, uno dei protocolli applicativi dello stack IP/OSI, al fine di espletare il processo noto come "risoluzione dei nomi". All'interno di una rete IP che abbia molti nodi è infatti frequente che per identificare un determinato nodo si preferisca fare riferimento ad una stringa "amichevole" piuttosto che ad una quaterna di numeri; tale necessità è diventata assolutamente indispensabile con la diffusione delle reti geografiche, prima fra tutti, ovviamente, Internet. Il servizio DNS si occupa in sintesi della traduzione di nomi in indirizzi (e viceversa) attraverso la consultazione di un database interrogato attraverso il protocollo DNS stesso. Tale database non è però gestito da un unico provider, ma è distribuito su tutti i server DNS presenti nel mondo, ciascuno con la propria "partizione" di competenza. La natura gerarchica dei nomi DNS ha proprio lo scopo di instradare una richiesta di risoluzione nomi verso il server DNS che la sappia gestire nel più breve tempo possibile.

Quando il nostro firmware di esempio esegue la chiamata a GetHostEntry("pip.main.innovactive.it") il sistema operativo (la runtime del .NET Micro Framework in questo caso) effettua una richiesta al primo dei server DNS impostati in fase di configurazione della rete; il server DNS contattato può contenere nel proprio database l'informazione richiesta oppure no: nel primo la richiesta del client viene immediatamente esaudita, mentre in caso contrario il server DNS diventa "client" del server relativo al dominio di primo livello relativo al nome richiesto (".it", nel nostro esempio). Quest'ultimo viene contattato quindi in merito all'indirizzo del server DNS che risolve i nomi relativi al dominio di secondo livello ("innovactive.it", nel nostro esempio) e così via, fino a trovare il server DNS in grado di risolvere il nome dell'host completo.

Da un punto di vista pratico realizzare questo test è molto semplice, anche se richiede ovviamente la presenza di una rete ethernet alla quale collegare il modulo TahoeII (si veda la figura seguente). Dopo aver correttamente impostato nel codice l’indirizzo al quale il dispositivo si deve connettere (quello che nel nostro esempio era "pip.main.innovactive.it") e aver mandato in esecuzione il servizio TCPSocketServer basta eseguire in modalità Debug il codice TCPClient e osservare nella finestra di Debug il messaggio che il Server trasmette (mel nostro caso abbiamo semplicemente impostato la stringa ‘TCPSocketServer 1.0’); contestualmente nella finestra console dell’applicativo TCPSocketServer potremo leggere la diagnostica dell’avvenuto collegamento.

Possiamo a questo punto provare ad implementare la soluzione inversa, in cui il server TCP è rappresentato dal nostro dispositivo embedded ed in cui il client è un'applicazione per PC. Per rendere ancora più generico il nostro firmware, ci proponiamo di supportare, come client TCP, l'applicazione Telnet, che di fatto non è altro che un'applicazione terminale simile a quella utilizzata per le comunicazioni seriali ma che lavora su un canale di comunicazione TCP/IP. L'applicazione Telnet è presente nella maggior parte dei sistemi operativi (in Windows XP è possibile utilizzare anche Hyper-Terminal), anche se, come nel caso di Vista o Windows 7, potrebbe non essere presente nell'installazione di default. 

La mancanza della classe TcpListener complica un po' le cose, ma l'uso diretto della classe Socket in modalità "server" non è poi così complesso. Nel frammento di codice seguente esaminiamo nel dettaglio l'applicazione firmware relativa ad un server embedded che, a fronte di uno specifico comando inviato via Telnet, esegua una determinata operazione (ad es. l'attivazione di un relé, ovvero da un punto di vista del microcontrollore la scrittura di uno stato logico alto ad un terminale I/O):

using System;

using Microsoft.SPOT;

using System.Net.Sockets;

using System.Net;

using System.Threading;

using System.Text;

using Microsoft.SPOT.Hardware;

using DeviceSolutions.SPOT.Hardware;


namespace TestTCPServer

{

     public static class Program

     {

         // Uscita digitale collegata al relé da attivare

         static OutputPort _relay = new OutputPort(Meridian.Pins.GPIO1, false);


         public static OutputPort Relay

         {

             get { return Program._relay; }

         }


         public static void Main()

         {

             // Creo il socket server, in ascolto sulla porta TCP 1234

             Socket serverSocket = new Socket(

                 AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);


             IPEndPoint localEndPoint = new IPEndPoint(IPAddress.Any, 1234);


             serverSocket.Bind(localEndPoint);


             serverSocket.Listen(int.MaxValue);


             while (true)

             {

                 // Resto in attesa del collegamento di un client...

                 Socket clientSocket = serverSocket.Accept();


                 // Creo un oggetto che processi la richiesta...

                 new ClientSocketProcessor(clientSocket, true);

             }

         }

     }


     internal class ClientSocketProcessor

     {

         Socket _clientSocket;


         // Il costruttore memorizza il socket client e lancia un worker thread se richiesto

         public ClientSocketProcessor(Socket ClientSocket, bool async)

         {

             _clientSocket = ClientSocket;


             if (async)

             {

                 new Thread(processRequest).Start();

             }

             else

             {

                 processRequest();

             }

         }


         void processRequest()

         {

             string rxstring = string.Empty;


             // con il socket client...

             using (_clientSocket)

             {

                 byte[] rx = new byte[1024];


                 // fino a che è leggibile...

                 while (_clientSocket.Poll(-1, SelectMode.SelectRead))

                 {

                     if (_clientSocket.Available == 0) break;


                     int bytesread=_clientSocket.Receive(rx, _clientSocket.Available, SocketFlags.None);


                     // concateno le stringhe ricevute con quella corrente

                     rxstring += new string(Encoding.UTF8.GetChars(rx));


                     // se trovo il fine riga esco dal ciclo e processo il comando

                     if (rxstring.Length > 2 && 

                         rxstring[rxstring.Length - 2] == '\r' && 

                         rxstring[rxstring.Length - 1] == '\n') break;

                 }


                 // processing dei comandi riconosciuti

                 switch (rxstring.ToLower())

                 {

                     // comando "?"

                     case "?\n\r\n":

                     case "?\r\n":

                         // mando al client la stringa di "benvenuto"

                         _clientSocket.Send(Encoding.UTF8.GetBytes(".NET MF TCP Server 1.0\r\n"));

                         break;


                     case "on\n\r\n":

                    case "on\r\n":

                         // attivo il relé

                         Program.Relay.Write(true);


                         // mando al client la stringa di "feedback"

                         _clientSocket.Send(Encoding.UTF8.GetBytes("Relay ON\r\n"));

                         break;


                     case "off\n\r\n":

                     case "off\r\n":

                         // disattivo il relé

                         Program.Relay.Write(false);


                         // mando al client la stringa di "feedback"

                         _clientSocket.Send(Encoding.UTF8.GetBytes("Relay OFF\r\n"));

                         break;


                     case "toggle\n\r\n":

                     case "toggle\r\n":

                         // scambio lo stato del relé

                         Program.Relay.Write(!Program.Relay.Read());


                         // mando al client la stringa di "feedback"

                         _clientSocket.Send(

                             Encoding.UTF8.GetBytes("Relay " +

                                  (Program.Relay.Read() ? "ON" : "OFF") + "\r\n"));

                         break;

                 }

             }

         }

     }

}

Le parti più significative del server TCP appena illustrato sono le seguenti:

  • nelle righe precedenti il metodo Main() viene definita la porta digitale utilizzata per pilotare il relé ed una proprietà statica solo "get" per renderla visibile ad altre classi
  • nel metodo Main() anzitutto viene creato e configurato un nuovo Socket in ascolto sulla porta 1234 di tutti gli indirizzi IP disponibili sul dispositivo (anche se di norma, a differenza di quanto avviene in un PC, in un dispositivo di questo tipo è presente un'unica interfaccia di rete, per la quale è definito un solo indirizzo IP, oltre ovviamente allo pseudo-indirizzo di loopback 127.0.0.1)
  • al momento della connessione di un nuovo client, viene creata una nuova istanza dell'oggetto ClientSocketProcessor, la cui funzione è quella di provvedere al processing delle richieste che arriveranno da parte di quel client
  • al momento della creazione della nuova istanza della classe ClientSocketProcessor, il costruttore valuta il parametro booleano "async" per eseguire il metodo processRequest(), che effettivamente si fa carico della richiesta TCP, nello stesso thread che ha eseguito il metodo Accept() del socket oppure utilizzando un thread separato al fine di rendere disponibile il socket in ascolto per altri client ed aumentare di conseguenza la scalabilità del servizio. E' importante notare infatti che è possibile creare più istanze della classe Socket che siano in ascolto delle richieste provenienti allo stesso indirizzo e sulla stessa porta, purché al momento del collegamento di ogni nuovo client un thread stia eseguendo il metodo Accept() del socket server
  • viene costruita la stringa relativa alla richiesta del client, attraverso la concatenazione dei vari frammenti in ingresso, fino a che non viene rilevata la presenza dei caratteri di fine-riga
  • viene processato il comando ricevuto: al comando "on" viene attivata la porta che pilota il relé, al comando "off" viene disattivata, al "toggle" viene invertito lo stato. In tutti i casi, viene dato un feedback testuale al client

Per il test pratico ancora una volta basta collegare il modulo TahoeII alla nostra rete locale oltre che al PC tramite porta USB per la programmazione L’azione che vedremo a seguito dei comandi impartiti tramite rete è in realtà il passaggio allo stato logico alto o basso del piedino di uscita GPIO10, operazione effettuabile tramite un semplice voltmetro. Stavolta è il firmware implementato a fungere da server in ascolto sulla porta 1234 dell’indirizzo IP assegnato al TahoeII. Tale servizio va interrogato tramite l'applicazione Telnet in modo estremamente semplice: basta eseguire a riga di comando (per sistemi Windows) "telnet 192.168.0.237 1234", in cui assumiamo che l'indirizzo configurato per il dispositivo sia 192.168.0.237, e digitare all'interno della finestra terminale il comando "on"+<invio>; se è tutto a posto, al momento dell'invio dovremmo vedere la tensione al piedino GPIO10 rispetto al piedino GND salire a circa 3.3V mentre la finestra terminale ci segnala l'avvenuta ‘accensione’ e la successiva disconnessione, come mostrato nella figura seguente. Analogo è il funzionamento per i comando “off”+<invio> e “toggle”+<invio>, mentre al comando “?”+<invio> il server implementato dal firmware risponderà con la stringa “.NET MF TCP Server 1.0”.