Contr. Sociale Copertina Linux in Viaggio

Articoli


I formati binari
Ristampato con il permesso del Linux Journal

Una delle potenzialità implementabili tramite moduli del kernel è l'aggiunta a run-time di nuovi formati binari. Un ``formato binario'' è fondamentalmente la struttura dati che definisce come eseguire i file di programma (quelli con il permesso di esecuzione attivato). Questo articolo spiega come è possibile aggiungere un nuovo formato binario al sistema mediante i moduli, accompagnando la spiegazione con un paio di esempi. Il codice d'esempio richiede la versione 2.x del kernel.

Introduzione

Come è noto i moduli del kernel sono stati introdotti per aggiungere nuove funzionalità ad un sistema Linux, in particolare i device driver del kernel possono essere compilati come moduli per essere caricati a run-time (manualmente o tramite il kerneld). In realtà, il modello estremamente modulare del kernel di Linux, permette di aggiungere a run-time molte altre funzionalità oltre ai device driver (ad esempio i file system possono essere implementati sotto forma di modulo, come si è visto nel numero di ottobre a proposito dei file /proc e dei punti di accesso a sysctl).

Un'altra funzionalità caricabile come modulo è la capacità di eseguire i formati binari, includendo in ciò sia i file eseguibili sia le librerie dinamiche. Mentre il meccanismo di caricamento dei programmi compilati e delle librerie dinamiche è particolarmente elaborato, l'utente medio di Linux può implementare facilmente un loader che richiami un interprete per nuovo formato binario, in modo da riuscire a chiamare per nome un file di dati ed vederne l'``esecuzione'', dopo avere chiamato chmod +x per il file stesso.

Come vengono eseguiti i file

Introducioamo l'argomento, spiegando come è implementata in Linux la chiamata di sistema exec. Questa è una delle parti più interessanti del kernel, poiché la possibilità di eseguire i programmi è alla base del funzionamento del sistema operativo.

Il codice della chiamata di sistema exec risiede nella parte dei sorgenti del kernel che dipende dall'architettura usata, ma tutto il codice interessante è contenuto in fs/exec.c (tutti i percorsi sono riferiti a /usr/src/linux, o comunque alla directory dei sorgenti del kernel). Chi volesse verificare i dettagli specifici ad ogni architettura può individuare la funzione relativa tramite il comando: grep -n 'asmlinkage.*exec' arch/*/kernel/*.c.

All'interno di fs/exec.c la funzione che racchiude la funzionalità di exec è do_execve, composta da meno di cinquanta linee di codice. Il suo ruolo è controllare gli errori, riempire la struttura ``binary parameter'' (struct linux_binprm) e cercare il gestore del formato del file che viene eseguito. L'ultimo passo è compiuto da search_binary_handler, un'altra funzione contenuta nello stesso file.

La ``magia'' di do_exec è contenuta in quest'ultima funzione, che è molto corta: il suo lavoro consiste nello scandire l'elenco dei formati binari noti al sistema, passando la struttura binprm a ciascuno di essi finché non viene accettata. Se nessun gestore è in grado di gestire il file eseguibile, il kernel cerca di caricare un nuovo gestore tramite kerneld, dopo di che scandisce l'elenco ancora una volta. Se nessun formato binario è in grado di lanciare il file eseguibile, la chiamata di sistema restituisce il codice di errore ENOEXEC (``Exec format error'').

Il principale problema con questo tipo di implementazione è quello di mantenere Linux compatibile con il comportamento standard di Unix: ogni file di testo eseguibile che inizi con #! deve essere eseguito dall'interprete richiesto, mentre tutti gli altri testi eseguibili devono essere interpretati da /bin/sh. Il primo problema è gestito mediante un semplice formato binario specializzato nell'esecuzione dei file interpretati (fs/binfmt_script.c), e l'interprete stesso è eseguito chiamando search_binary_handler() ancora una volta; la funzione è progettata per essere rientrante, e binfmt_script controlla di non venire invocato due volte. Il secondo problema è principalmente un ``refuso storico'' ed è semplicemente ignorato dal kernel, e sarà il programma utente che cerca di eseguire il file che dovrà tenerne conto. Tale programma in genere è una shell interattiva, oppure il comando make. È interessante notare che mentre le versioni recenti di gmake gestiscono correttamente gli script senza #!, le versioni precedenti non richiamano la shell in questo caso, e può suggeredere di ricevere il messaggio ``cannot execute binary file'' quando si invocano script senza il #! dall'interno di un Makefile.

Tutto il lavoro di gestione delle strutture dati necessarie per sostituire la vecchia immagine dell'eseguibile con la nuova è compiuta dallo specifico gestore per il formato binario, appoggiandosi su funzioni di utilità esportate dal kernel. Consiglio a chi vuole curiosare nel codice di iniziare con la funzione load_out_binary() contenuta nel file fs/binfmt_aout.c; tale funzione è molto più facile del corrispondente gestore per il formato ELF.

Registrazione dei formati binari

L'implementazione di exec è codice abbastanza interessante, ma Linux ha molto di più da offrire: la registrazione di un nuovo formato binario a run-time. L'implementazione è piuttosto semplice, benché coinvolga strutture dati piuttosto elaborate---o il codice o le strutture dati devono tenere conto della complessità di fondo; gestire la complessità nelle strutture dati offre una maggior flessibilità che una soluzione basata su codice complesso. L'essenza di un formato binario è rappresentato nel kernel dalla struttura chiamata struct linux_binfmt, definita in linux/binfmts.h nel seguente modo:

struct linux_binfmt {
        struct linux_binfmt *next;
        long *use_count;
        int (*load_binary)(struct linux_binprm *, struct  pt_regs *);
        int (*load_shlib)(int fd);
        int (*core_dump)(long signr, struct pt_regs *);
};

Le tre funzioni, o ``metodi'', dichiarate dal formato binario servono ad eseguire un file eseguibile, a caricare una libreria dinamica e a creare un file di core. Il puntatore next è usato dalla funzione search_binary_handler(), mentre il puntatore use_count aiuta a tenere traccia dell'utilizzo dei moduli: ogni qualvolta un processo p è eseguito da un formato binario modularizzato, il kernel utilizza *(p->binfmt->use_count) per prevenire la rimozione inaspettata del modulo.

Un modulo, poi, utilizza le funzioni seguenti caricare e scaricare se stesso:

extern int register_binfmt(struct linux_binfmt *);
extern int unregister_binfmt(struct linux_binfmt *);

Le funzioni ricevono un solo argomento invece della solita coppia puntatore-e-nome, poiché nessun file in /proc elenca i formati binari disponibili, a differenza di quello che accade per la maggior parte delle risorse di sistema (per cui, non occorre il nome del formato). Il tipico codice per caricare e scaricare un formato binario, perciò, è semplice come questo:


int init_module (void) {
    return register_binfmt(&bluff_format);
}

void cleanup_module(void) {
    unregister_binfmt(&bluff_format);
}

Queste poche righe di codice appartengono al modulo bluff (Binary Loader for an Ultimately Fallacious Format), il cui sorgente è disponibile qui (oppure all'interno del tar con tutti i sorgenti, come descritto in fondo).

La struttura che rappresenta il formato binario può dichiarare le funzioni che offre come NULL: le funzioni poste a NULL saranno ignorate dal kernel. Il formato binario più semplice, perciò, somiglierà al seguente:


struct linux_binfmt bluff_format = {
  NULL, &mod_use_count_, /* next, count */
  NULL, NULL, NULL       /* bin, lib, core */
 };

Sì, come vedete bluff è un bluff: è possibile caricarlo e scaricarlo ogni volta che si vuole, ma non fa assolutamente nulla.

I parametri binari

Per riuscire a implementare un formato binario con un minimo di funzionalità, il programmatore deve avere qualche nozione a proposito degli argomenti passati alla funzione di caricamento, cioè format->load_binary: il primo di tali argomenti contiene la descrizione del file binario, i ``parametri''; il secondo argomento è un puntatore ai registri del microprocessore.

Il secondo argomento è necessario solo per i loader binari reali, come i formati a.out e ELF che potete trovare nei sorgenti del kernel. Quando il kernel sostituisce un file eseguibile con uno nuovo, deve inizializzare in modo corretto i registri associati al processo corrente; in particolare, il puntatore all'istruzione (Instruction Pointer, IP) deve essere impostato all'indirizzo dove deve cominciare l'esecuzione del nuovo programma. La funzione start_thread è esportata dal kernel per semplificare tale l'impostazione del puntatore all'istruzione. In questo articolo non si andrà nei dettagli dei loader reali, la discussione sarà invece limitata ai formati ``wrapper'', come binfmt_script e binfmt_java.

La struttura linux_binprm, però, deve essere utilizzata anche per i gestori più semplici, ed occorre quindi desciverla.

La struttura contiene i seguenti campi:

La struttura include altri campi che però non interessano per la realizzazione di formati binari semplici. Quello che è rilevante, d'altra parte, sono un paio di funzioni esportate da exec.c. Le funzioni sono state ideate per semplificare il lavoro di semplici gestori di formati binari, come quello che verrà introdotto di seguito. Le funzioni sono:


unsigned long copy_strings(int argc,char ** argv,
	unsigned long *page, unsigned long p, int from_kmem);
void remove_arg_zero(struct linux_binprm *bprm);

La prima funzione si occupa di copiare argc stringhe dal vettore argv all'interno del puntatore p (un puntatore allo spazio utente, di solito bprm-> p). Le stringhe saranno copiate davanti al puntatore p. Le stringhe originali, quelle contenute in argv, possono risiedere sia nello spazio utente che nello spazio del kernel, e il vettore argv stesso può risiedere nello spazio del kernel, anche se le stringhe sono memorizzate nello spazio utente. L'argomento from_kmem è utilizzato per specificare se le stringhe da copiare ed il vettore risiedono entrambi nello spazio utente (0), entrambi nello spazio del kernel (2), o se le stringhe sono nello spazio utente e il vettore nello spazio del kernel (1). remove_arg_zero è molto più semplice: si limita a rimuovere il primo argomento da bprm incrementando bprm->p.

Una semplice realizzazione: visualizzare le immagini

Per mettere in pratica quanto appreso nella teoria, cerchiamo di espandere il nostro bluff (visto in precedenza) in un bloom (Binary Loader for Outrageously Ostentatious Modules). Il sorgente completo del nuovo modulo è distribuito insieme a bluff.

Il ruolo di bloom è visualizzare le immagini eseguibili. È sufficiente dare il permesso di esecuzione ai file GIF e caricare il modulo: chiamando le immagini per nome, verrà eseguito xv per visualizzarle.

Questo codice non è particolarmente originale (la maggior parte di esso viene da binfmt_script.c) e non è particolarmente elegante (gli utenti solo-testo come me preferiscono utilizzare visualizzatori ASCII e gli altri utenti potranno preferire un visualizzatore diverso). L'esempio è principalmente a fine didattico e può essere facilmente utilizzato da chiunque disponga di un sistema X - e possa accedere al computer come root per caricare il modulo.

Il file sorgente è costituito da meno di 50 linee, ed è in grado di eseguire GIF, TIFF ed i vari formati PBM; è inutile dire che è necessario eseguire preventivamente chmod +x sulle immagini. La scelta del visualizzatore si può fare quando si carica il modulo; il visualizzatore di default è usr/bin/X11/xv. Di seguito è riportata una semplice sessione effettuata sulla mia console di testo:

morgana.root# insmod bloom.o
morgana.root# ./snowy.tif
xv: Can't open display
morgana.root# rmmod bloom
morgana.root# insmod bloom.o viewer="/bin/cat"
morgana.root# ./snowy.tif | wc -c
1067564
Utilizzando il visualizzatore di default e lavorando in una sessione grafica, l'immagine ``fiorirà'' sul display.

Chi non volesse attendere di scaricare il codice sorgente può vedere comunque la parte saliente di bloom in questo listato. Si noti che bloom.c è distribuito secondo la licenza GPL, poiché la maggior parte di suo codice è derivato da binfmt_script.c.

Inclusione di un formato sotto l'egida di kerneld

La domanda che sorge naturale è ``come è possibile configurare il sistema in modo che kerneld gestisca in modo automatico il modulo del nuovo formato binario?''

Purtroppo, non sempre è possibile. Il codice in fs/exec.c tenta di utilizzare kerneld solo quando almeno uno dei primi 4 byte non è stampabile. Questo comportamento è stato pensato per evitare di perdere molto tempo con kerneld quando il file da eseguire è un file di testo senza la riga ``#!''. Benché i file compilati abbiano un byte non stampabile nei primi quattro, questo non è sempre vero per tipi di dati generici.

L'immediata conseguenza di questo comportamento è l'impossibilità di caricare automaticamente il visualizzatore bloom richiamando file GIF o PBM per nome. Entrambi i formati cominciano con una sequenza di testo, e saranno quindi ignorati dall'auto-loader.

Quando, invece, il file ha un carattere non stampabile fra i primi quattro, allora il kernel emette una richiesta al kerneld per un module ``binfmt-numero'', dove la sequenza esatta è generata da questa riga:

sprintf(modname, "binfmt-%hd", *(short*)(&bprm->buf));

L'ID del formato binario, perciò, rappresenta i primi due byte del file su disco. Tentando di eseguire file TIFF, il kerneld proverà a cercare ``binfmt-19789'' o ``binfmt-18761'', un file compresso con gzip chiamerà ``binfmt--29921'' (negativo), ed i file GIF saranno passati direttamente a /bin/sh, poiché iniziano con una stringa ti testo ASCII. Volendo conoscere il numero associato con ciascun formato binario è sufficiente riferirsi a /usr/lib/magic e convertire i valori in decimali; in alternativa, è possibile passare l'argomento debug a kerneld e guardare i messaggi che esso genera quando si tenta di caricare il formato binario necessario per eseguire un file dati.

È interessante notare che la versione 2.1.23 e le più recenti hanno cambiato il meccanismo ed utilizzano un ID più semplice e più significativo, usando la linea seguente per generarlo:

sprintf(modname, "binfmt-%04x", *(unsigned short *)(&bprm->buf[2]));

Questa nuova stringa ID rappresenta il terzo e il quarto byte del file binario ed è esadecimale invece di decimale (così da ottenere stringhe con un formato migliore evitando il caso del duplice meno, come succede per la stringa ``binfmt--29921'' visto in precedenza).

A cosa serve tutto cio?

Benché chiamare le immagini con il loro nome possa essere divertente, questa funzionalità non ha alcun bisogno di esistere in un sistema operativo. Personalmente preferisco chiamare il mio visualizzatore per nome, piuttosto che essere preso in giro da un sistema che finga di fare tutto da solo---e non credo neppure alla ``orientazione agli oggetti'' di questo tipo di approccio. Questo genere di funzionalità secondo me si adatta meglio ad un file manager, dove una corretta configurazione può gestire le cose senza introdurre alcun appesantimento al kernel (che andrebbe sempre a scapito della velocità computazionale).

Quello che realmente risulta interessante dei formati binari è la possibilità di eseguire file di programma che non ricadono nella comoda notazione ``#!''. Questo include tutti i file eseguibili che appartengono ad altri sistemi operativi o altre piattaforme, come pure tutti i linguaggi interpretati che non sono stati progettati per il sistema operativo Unix -- tutti quei linguaggi che si lamenterebbero di un ``#!'' nella prima linea.

Chi vuole giocare con una di queste possibilità può provare il modulo fail, il cui sorgente è distribuito con bloom (si veda il link al termine dell'articolo). Questo ``Format for Automatically Interpreting Lisp'' è un wrapper per richiamare emacs ogni volta che viene che un programma e-lisp compilato viene chiamato per nome (a patto che sia stato reso eseguibile). Tale pratica è decisamente poco funzionale, in quanto fa una certa impressione richiamare un programma di diversi megabyte di codic per eseguire solo alcune linee di lisp (c'è da dire, però che anche Java funziona così...). Altro svantaggio di fail è che emacs-lisp non è adatto all'utilizzo da linea di comando; ad ogni caso fail può essere una cosa divertente da provare. Insieme con fail si trovano anche un paio di eseguibili in lisp per provare.

Un sistema Linux completo è pieno di esempi interessanti di formati binari interpretati: il formato binario Java è uno di questi, la piattaforma Alpha è in grado di eseguire programmi per Linux-x86 utilizzando uno speciale formato binario, e le più recenti distribuzioni di dosemu sono in grado di eseguire i vecchi programmi DOS in modo trasparente (benché il programma debba essere configurato preventivamente in modo opportuno).

La versione 2.1.43 del kernel e le successive includono un supporto generico per i formati binari interpretati: binfmt_misc è simile a bloom, ma è molto più potente. Si possono facilmente aggiungere al modulo nuovi formati binari interpretati semplicemente scrivendo le informazioni opportune nel file /proc/sys/fs/binfmt_misc.

Tutti i programmi di esempio citati in questo articolo si trovano in questo tar compresso.

di Alessandro Rubini, traduzione di Andrea Mauro


Contr. Sociale Copertina Linux in Viaggio