Ecrire un module kernel sous Linux (3)

Previous Next

C From Scratch Episode 25

Qui dit nouveau stream d’Imil, dit aussi nouvelle prise de notes … Nous revoici donc reparti pour un nouvel article sur la création de modules Kernel ! Cette fois-ci, nous allons revenir sur la notion de char device, et approfondir plus particulièrement les notions de Major et Minor, sur lesquelles nous sommes passés un peu rapidement la dernière fois.

Attention: Les notes ci-dessous ne suivent pas toujours le plan suivi par Imil dans son stream, même si -je l’espère- tout sera traité ici aussi !

Un driver ? /dev ? Kezako ?

Un processus n’a pas accès à tout. On l’a largement évoqué sur le stream d’Imil, il ne “voit” qu’une mémoire virtuelle (merci la MMU !) et ne peut ni accéder directement aux disques, ni accéder directement aux fichiers, ni accéder à quoi que ce soit d’autre que la mémoire sans passer par une requête au tout puissant kernel. Et ces requêtes ce sont les syscalls.

Le hardware de notre machine est donc accessible uniquement au kernel et nous autres, pauvres gueux que nous sommes, sommes donc condamnés à faire appel aux services en charge de gérer ce hardware : les drivers. En résumé, notre code émet un syscall, que le kernel traite. En fonction de notre demande, celui-ci déclenche une fonction adaptée dans le driver chargé de gérer ce matériel en particulier. Jusque là tout va bien !

Cependant, il existe une grande variété de périphériques : écrans, webcams, lecteurs d’empreintes, brrr, disques durs, cartes réseau … Quelques exemples et pourtant autant de différences. L’approche suivie par les UNIX a donc été de proposer une interface unifiée pour causer avec tout ce petit monde : on va parler avec eux comme si nous ouvrions des fichiers. L’énorme avantage de cette idée est que les fonctions élémentaires pour manipuler les fichiers sont peu nombreuses, et nous les connaissons bien : open, close, read, write, seek … Cela réduit le nombre de syscalls différents nécessaires !

Les pseudo-fichiers permettant de communiquer avec les drivers sont bien rangés dans /dev.

Prenons un exemple, cela sera sûrement plus clair. Comme vous le savez surement, la commande hexdump permet d’afficher le contenu d’un fichier en hexadecimal :

$sudo hexdump /dev/input/mice

Bon, ça n’affiche rien … Mais si on joue un peu avec la souris :

$sudo hexdump /dev/input/mice
0000000 0108 0800 0001 0108 0800 0002 0208 0801
0000010 0002 0208 0800 0003 0208 0801 0003 0308
0000020 0801 0102 0308 0802 0103 0208 0801 0202
0000030 0208 0802 0202 0208 0802 0202 0208 0803
0000040 0202 0108 0802 0202 0108 0803 0201 0108
0000050 0802 0201 0008 0802 0200 0108 0802 0100
0000060 0008 0801 0101 0008 0801 0101 0008 0801
0000070 0100 0008 0801 0100 0008 0801 0200 0008
0000080 0801 0200 0008 1801 02ff 0008 0802 0100
0000090 0008 1802 01ff 0008 0801 0100 ff18 1800
00000a0 00ff 0008 1801 00ff ff18 1800 00ff 0028
00000b0 38ff ffff 0028 38ff ffff 0028 38fe ffff
00000c0 ff38 28ff fe00 ff38 38fe feff ff38 38ff
00000d0 feff 0028 38fe feff ff38 38fe feff ff38
00000e0 28fe fe00 ff38 38fe fdff ff38 38fe ffff
00000f0 ff38 38fe feff 0028 38fe ffff ff38 28ff
0000100 fe00 ff38 38ff feff ff38 28ff fe00 ff38
0000110 38ff feff ff38 28ff ff00 ff38 38ff ffff
0000120 0028 38ff ffff ff38 38ff ffff ff38 28ff
0000130 ff00 ff18 1800 00ff 0028 18ff 00ff 0028

Magique non ? En visualisant ce fichier, ce sont des octets en provenance de la souris que nous recevons : nous sommes en fait en train de discuter avec le driver de souris !!!!! Les fichiers dans /dev ne sont pas de vrais fichiers, mais des pseudo-fichiers qui servent à interagir avec les drivers.

D’ailleurs, si on les regarde d’un peu plus près, ils sont bizarres ces pseudo-fichiers :

$sudo ls -al /etc
total 1828
drwxr-xr-x 120 root  root   12288  9 avril 19:33 .
drwxr-xr-x  24 root  root    4096 12 sept.  2021 ..
drwxr-xr-x   2 root  root    4096 13 févr.  2020 a2ps
drwxr-xr-x   4 root  root    4096 28 nov.  19:23 acpi
drwxr-xr-x   3 root  root    4096 19 avril  2019 alsa
-rw-r--r--   1 root  root     541 28 nov.  15:31 anacrontab
drwxr-xr-x   3 root  root    4096 12 sept.  2021 audit
[...]

$sudo ls -al /dev/input/
total 0
drwxr-xr-x  4 root root     720  9 avril 19:33 .
drwxr-xr-x 19 root root    4580  9 avril 19:33 ..
[...]
crw-rw----  1 root input 13, 63  9 avril 19:33 mice
crw-rw----  1 root input 13, 32  9 avril 19:33 mouse0
crw-rw----  1 root input 13, 33  9 avril 19:33 mouse1
crw-rw----  1 root input 13, 34  9 avril 19:33 mouse2

Ici, point de taille de fichier : elle est remplacée par un couple de nombres : Les chars Major et Minor. De même, le premier caractère du bloc des droits indique c pour les chars devices, et b pour les block devices.

Quelle est cette sorcellerie ?

Des devices, des pilotes et des nœuds

Les services auxquels nous pouvons accéder via un pilote sont le plus souvent des périphériques matériel. On trouve derrière eux un circuit électronique que le pilote gère pour nous. Ce n’est pourtant pas toujours le cas. Les célèbres /dev/zero et /dev/null, par exemple, sont purement logiciels. Cela fait-il une différence ? Dans le fond, pas vraiment. On peut le voir comme du matériel émulé, ou virtuel : cela ne change absolument rien à notre propos. Cela reste une ressource à laquelle nous accédons via le driver. /dev/zero nous fournit des zéros. Que ce soit grâce à une puce ou un programme, peut importe. Le (ou les) device accessible au travers du pilote sont opaques pour nous : ils rendent un service, peut importe comment.

Par contre, la façon dont nous communiquons avec ce device peut être légèrement différente en fonction de la façon dont nous communiquons avec lui. On distingue, de ce fait, deux grandes familles de périphériques :

Lorsque l’on discute avec un char device, l’échange de données se fait octet par octet. Nous avons pu le voir avec l’exemple de la souris ci-dessus, la discussion se fait sous forme d’un flux d’octet que nous recevons ou émettons un par un.

Lorsque l’on discute avec un block device, l’échange de données se fait par blocs. L’exemple le plus flagrant, déjà évoqué dans les streams de la série “Linux From Scratch”, en sont les disques durs, dont l’accès se fait 512 Octets à la fois.

On peut visualiser les périphériques char et block dans /sys/dev :

$ls -l /sys/dev/block/
total 0
lrwxrwxrwx 1 root root 0 10 avril 14:08 254:0 -> ../../devices/virtual/block/dm-0
lrwxrwxrwx 1 root root 0 10 avril 14:08 254:1 -> ../../devices/virtual/block/dm-1
lrwxrwxrwx 1 root root 0 10 avril 14:08 254:2 -> ../../devices/virtual/block/dm-2
lrwxrwxrwx 1 root root 0 10 avril 14:07 259:0 -> ../../devices/pci0000:00/0000:00:1b.0/0000:02:00.0/nvme/nvme0/nvme0n1
lrwxrwxrwx 1 root root 0 10 avril 14:07 259:1 -> ../../devices/pci0000:00/0000:00:1b.0/0000:02:00.0/nvme/nvme0/nvme0n1/nvme0n1p1
lrwxrwxrwx 1 root root 0 10 avril 14:07 259:2 -> ../../devices/pci0000:00/0000:00:1b.4/0000:03:00.0/nvme/nvme1/nvme1n1
lrwxrwxrwx 1 root root 0 10 avril 14:07 259:3 -> ../../devices/pci0000:00/0000:00:1b.4/0000:03:00.0/nvme/nvme1/nvme1n1/nvme1n1p1
lrwxrwxrwx 1 root root 0 10 avril 14:07 259:4 -> ../../devices/pci0000:00/0000:00:1b.4/0000:03:00.0/nvme/nvme1/nvme1n1/nvme1n1p2
lrwxrwxrwx 1 root root 0 10 avril 14:07 259:5 -> ../../devices/pci0000:00/0000:00:1b.4/0000:03:00.0/nvme/nvme1/nvme1n1/nvme1n1p3
 
$ls -l /sys/dev/char/
total 0
lrwxrwxrwx 1 root root 0 10 avril 14:36 10:121 -> ../../devices/virtual/misc/vboxnetctl
lrwxrwxrwx 1 root root 0 10 avril 14:36 10:122 -> ../../devices/virtual/misc/vboxdrvu
lrwxrwxrwx 1 root root 0 10 avril 14:36 10:123 -> ../../devices/virtual/misc/vboxdrv
lrwxrwxrwx 1 root root 0 10 avril 14:36 10:124 -> ../../devices/virtual/misc/acpi_thermal_rel
lrwxrwxrwx 1 root root 0 10 avril 14:36 10:125 -> ../../devices/virtual/misc/cpu_dma_latency
lrwxrwxrwx 1 root root 0 10 avril 14:36 10:126 -> ../../devices/virtual/misc/udmabuf
lrwxrwxrwx 1 root root 0 10 avril 14:36 10:127 -> ../../devices/virtual/misc/vga_arbiter
[...]

Qui déclare l’existence de tous ces périphériques au kernel ? Et bien ce sont les pilotes. Dans le cas des char devices, ils le font grâce à une fonction telle que register_chrdevice, que nous avons vu la dernière fois.

Et c’est là qu’interviennent les char Major et Minor. Le char major, nous y reviendrons ci-dessous, permet de s’enregistrer auprès du kernel en tant que pilote gérant un device. Mais il est fréquent qu’un pilote doivent gérer plusieurs périphériques. Il va donc également enregistrer autant de minors que de devices qu’il gère.

Houlà … je vous vois palir, nous somme peut-être passé un peu vite là-dessus. Revenons un peu à nos souris de tout à l’heure :

$ls -l /dev/input/mouse*
crw-rw---- 1 root input 13, 32 10 avril 14:08 /dev/input/mouse0
crw-rw---- 1 root input 13, 33 10 avril 14:08 /dev/input/mouse1
crw-rw---- 1 root input 13, 34 10 avril 14:08 /dev/input/mouse2
crw-rw---- 1 root input 13, 35 10 avril 14:08 /dev/input/mouse3

Toutes nos souris présentent le même char major, 13, mais ont chacune un char minor différent (respectivement 32,33,34 et 35) : ce sont des périphériques différents, mais tous sont gérés par le même driver de périphérique.

D’ailleurs, on peut trouver quel est le driver qui a enregistré le char major 10 de façon très simple :

$grep 10 /proc/devices
10 misc

Et voilà !!!

Et le /dev dans tout ça ?

Lorsqu’un pilote déclare un device, le kernel sait qu’il doit désormais router tout syscall associé (read, write, etc.) vers ce driver. Sauf que … la création d’un pseudofichier dans /dev n’est pas du ressort du kernel.

En effet, /dev appartient au userspace : des droits, des propriétaires, des noms de fichier … Tout cela est associé aux pauvres gueux que nous sommes, pas au kernel space !

Nous pouvons créer un noeud dans /dev de façon manuelle à l’aide de la commande suivante :

$mknod /dev/prout c 246 0 

Grâce à cette commande, nous venons de créer un pseudo-fichier, un “noeud”, qui correspond à un char device (c) dont le char major est 246 et le char minor est 0. Toute tentative de lecture ou d’écriture de ce noeud entrainera des syscalls que le kernel transmettra au driver correspondant.

Pour la petite expérience, on peut vérifier qu’il est tout à fait possible de créer un autre noeud pour le driver de souris :

$mknod /dev/rancune c 13 35 

$ls -al /dev/rancune
crw-r--r-- 1 root root 13, 35 10 avril 15:19 /dev/rancune

$hexdump /dev/rancune
0000000 0108 0800 0103 0408 0801 0205 0408 0800
0000010 0105 0508 0801 0004 0508 0800 0006 0508
0000020 0800 0006 0528 28ff ff06 0528 28ff ff05
0000030 0528 28ff ff04 0428 28ff ff04 0328 28ff
0000040 ff03 0208 0800 0002 0108 0800 0001 ff18
0000050 1800 00ff ff18 1801 00ff fe18 1801 01fd
0000060 fd18 1801 01fc fd18 1802 01fc fc18 1801
0000070 01fd fd18 1801 01fd fd18 1800 01fd fd18
0000080 1801 01fd fd18 1800 01fc fc18 1801 01fd
0000090 fc18 1801 01fd fc18 1801 00fd fd18 1801
00000a0 00fd fd18 1800 00fd fe18 1800 00fe fe18
00000b0 1800 00ff fe18 1800 00ff ff18 1800 00ff
    

Notre noeud, /dev/rancune, a les mêmes major et minor que /dev/input/mouse3. L’utiliser ou utiliser /dev/int/mouse3 ne change strictement rien : les syscall seront de toute façon traités par le même driver !

Je sais ce que vous allez me dire : Comment est-ce que /dev est rempli sur mon PC ? Et bien c’est le job de udev, un daemon qui écoute les notifications du kernel ( transmises par une socket spéciale appelée netlink ) et scrute /sys. Lorsque cela est nécessaire, udev crèe un noeud dans /dev en se basant sur un ensemble de rêgles configurables pour gérer les droits, propriétaires, etc. du noeud.

Un petit exemple ?

Bon, comme tout ceci est un peu rude à comprendre, nous allons prendre un petit exemple : le driver mem, dont les sources se trouvent dans les sources du kernel Linux

Mem, c’est un driver qui propose des périphériques très simples :

Si on lit ses sources, on y retrouve une structure similaire au driver qui nous a fait vibrer la semaine dernière !

A la ligne 756 de /usr/src/linux/drivers/char/mem.c :

static int __init chr_dev_init(void)
{
	int minor;

	if (register_chrdev(MEM_MAJOR, "mem", &memory_fops))
		printk("unable to get major %d for memory devs\n", MEM_MAJOR);

	mem_class = class_create(THIS_MODULE, "mem");
	if (IS_ERR(mem_class))
		return PTR_ERR(mem_class);

	mem_class->devnode = mem_devnode;
	for (minor = 1; minor < ARRAY_SIZE(devlist); minor++) {
		if (!devlist[minor].name)
			continue;

		/*
		 * Create /dev/port?
		 */
		if ((minor == DEVPORT_MINOR) && !arch_has_dev_port())
			continue;

		device_create(mem_class, NULL, MKDEV(MEM_MAJOR, minor),
			      NULL, devlist[minor].name);
	}

	return tty_init();
}

Comme on peut le voir ci-dessus, le driver enregistre tout d’abord le char Major MEM_MAJOR à l’aide de la fonction register_chrdev de la même façon que nous l’avions fait la semaine dernière.

On trouve ensuite une boucle, qui va enregistrer chacun des chars minors dont il a besoin avec la structure fops correspondante. C’est la fonction device_create qui effectue cette tâche.

La définition du tableau contenant chacun des devices à déclarer auprès du kernel se trouve un peu plus haut dans le code, à la ligne 716 :

static const struct memdev {
        const char *name;
        umode_t mode;
        const struct file_operations *fops;
        fmode_t fmode;
} devlist[] = {
#ifdef CONFIG_DEVMEM
         [DEVMEM_MINOR] = { "mem", 0, &mem_fops, FMODE_UNSIGNED_OFFSET },
#endif
         [3] = { "null", 0666, &null_fops, 0 },
#ifdef CONFIG_DEVPORT
         [4] = { "port", 0, &port_fops, 0 },
#endif
         [5] = { "zero", 0666, &zero_fops, 0 },
         [7] = { "full", 0666, &full_fops, 0 },
         [8] = { "random", 0666, &random_fops, 0 },
         [9] = { "urandom", 0666, &urandom_fops, 0 },
#ifdef CONFIG_PRINTK
        [11] = { "kmsg", 0644, &kmsg_fops, 0 },
#endif
};

Le codeur affuté que vous êtes (Ben quoi ? On peut être simple gueux face au kernel et quand même bon en C !) aura noté que chacun des périphériques a sa propre structure file_operations … et donc ses propres fonctions pour réagir aux syscalls read, write, open, etc.

Un petit exemple ? Voici zero_fops, correspondant au périphérique /dev/zero :

static const struct file_operations zero_fops = {
        .llseek         = zero_lseek,
        .write          = write_zero,
        .read_iter      = read_iter_zero,
        .read           = read_zero,
        .write_iter     = write_iter_zero,
        .mmap           = mmap_zero,
        .get_unmapped_area = get_unmapped_area_zero,
#ifndef CONFIG_MMU
        .mmap_capabilities = zero_mmap_capabilities,
#endif
};

Et les fonctions correspondantes sont un peu plus bas dans le code …

Tout pareil que nous je vous dis !

En résumé :

Finalement, c’est pas si compliqué !

Implémentation de notre driver

Pour préparer la prochaine séance, nous avons commencé un nouveau module kernel, le célébre “prout”. Je ne commenterai pas ce code, puisqu’il reprend largement le dernier stream, mais j’invite le lecteur intéressé à regarder le stream précédent et son résumé, tout y est !!!

Fichier prout.c

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

MODULE_DESCRIPTION("Prout Prout");
MODULE_AUTHOR("CFS/LFS");
MODULE_LICENSE("PPL");

#define DEVNAME "prout"

static char proutdev[] = "proutproutproutprout\n" ;
static int proutlen ;
static int major ;

static ssize_t prout_read( struct file *, char*, size_t, loff_t * ); 

static struct file_operations fops = {
    .read = prout_read
};

static int 
prout_init(void) 
{
    printk("coucou la voila\n") ;
    major=register_chrdev(0, DEVNAME, &fops) ;
    if (major<0) {
        printk("nacasse!!\n");
        return major ;
    }
    proutlen = strlen(proutdev) ;
    return 0 ;
}


static void
prout_exit(void)
{
    if (major != 0 ) 
        unregister_chrdev(major, DEVNAME ) ;
    printk("napuuuuuuuuuuuuuuuu\n") ;   
}

static ssize_t
prout_read( struct file *filep, char* buf, size_t len, loff_t *off ) 
{
    int minlen = min( proutlen, len ) ;
    if ( copy_to_user( buf, proutdev ,minlen ) != 0 ) {
        printk("nacasse\n");
        return -EFAULT;
    }
    return minlen ;
}

module_init(prout_init);
module_exit(prout_exit);

Fichier Makefile

KDIR=/lib/modules/`uname -r`/build

kbuild:
	make -C $(KDIR) M=`pwd`
clean:
	make -C $(KDIR) M=`pwd` clean

Fichier Kbuild

obj-m = prout.o

A suivre !!!!!!

Rancune.

Note: Comme me l’a fait très justement remarquer Lea, il conviendrait de souligner ici l’usage de min(), utilisée dans notre fonction prout_read. Il s’agit en fait d’une macro déclarée dans kernel.h qui appelle __careful_cmp() et retourne le plus petit de deux éléments. Cette macro permet, notamment, de vérifier qu’il n’y a pas de problème de typage entre les deux éléments que l’on compare.

Bibliographie