di Alessandro Rubini
Riprodotto con il permesso di Linux Magazine, Edizioni Master.
Una descrizione, con un esempio funzionante, di come si creano periferiche di memorizzazione usando il sottosistema SCSI del kernel.
Uno dei tipi principali di periferica è l'unità di memorizzazione, tipicamente rappresentata da un disco. Tali periferiche sono normalmente gestite dai cosiddetti device driver a blocchi, cui si accedede tramite un file speciale di tipo b nella directory /dev, come per esempio /dev/hda. La realizzazione di un driver a blocchi risulta però alquanto laboriosa, perché richiede la gestione di varie strutture dati associate al trasferimento dati come code di buffer e di richieste.
Un modo decisamente più semplice per pilotare una periferica di memorizzazione è appoggiarsi al sottosistema SCSI, scrivendo un driver che si presenti come un host SCSI che controlli un'unità disco.
È questo il meccanismo usato dal modulo usb-storage, che registra degli host SCSI per presentare le periferiche USB come dischi SCSI, provvedendo ad decodificare i comandi SCSI traducendoli in transazioni USB.
Questo articolo spiega come gestire una periferica di memorizzazione
tramite il sottosistema SCSI riferendosi ad un pacchetto di esempio
chiamato sdm (SCSI Disk in Memory) che implementa un ramdisk. Il
codice completo è disponibile come
http://www.linux.it/kerneldocs/scsi-storage/src.tar.gz
ed è stato
sviluppato sotto Linux-2.6.8.1. Per lo sviluppo mo
sono basato pesantemente sui file
contenuti in drivers/usb/storage
, puntando a semplificare
l'implementazione anche se a discapito della completezza e della
stabilità.
Storicamente, i controllori SCSI si chiamano host, termine che viene più comunemente usato per indicare i grossi server (o i comuni PC di oggi, equivalenti ai grossi server di pochi anni fa). In ambito SCSI, il termine è un'abbreviazione di host adapter, cioè l'interfaccia tra il cavo SCSI e la macchina ospite (host, appunto). Tale lessico è stato ripreso in ambito USB, dove le sigle UHCI, OHCI ed EHCI che si riferiscono ai controllori USB indicano proprio Host Controller Interface ed i loro driver risiedono in drivers/usb/host.
|
Il supporto per le periferiche SCSI si trova nella directory drivers/scsi, nella quale vengono definiti vari moduli: l'infrastruttura di base (scsi), il supporto per i dischi (sd) i CDROM (sr) e altri tipi di periferiche, i driver per i vari controllori hardware.
Per la gestione di periferiche di memorizzazione è necessario avere nel proprio kernel il supporto di base e quello per i dischi. Se si sceglie di caricare tale codice come moduli del kernel, i nomi dei moduli da caricare saranno scsi_mod e sd_mod.
Al caricamento di scsi_mod verranno generati i seguenti messaggi:
mentre il caricamento di sd_mod corrisponderà a questo messaggio:
<7>device class 'scsi_host': registering
<7>bus type 'scsi' registered
<7>device class 'scsi_device': registering
<5>SCSI subsystem initialized
Si noti come abbia scelto di presentare i messaggi nella loro
forma grezza, completi di priorità, come appaiono in
/proc/kmsg (e come spiegato nel numero 40 di Aprile 2004).
<7>bus scsi: add driver sd
Ipotizzando che non ci siano controllori SCSI sulla macchina usata per le prove, il driver scsi non avrà alcun bus associato, come si può verificare in /proc/scsi/scsi . Anche il driver sd non starà gestendo alcun disco, ed non risulterà in uso, anche se /proc/devices mostrerà i major number associati ai dischi SCSI. A questo punto è ancora possibile rimuovere il driver sd e successivamente il modulo scsi.
Perché il sistema possa gestire un disco, occorre prima di tutto registrare nel driver SCSI un controllore (host), sul cui bus si possa successivamente istanziare il disco.
Per attivare un controllore, occorre allocare una struttura dati di
tipo struct Scsi_Host
, darla in gestione al driver SCSI e
richiedere la scansione del bus (virtuale, in questo caso).
Le tre funzioni che occorre chiamare per fare questa operazione
sono le seguenti, dichiarate in <scsi/scsi_host.h>
:
qui, la struttura
struct Scsi_Host \*scsi_host_alloc(struct scsi_host_template \*, int);
int scsi_add_host(struct Scsi_Host \*, struct device \*);
void scsi_scan_host(struct Scsi_Host \*);
scsi_host_template
serve a definire le caratteristiche
del nostro host e viene usata come riferimento per la creazione di
una nuova struttura Scsi_Host
. Con lo stesso schema si possono
più di un host, se necessario.
L'argomento di tipo struct device *
viene usato per
inserire questo host nella struttura complessiva di sistema relativa
ai driver. Nel programma di esempio questa funzionalità, come altre,
non viene utilizzata per non appesantire il codice e concentrarsi
solo sul nocciolo del problema.
Il momento in cui chiamare queste funzioni dipende dalle caratteristiche del driver. Nel caso di usb-storage esse vengono chiamate nella fase di probe del bus USB (che avviene all'inserimento del cavo USB, o subito se la periferica è già collegata): ogni periferica di tipo usb storage viene istanziata come un nuovo controllore SCSI mentre l'inizializzazione del modulo non fa altro che registrare la funzione di probe nel sottosistema USB. Nel caso del nostro programma di esempio, invece, il nuovo controllore SCSI viene creato e scandito durante l'inizializzazione del modulo, in quanto la nostra periferica, la RAM, è già disponibile nel sistema.
Il programma di esempio si chiama sdm, cioè "SCSI Disk in Memory",
anche se il file dei sorgenti si chiama src.tar.gz
come per tutti
gli altri articoli di questa serie.
Una caratteristica fondamentale delle periferiche di memorizzazione è la loro persistenza nel tempo, cosa che non si applica invece alla RAM. Per simulare la rimozione e il successivo ricollegamento di un disco, l'esempio sdm è stato diviso in due moduli del kernel: sdm-data per gestire l'area dati e sdm-code per implementare il controllore SCSI. E` cosi` possibile rimuovere il modulo sdm-code e reinserirlo successivamente senza perdita di dati nel ramdisk.
Il codice di sdm-data è estremamente semplice: si tratta di
un modulo configurabile tramite due parametri interi (sdm_size
e sdm_verbose
) che esporta alcuni simboli in modo che sdm-code
possa usufruirne. Il codice completo di sdm-data
è rappresentato
nel riquadro 2.
sdm-data
alloca un vettore di puntatori,
in cui verranno successivamente memorizzate le pagine di memoria
associate al ramdisk. Inizialmente vengono allocati
solo i puntatori ed e` l'uso del ramdisk che porterà
ad allocare lo spazio dati necessario. Si noti che
non è possibile, a questo livello, liberare la RAM quando
l'occupazione del ramdisk diminuisce.
Nel momento in cui sdm-data sia eliminato dal sistema, tutto il ramdisk viene liberato e la memoria torna disponibile.
I due parametri del modulo sono sdm_size
, che specifica la
dimensione in megabyte del ramdisk (valore predefinito: 128), e
sdm_verbose
che controlla il livello di prolissità del driver:
0 per nessun messaggio, 1 (default) per stampare i nomi dei comandi SCSI,
2 per stampare ulteriori dettagli delle operazioni di lettura e
scrittura sul disco.
|
La gestione vera e propria del controllore SCSI viene fatta dal modulo sdm-code, che consta di circa 400 righe di codice, piu` una tabella di stringhe per i messaggi di diagnostica. La procedura di inizializzazione (sdm_init) registra il controllore SCSI e chiede la scansione del bus, che porterà all'identificazione di un disco SCSI della dimensione specificata in sdm-data.
Se il parametro sdm_verbose è posto a zero, i messaggi stampati al
caricamento del modulo saranno quelli riportati nel riquadro 3. Si
noti come dopo aver rimosso e ricaricato il modulo il nome scsi0
viene sostituito da scsi1
(e così via in seguito), ma questa gestione degli
identificativi non e` dovuta ad un problema nell'allocazione e nel
rilascio delle risorse.
Le prime 7 righe riportate nel riquadro dei messaggi indicano una delle leggerezze del codice di esempio: non è stata implementata nessuna gestione dell'errore. Tale mancanza è considerato così grave da causare una chiamata alla funzione dump_stack, che decodifica lo stack del processo corrente per aiutare ad identificare il problema; tale decodifica occupa le quattro righe dopo le tre contrassegnate da "ERROR". Nonostante questo straziante grido di dolore, il codice del driver SCSI si comporta correttamente anche in mancanza di una gestione degli errori, che risulta in effetti superflua per un host che non genera errori né timeout.
Per semplificare al massimo il codice di sdm_data, sia la
dimensione del blocco hardware sia la dimensione massima di un
trasferimento dati sono definiti pari a PAGE_SIZE
; in questo modo
ogni operazione di lettura o scrittura deve solo trasferire il
contenuto di una pagina, evitando cicli iterativi e calcoli di
offset. La dimensione massima di un trasferimento è una
caratteristica del bus, definita in
sdm_host_template
(lo schema usato per la creazione dell'host),
mentre la dimensione del blocco hardware è una caratteristica
del disco e
viene riportata insieme alla dimensione totale in risposta al comando
SCSI READ_CAPACITY
.
Riquadro 3 - I messaggi di sdm-code
|
Tutte le operazioni sul controllore, a partire dalla scansione delle periferiche, vengono effettuate tramite l'invio di comandi, il cui formato è standardizzato. Normalmente, tali comandi vengono inviati direttamente alle periferiche SCSI e possono essere elaborati anche in maniera asincrona. Nel nostro caso semplificato i comandi vengono elaborati solo sequenzialmente in base a quanto specificato nella struttura host_template.
Il riquadro 4 mostra i comandi SCSI effettuati durante la scansione
del bus; si noti come INQUIRY
viene effettuato per 8 periferiche
SCSI, ma sdm-code risponde solo per l'identificativo 0, al quale
vongono diretti ulteriori comandi. La stampa dei nomi dei comandi
SCSI, che avviene solo se sdm_verbose
è maggiore di zero, usa una tabella
di stringhe derivata da <include/scsi/scsi.h>
.
Riquadro 4 - I comandi di scansione del bus
|
I comandi SCSI sono descritti da una struttura Scsi_Cmnd
, che
purtroppo viene definita nel file drivers/scsi/scsi.h, al di fuori
dell'albero include del kernel. Il
metodo canonico, usato per esempio in
drivers/usb/storage, per includere questo file consiste nello
specificare
nel Makefile e includere
EXTRA_CFLAGS := -Idrivers/scsi
"scsi.h"
nel sorgente C. Un via alternativa
sarebbe includere <../drivers/scsi/scsi.h>
senza modificare il Makefile.
In sdm-code mi sono attenuto alla forma suggerita dagli autori del kernel.
Il puntatore alla struttura Scsi_Cmnd
viene chiamato in modi
diversi nei vari file sorgente del kernel; sdm-code usa lo stesso
nome scelto da usb-storage, su cui si basa: srb
(SCSI Request
Block), che richiama il nome urb
(USB Request Block).
Il sottosistema SCSI chiede l'esecuzione di comandi (o "richieste")
chiamando una funzione dell'host interessato, alla quale viene
passata la richiesta stessa e una funzione da invocare a completamento
della richiesta; nel nostro caso le richieste vengono ricevute da
sdm_queuecommand
, che consta di poche righe:
Il ruolo di
static int sdm_queuecommand(Scsi_Cmnd *srb,
void (*done)(Scsi_Cmnd *))
{
srb->scsi_done = done;
sdm_dev.srb = srb;
up(&sdm_dev.sema);
return 0;
}
sdm_queuecommand
, quindi, è quello di incapsulare
le informazioni nella struttura sdm_dev
e poi delegare ad altri
l'effettiva esecuzione del comando e l'invocazione della funzione
done al completamento dello stesso.
L'uso della delega è estremamente importante perché la funzione
queuecommand
può essere invocata in qualsiasi circostanza, anche
in un contesto di interruzione. In tale contesto il codice del kernel
non può eseguire alcuna operazione "bloccante", cioe` le
operazioniin cui occorre
attendere un evento; sappiamo bene, pero`, che
una richiesta di trasferimento dati deve
normalmente attendere che il disco effettui il trasferimento e
comunichi al driver di aver completato
l'operazione. Tale comunicazione, che spesso avviene tramite
un'interruzione, è spesso proprio ciò che causa l'inoltro della
richiesta successiva, che viene quindi accodata mentre
ci si trova in un
contesto di interruzione.
Nel caso di sdm-code, che ricalca ancora una volta usb-storage,
queuecommand
delega l'esecuzione dei comandi ad un processo che,
come tutti i processi, può attendere che si verifichino eventi esterni senza
causare danni al sistema e senza perdere il proprio contesto di
esecuzione. Tale processo è un kernel_thread che viene
risvegliato dal semaforo sdm_dev.sema
nell'ultima istruzione
di sdm_queuecommand
Il thread relativo al dispositivo sdm viene creato tramite la chiamata a kernel_thread. La funzione che implementa il thread è mostrata nel riquadro 5.
La chiamata a daemonize (definita in kernel/exit.c) ha l'effetto di staccare completamente il processo corrente dallo spazio utente, permettendogli di lavorare come kernel thread senza occupare risorse inutili. L'argomento passato è il nome da dare al processo, visibile tramite il comando ps. Si tratta della codifica in ambito kernel della funzione descritta da Stevens (e altri) in contesto di rete per creare processi "demoni".
La funzione comunica di essere pronta invocando complete per poi
entrare in un ciclo infinito, dal quale uscire solo quando
un campo nella struttura sdm_dev
indica che è stata richiesta
la terminazione del thread. In tale ciclo infinito, la funzione
attende (tramite down_interruptible) il rilascio del semaforo,
che avverra` in smd_queuecommand quando arriva una nuova richiesta SCSI.
In caso di uscita dal ciclo di elaborazione, la funzione complete_and_exit comunica la distruzione del processo ed esce (si veda il riquadro 6).
Nonostante la parte piu` impegnativa del thread sia stata nascosta in sdm_scsi_command, si tratta di una funzione estremamente semplice che mostra fedelmente come deve comportarsi un processo associato ad un trasferimento dati su bus SCSI; l'unica differenza nel caso di periferiche reali e` nella presenza di attese, timeout, interruzioni ed errori, che in questo caso non si verificano.
|
struct completion e le funzioni associate sono dichiarate in
<linux/completion.h> e definite in kernel/sched.c (tranne
completeandexit, che si trova altrove).
Si tratta di strumenti per notificare ad uno o più processi il realizzarsi di un evento. La struttura dati contiene una variabile intera e una coda di attesa: il processo che chiama wait_for_completion dovrà aspettare finchè una chiamata a complete non abbia incrementato la variabile, che sarà subito decrementata nuovamente dal processo appena svegliato. Questo meccanismo permette, tramite una sola riga di codice per processo ed una struttura dati condivisa, di realizzare in modo generalizzato punti di sincronizzazione tra due o più processi, senza corse critiche. Nel nostro caso la stessa struttura dati viene usata due volte: per notificare l'avvenuto avvio e l'avvenuta uscita del thread. Poiché i due eventi vengono attesi e notificati nello stesso ordine, l'uso della stessa struttura dati non crea problemi. |
Un comando (o richiesta) SCSI viene eseguito decodificando il buffer
binario ricevuto dall'host: il primo byte indica il tipo di comando e i byte
successivi ne specificano i parametri. Ad esecuzione avvenuta, il campo
srb->result
dovra` contenere il risultato dell'operazione: in
caso di successo si tratta del valore SAM_STAT_GOOD
(zero), dove
il prefisso SAM indica "SCSI Architecture Model". Una volta
assegnato il campo result
, viene invocata la funzione done
che era stata passata come argomento a queuecommand.
L'implementazione dei comandi in sdm-code
risulta abbastanza
semplice, anche perché il numero di comandi diversi e` molto limitato.
Il comando START_STOP
serve per accendere o spegnere la periferica e
in questo caso viene ignorato. Il comando INQUIRY
è gestito da
sdm_fill_inquiry; la risposta presenta la periferica come un disco e
restituisce le stringhe identificative che vediamo riportate nel
riquadro 3.
La lettura e la scrittura vengono effettuate allocando e copiando
pagine dal vettore sdm_pages
, esportato da sdm-data. L'unica
peculiarità di queste funzioni sta nella necessità di accedere
ad un buffer in cui i numeri sono memorizzati in formato "big endian"
e i dati non sono allineati a dovere. Il codice contiene quindi
espressioni come la seguente:
La funzione be16_to_cpu converte interi a 16 bit da
big-endian al formato nativo del processore (little-endian nel caso del PC),
mentre get_unaligned è descritta nel riquadro 7.
be16_to_cpu(get_unaligned((u16*)(srb->cmnd+7)))
Il comando TEST_UNIT_READY
ritorna sempre successo, mentre
MODE_SENSE
permette di specificare se il disco è scrivibile
o in
sola lettura; il codice relativo è presente ma disabilitato in sdm
(che si presenta sempre come disco scrivibile).
L'ultimo comando gestito da sdm è ALLOW_MEDIUM_REMOVAL
. Si
tratta di un meccanismo tramite il quale il driver SCSI permette o
inibisce la rimozione della periferica: ogni volta che il disco viene
usato (tramite open(2) o mount(2)) se ne impedisce la
rimozione; quando l'uso della periferica
ha termine viene autorizzata la rimozione.
sdm implementa questo comando tramite le funzioni try_module_get
e module_put, per incrementare o decrementare il numero di
riferimenti al modulo
corrente. Tali funzioni rimpiazzano le precedenti, ora deprecate,
MOD_INC_USE_COUNT
e MOD_DEC_USE_COUNT
:
if (srb->cmnd[4] & 1) /* removal is denied */
try_module_get(THIS_MODULE);
else /* removal is allowed */;
module_put(THIS_MODULE);
srb->result = SAM_STAT_GOOD;
Normalmente, una variabile a 16 bit dovrebbe essere memorizzato a partire
da un indirizzo pari mentre una a 32 bit dovrebbe stare ad un
indirizzo multiplo di 4, e cosi` via. Molte famiglie di processori
generano una interruzione quando la richiesta
di accesso in memoria non e` allineata; in certi casi il sistema
operativo simula l'esecuzione dell'istruzione sbagliata, in certi
altri viene semplicemente segnalato un "Bus Error" al processo,
giusta punizione per programmatori male educati.
I progettisti hardware preferiscono fare bene i processori (meno transistor, più velocità, meno consumo, meno riscaldamento) anche se questo richiede un po' di disciplina da parte dei programmatori (o piu` che altro da parte dei compilatori). Per questo motivo, ogni accesso a valori di piu` byte non allineati in memoria dovrebbe essere effettuato tramite istruzioni speciali, che leggano o scrivano un byte alla volta: meglio essere 4 volte più lenti trasferendo 4 byte uno alla volta piuttosto che 4000 volte più lenti generando un'interruzione e un'emulazione software dell'istruzione.
Le funzioni getunaligned e putunaligned sono definite in
Naturalmente non bisogna stupirsi se le funzioni di accesso non allineato su piattaforma PC sono macro che fanno un accesso normale (e non allineato): l'x86 è un pessimo esempio di processore, ma per fortuna non esiste solo il PC. |
Una volta caricati i moduli sdm-data ed sdm-code è possibile usare il proprio ramdisk sda come se fosse un disco reale, partizionandolo con fdisk, creando filesystem e montandoli. È anche possibile studiare il codice, breve abbastanza da essere compreso velocemente.
Purtroppo, però, sdm non è un driver di qualità e risulta instabile in ambienti multi-processore o dove sia stata attivata la preemption del kernel, in quanto per semplificare e rendere leggibile il codice ho eliminato tutti gli accorgimenti per evitare corse critiche e altre situazioni improbabili ma spiacevoli.
Per chi volesse approfondire consiglio la lettura di drivers/usb/storage, in particolare di usb.c e scsiglue.c in tale directory. Nonostante per una comprensione accurata del codice occorra conoscere il sottosistema USB di Linux, si tratta di codice scritto molto bene e ricco di commenti utili. Con l'aiuto dei testi in Documentation si tratta sicuramente di una lettura istruttiva per chi vuole andare più a fondo nella programmazione del kernel.