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.
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
.
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.
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.
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.
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.
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.
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
#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);
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.
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.