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) etobjdump(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.
1.1 Configurer les dumps kernel
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
savecorelit le swap et écrit le core dans le répertoire/var/crash.
La ligne magique à ajouter à votre fichier /etc/rc.conf :
dumpdev="/dev/ad4s1b"
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 danssys/gnu/fsont 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 :
OK boot kernel.ref
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_tqui 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 :
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
GENERICpour 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#definemulti-ligne. - Une entrée commence par le nom du fichier. Ce nom est relatif au répertoire
sysdu noyau. - En 2ème position, le mot clé indique le type d'inclusion. Soit
standardpour dire que le fichier source est tout le temps dans le noyau, soitoptionalpour 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 :
OK boot kernel.hello
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 :
sys/modules/hello$ make
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
/devpour 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'unopen(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 utiliseioctl(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" (lemajoré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) commeO_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 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 parvmstat -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_DURATIONsecondes en se rappelant lui-même toutes lesCYCLE_UPDATE_PERIODsecondes. CYCLE_ALARM1secondes avant la fin, le module notifiedevd(8) toutes lesCYCLE_ALARM_UPDATE_PERIODsecondes du niveau d'alarme, en commençant par le niveau 1.CYCLE_ALARM2secondes avant la fin, le module passe en niveau d'alerte 2.CYCLE_ALARM3secondes 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 structurecallout(9) est initialisée et que l'entrée dans/devest 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 :
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 :
panic: SYSTEM FAILURE db>
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
- [1] The FreeBSD Projects, "Kernel Debugging", in FreeBSD Developers' Handbook,
http://www.freebsd.org/doc/en_US.ISO8859-1/books/developers-handbook/kerneldebug.html - [2] Jean-Sébastien PÉDRON, fichiers sources des exemples,
http://people.freebsd.org/~dumbbell/linuxmag_hs/ - [3] The FreeBSD Projects, FreeBSD list of projects and ideas for volunteers,
http://www.freebsd.org/projects/ideas/ - [4] The FreeBSD Projects, Problem Report Database,
http://www.freebsd.org/support/bugreports.html


