Cet article a pour objectif de vous présenter le développement sur le noyau FreeBSD. De la mise en place de l'environnement de développement à l'examen d'un core, vous saurez tout tout tout sur le FriBi !
Nous allons commencer par la récupération des sources. Ça va nous permettre de nous familiariser ensuite avec leur organisation et de compiler un premier noyau qui fonctionne encore. Après ça, codaz ! Nous allons réaliser un module kernel inutile donc indispensable. Enfin, nous terminerons avec un paragraphe sur le debugging et tout ce qui concerne la finalisation d'un projet pour permettre son inclusion dans le CVS officiel.
Pour comprendre la suite, vous devez :
- savoir ce qu'est un kernel (ou "noyau" en bon français de France) ;
- savoir utiliser la commande cvsup(1) ;
- savoir utiliser la commande cvs(1) pour un accès en lecture à un repository ;
- savoir lire du code C ;
- savoir compiler un noyau FreeBSD ;
- éventuellement, savoir faire un make world ;
- savoir accéder au prompt du loader au boot ;
- savoir utiliser un minimum les outils de développement comme make(1), gdb(1) et objdump(1).
Les manipulations qui suivent doivent être faites sur une machine de test qui ne fournit aucun service indispensable, qui ne contient aucune donnée critique et dont vous avez l'accès au compte
root. De plus, l'article se base sur la version 7-CURRENT encore en développement à l'heure de la rédaction de l'article. Des ISO snapshots sont disponibles sur le site de FreeBSD.
Au niveau espace disque, prévoyez 2,5 Go pour la mise en place de l'environnement et la compilation d'un noyau.
1. Environnement de développement
Avant de pouvoir coder tranquillement, il faut mettre en place un environnement de développement qui facilite notamment la mise à jour des sources de FreeBSD et la préparation de patchs.
En cas de panic, un core dump du kernel est très utile. Sous FreeBSD, ça fonctionne de la manière suivante :
- Au panic, le core est écrit dans une partition swap préalablement indiquée au noyau ;
- Au reboot suivant, le programme savecore lit le swap et écrit le core dans le répertoire /var/crash.
La ligne magique à ajouter à votre fichier
/etc/rc.conf :
Bien entendu, remplacez
/dev/ad4s1b par votre partition swap. Il faut aussi vous assurer que
/var/crash a suffisamment d'espace libre (sachez qu'un core peut atteindre l'équivalent de votre RAM en espace disque).
1.2 Miroir du CVS
Pour éviter de désynchroniser
/usr/src avec le système installé, nous allons travailler dans notre répertoire
home, d'autant plus que les sources dans
/usr/src ne sont pas accessibles en écriture par les utilisateurs normaux.
FreeBSD utilise un
repository CVS pour la gestion des sources. Il existe donc un serveur accessible publiquement à partir duquel nous pouvons récupérer directement la tête du CVS. Par contre, il est interdit de faire des
cvs update ou des
cvs diff à partir de ce serveur pour ne pas le surcharger. Pour tout de même pouvoir mettre à jour son arbre local et faire des patchs facilement, il existe une solution qui apporte en bonus d'autres avantages.
La première étape consiste à faire un miroir local du repository CVS maître. Pour cela, la commande
cvsup(1) qui s'utilise aussi pour mettre à jour les ports ou
/usr/src est notre amie. Ceux, parmi vous, qui utilisent déjà la nouvelle commande
csup(1) vont être déçus : elle ne supporte pas encore le mode qui nous intéresse.
CVSup utilise un fichier de configuration pour savoir ce qu'il doit récupérer et où le mettre. Pour notre utilisation, nous allons partir du template
cvs-supfile présent dans
/usr/share/example/cvsup. Mais avant de le remplir, voici la hiérarchie de répertoires utilisée dans l'article (bien entendu, modifiez-la pour qu'elle corresponde à votre habitude) :
|
|
/home/user `-- monfreebsd |-- cvs |-- cvsup | `-- db `-- 7-CURRENT |
Par convention, pour désigner un fichier dans le répertoire
cvsup ci-dessus, nous utiliserons le chemin
monfreebsd/cvsup/fichier. Donc sachez que ce chemin sera relatif au répertoire home de l'utilisateur.
Revenons à CVSup et son fichier de configuration. Commençons par copier l'exemple dans
monfreebsd/cvsup. Nous allons l'éditer pour indiquer nos préférences :
|
|
# Miroir CVS français *default host=cvsup.fr.FreeBSD.org # Fichiers d'état de CVSup *default base=/home/user/monfreebsd/cvsup/db # Racine du miroir CVS *default prefix=/home/user/monfreebsd/cvs |
Pour terminer cette étape, il suffit d'exécuter la commande suivante qui va mettre les fichiers CVS dans
monfreebsd/cvs ; pour mettre à jour votre miroir, il suffira simplement de la relancer.
|
|
~/monfreebsd/cvsup$ cvsup cvs-supfile |
Ce miroir CVS occupera environ 1,5 Go.
Profitez-en pour faire la vaisselle.
1.3 Checkout du CVS
Maintenant que vous avez une copie du CVS en local, il faut faire un checkout de la tête du CVS et peut-être de certaines autres branches. Mais avant de le faire, parlons de la politique de modification des différentes versions de FreeBSD.
La tête du CVS est la version de développement de FreeBSD : celle où toutes les nouveautés arrivent. On y fait référence avec le suffixe "-CURRENT". Par exemple, en ce moment, c'est la 7-CURRENT.
En parallèle de celle-ci, les versions stables qui sont en production chez les utilisateurs doivent être maintenues pour apporter en priorité des corrections de bug et, quand ça ne peut pas attendre la prochaine release majeure, certaines fonctionnalités. Ces versions sont notées par le suffixe
"-STABLE". En ce moment, les branches
6-STABLE, 5-STABLE
et
4-STABLE sont supportées, mais la branche 4-STABLE arrive en fin de vie.
Pour ne pas perdre de modifications en route, voici comment nous fonctionnons :
- Pour l'ajout d'une fonctionnalité, nous la committons uniquement dans -CURRENT, sauf cas exceptionnel.
- Pour la correction d'un bug, nous la committons d'abord dans -CURRENT (si le bug y existe encore) pour une période de probation. Une fois cette période écoulée et si la modification n'a pas créé de souci, celle-ci est committée dans 6-STABLE & friends (toujours si ça a un sens). Elle sera donc intégrée à la prochaine release faite sur chaque branche.
Maintenant, vous comprenez pourquoi nous allons travailler sur une -CURRENT. Donc, c'est parti, checkout de la tête :
|
|
~/monfreebsd$ cvs -d/home/user/monfreebsd/cvs \ co src -d 7-CURRENT |
Le checkout s'étale sur environ 500 Mo.
Sur ce, café.
1.4 Organisation de l'arbre des sources
L'action se déroule maintenant dans
monfreebsd/7-CURRENT.
Le checkout fraîchement réalisé contient les sources du système complet : le noyau, les bibliothèques et programmes de base. C'est exactement la même chose que nous trouvons dans
/usr/src.
À la racine de ce checkout, se trouve donc le fichier
UPDATING qui liste les changements qui peuvent impacter de manière significative un système en production. À côté, le
Makefile qui sert pour les classiques
make kernel et
make world. Parmi les répertoires, nous avons les programmes et les bibliothèques de bases, les headers, mais surtout le répertoire
sys qui contient le kernel. Y'a de la lumière, entrons !
Dans les noms de répertoires ci-dessous, je redonnerai à chaque fois le préfixe
sys pour montrer que nous sommes dans le kernel ; cette habitude est aussi utilisée sur les mailing-lists par exemple quand on veut indiquer un fichier du noyau. De la même manière, le préfixe
src désigne les sources du système, autres que le noyau.
Commençons par un des répertoires que nous consulterons très souvent :
sys/sys. Ce dernier contient les headers exportés : ceux-là même qui sont installés dans
/usr/include/sys. Une de leurs fonctions principales est la déclaration des appels système.
Le code de ces derniers est placé dans
sys/kern. Par exemple, vous trouverez les fichiers
sys/kern/vfs_* qui contiennent les appels système liés aux opérations sur les systèmes de fichiers (
mount(2) pour n'en citer qu'un) et sur les fichiers eux-mêmes (
open(2) & friends).
À côté, vous trouverez un répertoire nommé
sys/libkern. Cela n'est pas évident au premier abord, mais, dans le noyau, nous n'avons pas la bibliothèque standard du C (elle est dans l’userland). L'objectif des sources dans ce répertoire est de combler ce manque. On y retrouve donc des appels communs comme les fonctions de traitement de chaînes de caractères (
strlen(3),
strcmp(3), etc.).
Pour terminer avec la partie générique du kernel, notons le répertoire
sys/conf. En gros, il contient des fichiers qui listent les sources à intégrer au noyau (c'est-à -dire, pas les modules) et la déclaration des options qu'on retrouve dans le fichier de configuration du noyau. Mais nous y reviendrons en détail lors de la compilation de nos futurs modules dans le noyau.
Au même niveau se situent différents composants du noyau comme :
- sys/vm : la VM s'occupe de gérer tout ce qui concerne la mémoire ;
- sys/net* : comme leurs noms l'indiquent, ces répertoires sont liés à la partie réseau du noyau ;
- sys/dev : tous les drivers matériels sont là (clavier, cartes réseau, son, USB, etc.) ;
- sys/ufs, sys/fs, sys/isofs, sys/nfs, sys/coda, sys/gnu/fs : tous les systèmes de fichiers supportés ; ceux dans sys/gnu/fs ont une license non-BSD (typiquement du code GPL).
À côté de tout ce petit monde, nous avons le code dépendant de l'architecture. Chaque plate-forme supportée par FreeBSD a son propre répertoire. Par exemple :
- sys/i386 : regardez autour de vous, y'en a partout ;
- sys/amd64 : Opteron & Athlon 64 ;
- sys/sparc64 : machines Sun avec de l'UltraSPARC par exemple ;
- sys/arm : processeur basse consommation utilisé dans certains PDA.
Concluons cette rapide visite par le répertoire
sys/modules. Il contient un sous-répertoire par module noyau disponible. Chacun de ces sous-répertoires contient à son tour un
Makefile où l'on trouve surtout la liste des fichiers sources nécessaires à la compilation du module. Les fichiers sources sont, bien entendu, dans le répertoire du composant. Par exemple, le Makefile présent dans
sys/modules/fxp référence les sources du driver de la carte réseau fxp(4) présents dans
sys/dev/fxp. Nous en reparlerons quand il sera l'heure de compiler un premier module noyau.
1.5 Compilation du noyau
En compilant et utilisant un premier noyau avant toute modification, nous pouvons, d'une part, nous assurer que la machine fonctionne avec la tête du CVS de FreeBSD. D'autre part, ce noyau nous servira de référence pour vérifier l'impact que nos modifications auront, en voyant les différences (ou l'absence de différence) de comportements entre ce noyau et le futur customisé.
La nouvelle méthode
make buildkernel/
installkernel est très pratique pour la compilation d'un noyau de production, mais comme nous allons certainement recompiler souvent l'engin, l'ancienne méthode sera plus souple. Notez que nous allons travailler avec un noyau GENERIC qui est un bien meilleur référentiel qu'un kernel custom, surtout si nous avons besoin de poser des questions à la communauté. Fight !
|
|
sys/i386/conf$ config GENERIC sys/i386/conf$ cd ../compile/GENERIC sys/i386/compile/GENERIC$ make cleandepend sys/i386/compile/GENERIC$ make depend && make (...) sys/i386/compile/GENERIC$ make install KERNEL=kernel.ref |
Si vous ne testez pas sur une machine i386, modifiez l'architecture dans les répertoires de l'exemple ci-dessus et ailleurs dans l'article si nécessaire.
Sur i386, un noyau utilise un peu moins de 400 Mo de disque pour compiler.
Sur la dernière ligne de commande, l'option
KERNEL=kernel.ref nous permet d'installer le noyau sous un autre nom ; nom qui est kernel par défaut. Donc, les fichiers seront copiés dans
/boot/kernel.ref. Pour booter ce noyau, il faut appuyer sur une touche au moment du loader (ou appuyer sur une touche s'il n'y a pas de menu), puis taper :
Bien entendu, le clavier qwerty est familier pour vous.
À présent, nous avons :
- Les sources en local pour travailler en paix.
- Une connaissance superficielle de leur organisation.
- Un noyau de référence installé.
Tout est donc en place pour s'offrir quelques panics.
2. Codage d'un device driver
C'est l'heure de coder ! Pour ne pas rompre avec la tradition et pour faire un parallèle avec l'article qui est paru dans GNU/Linux Magazine en novembre 2006 sur le développement dans Linux, commençons avec un driver Hello World ; d'abord dans le noyau, ensuite en module.
2.1 Source du module
Comme nous l'avons vu dans la présentation de la hiérarchie des sources, les device drivers sont dans
sys/dev. C'est là que nous nous installons en créant un sous-répertoire
hello. À l'intérieur, éditons un fichier
hello.c. La première chose à faire est de déclarer notre intention :
|
|
#include <sys/param.h> #include <sys/kernel.h> #include <sys/module.h> #include <sys/conf.h> DEV_MODULE(hello, hello_modevent, NULL); |
DEV_MODULE(9) est une macro qui permet de déclarer un device driver :
- En 1er argument, nous indiquons le nom du module.
- En 2ème argument se trouve le nom de la fonction main (le point d'entrée) qui traite les chargements/déchargements du module.
- En dernier argument, nous pouvons mettre une donnée qui sera passée à cette fonction.
Notez que la ligne
DEV_MODULE est très souvent placée en fin de fichier, même si ça n'apparaît pas dans l'exemple ci-dessus. Dans la logique, il faut au moins qu'elle apparaisse après la déclaration du prototype ou de la fonction elle-même.
Le plus important est donc ce fameux point d'entrée. Dans le cas d'une compilation dans le noyau, il est appelé au boot et au shutdown. Dans le cas d'un module externe, il est d'abord appelé quand le module est chargé. Il est à nouveau rappelé quand le module doit être déchargé ou quand le système est arrêté (ou rebooté).
Le prototype de ce callback est :
|
|
static int hello_modevent( module_t mod, int type, void *data); |
Les arguments sont :
- Une structure module_t qui décrit l'instance du module.
- Un entier qui nous indique si le module est chargé, déchargé ou si le système est en cours de reboot.
- La donnée précisée en 3ème argument de DEV_MODULE(9).
Le module que nous réalisons doit afficher un message quand il est chargé, puis un autre quand il est déchargé. Le code est donc relativement court :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
static int hello_modevent(module_t mod, int type, void *data) { int error; error = 0; switch(type) { case MOD_LOAD: /* Le module est fraichement chargé. */ printf("Module chargé\n"); break; case MOD_QUIESCE: /* L'utilisateur aimerait décharger le module. */ printf("Module prêt pour déchargement\n"); break; case MOD_UNLOAD: /* Le module va être déchargé. */ printf("Module déchargé\n"); break; case MOD_SHUTDOWN: /* Reboot du système. */ printf("Reboot\n"); break; default: error = EOPNOTSUPP; break; } return (error); } |
La fonction utilise les 4 valeurs possibles de
type :
- MOD_LOAD : c'est simple, le module vient d'être chargé, il peut procéder à l'initialisation de ce dont il a besoin.
- MOD_QUIESCE : c'est la première étape, quand un utilisateur demande le déchargement d'un module. Le rôle du module est d'indiquer soit qu'il est prêt à être déchargé, soit qu'il est encore utilisé (et retourne un code d'erreur).
- MOD_UNLOAD : seconde étape du déchargement : le module tente cette fois de s'arrêter réellement. Il peut bien sûr retourner une erreur s'il ne peut pas.
- MOD_SHUTDOWN : le module est prévenu que le système va rebooter.
Notez que les phases
MOD_QUIESCE/MOD_UNLOAD ne sont appelées qu'en cas de déchargement explicite du module. Donc lors d'un reboot, seule la phase
MOD_SHUTDOWN est appelée.
La phase
MOD_LOAD est appelée que le module soit compilé dans le noyau ou sous forme de module externe.
2.2 Compilation dans le noyau
Quand un module est compilé directement dans le noyau, nous pouvons en profiter dès le
boot, même avec un
boot
réseau. Ça s'avère très pratique si, par exemple, le système est installé sur un disque branché sur un contrôleur exotique. Le second avantage est de rendre le debugging plus facile. Le noyau par défaut livré avec FreeBSD,
GENERIC, est compilé pour avoir un maximum de drivers embarqués, plutôt que sous forme de modules externes. L'objectif est de supporter la plupart des plateformes sans avoir à préparer soi-même son CD d'installation.
Pour compiler notre beau module dans le noyau, il y a deux étapes :
- Ajouter la liste de nos fichiers sources dans les fichiers présents dans sys/conf – répertoire dont nous n'avons pas encore parlé.
- Faire une copie du fichier de configuration GENERIC pour y ajouter notre module.
Dans le répertoire
sys/conf, vous trouverez deux types de fichiers que vous serez probablement amenés à éditer. Le premier type correspond aux fichiers
sys/conf/files*. Il y a d'abord un fichier sans extension commun à toutes les plateformes, puis un fichier par plate-forme (dont le nom sert d'extension). Ces fichiers contiennent la liste des sources à inclure dans le noyau en fonction de ses options. Sur i386, la compilation prendra donc en compte les fichiers nécessaires dans
sys/conf/files et
sys/conf/files.i386. Cette séparation permet de déclarer les modules multiplateformes dans le premier et les modules spécifiques aux PC dans le second.
Notre module étant multiplateforme, nous allons ajouter la ligne suivante dans
sys/conf/files :
|
|
dev/hello/hello.c optional hello |
Quelques explications sur le format de ce fichier :
- Une entrée de sys/conf/files* correspond à un fichier source à inclure et ses options d'inclusion. Une entrée se limite généralement à une ligne du fichier, mais peut s'étaler sur plusieurs lignes en échappant avec \, le retour à la ligne comme on le ferait avec un #define multi-ligne.
- Une entrée commence par le nom du fichier. Ce nom est relatif au répertoire sys du noyau.
- En 2ème position, le mot clé indique le type d'inclusion. Soit standard pour dire que le fichier source est tout le temps dans le noyau, soit optional pour indiquer qu'il ne doit être inclus que si une option est précisée.
- Dans le cas de optional, nous donnons ensuite l'option qui entraînera l'inclusion dudit source.
- Il existe plusieurs arguments possibles, mais les décrire tous sort du cadre de cet article.
Donc, dans l'exemple, on indique au processus de compilation que si le fichier de configuration contient
device hello, il faut utiliser notre fichier source.
Le 2ème type correspond aux fichiers
sys/conf/options*. Comme pour le 1er type, nous retrouvons le même genre de séparation générique/par plate-forme. Dans ces fichiers, nous pouvons indiquer les éventuelles options (ligne
options OPTION dans la configuration du noyau). Pour le fichier source, chaque option est un
#define. Notre module n'a pas d'option, donc nous n'y touchons pas.
Maintenant, la 2ème étape. Dans le répertoire
sys/i386/conf (adaptez-le en fonction de votre plate-forme). Copions le fichier
GENERIC pour y ajouter notre module et compilons l'ensemble :
|
|
sys/i386/conf$ cp GENERIC GENERIC_HELLO sys/i386/conf$ $EDITOR GENERIC_HELLO # pour ajouter "device hello" sys/i386/conf$ config GENERIC_HELLO sys/i386/conf$ cd ../compile/GENERIC_HELLO sys/i386/compile/GENERIC_HELLO$ make cleandepend && make depend && make sys/i386/compile/GENERIC_HELLO$ make install KERNEL=kernel.hello |
Pour tester ce noyau, rebootez la machine et, au loader, tapez :
Très tôt dans le processus de boot, vous apercevrez le message "Module chargé". Si vous le ratez, remontez dans l'historique de la console ou consultez le fichier
/var/run/dmesg.boot.
En revanche, nous n'avons rien à décharger, donc nous ne pouvons pas tester les phases
MOD_QUIESCE/MOD_UNLOAD. Par contre,
MOD_SHUTDOWN va être appelée dès que nous allons rebooter la machine. Faites-le et guettez le message "Reboot" : il apparaît entre l'affichage de l'uptime et le message "Rebooting...".
2.3 Compilation en module externe
C'est souvent plus élégant de pouvoir compiler du code sous forme de module, bien que ça ne soit pas toujours possible. L'inconvénient du module est la difficulté à le debugger, mais nous y reviendrons plus tard.
Cette fois, nul besoin de toucher aux fichiers dans
sys/conf. Tout se passe dans le répertoire
sys/modules. La première chose à faire est de créer un sous-répertoire
hello, puis un
Makefile à l'intérieur :
|
|
sys/modules$ mkdir hello sys/modules$ cd hello sys/modules/hello$ $EDITOR Makefile |
Le contenu du
Makefile est très court :
|
|
1 .PATH: ${.CURDIR}/../../dev/hello 2 3 KMOD= hello 4 SRCS= hello.c 5 6 CFLAGS+= -I${.CURDIR}/../.. 7 8 .include <bsd.kmod.mk> |
La ligne 1 indique où se trouvent les sources du module. Ceux-ci sont listés dans la variable
SRCS. Ici nous avons un seul fichier compilé systématiquement, mais il est possible d'utiliser tous les mécanismes des
Makefile pour construire cette variable de manière plus élaborée. La ligne 3 précise le nom du module (sans l'extension
.ko). Enfin, la ligne 8 inclut le
Makefile qui contient toutes les règles pour la compilation d'un module.
Avant de continuer, vous pouvez tester la compilation du module seul, sans avoir à refaire tout le noyau. C'est un autre avantage de cette méthode. Pour cela :
En période de développement, avoir les informations de debugging peut nous intéresser, donc à la place d'un simple
make :
|
|
sys/modules/hello$ make DEBUG_FLAGS=-g |
Si tout fonctionne bien, un
make install copiera le module au bon endroit. Sachez que vous pouvez tester votre module dès maintenant, si vous utilisez un noyau compilé à partir des mêmes sources que ceux du présent module.
Maintenant que nous sommes sûrs que la compilation du module est correcte, nous allons lier sa compilation à celle du noyau. Pour cela, il faut simplement modifier le
Makefile dans
sys/modules pour ajouter notre nouveau répertoire à la liste : c'est la variable
SUBDIRS qui vous intéresse dans ce
Makefile, sinon, rien de très dépaysant. La compilation du noyau ne demande pas de modification du fichier de configuration.
Que ce soit avec un
make install du module seul ou un
make installkernel, vous retrouverez votre module
hello.ko dans
/boot/kernel par défaut.
Pour le tester, nous le chargeons avec la commande classique :
|
|
$ kldload hello Module chargé |
Parfait, notre message apparaît et la machine est encore debout. Testons le déchargement maintenant :
|
|
$ kldunload hello Module prêt pour déchargement Module déchargé |
Excellent travail.
Si vous désirez tester l'appel Ã
MOD_SHUTDOWN comme à l'étape précédente, laissez le module chargé (ou rechargez-le) et rebootez la machine. Le message "Reboot" apparaîtra au même endroit.
Avec ça, nous avons une base de code pour faire des choses intéressantes.
3. Codage du device driver "perdu"
Le module
hello est bien joli, mais nous n'avons pas touché à grand-chose dans ce noyau. Ne parlons même pas de l'interaction avec les utilisateurs... Il est temps de faire quelque chose d'inutile donc indispensable !
L'objectif est de programmer un module à qui on doit envoyer la phrase magique "Le code est beau !" régulièrement, sans quoi il paniquera. Cette idée a été récemment reprise dans une célèbre série télévisée. Pour détailler un peu plus, il devra :
- créer une entrée dans /dev pour que les utilisateurs puissent lui envoyer la phrase magique ;
- s'attendre à recevoir des données en provenance de cette entrée ;
- vérifier régulièrement s'il est l'heure de contrôler la phrase magique ou de paniquer.
Reprenons l'architecture de
hello en créant les répertoires
sys/dev/perdu et
sys/modules/perdu avec un code source et un
Makefile équivalent (en renommant les
hello en
perdu).
3.1 Gestion de l'entrée dans /dev
La première étape consiste à créer une entrée dans
/dev pour pouvoir envoyer la phrase magique au module. Pour permettre cette communication, il faut aussi implémenter un callback qui sera appelé à chaque fois que l'utilisateur écrira des données sur cette entrée.
Pour décrire l'entrée, nous utilisons une structure de type struct cdevsw que nous appellerons
perdu_cdevsw :
Elle est définie dans l'include
<sys/conf.h>.
Le plus important dans cette structure est la déclaration des callbacks. Quelques exemples :
- .d_open, appelé quand quelqu'un open(2) l'entrée ;
- .d_read, appelé quand quelqu'un lit à partir de l'entrée ;
- .d_write, appelé quand quelqu'un écrit sur l'entrée ;
- .d_ioctl, appelé quand quelqu'un utilise ioctl(2) sur l'entrée.
Il en existe encore bien d'autres, mais celui qui nous intéresse est
.d_write comme le montre l'extrait du source ci-dessus.
Avant d'expliquer ce callback, revenons à la création de l'entrée. Nous voulons le faire au moment où le module est chargé, donc dans la partie
MOD_LOAD de la fonction
perdu_modevent :
|
|
#define PERDU_MINOR 0 #define PERDU_FILENAME "perdu" /* For use with make_dev(9)/destroy_dev(9). */ static struct cdev *perdu_dev; static int perdu_modevent(module_t mod, int type, void *data) { ... switch (type) { case MOD_LOAD: ... perdu_dev = make_dev(&perdu_cdevsw, PERDU_MINOR, UID_ROOT, GID_WHEEL, 0666, PERDU_FILENAME); |
Notre ami est la fonction
make_dev(9). En argument, elle prend :
- la fameuse structure que nous avons définie juste avant ;
- le numéro "minor" (le major étant automatiquement attribué) ;
- l'utilisateur et le groupe (ici, "root:wheel") ;
- les permissions (ici, accès en lecture/écriture à tout le monde) ;
- le nom de fichier (ici, "perdu").
En retour, nous avons une structure de type
struct dev qui représente l'instance de notre entrée. Nous nous en servons pour la destruction au moment du déchargement du module :
|
|
case MOD_UNLOAD: case MOD_SHUTDOWN: ... destroy_dev(perdu_dev); |
Maintenant que l'entrée est proprement créée et détruite, voyons ce qui se passe quand l'utilisateur écrit sur
/dev/perdu. Tout se déroule donc dans le callback évoqué plus tôt. Celui-ci a le prototype suivant :
|
|
static int perdu_write(struct cdev *dev, struct uio *uio, int ioflag) { |
En argument, nous trouvons :
- La même structure que celle retournée par make_dev(9).
- Une struct uio, utilisée pour les transferts de données entre le kernel space et l’user space.
- Des flags que l'utilisateur peut par exemple donner avec fcntl(2) comme O_NONBLOCK.
Dans notre fonction, nous allons nous concentrer exclusivement sur cette structure
uio(9) ; les autres paramètres ne nous intéresseront pas.
L'algorithme est simple : la fonction récupère les données écrites par l'utilisateur puis, si le timer est arrivé en phase de vérification, elle compare les données à la phrase magique attendue. En fonction du résultat, le timer est remis à zéro ou continue.
Commençons donc par allouer un buffer et copier les données dedans :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
1 int error; 2 size_t len, pos, data_available; 3 char *buf; 4 5 len = 0; 6 error = 0; 7 8 buf = malloc(PAGE_SIZE, M_TEMP, M_WAITOK | M_ZERO); 9 pos = 0; 10 11 while (uio->uio_resid > 0) { 12 data_available = PAGE_SIZE - pos; 13 if (data_available > 0) { 14 len = MIN(uio->uio_resid, data_available); 15 error = uiomove(buf + pos, len, uio); 16 if (error) 17 goto OUT; 18 19 pos += len; 20 } else 21 break; 22 } |
Pour l'allocation mémoire, pas de suspens inutile :
malloc(9). Les arguments varient par rapport au
malloc(3) de la bibliothèque standard :
- La taille à allouer, classique.
- Le "type" (ici, M_TEMP) qui sert pour diverses vérifications et statistiques (affichéés par vmstat -m)
- Des flags.
Nous n'allons pas nous étendre sur le type, ça sort du cadre de cet article. Pour en apprendre plus, consultez le manuel de
malloc(9). Concernant les flags, ici, nous déclarons que :
- Le module est d'accord pour attendre que les ressources demandées se libèrent si la mémoire vient à manquer (M_WAITOK).
- La zone mémoire doit être mise à zéro (M_ZERO).
Notre buffer est alloué, la boucle
while qui commence en ligne 11 va récupérer les données de l’userland jusqu'à ce qu'il n'y en ait plus ou que notre buffer soit plein.
La structure
uio(9) et la fonction
uiomove(9) permettent les transferts de données, même si celles-ci doivent traverser la frontière entre le kernel space et l’user space. Dans le cas présent, nous n'avons justement pas à nous soucier de la provenance des données.
uio->uio_resid indique le nombre d'octets restant à récupérer ; c'est pourquoi nous bouclons sur sa valeur. Au cœur de cette boucle, nous appelons la fonction
uiomove(9) en ligne 15 pour effectuer le transfert de
len octets de la source décrite par
uio vers notre buffer
buf ; le décalage de
pos est là pour gérer la concaténation des données si les données sont copiées en plusieurs fois.
Le reste de cette fonction s'occupe de la comparaison de la phrase entrée avec celle attendue pour le reset du timer (seulement si le module est en phase d'alerte).
3.2 Boucle principale avec callout(9)
Maintenant que nous avons mis en place la récupération de la phrase magique, il faut faire tourner la boucle principale qui vérifie le temps restant. Pour s'approcher du comportement du terminal utilisé dans la série télévisée, voici comment se déroule le compte à rebours :
- Le module démarre un décompte de CYCLE_DURATION secondes en se rappelant lui-même toutes les CYCLE_UPDATE_PERIOD secondes.
- CYCLE_ALARM1 secondes avant la fin, le module notifie devd(8) toutes les CYCLE_ALARM_UPDATE_PERIOD secondes du niveau d'alarme, en commençant par le niveau 1.
- CYCLE_ALARM2 secondes avant la fin, le module passe en niveau d'alerte 2.
- CYCLE_ALARM3 secondes avant la fin, le module passe en niveau d'alerte 3.
- À la fin du compte à rebours, il génère un panic(9).
Pour implémenter ça, le kernel propose un système de
callout(9) grâce auquel on demande à ce qu'une fonction soit appelée au bout de
N*(ticks/
HZ)
secondes. En gros,
HZ est la fréquence d'exécution du scheduler. Le terme "tick" est employé pour désigner un cycle. Par exemple, si
HZ vaut 1000, il y aura un tick toutes les millisecondes.
Une instance de callout est représentée par la structure
struct callout. Nous en définissons une qui est initialisée au
MOD_LOAD :
|
|
/* For use with callout(9). */ static struct callout perdu_callout; static int perdu_modevent(module_t mod, int type, void *data) { ... switch (type) { case MOD_LOAD: callout_init(&perdu_callout, 0); |
Rien de spécial avec cette initialisation. Il suffit maintenant de démarrer le décompte. Pour ça, nous allons ajouter une fonction
cycle_start appelée à deux endroits :
- À la fin du MOD_LOAD (une fois que la structure callout(9) est initialisée et que l'entrée dans /dev est créée).
- Dans perdu_write, si la phrase magique a été correctement entrée par l'utilisateur.
Et voici, en avant-première, le contenu de la fonction
cycle_start pour vous !
|
|
/* Remaining time. */ 1 static int remaining; 2 3 int 4 cycle_start() 5 { 6 7 remaining = CYCLE_DURATION; 8 9 return (callout_reset(&perdu_callout, 10 CYCLE_UPDATE_PERIOD * hz, 11 cycle_update, NULL)); 12 } |
À la ligne 7, la variable
remaining prend comme valeur la durée de départ du cycle. Cette variable sera décrémentée à chaque rappel des fonctions données Ã
callout(9). Elle servira également Ã
perdu_write pour savoir si le module est en phase d'alerte pour décider s'il faut ou non vérifier la phrase magique entrée.
À la ligne 9, la fonction
callout_reset(9) nous permet de lancer un timer qui appelera
cycle_update sans paramètre (
NULL) au bout de
CYCLE_UPDATE_PERIOD secondes. Le fait d'utiliser cette fonction annule automatiquement un éventuel timer encore actif qui appellerait
cycle_update sans argument. Nous donnons le temps Ã
callout_reset(9) en ticks, c'est pourquoi nous multiplions la valeur en seconde par
hz.
La fonction
cycle_update commence par décrémenter notre variable
remaining, puis, en fonction de sa valeur, utilise
callout_reset(9) pour se rappeler elle-même ou pour appeler
cycle_update_alarm en phase d'alerte :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
static void cycle_update(void *args) { remaining -= CYCLE_UPDATE_PERIOD; if (remaining <= 0) { cycle_system_failure(); return; } else if (remaining <= CYCLE_ALARM1) { cycle_beep1(); callout_reset(&perdu_callout, CYCLE_ALARM_UPDATE_PERIOD * hz, cycle_update_alarm, NULL); } else { callout_reset(&perdu_callout, CYCLE_UPDATE_PERIOD * hz, cycle_update, NULL); } } |
Vous remarquerez que la fonction appelle
cycle_beep1 juste avant de donner la main Ã
cycle_update_alarm. Cette fonction émet un évènement correspondant au premier niveau d'alerte.
Elle est aussi susceptible d'appeler
cycle_system_failure si le temps total est écoulé. Cette dernière s'occupe de signifier à l'utilisateur qu'il a échoué.
La fonction
cycle_update_alarm fait exactement la même chose que
cycle_update, mais avec un cycle de rappel différent, typiquement plus court (
CYCLE_ALARM_UPDATE_PERIOD secondes). En plus d'utiliser
cycle_beep1, elle fait appel Ã
cycle_beep2 et
cycle_beep3 pour les deux autres niveaux d'alerte.
Les évènements envoyés par ces trois fonctions sont des notifications
devd(9). L'objectif est que l'utilisateur puisse configurer le daemon avec
devd.conf(5) pour exécuter la commande de son choix pour signaler l'alarme. Ça peut être jouer un son, envoyer un mail ou entrer automatiquement la phrase magique (mais là , c'est pas drôle).
Prenons
cycle_beep3 comme cobaye :
|
|
#define DEVD_SYS "PERDU" #define DEVD_SUBSYS "ALARM" void cycle_beep3() { devctl_notify(DEVD_SYS, DEVD_SUBSYS, "3", NULL); } |
En argument, elle prend des chaînes de caractères désignant le système et le sous-système qui envoient la notification, le type de cette notification et d'éventuelles données supplémentaires.
Dans
devd.conf(5), une simple entrée comme celle-ci sera exécutée à chaque passage dans
cycle_beep3 :
|
|
notify 50 { match "system" "PERDU"; match "subsystem" "ALARM"; match "type" "3"; action "play alarm3.wav"; }; |
Avec cette série de fonctions, nous avons implémenté la boucle principale du module : celle qui gère le compte à rebours et déclenche les alarmes.
3.3 Panic
Comme nous avons l'habitude de coder sans bug, le kernel ne va pas paniquer, ce qui nous empêche de jouer avec un core dump. Pour remédier à ça, si la phrase magique n'a pas été entrée dans les temps, le module va forcer un panic.
C'est la fonction
cycle_system_failure qui s'en occupe. Encore une fois, du code extrêmement obscur :
|
|
void cycle_system_failure() { panic("SYSTEM FAILURE"); } |
panic(9) prend les mêmes arguments que
printf(3). Ici, nous n'avons pas besoin d'une chaîne de format compliquée, un simple message suffira. La fonction
panic(9) ne retourne pas, donc c'est inutile de vouloir procéder à toute sorte de cleanup après.
3.4 Tests en prod
Pour le compiler, procédez comme pour le module
hello. N'oubliez pas de mettre à jour votre configuration
devd(8) pour les alarmes. Prenons comme exemple cette entrée qui ne fait que loguer le niveau de l'alarme :
|
|
notify 50 { match "system" "PERDU"; match "subsystem" "ALARM"; action "logger PERDU: alarm (level: $type)"; }; |
Une fois votre nouveau noyau booté ou votre module externe chargé, le décompte commence. Guettez donc vos logs
syslog pour repérer le déclenchement de l'alerte. Dès que ça arrive, choisissez votre camp :
- Vous pensez que cette cause est la vôtre :
|
|
$ echo "Le code est beau !" > /dev/perdu |
- Au contraire, vous êtes persuadé que c'est du bluff :
4. Debugging
Si pour vous le code est beau, passez directement à la prochaine section.
Vous êtes resté ? Vous faites moins le malin maintenant. Le debugging est un sujet qui mérite un article à lui seul. Je vous renvoie donc à la documentation FreeBSD [1] : elle informe sur l'utilisation de DDB, GDB (remote ou post-mortem) et le cas des modules externes.
Cependant, nous allons nous attarder un peu sur le debugging avec GDB d'un module externe, puisqu'il manque un exemple dans la documentation.
Vous avez donc atterri dans le debugger après le
panic(9). Pour obtenir votre
dump, vous appuyez sur [c] comme "continue". Une fois que le dump est fait, votre machine reboote. Lors du redémarrage, le core est écrit dans
/var/crash sous le nom
vmcore.XXX (où
XXX est un entier). Il suffit ensuite de démarrer
kgdb(1) (et non GDB) de la manière suivante :
|
|
$ kgdb /boot/kernel/kernel /var/crash/vmcore.27 |
Bien entendu, si vous avez utilisé un autre kernel que celui par défaut, adaptez le chemin ci-dessus à votre cas.
Une fois lancé, GDB aura pris connaissance des symboles du noyau, mais pas des modules. C'est à vous de parcourir la liste des modules internes au noyau en commençant par
linker_files.tqh_first[0] :
|
|
(kgdb) p linker_files.tqh_first[0] (...) $1 = {ops = 0xc33b7800, refs = 26, userrefs = 0, flags = 0, link = { tqe_next = 0xc3482e00, tqe_prev = 0xc06b0b1c}, filename = 0xc33b2770 "kernel", id = 1, address = 0xc0400000 "\177ELF010101\t", size = 3873960, ndeps = 0, deps = 0x0, common = {stqh_first = 0x0, stqh_last = 0xc3483030}, modules = { tqh_first = 0xc33b6e40, tqh_last = 0xc3486d48}, loaded = {tqe_next = 0x0, tqe_prev = 0x0}} |
Là , c'est l'image du noyau elle-même, puisqu'on reconnaît le nom de fichier
kernel. La liste des modules commence Ã
link = { tqe_next = 0xc3482e00 }.
|
|
(kgdb) p *(struct linker_file *)0xc3482e00 $2 = {ops = 0xc33b7800, refs = 1, userrefs = 1, flags = 1, link = { tqe_next = 0xc3482d00, tqe_prev = 0xc3483010}, filename = 0xc33b2750 "snd_ich.ko", id = 2, address = 0xc07b2000 "\177ELF010101\t", size = 24648, ndeps = 2, deps = 0xc33b2670, common = {stqh_first = 0x0, stqh_last = 0xc3482e30}, modules = {tqh_first = 0xc33b5b80, tqh_last = 0xc33b5b88}, loaded = { tqe_next = 0xc3482b00, tqe_prev = 0xc3482d40}} |
Ici, nous trouvons le module
filename = 0xc33b2750 "snd_ich.ko". Vous aurez compris, il faut parcourir les
link.tqe_next jusqu'à ce que nous trouvions le module à debugger. Toujours avec l'exemple ci-dessus, il faut continuer avec
link = { tqe_next = 0xc3482d00 }.
Quand vous avez mis la main sur le bon module, notez l'adresse de chargement notée dans le membre
address de la
struct linker_file. Pour
snd_ich.ko, l'adresse est donc
address = 0xc07b2000.
En plus de cette information, il nous faut l'adresse du début des instructions dans le binaire du module. Nous l'obtenons avec
objdump(1) :
|
|
$ objdump --section-headers /boot/kernel/snd_ich.ko | grep text 4 .text 00001c5b 00001cf8 00001cf8 00001cf8 2**2 |
C'est le quatrième nombre hexadécimal qui nous intéresse, 0x00001cf8.
Il ne reste plus qu'à additionner ces deux adresses (0xc07b2000 + 0x00001cf8 = 0xc07b3cf8) et la fournir à GDB en argument de la commande pour charger le module :
|
|
(kgdb) add-symbol-file /boot/kernel/snd_ich.ko 0xc07b3cf8 add symbol table from file "/boot/kernel/snd_ich.ko" at .text_addr = 0xc07b3cf8 (y or n) y Reading symbols from /boot/kernel/snd_ich.ko...done. |
Maintenant, vous pouvez debugger confortablement votre module.
5. Finalisation
C'est fait, nous avons ajouté une fonctionnalité importante au kernel FreeBSD. Maintenant, il convient d'en faire profiter les autres. Le meilleur moyen est d'envoyer un mail avec le patch sur la mailing-list freebsd-current@FreeBSD.org.
Mais pour que le patch ait plus de chance d'être inclus dans le CVS, il faut régler quelques points avant : le style du code et la documentation.
5.1 style(9)
Le style utilisé dans le code de FreeBSD est décrit dans la page man
style(9). Je ne vais pas m'amuser à paraphraser ce manuel ici : il faut le lire, même si c'est un peu pénible.
Les exemples montrés ici, ainsi que le code fourni, respectent ce style.
5.2 Documentation
Le minimum est une page man dans la section 4 pour notre module. Cette page est fournie avec le code source. Elle doit être ajoutée dans le répertoire
src/share/man/man4, puis déclarée dans le
Makefile présent dans ce même répertoire.
6. Trouver l'inspiration
Pour conclure, vous trouverez le code des deux modules, les
Makefile et les pages man sur people.FreeBSD.org [2].
À présent, le développement noyau vous intéresse ? Si vous ne trouvez pas d'idée pour débuter, inspirez-vous de celles proposées sur la page Projects [3] du site officiel ou tapez dans les rapports de bug [4] (appelés "PR" pour Problem Report dans le jargon FreeBSD). En parlant d'idée, merci à Julien Barbot, aka Klyr (<klyr@quicheaters.org>) pour celle du module
perdu !
Références