Introduzione alla scrittura di driver per linux
Questo thread è destinato esclusivamente ai programmatori che intendono imparare le basi di programmazione in kernel space. Assumo che i dettagli della programmazione in c e di compilazione del kernel siano noti.
Coprirò esclusivamente i kernel della serie 2.6, anche se le differenze con i kernel 2.4 non sono sempre profonde. Argomenti trattati: - Documentazione - Utilizzo di kbuild - Hello world in kernel space - Esempio di kernel process - Passare parametri a un driver - Esempio di interfaccia con lo userspace: i miscdevice - Esempio di interfaccia con lo userspace: read e write - Tecniche di locking: semafori - Esempio di interfaccia con lo userspace: mmap - Le tecniche di allocazione della memoria - Ancora sulle tecniche di locking - Introduzione ai block device - Strategie di sincronizzazione - Demandare una operazione ad un altro contesto: le workqueue - Demandare una operazione ad un altro contesto: i tasklet - Esempio: driver di acquisizione video - Organizzazione degli oggetti nel kernel: kobject e sysfs. Il nuovo Device Model. - Device Model: revisione del driver di acquisizione video |
Documentazione
Esistono alcuni libri di riferimento, scritti però per i kernel 2.4. Sono un utile strumento di riferimento: Linux Device Drivers (seconda edizione) scritto da Rubini: (update: è uscita la terza edizione, aggiornata al kernel 2.6.10) http://www.xml.com/ldd/chapter/book/ Understanding the Linux Kernel (Bovet, Cesati): http://www.amazon.com/exec/obidos/tg...=UTF8&v=glance Linux kernel internals (aa.vv.) http://www.amazon.com/exec/obidos/tg...35837?v=glance Understanding the linux virtual memory manager (Mel Gorman): http://phptr.com/title/0131453483 Differenze con il kernel 2.6: Esistono una serie di articoli che coprono le differenze tra le due serie. Gli articoli sono stati pubblicati da lwn, e costituiscono un importante completamento dei libri suggeriti in precedenza: http://lwn.net/Articles/driver-porting/ Mailing list The Linux Kernel Mailing List: http://marc.theaimsgroup.com/?l=linux-kernel&r=1&w=2 Kernelnewbies mailing list: http://mail.nl.linux.org/kernelnewbies/ Sorgenti del kernel Al di là dei libri, un driver ben scritto vale più di mille pagine. I sorgenti del kernel sono una risorsa inestimabile di documentazione. Esiste anche la directory Documentation dentro all'albero dei sorgenti, che copre argomenti specifici. Altri documenti Sono disponibili presso kernelnewbies.org; alcuni però sono diventati obsoleti: http://www.kernelnewbies.org/documents/ Uno script utilissimo Questo script: Codice:
#!/bin/bash |
kbuild
Il kernel dispone di un sistema di compilazione flessibile ed efficiente. E' molto facile utilizzarlo per compilare un driver esterno: tutto ciò di cui avete bisogno, è dei sorgenti correttamente configurati del vostro kernel in esecuzione. Per utilizzare il sistema kbuild del kernel per compilare un driver esterno, occorre - i sorgenti del driver, ovviamente - preparare un semplice Makefile - invocare kbuild Un esempio varrà come spiegazione: create una directory che dovrà contenere il vostro driver; all'interno della directory, create un file sorgente vuoto (touch hello.c ad esempio). Create quindi il Makefile, contenente solo questa riga: obj-m := hello.o kbuild viene invocato eseguendo make con alcuni parametri; visto che si tratta di una operazione comune per i driver esterni, vi consiglio di creare uno script che automatizza il tutto. Create quindi lo script kbuild, che renderete eseguibile, contenente queste righe: Codice:
#!/bin/bash Da dentro la directory contenente il Makefile e hello.c appena creati, eseguite quindi kbuild modules Vi verrà generato il file hello.ko, il vostro "driver" che potete inserire e rimuovere con insmod/rmmod (provate!). Ovviamente questo modulo non farà nulla, in quanto non contiene codice, ma vi illustra quanto sia semplice compilare un driver esterno. Per ripulire i sorgenti, basta che eseguite kbuild clean dentro la directory da ripulire. Per ulteriore documentazione, leggete Documentation/kbuild/modules.txt dentro i sorgenti del kernel. |
Hello world in kernel space
Ora che avete capito come compilare un modulo per inserirlo nel kernel, vediamo come è fatto lo scheletro di un driver. Partiamo dal comune hello world: Codice:
#include <linux/kernel.h> Codice:
#include <linux/kernel.h> linux/ contenente gli header piattaforma-indipendenti del kernel asm/ contenente gli header piattaforma-dipendenti Per nessun motivo un driver può includere gli header userspace, ovviamente. I tre #include inseriti sono il minimo necessario per un modulo. Quote:
Le macro __init ed __exit servono ad indicare che le funzioni hanno validità solo in fase di inizializzazione o di finalizzazione: quindi ad esempio la funzione hello_init può essere scartata dopo l'inizializzazione del modulo (liberando così un pò di memoria); la hello_exit invece può non essere proprio compilata se inserite il vostro driver staticamente nel kernel. Codice:
printk(KERN_INFO "Hello world!\n"); La macro (opzionale) KERN_INFO indica il livello di priorità del messaggio; esistono 8 livelli di priorità, definiti in kernel.h. Codice:
module_init(hello_init); Codice:
MODULE_AUTHOR("ilsensine"); Codice:
MODULE_LICENSE("GPL"); Notate inoltre che alcune funzionalità del kernel sono disponibili solo ai driver con licenza GPL. |
Esempio di kernel process
Stranamente, alcune operazioni semplici in user space (come ad esempio la lettura di un file) risultano molto più complicate se effettuate dentro al kernel. Una operazione che, con le dovute cautele, risulta ancora molto semplice è la creazione di un thread. Un modulo di per se non è un kernel thread: quando ne vengono invocate le funzionalità, il codice viene eseguito nel contesto del chiamante (che può essere un processo utente, o chiunque altro). A volte risulta utile creare un processo apposito, esistente solo in kernel space, per effettuare con calma alcune operazioni che risulterebbe troppo oneroso eseguire in altri contesti (ad es. in contesto di interruzione, o in una sezione atomica). Questo esempio crea un processo che stampa "tick" ogni secondo: Codice:
#include <linux/kernel.h> La kthread_create crea il processo nello stato di "sospeso"; per lanciarlo, è sufficiente "svegliarlo" con wake_up_process. La terminazione del processo avviene invocando la kthread_stop: questa funzione è _bloccante_ fino alla terminazione del processo, e ne restituisce il valore di ritorno. Notate che il processo deve attivamente esaminare la richiesta di terminazione (kthread_should_stop). Nota importante: tutti i segnali diretti al nuovo processo sono bloccati di default (compreso l'imbloccabile SIGKILL!). Sbloccarne alcuni è semplice, ma non entro nei dettagli ora. Notate infine che la funzione msleep non può essere interrotta da alcun segnale; se vi interessa uno sleep interrompibile, occorre usare una tecnica leggermente diversa. Un'ultima nota riguardo questa riga: Codice:
static struct task_struct *ktask; |
Passare parametri a un driver
Piccola variazione sull'esempio precedente: rendiamo configurabili il messaggio stampato e il delay: Codice:
#include <linux/kernel.h> I parametri disponibili saranno visualizzati eseguendo modinfo sul modulo. Possono essere impostati in fase di insmod; ad es: insmod <modulo.ko> kdelay=500 kmsg="Ciao" |
Esempio di interfaccia con lo userspace: i miscdevice
Esistono diversi modi di interfacciarsi con lo userspace. La scelta dell'interfaccia è una scelta di design, prima che tecnica. I modi più comuni sono: - syscall - char device - block device Le syscall non possono essere gestite da un modulo esterno; è improbabile che vi capiterà mai di aggiungerne una. Non ne parleremo. I char device sono dispositivi molto semplici, tramite i quali lo scambio di dati (tramite read/write/ioctl) è molto intuitivo. I block device sono molto più complicati, ne parleremo più avanti. Per ora parleremo di un tipo particolare di char device, i misc device: sono a tutti gli effetti dei char device (con major fisso pari a 10), ma con alcune semplificazioni per lo sviluppatore. Sono l'ideale per la creazione di driver semplici, oppure per iniziare a capire di cosa si sta parlando. Questo codice crea un misc device che stampa un messaggio in syslog ogniqualvolta qualcuno apre e chiude il dispositivo. Visto che ho detto al sistema di assegnare un minor automatico, è consigliabile utilizzarlo con il devfs o udev: Codice:
#include <linux/kernel.h> Codice:
static struct file_operations kops = { A queste funzioni vengono passati due parametri: l'inode e il file. L'inode è il "dispositivo": ne esiste uno solo per il nostro miscdevice, comune a tutto il sistema. Il "file" è una istanza dell'inode, ovvero un inode aperto. E' direttamente legato al concetto di "file descriptor" ritornato dalla open: dentro al kernel infatti la "open" viene effettuata su un "inode", e restituisce un "file" (aperto). Possono ovviamente esistere più istanze "file" per un dato "inode". A questo punto vi consiglio di dare una occhiata a fs.h (uno dei file più importanti del kernel), per avere una idea di come siano definite le strutture "inode", "file", "file_operations". Compilato e inserito il modulo, potete forzare la stampa dei messaggi tramite un semplice "cat" sul dispositivo. Il cat ovviamente ritornerà errore, in quanto non abbiamo ancora implementato alcun metodo di lettura, ma in syslog potremo vedere i messaggi stampati all'apertura e chiusura del device. |
Esempio di interfaccia con lo userspace: read e write
Ora che abbiamo visto come creare un nodo di comunicazione con i programmi user space, implementiamo delle semplici operazioni di lettura/scrittura. L'idea è di allocare una certa quantità di memoria, e utilizzarla per scrivere e leggere da userspace. Il dispositivo quindi si comporterà come un file di dimensione fissa. Dico subito che questo driver ha una grossolana race condition, che userò come spunto in seguito per introdurre un esempio di lock. Codice:
#include <linux/kernel.h> La parte più importante del codice è come vengono letti e scritti i valori da userspace. I buffer userspace forniti alle funzioni hanno l'attributo __user, una macro che viene espansa in una direttiva per il compilatore che sostanzialmente significa "sevizia a sangue lo sviluppatore se tenta di accedere direttamente a questo puntatore". Accedere allo spazio di memoria utente è una operazione molto delicata, per due principali ragioni: - sicurezza: l'utente può fornire alla read un puntatore puntante alla memoria del kernel; occorre controllare che appartenga al suo spazio di indirizzamento - gestione della memoria: il buffer indicato dall'utente può non essere fisicamente in memoria: potrebbe essere finito in swap, oppure essere una pagina COW. In questo caso tentare di accedere a quella zona provoca un page fault, che va gestito. Di queste operazioni se ne occupano le funzioni di accesso alla memori autente: copy_to_user, copy_from_user, get_user, put_user. Queste funzioni restituiscono 0 se l'operazione è andata a buon fine (notate che può essere necessario gestire un page fault, quindi queste funzioni possono causare un context switch verso altre parti del kernel senza preavviso), oppure un valore non nullo se si è verificato un errore (ad es. indirizzo non valido o riferito a memoria non mappata; in questo caso occorre restituire l'errore -EFAULT -- notate che questo _non_ causa un segmentation fault del programma). Per ultimo, notate che la posizione attuale del file è presente nel parametro loff_t *off: proprio qui ho messo una bella race, in quanto tutte le funzioni lo utilizzano e modificano allegramente senza alcuna sicurezza che qualche altro thread del programma utente stia facendo lo stesso. Potete testare questo modulo scrivendo qualcosa tramite echo, e rileggendola tramite cat. |
Tecniche di locking: semafori
Esistono diverse tecniche di locking a disposizione degli sviluppatori; le principali sono gli spinlock e i semafori. Questi ultimi possono essere usati come mutex. L'esempio seguente aggiunge al driver precedente il locking per-file utilizzando i semafori: Codice:
#include <linux/kernel.h> La down() pura invece non ritorna mai, a meno di non aver acquisito il semaforo. Neanche un SIGKILL riuscirà ad uccidere un processo fermo dentro una down(). Notate che tutte le down, tranne la versione trylock, possono bloccare se il semaforo non è acquisibile: quindi non possono essere utilizzate in regioni del codice che non possono essere schedulate (notoriamente irq service routine, dentro uno spinlock, con gli irq disabilitati, ecc). E' legale, per contro, schedulare con un semaforo acquisito (alcuni driver, che non gradiscono multiple open, bloccano un semaforo dentro la open e lo rilasciano nella release). Notare infine che il semaforo può essere usato anche come...semaforo, invece che come mutex; in questo caso per inizializzarlo usare sema_init (init_MUTEX non fa altro che inizializzare il semaforo a 1). |
Esempio di interfaccia con lo userspace: mmap
Un mmap (memory map) consente di accedere direttamente da userspace a una zona di memoria del kernel (oppure memoria fisica, o anche memoria di i/o). Un driver che supporta l'mmap deve implementare la chiamata omonima delle file_operations. Il modo classico per effettuare una mappatura è la funzione remap_page_range (in trasformazione nella più recente remap_pfn_range), che non tratterò (i driver sono pieni di esempi). Mostrerò invece una tecnica molto elegante (e semplice!), chiamata "nopage". Per semplicità ho rimosso le altre file_operations, lasciando la sola mmap: Codice:
#include <linux/kernel.h> Codice:
static int kmisc_mmap(struct file *filp, struct vm_area_struct *vma) - vma->vm_start, vma->vm_end: limiti (indirizzi _virtuali_!!) della regione - vma->vm_pgoff: offset di pagina (il parametro "offset" della syscall mmap, diviso per la dimensione di pagina). In questo esempio la funzione kmisc_mmap fa veramente poco, anzi nulla: controlla la dimensione della regione, controlla che la mappatura sia "condivisa" (non può essere altrimenti, stiamo condividendo memoria tra kernel e user space; non possiamo gestire una MAP_PRIVATE qui), imposta il flag VM_RESERVED (informa il sistema di gestione della vm che non deve mettere in swapout questa vma) e imposta le vm_operations per la vma. Nessuna mappatura fisica viene creata tra la memoria di sistema e la vma del processo. Non ancora. La prima volta che il processo tenterà di accedere fisicamente a questa vma, non essendoci ancora nessuna pagina di memoria mappata, verrà generato un page fault. Il kernel controllerà quindi se per questa vma esiste il metodo "nopage", e lo invocherà per chiedere l'assegnazione della pagina di memoria richiesta. Il kernel vuole solo la pagina, penserà lui al resto (gestione mmu, ecc.) Alla funzione Codice:
static struct page *kmisc_vm_nopage(struct vm_area_struct *vma, Se il gestore nopage non può reperire la pagina, restituirà NOPAGE_SIGBUS (sostanzialmente NULL) e il programma si beccherà un SIGBUS. Per testare la mmap di quero modulo si può usare il seguente programma, che legge il byte indicato nella forma (offset, posizione), e lo incrementa: Codice:
#include <stdlib.h> |
Le tecniche di allocazione della memoria
Negli esempi precedenti ho utilizzato le funzioni kmalloc (o kzalloc)/kfree e vmalloc/vfree senza dare particolari spiegazioni. E' ora di ritornare sull'argomento. Consiglio di leggere questo articolo per avere una idea più precisa di come funzionano le cose: http://www.csn.ul.ie/~mel/projects/v...ml/understand/ Va ben oltre quello che può interessarvi, ma vale la pena leggerlo. nb Non tratta le tabelle di pagina di quarto livello (page upper directory, PUD), introdotte recentemente. - Struttura della memory map del kernel: Il kernel risiede nell'ultimo GB di memoria, da 0xc0000000 (PAGE_OFFSET) in poi. I 3 GB precedenti sono riservati per lo user space. E' possibile effettuare mappature diverse (io ad es. ho usato 2.5/1.5), oppure la totale separazione 4/4, ma il concetto è lo stesso. Questo è il layout di indirizzi, visto dal kernel linux: http://spazioinwind.libero.it/ilsens...ages/img18.png La RAM fisica è divisa in tre zone: zona DMA "legacy", corrispondente ai primi 16MB di indirizzi fisici (utilizzata dai vecchi dispositivi ISA che potevano fare DMA solo in questi indirizzi); la zona NORMAL (utilizzabile anche per DMA a 32 bit), e la zona HIGHMEM. Le prime due zone sono permanentemente mappate in memoria: ovvero il kernel ha già indirizzi virtuali che corrispondono agli indirizzi fisici di queste regioni. La zona HIGHMEM invece è mappata, dinamicamente, solo quando necessario. Per ogni pagina di memoria il kernel tiene una piccola struttura (struct page; v. linux/mm.h) ordinate nell'array mem_map. La funzione page_address(page) restituisce l'indirizzo di una pagina, se mappata (come accade per le zone NORMAL o DMA). Le pagine sono gestite, al livello più basso, dal buddy allocator. Questo signore gestisce la memoria in pagine (la dimensione di una pagina è 4kb su x86), raggruppandole in blocchi contigui composti da un numero potenza di due di pagine. Per richiedere delle pagine al buddy allocator si possono usare le funzioni alloc_pages/free_pages (linux/gfp.h): Codice:
struct page *alloc_pages(unsigned int gfp_mask, unsigned int order) Nota importante: la memoria allocata da alloc_pages è memoria fisicamente continua. L'ordine massimo è MAX_ORDER (da 10 a 11 su x86, a seconda del kernel), che ci consente di allocare fino a PAGE_SIZE*2^MAX_ORDER byte di memoria fisicamente contigua. Per dimensioni superiori, occorrono altre tecniche (ovvero: è un casino). La memoria fisicamente contigua è importante per operazioni DMA. Sotto il buddy allocator siede lo slab allocator (v. linux/slab.h e /proc/slabinfo), il cui scopo è consentire e gestire l'allocazione di oggetti più piccoli di una pagina. E' difficile che dobbiate usarlo direttamente. Allo slab allocator accede la funzione kmalloc, la tecnica preferita di allocazione dentro al kernel: questa funzione consente di allocare velocemente piccole quantità di memoria (tipicamente fino a 128 KB) dalle zone NORMAL o DMA. Si tratta di memoria contigua fisicamente, volendo la si può usare per operazioni DMA. La sintassi è kmalloc(size, GFP_KERNEL) per le allocazioni normali e kmalloc(size, GFP_ATOMIC) per le operazioni atomiche (ad es. dentro un irq). Aggiungete GFP_DMA se vi serve memoria dalla zona DMA riservata alle schede legacy. La variante kzalloc è equivalente a kmalloc, ma ritorna memoria già inizializzata a 0. *Importante*: E' perfettamente normale (non è necessariamente un errore!) che una allocazione atomica possa fallire. Se usare GFP_ATOMIC, siate preparati. O meglio, cercate di evitarle se non è indispensabile. Altra tecnica di allocazione è la funzione vmalloc/vfree. Caratteristiche: - L'allocazione avviene solo per multipli di pagina (chiedete 1 byte, ne sprecate 4095) - L'allocazione proviene da tutte le zone: non è importante se la memoria è già mappata o meno, viene comunque creata una nuova mappatura virtuale. - La memoria restituita è virtualmente contigua, ma non fisicamente - Può bloccare: non usare in contesti atomici! - Può allocare regioni enormi, da VMALLOC_START a VMALLOC_END. Non esagerare con l'uso: appesantisce le tabelle di pagine. - E' la funzione più simile a malloc: ciò nonostante, va usata solo quando necessario (nell'esempio sulla mmap ne ho in effetti "abusato"). Direi che non è il caso di addentrarsi oltre. Gli interessati possono leggere il link che ho consigliato, dove viene spiegato anche il significato delle regioni kmap e fixed map e tante altre cose. |
Ancora sulle tecniche di locking
Una delle maggiori differenze tra la programmazione in user space e in kernel space è come fare il locking. Abbiamo già visto un esempio di semafori, praticamente uguale ai classici semafori user space; presentano di diverso sostanzialmente la primitiva down_interruptible(), che consente di interrompere il tentativo di lock in presenza di un segnale. I semafori sono solo una delle tecniche a disposizione, e non possono essere usati ovunque: per questioni tecniche (non possiamo "dormire" aspettando che un semaforo si liberi in certe sezioni critiche), e per questioni di scalabilità. Esistono diverse altre tecniche che illustrerò brevemente. - rwsem Sono molto simili ai semafori, con la differenza di poter specificare se il tipo di lock è per operazioni di lettura o di scrittura. Sono pensati per la scalabilità, essendo idonei in situazioni dove ci sono molti "lettori" e pochi "scrittori" dell'oggetto protetto sal semaforo. E' possibile acquisire più "read lock" contemporanei, ma un "write lock" blocca qualsiasi altro tipo di lock. L'utilizzo è simile ai semafori (v. linux/rwsem.h): struct rw_semaphore sem; init_rwsem(&sem); down_read[_trylock](&sem); up_read(&sem); down_write[_trylock](&sem); up_write(&sem); downgrade_write(&sem); (trasforma un write lock in un read lock) Non esiste la versione _interruptible di queste funzioni. Presentano la stessa limitazione dei semafori riguardo le sezioni atomiche. - seqlock I rwsem scalano meglio dei semafori in lettura, ma rischiano di causare un "write starving" se ci sono molti lettori all'opera. Per ovviare a questa situazione si possono usare i seqlock, leggermente più complessi dei rwsem. Non ne parlerò in dettaglio; gli interessati possono consultare linux/seqlock.h - Gestione irq Non è una tecnica di locking vera e propria. Per disabilitare/riabilitare gli irq sulla cpu corrente, usare local_irq_disable(); local_irq_enable(); Se gli irq possono essere già disabilitati nella funzione corrente (ad es. perché la funzione può essere invocata da contesti diversi) utilizzare questa variante: unsigned long flags; local_irq_save(flags); local_irq_restore(flags); Note: - Gli irq vengono disabilitati solo sul processore corrente. Attenzione agli ambienti smp (usare gli spinlock) - Se possibile, è più efficiente disabilitare gli irq solo del proprio dispositivo. - Disabilitare gli irq aumenta la latenza: usare solo per lo stretto indispensabile. - Una sezione con gli irq disabilitati diventa una sezione atomica (non schedulare in tale contesto, e non chiamare funzioni che possono schedulare, come ad es. down() ) - Gestione della preemption Versione più blanda della disabilitazione degli irq: preempt_disable(); preempt_enable(); Disabilitano la preemption in kernel space. Come corollario, si è certi che il codice protetto non cambierà cpu di esecuzione (v. anche get_cpu(); put_cpu() ) Note: - E' possibile annidare più preempt_disable(). - Aumenta la latenza delle syscall e dei wakeup, ma non della gestione degli irq - the Big Kernel Lock (BKL) Aiutate l'umanità a liberarsi definitivamente di questa bestia ignorandone l'esistenza. - Gli spinlock Tecnica di locking importantissima. E' l'unica che può essere usata con sicurezza all'interno di un irq handler. Il comportamento degli spinlock è molto diverso a seconda di come è compilato il kernel: UP, UP+preempt, SMP, SMP+preempt. Per spiegare il loro funzionamento, partiamo dal motivo della loro introduzione: i sistemi SMP. Uno spinlock per SMP è simile a un semaforo, con la differenza importante di tenere il processore in loop finché lo spinlock non risulta acquisibile. Il corollario ovvio è non tenere uno spinlock bloccato a lungo, ma solo per alcune istruzioni. Con l'introduzione dei sistemi preempt, gli spinlock sono stati modificati: un sistema UP preempt si comporta in modo simile a un sistema multiprocessore. Uno spinlock per un sistema UP preempt è semplicemente un preempt_disable; per un sistema SMP preempt, la preemption viene disabilitata oltre ad acquisire il lock. Per sistemi UP ovviamente gli spinlock sono dei no-op. Sintassi: spinlock_t lock; spin_lock_init(&lock); spin_lock(&lock); spin_unlock(&lock); E' disponibile anche la versione con disabilitazione degli irq: unsigned long flags; spin_lock_irqsave(&lock, flags); <== compilato come local_irq_save nei sistemi UP spin_lock_irqrestore(&lock, flags); <== compilato come local_irq_restore nei sistemi UP Gli spinlock rappresentano la tecnica di locking corretta tra process context e irq context. Per altre informazioni sulle tecniche di locking illustrate (e alcune non illustrate, come le RCU o i lock per le Bottom Half) una buona guida sintetica di riferimento è la "Unreliable guide to Locking" di Rusty Russell: http://www.kernel.org/pub/linux/kern...ernel-locking/ |
Introduzione ai block device
I char device offrono dei metodi di interfaccia (file_operations) molto completi, che coprono praticamente tutte le necessità. Perché allora sono stati introdotti i "block device"? Immaginate il driver per una unità disco. Un simile driver deve soddisfare una serie di requisiti: - supporto per il partizionamento - supporto per il caching - supporto per il read ahead - supporto per una famiglia di ioctl specifiche per operazioni su disco - semplicità di interfacciamento con altri layer del kernel (ad es. i file system) - generalmente i dispositivi di storage permettono un accesso ai dati "per blocchi di byte" Questi requisiti sono comuni con tutte le unità a disco, è superfluo che ogni driver ne duplichi le funzionalità. Il kernel di linux supporta automaticamente tutto questo; in particolare, supporta - gestione avanzata della cache (buffer cache) - ottimizzazione delle operazioni di i/o (i/o scheduler; al momento ne abbiamo quatto: anticipatory scheduler, cfq scheduler, deadline scheduler, null scheduler) Il modello di servizi che il driver deve fornire è dunque diverso: anziché gestire direttamente le operazioni di accesso, il driver deve poter rispondere a delle operazioni che di tanto in tanto il kernel ci chiede di compiere (ad es. "leggi questa regione, scrivi quest'altra"...). Il resto è automaticamente svolto dal kernel. Dicevo prima che l'implementazione di un block device è complicata: la complicazione sta appunto nel come il driver deve svolgere le richieste. L'esempio seguente è molto banale; implementeremo un driver che usa la memoria di sistema come storage per emulare un disco, e su di questo compie le operazioni di lettura/scrittura richieste dal kernel: Codice:
#include <linux/kernel.h> Nella funzione di inizializzazione, viene dapprima allocata la memoria che conterrà il nostro disco: Codice:
kbdata.ssize = DEVICE_SECTOR_SIZE; Proseguiamo oltre. Tutta la gestione della "interfaccia come disco" del block device, compreso il supporto per il partizionamento, è gestito dall'interfaccia "gendisk" del kernel. Possiamo allocare un gendisk tramite la funzione alloc_disk: Codice:
kbdata.disk = alloc_disk(16); Codice:
set_capacity(kbdata.disk, kbdata.sectors*(kbdata.ssize/KERNEL_SECTOR_SIZE)); Quindi dobbiamo "registrare" il nostro block device, riservando un major number: Codice:
kbdata.disk->major = register_blkdev(0, "kblock"); La funzione successiva è molto importante, è la vera differenza con i char device ed entra nel cuore del driver: Codice:
kbdata.disk->queue = blk_init_queue(kblock_request, &kbdata.lock); Soffermiamoci quindi su questa funzione, che è sorprendentemente (ingannevolmente) semplice: Codice:
static void kblock_request(request_queue_t *q) Note: - Dove scrivono le funzioni memcpy in kblock_do_request? Nella buffer cache del kernel. - Occhio che kblock_request (quindi kblock_do_request) è eseguita sotto spinlock (v. drivers/block/ll_rw_blk.c). Quindi deve essere veloce, e in particolare non deve per nessun motivo schedulare. Sarebbe una buona idea demandare a "qualcun altro" lo svolgimento fisico delle operazioni, per motivi di latenza. Farò un esempio più avanti, tempo permettendo. - blk_init_queue non è l'unico modo di gestire una request queue, ma è il più semplice. Esistono infatti diversi modi di lavorare con le richieste, dal più semplice (il nostro) al più dettagliato, a basso livello. Ad es. il driver loop.c utilizza un altro approccio. |
Strategie di sincronizzazione
La sincronizzazione tra diversi eventi asincroni fa uso, implicito o esplicito, delle code di attesa (wait queue). In questo esempio, ricco di tecniche nuove, vengono illustrati l'attesa su eventi e l'attesa di completamento: Codice:
#include <linux/kernel.h> Questo modulo simula la lettura di dati da un dispositivo. La creazione effettiva dei dati fittizzi avviene in un kernel thread. Il modulo mantiene una lista di richieste di lettura dai processi utente (kdata.reqlist; v. linux/list.h per la gestione delle liste su linux). Il thread kmisc_thread rimane "a dormire" sulla waitqueue kdata.wq finché non viene "svegliato" dalla funzione read, tramite la wake_up_interruptible. Il secondo parametro della funzione wait_event_interruptible è una condizione, che viene esaminata prima di andare "a dormire" -- se risulta vera, il codice continua senza bloccare (serve ad evitare una race condition -- riuscite a vederla?). La funzione ritorna anche in presenza di un segnale, che utilizzeremo per terminare il thread (v. force_sig in kmisc_exit -- notate che force_sig invia il segnale anche se è normalmente bloccato). La read, prima di svegliare il thread, accoda la richiesta alla lista di richieste. Visto che sia la read che il kernel thread girano in contesto di processo, possiamo proteggere la lista tramite un semaforo o uno spinlock (senza disabilitare gli irq). Visto che il codice da proteggere è veramente breve, utilizziamo lo spinlock (un semaforo richiederebbe uno schedule se risulta bloccato, e uno schedule richiede molte più istruzioni di quelle eseguite nella regione critica. Molto più efficiente un veloce spinlock, in questa situazione). Una volta aggiornata la lista, il kernel thread viene eventualmente svegliato, nel caso stia "dormendo", dalla wake_up_interruptible. Quindi, la read deve aspettare che la richiesta venga soddisfatta, e attende quindi il completamento dell'operazione andando a "dormire" su un oggetto completion (tramite wait_for_completion). Ogni volta che una richiesta è ultimata, il kernel thread lo segnala tramite una chiamata a complete(). Notate che un oggetto completion ha una waitqueue all'interno (v. linux/completion.h) Come funzionano le waitqueue? Ogni processo è contraddistinto da uno "stato" di esecuzione (current->state). Questo stato può essere "task in esecuzione" (TASK_RUNNING), "task non in esecuzione, non interrompibile da segnali" (TASK_UNINTERRUPTIBLE), "task non in esecuzione, interrompibile da segnali" (TASK_INTERRUPTIBLE). Ad ogni ciclo di scheduling, solo i task RUNNING vengono presi in considerazione per l'esecuzione. Gli altri sono semplicemente ignorati. Quando poniamo il nostro task "a dormire" su una waitqueue, semplicemente "cambiamo stato di esecuzione" in INTERRUPTIBLE oppure UNINTERRUPTIBLE e invochiamo lo scheduler. Lo aggiungiamo anche ad una lista di task in attesa, lista contenuta nell'oggetto wait_queue_head_t (v. linux/wait.h), in modo da poterlo rintracciare con le apposite funzioni di wake. Quando lo "svegliamo", semplicemente riportiamo il suo stato in RUNNING. Semplice ed efficiente. Per un semplice esempio di come invocare direttamente lo scheduler, v. il codice di msleep in kernel/timer.c. Q&A - Cosa sono BUG_ON e WARN_ON? Sono delle macro di debug, che vengono ottimizzate via se non si abilita il kernel debugging. La prima causa l'uccisione del processo se la condizione è vera, la seconda solo un warning. In entrambi i casi viene stampato il file e la riga di errore, e lo stack trace. Attenzione che un BUG in irq context implica un kernel panic (un irq non ha un "contesto di processo"). - Cosa è "unlikely"? likely e unlikely sono delle macro che si espandono in direttive del compilatore, che indicano che la condizione è probabile o improbabile. Servono al compilatore per ottimizzare il codice, indicando quale è il path di esecuzione "più probabile". - Posso usare le funzioni di wake up/completion in irq context? Sì - Posso dormire su una wait queue in irq context? Mai - Cosa posso allora fare se dentro un irq devo "attendere un evento"? Probabilmente cambiare design. Oppure, demandare l'operazione ad una workqueue da eseguire successivamente in contesto di processo. - Possono esserci più task in attesa su una wait queue? Sì. wake_up ne sveglia solo uno però. Per svegliarli tutti, usare wake_up_all & co. - Voglio che il task svegliato sia eseguito immediatamente. Cosa devo fare? Usare la variante wake_up_sync. - Hai usato un buffer temporaneo; perché la read non ha passato direttamente il puntatore dst al kernel thread? Quel puntatore è valido solo per il contesto di processo corrente, ed è tabù altrove. Il kernel thread ha un proprio contesto. |
Demandare una operazione ad un altro contesto: le workqueue
Una workqueue è - lo dice il nome - una coda di operazioni. Ad ogni workqueue è associato un kernel thread (o meglio, un thread per cpu) che esegue le operazioni indicate in contesto di processo. Il loro utilizzo è molto semplice; riprendiamo l'esempio sui kernel thread: Codice:
#include <linux/kernel.h> Note: - In questo esempio la workqueue viene creara e distrutta esternamente al kernel thread. Potevamo crearla e distruggerla direttamente nel thread? La risposta è no: il nostro thread viene terminato invocando la funzione kthread_stop. Anche la funzione destroy_workqueue chiama kthread_stop per terminare i propri thread. Orbene, kthread_stop blocca un semaforo finché il thread non è terminato; quindi se il thread in fase di chiusura chiama a sua volta kthread_stop (o una funzione che la richiama, come destroy_workqueue), abbiamo un bel deadlock. - Un work può essere schedulato nel futuro tramite la funzione queue_delayed_work, che ha il prototipo (v. linux/workqueue.h): Codice:
int queue_delayed_work(struct workqueue_struct *wq, - Solo i moduli con licenza GPL possono creare proprie workqueue. I moduli proprietari possono utilizzare le workqueue di keventd, tramite le funzioni schedule_[delayed_]work. - flush_workqueue attende la terminazione dei work in coda. Viene chiamata anche da destroy_workqueue, quindi nel codice illustrato risulta superflua (è stata messa solo come esempio). - Visto che le workqueue sono eseguite in contesto di processo, è possibile chiamare funzioni che possono schedulare (come ad es. down). |
Demandare una operazione ad un altro contesto: i tasklet
Le workqueue sono utili per la maggior parte delle operazioni. Vengono eseguite con priorità molto alta (nice -10). Se avete bisogno di un work da eseguire con priorità VERAMENTE alta, potete utilizzare i tasklet. Occorre però rinunciare a qualcosa: i tasklet sono eseguiti in contesto di irq (softirq), quindi c'è qualche limitazione. Questi link descrivono sinteticamente i tasklet e lo scopo delle bottom half in generale: http://www.opentech.at/papers/embedd...ces/node8.html http://www.cs.utexas.edu/users/ygz/3...lecture11.html L'uso è molto simile alle workqueue: Codice:
#include <linux/kernel.h> - Per disabilitare i tasklet sulla cpu corrente, occorre disabilitare i softirq. Questo avviene tramite le funzioni local_bh_disable. Per sincronizzare da contesto di processo con un tasklet, il lock da usare è spin_lock_bh in contesto di processo e spin_lock in contesto di softirq (dentro il tasklet). La guida sul locking linkata in precedenza ne parla. - Non esiste un equivalente di flush_workqueue per i tasklet. Utilizzare tasklet_kill. - L'oggetto tasklet_struct deve essere ancora vivo alla fine del tasklet, quindi non possiamo usare il trucco con il kfree utilizzato con le workqueue. - Possono essere allocati nuovi softirq, al pari di nuove workqueue. Il loro uso è fortemente scoraggiato. I tasklet non sono però workqueue, anche se ingannevolmente simili; vanno trattati con la dovuta cautela. - Il contesto softirq può essere interrotto solo da un hardirq. |
Esempio: driver di acquisizione video
Applichiamo quanto visto fin'ora a un caso semi-pratico. L'esempio seguente è un modulo che emula un dispositivo di acquisizione video. La sequenza video generata è prodotta da un codice preso in prestito dal file output_example.c di ffmpeg. Un driver di acquisizione video è un char device; come fatto con i miscdevice, non gestiremo direttamente il char device, ma ci affideremo al framework v4l già presente nel kernel. Per motivi di semplicità useremo le API v4l v.1, ancora presenti nel kernel. La novità principale introdotta qui è la ioctl: questo metodo consente di eseguire tutte quelle operazioni che non possono rientrare nel paradigma read/write. Una ioctl ha questo prototipo: Codice:
int ioctl(struct inode *inode, struct file *file, Le ioctl v4l sono elencate in include/videodev.h; ne implementeremo solo alcune. Oltre a quelle di inizializzazione e di query, le ioctl principali sono la VIDIOCMCAPTURE e la VIDIOCSYNC. Un driver v4l normalmente mette a disposizione dell'applicazione almeno due buffer di scambio dati, utilizzabili tramite mmap; con la VIDIOCMCAPTURE scheduliamo l'acquisizione su un buffer specificato, con la VIDIOCSYNC attendiamo che il buffer sia pieno. Tra le due l'applicazione può fare altro, l'acquisizione è asincrona. Un programma di acquisizione video normalmente esegue queste operazioni: - richiede i buffer A e B, e le loro dimensioni - mmap dei buffer - VIDIOCMCAPTURE(A) - VIDIOCMCAPTURE(B) - VIDIOCSYNC(A) - visualizza A - VIDIOCMCAPTURE(A) - VIDIOCSYNC(B) - visualizza B - VIDIOCMCAPTURE(B) - VIDIOCSYNC(A) - visualizza A - VIDIOCMCAPTURE(A) ... Notate infine che questo modulo utilizza le funzioni esportate dal modulo videodev.ko: occorre caricarlo (modprobe videodev) prima di caricare questo driver. Le immagini prodotte possono essere visualizzate tramite un qualsiasi programma di acquisizione, come xawtv. Codice:
#include <linux/kernel.h> |
Organizzazione degli oggetti nel kernel: kobject e sysfs. Il nuovo Device Model.
Una delle novità maggiori introdotti nel kernel 2.6 è la generalizzazione di concetti quali "driver", "dispositivo", "classe di dispositivi". E' stata introdotta una entità, kobject, che rappresenta in maniera astratta ogni oggetto del kernel. Potete pensare al kobject come alla classe base astratta in un toolkit c++, ad esempio. I vari kobject sono raggruppati gerarchicamente in insiemi (kset), raggruppabili a loro volta in altre gerarchie. La struttura è alquanto sofisticata. L'organizzazione gerarchica interna del kernel viene esportata in userspace tramite il "sysfs". Questo file system virtuale mostra la gerarchia degli oggetti, consentendone di modificare o esaminare eventuali attributi (non usate più /proc nei vostri driver!). Una descrizione generale e più esauriente del sysfs è pubblicata da lwn, che riporto qui per comodità: http://lwn.net/Articles/driver-porting/ Qui parleremo solo degli oggetti device e driver, che sono ciò che maggiormente interessano al programmatore. Un "device" è un oggetto connesso fisicamente (o logicamente) a un qualche "bus". E' descritto a livello astratto dalla "struct device" (v. linux/device.h), che è la base per la descrizione di altre classi di device (ad es. pci_dev, usb_device...). Descrivere un "device" vuol dire descrivere anche l'oggetto bus (struct bus_type) a cui è collegato o può essere collegato. Facciamo un esempio. Un tipo di bus presente ovunque è il "platform_bus_type". Questo bus è pensato per contenere gli oggetti intrinsecamente presenti nella piattaforma (ad es. controller dma, controller floppy ecc.) che non possono essere pensati come connessi ad altri bus. Altri esempi di bus sono il bus pci, il bus usb, il bus i2c... Descrivere un dispositivo connesso al platform_bus_type è semplice; questo modulo ad esempio dichiara e registra nel sistema due dispositivi virtuali: Codice:
#include <linux/kernel.h> La (de)registrazione può essere fatta dinamicamente: il driver model prevede intrinsecamente l'hotplug. Potete compilare e inserire il driver, notando come vengano creati i device dummy_dev0 e dummy_dev1 in /sys/bus/platform/device. Accanto ai device esistono i device_driver. Sono anch'essi specifici per tipo di bus (dialogare con un dispositivo usb è per forza diverso dal dialogare con un dispositivo pci). Questo modulo è lo skull di un driver per il dispositivo creato nel modulo precedente: Codice:
#include <linux/kernel.h> *importante*: come un "device" può esistere senza un "driver", è vero anche il viceversa: il modulo "driver" può essere presente in memoria anche se il "device" non esiste, ed essere invocato solo quando il "device" compare. Questo comportamento è fondamentale per supportare l'hotplug. Potete ad esempio provare a caricare il secondo modulo, e verificare che compare in /sys/bus/platform/drivers. Potete giocare con i due moduli illustrati, controllando i messaggi stampati alla rimozione/caricamento degli stessi. |
Device Model: revisione del driver di acquisizione video
L'esempio seguente, molto importante, mostra l'aspetto finale di un driver classico di linux. Non ho introdotto altri concetti oltre un esempio di creazione di un attributo modificabile runtime: per ogni device v4l, ho definito l'attributo "frame_inc"; un attributo numerico, che ci consentirà di variare l'incremento dei frame del filmato. Visto che il device v4l è in effetti un class_device, ho dichiarato un attributo per questo tipo di device. L'attributo sarà accessibile e modificabile, tramite comuni echo e cat, dal file /sys/class/video4linux/video<n>/frame_inc; potete inserire qualsiasi valore compreso tra -16 e 16. Ognuno dei due device creati ne possiederà uno, indipendente dall'altro. Come esercizio utile, potete cercare di definire ed implementare l'attributo "frame_rate". Driver testato su un kernel 2.6.8.1: Codice:
#include <linux/kernel.h> |
Tutti gli orari sono GMT +1. Ora sono le: 04:02. |
Powered by vBulletin® Version 3.6.4
Copyright ©2000 - 2024, Jelsoft Enterprises Ltd.
Hardware Upgrade S.r.l.