Linux Kernel Hacking: Un semplice filesystem

Il VFS (Virtual File System) è il layer di Linux per gestire i file system. Si tratta di uno dei componenti più importanti del kernel non solo perché permette l’organizzazione e la gestione dei dati sui nostri sistemi di storage ma anche perché è un modo molto comodo per far dialogare lo userspace con il kernel. Progetti importantissimi come SeLinux utilizzano un filesystem per la loro configurazione. In questo articolo vedremo come realizzare un semplice filesystem per l’organizzazione di dati su un dispositivo a blocchi.

Per prima cosa bisogna tenere a mente che quando si lavora in kernel space è molto facile che si causino dei freeze sul proprio sistema.Un buon
modo di risparmiare tempo è usare la virtualizzazione. Qemu o più semplicemente UserModeLinux saranno perfetti.
Abbiamo poi bisogno dei tool GNU standard per lo sviluppo (gcc,make,ecc. ecc.) e degli header del kernel su cui stiamo lavorando.

Lo scheletro di un modulo

Realizzare un modulo kernel, contrariamente a quanto si possa pensare, non è molto complicato (a patto che si abbia dimestichezza con il C):

#include <linux/module.h>

static int __init init_stacktrace(void) {
printk("inizializzo modulo stacktrace...\n") ;
return 0 ;
}

static void __exit free_stacktrace(void) {
printk("libero il modulo stacktrace...\n") ;
}


MODULE_DESCRIPTION("Una breve descrizione del modulo");
MODULE_AUTHOR("StackTrace <info@stacktrace.it>");
MODULE_LICENSE("GPL");

module_init(init_stacktrace);
module_exit(free_stacktrace);

Dopo aver incluso l’header linux/module.h (che esporta prototitpi e macro per i moduli)
si definiscono una funzione di inizializzazione e una di chiusura, chiamata quando si rimuove il modulo
(con il classico rmmod).

Gli attributi __init e __exit vengono utilizzati se il modulo è compilato staticamente nel kernel.
In questo caso la memoria per la funzione marcata con __init verrà liberata subito dopo la sua esecuzione, mentre
la funzione marcata con __exit non sarà compilata.

Le macro module_init e module_exit indicano proprio quali sono le due funzioni per l’inizializzazione e la chiusura.

Una nota particolare va alla macro MODULE_LICENSE, se il valore non fa parte delle licenze ammesse dal kernel, nel momento
del loading del modulo si riceverà un minaccioso messaggio di ‘tainting’ nei log. Questo non impedisce di usare il modulo,
è solo un avvertimento per l’utente.

Compilare il modulo

A questo punto abbiamo il nostro scheletro (che chiameremo stacktrace_fs.c), non resta che compilarlo.
Serve un Makefile:

ifndef KERNELRELEASE

PWD := $(shell pwd)
all:
$(MAKE) -C /lib/modules/`uname -r`/build SUBDIRS=$(PWD) modules
clean:
rm -f *.o *.ko *.mod.* .*.cmd Module.symvers
rm -rf .tmp_versions

install:
$(MAKE) -C /lib/modules/`uname -r`/build SUBDIRS=$(PWD) modules_install

else
obj-m := stacktrace_fs.o
endif

Ovviamente è necessario avere istallati sul proprio sistema gli header del kernel in uso.
Questo Makefile funziona se avete header e configurazione all’interno di /lib/modules/build,
in caso non sia così dovrete cambiare il path in base alla vostra distro.
L’unica linea nel Makefile a cui prestare attenzione è la penultima che deve corrispondere
al nome del file C ma con estensione .o (object).

Le linee precedenti (sebbene i Makefile non siano proprio leggibilissimi) sono abbastanza intuitive e si limitano
a chiamare il “make modules” all’interno della directory contenente headers e configurazione del nostro kernel, aggiungendo
però la directory del nostro modulo a quelle standard.

Dopo aver completato la compilazione (lanciando il comando ‘make’) avremo il nostro file stacktrace_fs.ko che potremo
caricare con “insmod stacktrace_fs.ko” e rimuovere con “rmmod stacktrace_fs”.

Lanciati i 2 comandi controllate i log del kernel con il comando ‘dmesg’ e se tutto è andato bene trovere i 2 messaggi
definiti nelle printk() del modulo stacktrace_fs.

La struttura dello stacktrace_fs

Quando si realizza un filesystem che lavori sui dispositivi a blocchi (come in questo caso) la prima cosa da fare è definire il layout
dei dati all’interno del nostro device.
Bisogna prendere in considerazione molti fattori, primo fra tutti l’efficienza (i dispositivi a blocchi sono sempre relativamente lenti), ma
anche evitare la troppa complessità poiché perdere un dato in un filesystem è più grave che avere un filesystem lento.

Per questo articolo utilizzeremo una struttura molto semplice (e come al solito abbastanza inutile ai fini pratici).

Il primo blocco del nostro dispositivo (ad esempio un harddisk o un loop device) conterrà una specie di header con un codice che identifichi
che è davvero un filesystem di tipo ‘stacktrace’. I blocchi successivi contengono i nostri file. Per semplificare il più possibile non saranno previste
directory (se non la radice) e ogni file deve essere grande 496 byte con un nome non superiore ai 16 caratteri. Questo permette di avere ogni file
su un unico blocco di 512 byte.
Inoltre il filesystem non permetterà modifiche, il che renderà tutto ancora più semplice.

La struttura di un blocco del nostro filesystem può essere rappresentata come:

struct stacktrace_fs_block {
char filename[STACKTRACE_MAX_FILENAME];
__u8 filedata[STACKTRACE_MAX_FILESIZE];
};

Inizializzazione

Il primo passaggio è definire la struttura C file_system_type:

static struct file_system_type stacktrace_fs_type = {
.name = "stacktrace_fs",
.get_sb = stacktrace_fs_get_sb,
.kill_sb = kill_block_super,
.fs_flags = FS_REQUIRES_DEV,
};

L’attributo name definisce il nome del nostro filesystem all’interno del kernel.
L’hook get_sb definisce la funzione che verrà chiamata nel momento in cui si tenterà di montare
un dispositivo con il nostro filesystem.
kill_sb è l’operazione inversa chiamata quando si effettua l’umount.
L’unico flag applicato è FS_REQUIRES_DEV che obbliga l’utilizzo del filesystem su un dispositivo a blocchi.

Il passaggio successivo è inizializzare il nostro filesystem per aggiungerlo all’elenco di quelli supportati
dal nostro kernel.
Nella funzione di init del modulo chiameremo:

register_filesystem(&stacktrace_fs_type);

mentre in quella di exit:

unregister_filesystem(&stacktrace_fs_type);

Il nostro modulo ora ha questo aspetto:

#include <linux/module.h>
#include <linux/fs.h>

struct stacktrace_fs_block {
char filename[STACKTRACE_MAX_FILENAME];
__u8 filedata[STACKTRACE_MAX_FILESIZE];
};



static struct file_system_type stacktrace_fs_type = {
.name = "stacktrace_fs",
.get_sb = stacktrace_fs_get_sb,
.kill_sb = kill_block_super,
.fs_flags = FS_REQUIRES_DEV,
.owner = THIS_MODULE,
};



static int __init init_stacktrace(void) {
printk("inizializzo modulo stacktrace...\n") ;
return register_filesystem(&stacktrace_fs_type);
}

static void __exit free_stacktrace(void) {
printk("libero il modulo stacktrace...\n") ;
unregister_filesystem(&stacktrace_fs_type);
}


MODULE_DESCRIPTION("Una breve descrizione del modulo");
MODULE_AUTHOR("StackTrace <info@stacktrace.it>");
MODULE_LICENSE("GPL");

module_init(init_stacktrace);
module_exit(free_stacktrace);

Notare l’inclusione dell’header linux/fs.h che esporta le funzioni di gestione dei filesystem.

Superblock

Il superblock è un blocco del nostro dispositivo che contiene le informazioni sul filesystem.
Nel nostro filesystem è il primo blocco (il blocco 0), ma può essere dovunque o addirittura non esserci e simulato
via software.

In fase di mount il kernel chiamerà la funzione get_sb (dove sb sta proprio per superblock) che abbiamo mappato
nella nostra struttura stacktrace_fs_type su stacktrace_fs_get_sb:

int stacktrace_fs_get_sb(struct file_system_type *fs_type,
int flags, const char *dev_name, void *data, struct vfsmount *mnt)
{
return get_sb_bdev(fs_type, flags, dev_name, data, stacktrace_sb_fill, mnt);
}

get_sb_bdev(), prepara la strada per la funzione definita come quinto argomento (stacktrace_sb_fill) che si occuperà
di inizializzare il nostro superblocco.

Esistono diverse varianti di get_sb. La funzione get_sb_bdev si utilizza quando è necessario un dispositivo a blocchi (bdev) per allocare
il superblock.

static int stacktrace_sb_fill(struct super_block * sb, void * data, int silent)
{
struct inode * inode;
struct dentry * root;

sb->s_flags |= MS_RDONLY;
sb->s_maxbytes = 498;
sb_set_blocksize(sb, 512);

sb->s_magic = 0x11223344;

...

Viene riempita la struttura super_block con diversi dati.
Il flag MS_RDONLY impone che il filesystem sia in sola lettura, s_maxbytes definisce la dimensione massima (in byte) che un file può avere.
La funzione sb_set_blocksize imposta la dimensione del blocco del nostro dispositivo (in byte).
L’attributo s_magic è una semplice sequenza numerica univoca per ogni filesystem.

Per verificare che il nostro dispositivo contenga davvero un filesystem di tipo stacktrace_fs dovremo leggere il primo blocco
e controllare che i primi 13 byte contengano la stringa “stacktrace_fs”.

Per leggere un blocco dal dispositivo si usa la funzione sb_bread(sb,num), dove sb è il superblock e num è il numero del blocco.
La funzione restituisce una struttura di tipo buffer_head il cui attributo ‘data’ contiene i dati del blocco.

#include <linux/buffer_head.h>

...


struct buffer_head * bh;

...


bh = sb_bread(sb, 0);
if (!bh)
return -ENOMEM;

if (strncmp(bh->b_data, "stacktrace_fs",13)) {
printk("not a valid stacktrace_fs filesystem !!!\n") ;
brelse(bh);
return -EINVAL;
}

La funzione brelse() libera la memoria allocata per la struttua buffer_head. Ovviamente il secondo argomento della funzione sb_bread
è 0 poiché stiamo leggendo il primo blocco.

Inode

Con inode si indica un qualsiasi oggetto all’interno del nostro filesystem (file, directory, link…).
Ogni inode ha un suo numero identificativo univoco all’interno del filesystem, per comodità lo stacktrace_fs utilizzerà
come numero di inode il numero del blocco in cui si trova il file.

Una volta preparata la struttura super_block bisogna allocare un inode per la directory radice e associarvi gli hook (vedi sezione successiva):

struct dentry * root;

inode = new_inode(sb) ;
/* assegno un numero identificativo al root inode */
inode->i_ino = 65535 ;
/* tipologia di inode, in questo caso è una directory con permesso 755 */
inode->i_mode = S_IFDIR | 0755 ;
/* proprietario e gruppo dell'inode */
inode->i_uid = inode->i_gid = 0 ;

inode->i_op = &stacktrace_fs_inode_ops;
inode->i_fop = &stacktrace_fs_dir_ops;


root = d_alloc_root(inode);
if (!root) {
iput(inode);
return -ENOMEM;
}

sb->s_root = root;
return 0;

La funzione new_inode() alloca la memoria per un nuovo inode mentre iput()
decrementa il contatore di utilizzo dell’inode. Quando il contatore è a 0 l’inode può essere eliminato.

Hook

Nel kernel è molto facile imbattersi nell’uso degli hook.
Nell’allocazione del root inode abbiamo assegnato
due gruppi di hook, uno per la gestione degli inode e uno per la gestione dei file (nel caso del root inode si tratta di una directory).

La definizione degli hook avviene tramite le solite strutture dati:

static const struct inode_operations stacktrace_fs_inode_ops = {
.lookup = stacktrace_fs_lookup,
};

static const struct file_operations stacktrace_fs_dir_ops = {
.readdir = stacktrace_fs_readdir,
};

static const struct file_operations stacktrace_fs_file_ops = {
.read = stacktrace_fs_read,
};

La prima struttura definisce un hook per il lookup di un inode, ovvero ricavare l’inode dal nome di un file.
La seconda struttura associa la funzione per elencare il contenuto della directory.
L’ultima si riferisce agli hook per i normali file e verrà utilizzata qualche linea più sotto.
Ovviamente sono presenti hook per tutti gli altri aspetti di un file system (cancellare, rinominare…) ma lo scopo
di questo filesystem è essere il più inutile possibile.

La funzione stacktrace_fs_lookup e’ molto semplice (anche se poco elegante): legge ogni blocco dal dispositivo e se trova una
corrispondenza con il nome del file restituisce un inode con numero uguale al blocco in cui e’ stato trovato. Imposta anche gli hook per
la gestione del file (in questo caso solo per la lettura)

È interessante notare che per ogni inode all’interno del filesystem possiamo definire una serie diversa di hook, il che
rende praticamente illimitate le possibilità del VFS.

static struct dentry * stacktrace_fs_lookup(struct inode *i, struct dentry *dentry, struct nameidata *nd)
{
int block,totalblocks;
struct super_block *sb = i->i_sb;
struct buffer_head * bh;
struct stacktrace_fs_block *stackblock ;
const char *name;
struct inode *inode;

name = dentry->d_name.name;
totalblocks = sb->s_bdev->bd_inode->i_size >> 9 ;

for(block=1;block<totalblocks;block++) {
bh = sb_bread(sb, block);
if (!bh)
return ERR_PTR(-EACCES);

stackblock = (struct stacktrace_fs_block *) bh->b_data ;

if (!strncmp(name,stackblock->filename,strlen(name))) {
inode = new_inode(sb) ;
/* imposto il numero dell'inode */
inode->i_ino = block;
inode->i_uid = inode->i_gid = 1001 ;
inode->i_mode = S_IFREG|0644 ;
inode->i_size = STACKTRACE_MAX_FILESIZE ;
/* associo gli hook per le operazioni sul file */
inode->i_fop = &stacktrace_fs_file_ops ;
d_add(dentry, inode);
brelse(bh) ;
return NULL ;
}
}

return ERR_PTR(-EACCES) ;

}

Per elencare il contenuto di una directory si utilizza la funzione stacktrace_fs_readdir.
È abbastanza macchinosa poiché il suo scopo è riempire la struttura dirent con i dati ricavati dal filesystem.
La funzione filldir() si occupa proprio di aggiungere il nome di un oggetto (directory, file…) alla struttura.
L’implementazione che ho scelto è abbastanza orribile, ma dovrebbe essere sufficientemente chiara.
Quando lo userspace richiama readdir() la prima volta setta la posizione (quella usata per il seek) a 0, la funzione nel kernel
verifica che sia a 0 e in tal caso inizia a riempire dirent.
Lo userspace continuerà a chiamare readdir() fin quando il kernel non restituirà 1.
In pratica stacktrace_fs_readdir() verrà chiamata 2 volte per ogni richiesta di una directory. La prima volta riempirà la struttura
dirent, la seconda restituirà 1 per chiudere il ciclo.
Ovviamente si possono utilizzare altri algoritmi, in base anche alla struttura del proprio filesystem.

static int stacktrace_fs_readdir(struct file *f, void *dirent, filldir_t filldir)
{
unsigned long offset;
struct dentry *de = f->f_dentry;
struct inode *i = de->d_inode;
struct super_block *sb = i->i_sb;
struct buffer_head * bh;
int res,block,totalblocks;
struct stacktrace_fs_block *stackblock ;

offset = f->f_pos;

/* sono nella seconda richiesta ? */
if (offset >0)
return 1;

totalblocks = sb->s_bdev->bd_inode->i_size >> 9 ;

block = 1 ;

/* leggo tutti i blocchi del dispositivo */
for(;;) {
if (block >= totalblocks)
break ;

bh = sb_bread(sb, block);

if (!bh)
break ;

/* leggo i dati dal blocco */
stackblock = (struct stacktrace_fs_block *) bh->b_data ;

/* fill della struttura dirent */
res = filldir(dirent, stackblock->filename, strlen(stackblock->filename), f->f_pos++, block, DT_REG) ;

brelse(bh);
if (res)
/* ritornando 0 la funzione viene chiamata nuovamente dallo userspace */
return 0;

block++ ;
}

return 1 ;
}

Leggere un file

static ssize_t stacktrace_fs_read(struct file *f, char __user *buf, size_t count, loff_t *ppos) {
struct dentry *de = f->f_dentry;
/* ricavo l'inode dal file */
struct inode *i = de->d_inode;
struct super_block *sb = i->i_sb;
struct buffer_head * bh;
struct stacktrace_fs_block *stackblock ;

/* supporto solo la lettura dalla posizione 0, questa cosa fara' arrabbiare programmi come less che fanno un uso pesante di seek() */
if (*ppos != 0)
return 0;

bh = sb_bread(sb, i->i_ino);
if (!bh)
return -ENOMEM;

stackblock = (struct stacktrace_fs_block *) bh->b_data ;

memcpy(buf,stackblock->filedata,STACKTRACE_MAX_FILESIZE) ;

brelse(bh) ;

/* incremento la posizione per il seeking */
*ppos = (loff_t) STACKTRACE_MAX_FILESIZE-1 ;

return count ;
}

Questa funzione dopo aver ricavato il numero di inode (che corrisponde al blocco) esegue la lettura dei dati
del file e li copia nel buffer da passare allo user space incrementando la posizione (rappresentatata da ppos) utilizzata per il seeking .
Il valore restituito è il numero di byte letti.

A questo punto abbiamo un modulo relativamente completo:

stacktrace_fs.c
Makefile

Creiamo un filesystem su un file (che useremo come device di loopback) con questo semplice script perl (mkfs.stacktrace_fs.pl):

my $sb = 'stacktrace_fs' ;
for(my $i =13;$i<512;$i++) {
$sb.= "\0" ;
}

my $file1 = "pippo\0\0\0\0\0\0\0\0\0\0\0" ;
for(my $i =16;$i<512;$i++) {
$file1.= "R" ;
}

my $file2 = "pluto\0\0\0\0\0\0\0\0\0\0\0" ;
for(my $i =16;$i<512;$i++) {
$file2.= "S" ;
}

my $file3 = "topolino\0\0\0\0\0\0\0\0" ;
for(my $i =16;$i<512;$i++) {
$file3.= "T" ;
}

print $sb.$file1.$file2.$file3 ;

Creiamo il filesystem:

$ perl mkfs.stacktrace_fs.pl > stacktrace.img
$ mount -t stacktrace_fs stacktrace.img /mnt -o loop

Se il vostro computer non è esploso il filesystem è montato dentro /mnt.
Non vi resta altro che visualizzare i vari file all’interno (un cat è sufficiente, applicazioni complesse come less potrebbero non gradire la poca completezza dello stacktrace_fs) .

Se volete potete usare un vero dispositivo:

$ perl mkfs.stacktrace_fs.pl > /dev/sdz1
$ mount -t stacktrace_fs /dev/sdz1 /mnt

Per fare sul serio

Ovviamente lo stacktrace_fs ha senso solo in un contesto didattico, chi vuole realizzare un filesystem completo deve prendere in considerazione diversi fattori.
Il modo migliore è leggere i sorgenti di filesystem molto semplici (come il romfs) per poi passare a quelli più complessi come ext2.
Personalmente ho trovato molto utile lo studio dei sorgenti del filesystem msdos/fat.

Quello che bisogna sempre tenere a mente (a costo di ripetermi) è che nel campo dei filesystem viene prima l’affidabilità e poi le performance. Un filesystem veloce ma che corrompe i dati è utile come un calcio negli stinchi.

Per quanto riguarda le performance l’aspetto più importante è quello della page cache.
Leggere i dati dal disco ad ogni richiesta è molto dispendioso pertanto nel corso degli anni si sono cercate (e trovate) diverse
soluzioni atte a ridurre al minimo gli accessi ai dispositivi.

Il page caching si basa su un semplice concetto:
ad ogni lettura corrisponde una verifica nella ‘page cache’ (un’ area della nostra memoria fisica) e se il dato è presente
non sarà necessario un accesso al disco.
In caso di scritture viene modificato il dato nella page cache (e la pagina interessata viene marcata come ‘dirty’) e solo dopo un determinato lasso di tempo (configurabile) un kernel thread (pdflush) si occuperà di scrivere i dati su disco. Questo aspetto è molto importante, ogni bravo sysadmin dovrebbe conoscere i parametri necessari alla configurazione del page caching, soprattutto in contesti
dove la gestione dell’I/O è vitale.

Da qualche versione, Linux include anche il supporto per FUSE, un layer per la creazione di filesystem in user space, il che rende possibile utilizzare
anche linguaggi di alto livello aprendo la strada ad applicazioni prima difficilmente implementabili.

È un progetto molto interessante e probabilmente arriverà un articolo in proposito su Stacktrace.

Buon hacking a tutti.

Comments

  1. anonimo timido says:

    Fantastico articolo!!! Complimenti!!!

  2. ryuujin says:

    bell’articolo!

  3. *_* Ottimo (e interessantissimo) articolo.

  4. trueict says:

    WOW…. che goduria!!

  5. Emanuele says:

    Complimenti: articolo interessante, ben scritto e pieno di spunti per chi voglia approfondire.

    Se posso permettermi, vorrei tuttavia proporre un’implementazione alternativa dello script Perl mkfs.stacktrace_fs.pl.

    Si tratta ovviamente di una cosa del tutto inessenziale nell’economia dell’articolo, volta esclusivamente a mostrare l’espressività del tanto vituperato codice Perl.

    Di nuovo complimenti per l’articolo!

    mkfs.stacktrace_fs.pl

    my $sb    = 'stacktrace_fs'               . "\0" x 499;
    my $file1 = "pippo\0\0\0\0\0\0\0\0\0\0\0" .  'R' x 496;
    my $file2 = "pluto\0\0\0\0\0\0\0\0\0\0\0" .  'S' x 496;
    my $file3 = "topolino\0\0\0\0\0\0\0\0"    .  'T' x 496;
    print $sb, $file1, $file2, $file3;
    

Policy per i commenti: Apprezzo moltissimo i vostri commenti, critiche incluse. Per evitare spam e troll, e far rimanere il discorso civile, i commenti sono moderati e prontamente approvati poco dopo il loro invio.