Interfaccia KFile: quando la programmazione ad oggetti (in C!) è utile nell’embedded

Interfaccia KFile: quando la programmazione ad oggetti (in C!) è utile nell'embedded

Nel nostro sistema operativo embedded (BeRTOS) ci troviamo molto frequentemente a creare driver per periferiche che trasferiscono dati. Programmiamo in C ma ricorriamo spesso all'approccio a oggetti che non è solo appannaggio di linguaggi di alto livello come il C++ e non è necessariamente affamato di risorse. Se usato nel modo giusto risolve elegantemente tutta una serie di problemi legati alla creazioni di interfacce comuni e riusabili, che permettono di risparmiare tempo nello sviluppo e spazio prezioso in memoria.

Tornando al problema, il modo che sembra più veloce ed efficiente è implementare una serie di funzioni specifiche che non fanno altro che leggere/scrivere dal dispositivo.

Prendendo d'esempio la seriale potremmo avere:

ser_init - inizializza la seriale
ser_read - legge
ser_write - scrive
ser_close - chiude la seriale

Poi, per esempio, passiamo a implementare il driver per la spi, e alla fine ci troveremmo:

spi_init - inizializza la SPI
spi_read - legge
spi_write - scrive
spi_close - chiude la SPI

Non notate nulla di strano?
Queste funzioni fanno semanticamente le stesse cose. Ok, l'implementazione farà cose diverse, ma parlando in modo generico avremo più o meno sempre bisogno di una funzione per inizializzare il dispositivo, una per leggere, una per scrivere e una per eventualmente de-inizializzare la periferica. Se ora dovessimo sviluppare un protocollo che usa la seriale, useremmo dentro al codice di detto protocollo le chiamate alle varie ser_read, ser_write, etc... E se devo usare lo stesso protocollo sulla SPI? Basta rinominare tutte le chiamate direte voi, velocissimo. Certo, ma in questo modo sono costretto a scrivere due versioni dello stesso protocollo, una con le chiamate a ser_* e una con le chiamate a spi_*. Nel caso in cui, nella stessa applicazione, dovessi usare lo stesso protocollo con entrambi i driver occuperei in memoria il doppio dello spazio!

C'è qualcosa che non va. Questa architettura costringe a riscrivere una versione personalizzata di ogni cosa di alto livello che usa driver di basso livello, anche se questi ultimi sono simili.

Non sarebbe più comodo avere sempre le stesse funzioni per fare queste cose?
Dopotutto quello che vogliamo fare è solo leggere e scrivere, perché dobbiamo preoccuparci del dove e del come? Se per esempio implemento un bootloader, perché il codice di quest'ultimo deve sapere che sto leggendo il firmware da una seriale? Il codice del bootloader è generico: legge il nuovo firmware da qualche parte, fa controlli di checksum e simili e lo riprogramma nella flash del microcontrollore. Sarebbe riusabile in altre situazioni (lettura del firmware via radio, via ethernet, da una memoria di appoggio, etc...), però in questo modo lo lego ad un driver specifico. Non posso riusarlo senza modificarlo e farne un'altra versione. Dovremmo trovare il modo di astrarre dal driver che stiamo usando e usare un'interfaccia generica. Sarebbe molto comodo avere una serie di funzioni di accesso generale da usare quando dobbiamo interfacciarci con un driver. Useremmo sempre quelle, il codice applicativo rimarrebbe sempre generico e potrebbe essere riusato in tante occasioni con risparmio di tempo di sviluppo e spazio in memoria.

Chi conosce un po' di Object Oriented Programming (OOP, come abbreviano gli anglofoni), capirà che sto parlando di avere quindi una classe base astratta che descrive un'interfaccia. Per chi non conosce l'OOP, questa "interfaccia generica", non è altro che una serie di "prototipi di funzione" che descrivono come svolgere dei compiti. Queste funzioni però sono solo astratte e non sono implementate. O almeno non sono implementate in un modo solo.

Ogni driver potrebbe implementare queste funzioni descritte in modo generico per fare davvero quello di cui ha bisogno. In OOP si dice che il driver potrebbe "derivare" da questa "classe" base e implementare i vari metodi.

Ma come si fa questo in C? Non esistono classi!
Esistono però le struct, che sono simili. Esse possono contenere solo dati, ma anche puntatori. E in particolare anche puntatori a funzione.

Ecco quindi che se definiamo dei tipi per puntatori a funzioni generiche adatte ai nostri scopi, possiamo metterli tutti insieme in una struct ... ed ecco che è nata la nostra interfaccia!

typedef struct KFile
{
    OpenFunc_t   open;
    ReadFunc_t   read;
    WriteFunc_t  write;
    CloseFunc_t  close;
} KFile;

L'abbiamo chiamata KFile, perché a tutti gli effetti descrive un file, nel senso generico di "contenitore di informazioni". In realtà la definizione che abbiamo nel nostro RTOS è più completa (potete vederla qui) ma per capire il senso anche questa qui sopra va più che bene.

Ma facciamo un passo indietro, come sono definiti questi puntatori a funzione?
Per esempio il tipo per la funzione di lettura, ReadFunc_t, potrebbe essere così:

typedef size_t (*ReadFunc_t) (struct KFile *fd, void *buf, size_t size);

Questa funzione dovrà ritornare il numero di byte letti.
Analizzando invece i parametri passati:

  • buf è il buffer in cui i dati letti verranno messi.
  • size è la lunghezza che vogliamo leggere.
  • fd è un puntatore alla struttura KFile corrente che ci serve come contesto.

Ok, bella questa interfaccia, ma a cosa serve? Come si usa? E a cosa serve il parametro fd?
Per usarla definiamo una serie di (piccole) funzioni inline di comodità che saranno quelle che chiameremo davvero nel codice che usa l'interfaccia.

Parlando sempre della lettura, potremmo avere:

inline size_t kfile_read(struct KFile *fd, void *buf, size_t size)
{
     return fd->read(fd, buf, size);
}

E così via per gli altri membri dell'interfaccia.

Perché sono necessarie? Non potrei chiamare direttamente fd->read?
Sì, potrei. Però usando questa funzione si ha un unico punto nel codice in cui passano tutte le funzioni di lettura ed è quindi comodo per inserire informazioni di debug, statistiche e/o controlli particolari. Nel nostro RTOS, per esempio, tutti questi stub contengono una verifica sul fatto che il membro che viene chiamato non sia NULL.

Questo "rimando" in più (o indirezione, come si dice in gergo), spreca cicli macchina e spazio in flash?
No, essendo inline ed essendo una sola riga, il compilatore non esegue davvero una chiamata a kfile_read e poi a fd->read ma viene inserito nel codice direttamente la sola chiamata a fd->read senza nessun spreco di risorse.

A questo punto abbiamo definito in astratto una serie di funzioni di interfaccia e sappiamo come chiamarle nel codice che le userà.

Ok, ma come posso scrivere un driver affinché implementi questa interfaccia?
Partiamo, per esempio, da un driver per una seriale. Di solito essa necessita di diverse cose per poter funzionare. Avremo per esempio bisogno di sapere quale seriale voglio aprire (spesso sono più di una). Senza considerare che sarà praticamente sempre necessario un buffer di ricezione in cui metteremo i caratteri ricevuti (di solito sotto interrupt) ed altre cose del genere.

Tutto questo potremmo definirlo "stato" della seriale. Dove lo mettiamo? Di solito in variabili statiche di un modulo in modo che siano accessibili a tutte le funzioni che lavorano con la seriale. Poi avremo bisogno di scrivere queste funzioni che svolgono i compiti che sappiamo (read, write, etc...). Se le seriali sono più di una avremo una serie di funzioni gemelle che differiscono solo per il fatto di lavorare su seriali diverse: ser0_read, ser1_read, ser2_read, etc...

Queste funzioni sono tutte praticamente identiche, tranne per il fatto che lavorano su "stati" della seriale diversi.

Ma se questo stato, invece di lasciarlo come variabili globali del modulo, lo mettiamo in una struttura già otteniamo dei benefici:

typedef struct Serial
{
   /** Physical port number */
   unsigned int unit;

   /**
    * \name Transmit and receive FIFOs.
    *
    * Declared volatile because handled asinchronously by interrupts.
    *
    * \{
    */
   FIFOBuffer txfifo;
   FIFOBuffer rxfifo;
   /* \} */

   /* ...other members... */
} Serial;

Qui, per semplificare, lo stato è rappresentato dal numero identificativo della seriale con cui voglio parlare e dai buffer FIFO di ricezione/trasmissione.

Poi cambiamo le funzioni in modo che non accedano più implicitamente e direttamente allo stato, ma attraverso un passaggio esplicito di questa struttura. Il prototipo della funzione di lettura dalla seriale potrebbe essere:

size_t ser_read(Serial *ser, void *buf, size_t len);

Quest'ultima, invece di accedere direttamente ai registri hardware e allo stato di cui ha bisogno, potrebbe prenderli in base al parametro "unit" nella struttura Serial passata per puntatore. In questo modo scrivo le funzioni di accesso alla seriale una volta sola con risparmio di tempo di sviluppo, tempo di debug e spazio in memoria flash del microcontrollore!

Forse non ve ne siete accorti, ma ormai il più è fatto e implementare l'interfaccia KFile adesso è molto semplice. Sì perché se allo stato della seriale, oltre ai dati, aggiungiamo i puntatori alle funzioni da chiamare il gioco è fatto. Non c'è bisogno di definire prototipi, lo abbiamo già fatto quando abbiamo definito la struttura KFile. Anzi in realtà potremmo proprio usarla, come primo membro (questo è importante, dopo capirete perché):

typedef struct Serial
{
   /* This driver implements a KFile interface */
   KFile fd;

   /** Physical port number */
   unsigned int unit;

   /* ...other members... */
} Serial;

Poi definiamo una funzione di "init" che setta tutti i parametri richiesti:

int ser_init(Serial *ser, unsigned int unit, ...)
{
   /* Setta il numero di seriale che vogliamo aprire */
   ser->unit = unit;

   /* Assegna le funzioni dell'interfaccia KFile da chiamare */
   ser->fd->read = ser_read;
   ser->fd->write = ser_write;
   ...
}

Le funzioni assegnate nelle ultime due righe sono quelle che davvero eseguono il lavoro e il loro prototipo dovrà essere uguale a quello definito dall'interfaccia KFile (ricordate ReadFunc_t?). Potranno essere anche statiche nel modulo seriale, perché nessuno al di fuori di quel file C le chiamerà direttamente per nome.

A questo punto il lavoro è finito, e il driver seriale può essere usato da qualsiasi funzione KFile! Come è possibile?
Ricordate che il membro KFile all'interno della struttura Serial doveva essere il primo? Se passo una struttura Serial alla funzione kfile_read() essa, siccome di fatto la prima parte di Serial è proprio una struttura KFile, troverà in memoria proprio quello che cerca. kfile_read() chiamerà fd->read che, essendo assegnata a ser_read, farà proprio quello che volete! Qui si comprende poi la necessità del parametro fd che ogni funzione KFile prende come primo argomento: è lo stato del driver che le funzioni di basso livello come la ser_read richiedono per sapere su cosa lavorare.

Una volta scritti i driver secondo l'interfaccia KFile l'accesso a questi ultimi diventa quindi totalmente generico e astratto.

Un codice di esempio potrebbe essere:

/* Dichiaro il contesto della seriale */
Serial ser0;

/* Inizializzo la seriale 0 */
ser_init(&ser0, 0); 

/* Leggo dalla seriale 0 */
kfile_read(&ser0.fd, buf, len);

Come vedete ho scelto di passare a kfile_read il puntatore al membro KFile dello stato di Serial per evitare di fare un cast. Altrimenti poteva essere anche fatto così:

/* Leggo dalla seriale 0 */
kfile_read((KFile *)&ser0, buf, len);

Queste due modalità sono equivalenti se il membro KFile all'interno dei driver è il primo, anche se quest'ultimo modo è un po' più "sporco".

Ok, tutto bello, ma si sprecano risorse?
Non necessariamente. Se guardate attentamente, per ogni driver che implementa l'interfaccia KFile, abbiamo solo un overhead dato dalla struttura KFile stessa. Essa contiene solo pochi puntatori a funzione e quindi la RAM usata è dell'ordine della decina di byte per ogni driver. Un altro fattore potenzialmente "negativo" è che le chiamate a funzione del driver non sono dirette ma indirette tramite puntatore a funzione: pochi cicli macchina in più ogni volta che chiamiamo una funzione da magari centinaia/migliaia di cicli macchina.

Ok, ma per quali driver posso usare questa interfaccia?
Molti. Se l'interfaccia che usate è progettata bene ed è sufficientemente generica non ci sono limiti. Noi in BeRTOS l'usiamo praticamente per tutto: per l'accesso alla seriale, all'SPI, a EEPROM, a Dataflash, etc...

Inoltre, in caso di driver che necessitano di operazioni particolari, è sempre possibile definire funzioni specifiche che operano solo su driver specifici: per la seriale servirà una funzione che setta il baudrate, per la EEPROM una che permette di spostarsi e indicare a quale indirizzo vogliamo leggere/scrivere e così via.

Ok, ma la domanda più importante, a cosa serve tutto questo?
Eccoci quindi al punto fondamentale. A fronte di lati negativi minimi si ottengono vantaggi molto grandi da questo approccio:

  • Se esistono più istanze possibili dello stesso driver: 4 seriali uguali, 8 memorie uguali, etc... il driver lo scrivo una sola volta invece di 4 o 8. E' vero che in questi casi potrei andare di copia-incolla, ma i bug aumentano a dismisura. Quando incollate dovete cambiare in ogni posto i riferimenti ai registri ed allo stato (qualcosa scappa sempre e per trovarlo son dolori). Quando trovate un bug in un driver dovete ricordarvi di riportarlo (con i dovuti adattamenti) alle copie. Soprattutto però sprecherete 4 o 8 volte lo spazio necessario in memoria flash!
  • Più driver diversi possono essere controllati con le stesse funzioni! Non sarà più necessario riscrivere una printf per ogni driver che avete, ne basta una sola che scrive su un KFile. Una volta definita l'interfaccia potete chiamare la vostra printf su seriali, SPI, EEPROM e display senza scrivere una riga di codice in più, con un risparmio di tempo e spazio enormi. E' sufficiente che il driver di basso livello implementi le poche funzioni definite dall'interfaccia KFile per poi avere accesso a tutte le funzioni KFile generiche (visibili qui) più tutti i protocolli di alto livello e applicazioni che ci scriverete sopra (o che troverete già fatte in BeRTOS).
  • Potrete inoltre cambiare al volo tra un driver e l'altro senza nessuna modifica al codice applicativo. Se avete una memoria EEPROM e dovete cambiarla con una flash, l'interfaccia KFile garantisce che la vostra applicazione, che scriveva in EEPROM, magicamente all'improvviso scriverà su una flash senza nessuna modifica. Se scrivete protocolli potrete implementarli in modo astratto su un KFile e poi scegliere al volo il layer fisico su cui usarli: seriale, SPI o anche Ethernet non fa differenza quando c'è un KFile!
  • Non solo si può cambiare tra driver al volo, ma si può farlo addirittura a runtime! Se quindi predisponiamo un protocollo di configurazione (oppure un meccanismo hw per sapere il numero di revisione della board), è possibile usare lo stesso firmware binario per tutte le revisioni della stessa scheda, anche se tra le varie revisioni cambia l'hardware (quindi semplificando infinitamente le procedure di produzione, di aggiornamento firmware, evitando la confusione di sbagliare versione, ecc.). Basta che a startup il firmware si configuri (o via protocollo o "scoprendo" su quale board è in esecuzione) collegando magari una periferica hardware invece di un'altra tramite KFile.

Source: http://www.bertos.org

Scarica subito una copia gratis

2 Commenti

  1. Avatar photo slovati 2 Febbraio 2009
  2. Avatar photo batt 3 Febbraio 2009

Scrivi un commento

Seguici anche sul tuo Social Network preferito!

Send this to a friend