I moduli del kernel

di Alessandro Rubini

Riprodotto con il permesso di Linux Magazine, Edizioni Master.

Un articolo dove si descrive come funziona il caricamento dei moduli del kernel e come scrivere un semplice modulo caricabile dinamicamente, assumendo che il lettore sia già in grado di ricompilare un kernel Linux e abbia qualche competenza di programmazione in linguaggio C.

Introduzione

La possibilità di compilare parti del kernel sotto forma di modulo non è certo una novità introdotta con Linux-2.6. Con la nuova versione vengono però introdotte alcune modifiche sostanziali nel formato in cui il codice modularizzato viene archiviato su disco e nel modo in cui viene caricato in memoria.

Tali modifiche sono abbastanza significative da richiedere una nuova collezione di strumenti per caricare e scaricare i moduli dal kernel: mentre il pacchetto modutils è in grado di caricare moduli per tutte le versioni di kernel tra 2.0 e 2.4, per caricare moduli in Linux-2.6 occorre dotarsi del pacchetto module-init-tools. Le informazioni contenute in questo articolo sono state verificate con linux-2.6.0-test11 e module-init-tools-0.9.14.

Riquadro 1 - module-init-tools

Normalmente, per caricare un modulo del kernel (anche quando il caricamento avviene automaticamente) viene usato il comando insmod o il comando modprobe. Il pacchetto module-init-tools contiene un'implementazione di questi comandi in grado di gestire i moduli della versione 2.6 del kernel, chiamando automaticamente la vecchia versione dei comandi (quella del pacchetto "modutils") se invocati in un sistema precedente.

Il pacchetto può essere scaricato da ftp://ftp.it.kernel.org/pub/linux/utils/kernel/module-init-tools/ e si compila come la maggior parte dei pacchetti eseguendo:

    ./configure && make && make install

Personalmente preferisco passare l'opzione --prefix=/opt/module-init-tools al comando configure, per non intaccare la coerenza dei file gestiti dalla distribuzione.

Il pacchetto è parte di Debian unstable, dove può essere installato con apt-get install module-init-tools.

Il concetto di modulo

Un modulo del kernel è fondamentalmente un file oggetto, cioè un frammento di codice eseguibile che fa riferimento a funzioni e variabili esterne, oltre a dichiarare le funzione e le variabili che lui stesso definisce. Normalmente i file oggetto sono salvati su disco con l'estensione .o e vengono identificati dal comando file come "ELF relocatable".

Durante la compilazione di applicazioni, i file oggetto generati da ciascun file sorgente vengono alla fine collegati ("linkati") in un unico eseguibile, risolvendo i riferimenti esterni di ogni file tramite i simboli esportati dagli altri file oggetto e dalle librerie di sistema. Quando si compilano i moduli per il kernel, invece, i file oggetto non vengono linkati, in quanto i riferimenti esterni del modulo si riferiscono a simboli (funzioni e variabili) che fanno parte del kernel e non possono essere risolti nello spazio utente.

Ma a ben guardare, anche Linux è un file eseguibile generato dal collegamento di vari file oggetto. Se chiedete al comando file cosa sia il vmlinux che viene generato durante la compilazione del kernel, vi verrà detto che si tratta di un "ELF executable", proprio come /bin/ls o /usr/bin/mozilla. I moduli, perciò, sono semplicemente parti di questo applicativo che vengono aggiunte al programma eseguibile durante il suo funzionamento.

Per molti versi, un modulo del kernel assomiglia ai cosiddetti "plugin" che si possono caricare su molte applicazioni, da xmms ai vari browser per navigare in rete. Mentre, però, un plugin gira all'interno di uno specifico processo e di solito si limita a svolgere funzioni abbastanza circoscritte di elaborazione dati, un modulo del kernel esegue in un contesto privilegiato e può offrire funzionalità di base per la vita del sistema, come decodificare un filesystem, pilotare una tastiera, gestire una famiglia di protocolli di comunicazione. Se un errore in un plugin può portare alla morte prematura dell'applicazione o al salvataggio di un'istanza di dati errati, un errore in un modulo può far cadere l'intera macchina o corrompere un intero filesystem.

Problemi associati alla modularizzazione

La possibilità di estendere e ridurre le funzionalità del kernel durante il suo funzionamento è spesso un grosso vantaggio (per esempio è grazie all'uso di moduli che le distribuzioni GNU/Linux possono offrire un kernel che funziona con quasi qualunque PC senza essere eccessivamente voluminoso), ma introduce una serie di problemi non indifferente, a causa del contesto privilegiato accennato in precedenza in cui girano il kernel ed i suoi moduli.

Innanzitutto, la possibilità di modificare il kernel durante il suo funzionamento introduce un problema non indifferente di integrità del sistema in caso di intrusione: laddove la sicurezza, in senso informatico, è critica, conviene disabilitare preventivamente la possibilità di caricare moduli nel kernel. Questo si può fare disattivando l'opzione CONFIG_MODULES prima di ricompilare il kernel per la propria macchina.

Un altro problema rilevante è rappresentato dalle corse critiche ("race condition") associate all'eliminazione di un modulo dal kernel. A causa degli accessi concorrenti al kernel da parte di più processi, non è immediata l'implementazione di un meccanismo di distacco di un modulo che sia immune da errori in tutte le situazioni possibili di uso. In effetti, quasi metà del codice in kernel/module.c è dedicata all'implementazione di questo meccanismo. Anche per questo motivo è possibile configurare il proprio sistema perché non permetta di eliminare un modulo una volta che questo sia stato caricato, tramite l'opzione CONFIG_MODULE_UNLOAD. Tale opzione non è disponibile nelle versioni precedenti del kernel.

La questione più rilevante da fronteggiare quando si ha a che fare con i moduli rimane comunque la compatibilità (o meglio incompatibilità) tra versioni differenti del kernel e impostazioni differenti ma incompatibili della stessa versione.

Una buona parte dei meccanismi di base del kernel sono dichiarati nei file di header sotto forma di macro o funzioni "inline", soprattutto per quanto riguarda le primitive di gestione degli accessi concorrenti come semafori, spinlock e operazioni atomiche. Questo codice, unitamente alle informazioni relative alle strutture dati, viene a far parte di ogni file oggetto che ne fa uso. Se un file oggetto contiene il codice di un modulo, esso potrà essere collegato solo con un kernel che usa strutture dati e funzioni uguali, cioè quello i cui header sono stati usati per compilare il modulo. Inoltre, poiché alcune strutture dati e alcune funzionalità vengono istanziate in modo diverso a seconda di come è stato configurato il kernel, un modulo, nella sua forma compilata, può addirittura essere incompatibile con la stessa versione di kernel, se configurata diversamente. Questo accade per esempio tra codice compilato per macchine multiprocessore (CONFIG_SMP) piuttosto che monoprocessore. Nel caso monoprocessore alcune situazioni di concorrenza non possono verificarsi, perciò le strutture dati e le funzioni associate vengono compilate in maniera diversa nei due casi.

Senza addentrarci nella descrizione di CONFIG_MODVERSIONS, vediamo come viene affrontato il problema della compatibilità tra il kernel e i suoi moduli nella versione 2.6, ma prima occorre spendere alcune parole sulla struttura di un file oggetto ELF.

Riquadro 2 - La programmazione ad oggetti

Le regole della buona programmazione dicono che metodi (funzioni) e istanze (dati) devono essere incapsulati dentro a strutture ("oggetti") che rimangano opache nei confronti del codice che le usa. Questo permette di modificare la struttura interna degli oggetti senza richiedere la riscrittura del codice che rimane esterno.

Ma le regole della buona programmazione dicono anche che il codice deve essere efficiente, e i due requisiti sono spesso incompatibili.

Il kernel Linux utilizza una buona incapsulazione di metodi e istanze nelle sue strutture di più alto livello (file, driver, aree di memoria) dove il guadagno in manutenibilità è notevole e i costi in prestazioni praticamente nulli, prediligendo l'efficienza (quindi macro e funzioni inline che accedono direttamente alle strutture dati) nelle operazioni di più basso livello, dove i costi di un approccio più canonico sarebbero molto rilevanti.

Ma i problemi di compatibilità tra i moduli e le versioni del kernel non dipendono solo dal legame stretto tra il file oggetto e gli specifici header usati per compilarlo; il kernel è anche una realtà in continuo movimento, dove si implementano continuamente nuove astrazioni e nuove ottimizzazioni; modifiche frequenti a come il kernel parla con se stesso (cioè i suoi moduli) sono la norma, mantenendo la piena compatibilità delle chiamate di sistema, l'interfaccia del kernel con il mondo esterno.

Le sezioni ELF

Un file oggetto, ma anche un eseguibile, in formato ELF è composto da una o più parti, dette sezioni, identificate da un nome, un po' come un file TAR è composto da vari file ciascuno con un nome diverso. L'intestazione associata ad ogni sezione ne specifica, oltre al nome e alla dimensione, anche alcune altre caratteristiche, come l'indirizzo di caricamento e alcuni attributi speciali. L'intestazione globale ELF specifica il tipo di file e la piattaforma cui si riferisce (per esempio i386 o PowerPC).

Mentre molti formati binari antecedenti richiedevano espressamente che un file oggetto o eseguibile contenesse solo le sezioni chiamate .text, .data e .bss, il formato ELF permette la definizione di sezioni con un nome arbitrario e un contenuto arbitrario, la cui interpretazione è lasciata allo specifico contesto di uso del file.

Gli autori di Linux hanno trovato da tempo modi intelligenti per sfuttare la flessibilità del formato ELF; mentre Linux-2.0 può essere compilato a scelta con un compilatore ELF o con uno precedente, tutte le versioni successive (a partire dalla 2.1.0) definiscono sezioni di output con nomi specifici e richiedono perciò un compilatore ELF.

Gli header del kernel (in particolare <linux/init.h> e <linux/module.h>) utilizzano la direttiva section del compilatore per assegnare elementi del programma a specifiche sezioni ELF. Nel caso di vmlinux, le sezioni vengono usate come definito nel file vmlinux.lds.S (un file da leggere dopo un buon caffè). È in questo modo, per esempio, che tutto il codice di inizializzazione viene caricato in memoria consecutivamente e può essere liberato dopo aver avviato il sistema (questo succede quando il kernel stampa il messaggio "freeing init memory").

Nel caso dei moduli, le sezioni ELF speciali fanno parte del file oggetto e vengono usate durante il processo di caricamento.

Riquadro 3 - ELF e a.out

ELF (Executable and Linkable Format) è il formato oggi più usato per la memorizzazione di file oggetto, programmi eseguibili e librerie dinamiche, sia su GNU/Linux sia sugli altri principali sistemi operativi. Solo lo spazio utente di uClinux non usa ELF, perché non è un formato ottimizzato per processori senza MMU.

Su piattaforma IA32, si è passati ad ELF circa nel 1995, soppiantando il precedente formato cosiddetto "a.out", supportato anche dai kernel recenti se si carica il modulo "binfmt_aout.c".

È ancora oggi possibile installare la distribuzione Mastodon ("The last a.out Linux distribution"), disponibile su href="http://www.pell.portland.or.us/~orc/Mastodon/" e rileggere l'ELF-HOWTO, che spiega come aggiornare la propria macchina a.out ricompilando tutto (a partire dal compilatore stesso) dai pacchetti sorgente. Un'esperienza interessante, per i più curiosi e per chi vuole per un paio d'ore sentirsi giovane come allora.

La struttura di un file .ko

Normalmente, il nome dei file oggetto termina con .o, e così è sempre stato anche per i moduli del kernel fino alla versione 2.4. Con Linux-2.6, anche per facilitare l'utente nell'identificarlo, i moduli usano il suffisso .ko (kernel object). Il file .ko, in effetti, è il risultato del collegamento di due file: il vero e proprio file oggetto contenente il codice, chiamato .o, e un file che contiene informazioni aggiuntive riguardo all'ambiente di compilazione del modulo, un file oggetto ELF il cui nome usa il suffisso .mod.o. Per vedere le sezioni incluse in un file oggetto si può usare il comando objdump -h (section Headers), e si otterrà un risultato simile a quello mostrato in Figura 1, che si riferisce al modulo ide-scsi.ko .

Figura 1 - Sezioni di ide-scsi.ko

Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         000016d0  00000000  00000000  00000040  2**4
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .fixup        0000000a  00000000  00000000  00001710  2**0
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  2 .init.text    00000010  00000000  00000000  0000171c  2**2
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  3 .exit.text    00000010  00000000  00000000  0000172c  2**2
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  4 .rodata       00000880  00000000  00000000  00001740  2**5
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  5 __ex_table    00000008  00000000  00000000  00001fc0  2**2
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
  6 .modinfo      00000060  00000000  00000000  00001fe0  2**5
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  7 .data         00000180  00000000  00000000  00002040  2**5
                  CONTENTS, ALLOC, LOAD, RELOC, DATA
  8 .gnu.linkonce.this_module
                   00000100  00000000  00000000  000021c0  2**5
                  CONTENTS, ALLOC, LOAD, RELOC, DATA, LINK_ONCE_DISCARD
  9 .bss          00000004  00000000  00000000  000022c0  2**2
                  ALLOC
 10 .comment      00000060  00000000  00000000  000022c0  2**0
                  CONTENTS, READONLY
 11 .note         00000028  00000000  00000000  00002320  2**0
                  CONTENTS, READONLY

Le sezioni .init.text e .exit.text contengono il codice di inizializzazione e rimozione del modulo, la sezione .modinfo contiene informazioni sulle dipendenze del modulo, la sua licenza e la versione del kernel usata per compilarlo, __extable è usata dal kernel per gestire alcune eccezioni e .gnu.linkonce.this_module è un marcatore speciale che vedremo in seguito.

Scrittura e compilazione di un modulo

Per meglio vedere come avviene la creazione di un modulo, compiliamo un modulo vuoto. Quanto segue fa riferimento ad un modulo empty, che è disponibile, con gli altri sorgenti di questo articolo, sul CD redazionale allegato alla rivista, ma anche all'indirizzo http://www.linux.it/kerneldocs/modules26/src.tar.gz. Le figure 2 e 3 mostrano il Makefile necessario per compilare il modulo e il sorgente, empty.c. Questi due file vanno messi nella directory dove andremo a compilare, che per me si chiama /home/rubini/modules-2.6/src/.

Il Makefile è un po' più complicato di quanto ci si aspetterebbe semplicemente perché usa un costrutto condizionale di GNU make: se la variabile KERNELRELEASE non è definita (e non lo è) allora definisco LINUX e PWD, poi specifico che per fare tutto devo reinvocare make nella directory $LINUX specificando il valore della variabile SUBDIRS. A questo punto, il Makefile del kernel dirà a make di (ri)leggere questo file, ma a questo punto KERNELRELEASE è definita, quindi prendiamo il ramo else della condizione, dove viene semplicemente assegnata la variabile obj-m. Il resto viene gestito automaticamente.

Reinvocando make nella directory principale dei sorgenti del kernel, non dobbiamo preoccuparci di alcun dettaglio relativo alla piattaforma o alle opzioni da passare al compilatore, ma tale albero di sorgenti dovrà già essere stato compilato e dovrà corrispondere al kernel nel quale vogliamo caricare questo modulo.

Quando invochiamo make per compilare empty.c possiamo passare parametri a make nelle variabili di ambiente o sulla linea di comando, come descritto il mese scorso. In più, possiamo assegnare la variabile LINUX per dire dove si trova il sorgente del kernel, nel caso in cui non si trovi in /usr/src/linux-2.6.

Qui sotto riporto il comando di compilazione da me usato e le righe più importanti che vengono stampate durante la procedura:

  bash$ make LINUX=/opt/kernel/linux-2.6.0-test11
  CC [M]  /home/rubini/modules-2.6/src/empty.o
  Building modules, stage 2.
  MODPOST
  CC      /home/rubini/modules-2.6/src/empty.mod.o
  LD [M]  /home/rubini/modules-2.6/src/empty.ko

È ora possibile invocare insmod empty.ko, usando il comando insmod presente nel pacchetto module-init-tools. Il modulo si caricherà senza errori e senza dare segni di vita (poiché vuoto). Per rimuovere il modulo, eseguire rmmod empty -- senza il suffisso .ko in quanto ora non ci riferiamo ad un file ma ad un oggetto di nome "empty" che esiste all'interno del kernel.

Figura 2 - Makefile per empty.c

   ifndef KERNELRELEASE
   
     LINUX ?= /usr/src/linux-2.6
     PWD := $(shell pwd)
     all:
	     $(MAKE) -C $(LINUX) SUBDIRS=$(PWD) modules
     clean:
	     rm -f *.o *.ko *~ core .depend *.mod.c *.cmd    
   
   else
     obj-m := empty.o
   endif

Figura 3 - empty.c

   #include <linux/module.h>
   #include <linux/init.h>
   
   /* public domain */
   MODULE_LICENSE("GPL and additional rights");           
   
   static int mtest_init(void)
   {
       return 0;
   }
   static void mtest_exit(void)
   {
   }
   module_init(mtest_init);
   module_exit(mtest_exit);

Come vengono usati i contenuti del modulo

Se avete già avuto a che fare con il pacchetto modutils, che implementa tutto il collegamento dinamico del modulo con il kernel, una cosa che colpisce di di module-init-tools è la sua ridotta dimensione, sia come sorgente sia come eseguibile. Il comando insmod, per esempio, occupa solo 6KiB. In effetti, il nuovo insmod non effettua il collegamento dinamico ma passa semplicemente il file oggetto a Linux-2.6, senza toccarlo.

Il collegamento dinamico avviene all'interno del kernel, nel file kernel/module.c. Il lavoro effettuato nello spazio kernel risulta più semplice, in quanto il codice ha accesso diretto alle strutture dati (per esempio la tabella dei simboli) che precedentemente dovevano essere rese disponibili allo spazio utente; inoltre, l'accesso diretto alle strutture dati del kernel permette facilmente di gestire più meta-informazione riguardo al modulo di quanto non fosse possibile con il vecchio approccio.

La chiamata di sistema che riloca il modulo e lo collega al kernel si chiama init_module, e la sua implementazione risiede nella funzione sys_init_module(), che delega tutto il "duro lavoro" alla funzione load_module(), nello stesso file.

Si tratta di codice molto ben leggibile, come tutto il codice di Rusty Russel (autore di questa implementazione) e come la maggior parte del codice del kernel. Scorrendo la funzione load_module è interessante notare come le sezioni di rimozione (come .text.exit vista precedentemente) non vengono caricate se CONFIG_MODULE_UNLOAD non è definito, risparmiando quindi un po' di memoria. Così pure si nota come il controllo di compatibilità tra il modulo e il kernel viene effettuato usando la stringa vermagic definita nella sezione modinfo del file oggetto. Tale stringa viene inclusa nel modulo dal file temporaneo empty.mod.c, generato durante la compilazione. Questo file include <linux/vermagic.h> dove la stringa viene generata in base al numero di versione del kernel, in base alle opzioni che possono generare incompatibilità (come CONFIG_SMP), in base alla versione di compilatore usata. Per chi volesse leggere la stringa risultante nella sua situazione specifica, il modo più veloce è leggere empty.mod.o oppure empty.ko con objdump -s (show), guardando il contenuto della sezione .modinfo.

La sezione .gnu.linkonce.this_module viene identificata dalla funzione load_module e viene usata per verificare che il file oggetto sia effettivamente un modulo del kernel.

Conclusioni

I meccanismi di compilazione e caricamento dei moduli in Linux-2.6 sono abbastanza diversi da quelli usati nelle precedenti versioni del kernel ma si tratta, in questo come in altri casi di incompatibilità, di realizzazioni decisamente più ordinate e più flessibili rispetto all'approccio precedente. Passare da un sistema basato su Linux-2.4 ad uno basato su Linux-2.6 può essere impegnativo, ma può essere interessante anche se la distribuzione che si usa non contiene ancora il pacchetto module-init-tools o le altre piccole cose necessarie al cambiamento. Sicuramente non è impegnativo come passare da a.out ad ELF.

Alessandro Rubini

La copia letterale e la redistribuzione su qualsiasi supporto di questo articolo nella sua integrità è permessa, purché questa nota sia conservata.