Retrouvez cet article dans : Linux Magazine 87
Pour ce troisième volet, l’actualité noyau ne sera pas très florissante en raison de la stabilisation de la série 2.6.18 et donc de l’absence d’une nouvelle mouture 2.6.19 (et des vacances de Linus en août ;), du moins à l’heure de l’écriture de la rubrique. C’est pourquoi nous avons opté pour une actualité moins technique que d’habitude. Toutefois, le récit de certaines conférences ayant eu cours au Kernel Summit nous ramène au cœur de discussions techniques avant d’aborder deux nouvelles brèves. La première concerne les aspects synchronisation avec une explication sur les spinlocks et la seconde aborde la détection du matériel via la description des arcanes de sysfs, hotplug, udev...
Actualité noyau
Nous pouvons noter quelques faits importants sur la maintenance du noyau. Tout d’abord, la branche 2.6.16 se voit maintenue sans interruption par Adrian Bunk. En effet, depuis la version 2.6.11 qui a marqué le passage à un processus de développement fondé sur un quatrième chiffre, une version stable 2.6.x ne se voyait maintenue que jusqu’à la sortie de la version 2.6.(x+2). Notez que l’incrémentation du troisième chiffre aboutit à des modifications importantes (à partir du 2.6.11, bien évidemment). Avec une série 2.6.x maintenue indéfiniment, les utilisateurs du 2.4 pourraient bien migrer à long terme vers la branche 2.6. Ces utilisateurs préfèrent la série 2.4 de par sa stabilité et sa sécurité éprouvées depuis plus de deux ans. Le maintien d’une série du 2.6 à la sauce 2.4 permettra de le stabiliser et de le sécuriser à long terme : une initiative d’Adrian Bunk à féliciter.
Du côté du 2.4, Marcelo Tosatti passe le relais à Willy Tarreau en tant que mainteneur officiel. Ce dernier a d’ailleurs adopté une maintenance à la façon du 2.6, avec l’utilisation d’un quatrième chiffre servant principalement aux correctifs de sécurité. Cela de façon à pouvoir les intégrer entre des -rc (release candidate) successives. Un autre fait important concerne le passage du 2.4 à gcc 4.1 que Willy a entamé en utilisant les patchs de Mikael Petterson (à l’heure de l’écriture de cette actualité, le 2.4.34-pre3 a pour vocation de fixer les derniers soucis pour le support de gcc 4). Ce choix n’est bien évidemment pas arbitraire. Il vient de la constatation des faits suivants. Les noyaux 2.4 sont utilisés principalement sur des serveurs ou dans l’embarqué. La compilation de noyaux pour ces cibles se fait généralement sur un autre système, car, la plupart du temps, elles sont dépourvues de chaînes de compilation. Or, la majeure partie des distributions Linux sont passées à du gcc 4.1 (Gentoo vient d’ailleurs tout juste d’y passer dans sa dernière mouture : la 2006.1), avec lequel il n’est pas possible de compiler un noyau 2.4. En outre, les versions antérieures de gcc (3.3 et 3.4) n’étant plus maintenues, la conversion du 2.4 à gcc 4.1 semble donc une bonne marche à suivre.
Nous finissons cette partie actualité en développant certains points du Kernel Summit s’étant déroulé le 17 et 18 juillet. Concernant le processus de développement, Andrew Morton s’est plaint d’une diminution de la qualité des versions successives du noyau. Selon lui, plus d’attention devrait être appliquée à la recherche et à la correction de bugs. Il souhaite également qu’il y ait plus de coopération entre les différents sous-systèmes ; en d’autres termes, que les développeurs acceptent également de regarder du code qui ne concerne pas uniquement leur domaine de prédilection. Une suggestion émise était de mettre en place des règles forçant le séjour de tous les nouveaux patchs dans la branche -mm d’Andrew afin de garantir leur revue avant de passer dans la branche stable du noyau. Malgré ces points négatifs, il est très majoritairement accepté que le processus de développement se déroule plutôt bien et qu’il n’y a pas lieu d’effectuer de modifications majeures. De plus, Linus a réaffirmé qu’il ne voyait pas d’intérêt à créer une branche 2.7 (du moins à court terme) pour le noyau, au vu des modifications si importantes déjà effectuées et parfaitement intégrées par le 2.6. Le passage à un 2.7 serait synonyme de changements tellement importants, qu’aucun ne le souhaiterait.
Une discussion importante s’est portée sur les problèmes d’interfaçage avec l’espace utilisateur. L’exemple le plus probant est sans conteste sysfs qui évolue rapidement. Étant le reflet de structures internes au noyau, il est impossible actuellement de ne pas répercuter les changements internes aux yeux de l’espace utilisateur. L’idée d’embarquer dans la distribution du noyau les outils userland de première importance comme udev n’est pas nouvelle et a encore fait parler d’elle. La gestion de la dépendance entre ces outils et les différentes versions du noyau pourrait donc migrer vers la communauté du noyau et ne plus être assurée par conséquent, au niveau des distributions. Concernant la
klibc – la bibliothèque qui permettrait entre autres le passage de la majorité de la séquence de boot en userland – Linus a fermement exprimé son point de vue. Selon lui, le passage massif de code fonctionnant très bien en mode noyau vers l’userland n’est pas une bonne idée. D’autres inquiétudes viennent de la création probable d’une dépendance entre l’utilisateur et les noyaux des distributions empêchant l’utilisateur de se servir d’un vanilla kernel.
Le temps réel a également été abordé par les acteurs principaux de cette activité dans le kernel : Ted Ts’o et Ingo Molnar. Beaucoup de modifications ont déjà été intégrées dans la branche principale du noyau. Une modification nécessaire pour que Linux soit capable de garantir des temps de réponse (et donc de servir dans un système temps réel à contraintes dures), correspond à rendre préemptif les spinlocks. Ce patch utilise la même infrastructure noyau gérant le mécanisme d’héritage de priorité pour les futex en userland. Toutefois, ces patchs temps réel introduisent complexité et pertes de performance. Ces dernières sont un désagrément souvent inévitable à la garantie du déterminisme (si cher aux systèmes temps réel). Cependant, aux dires d’Ingo Molnar, elles ne seraient pas significatives.
Au niveau de l’embarqué, le nouveau patch
dyntick (l’ancien correspondait aux travaux de Con Kolivas et implémentait uniquement une variation de la fréquence des ticks en fonction de la charge du système) de Thomas Gleixner et Ingo Molnar pourrait être inclus d’ici peu au sein de la branche principale du noyau. Il consiste en une modification de la gestion des ticks et est construit sur l’infrastructure des " high resolution timers ", ce qui lui apporte une précision sur les timers aussi grande que ce que permet le matériel. Ce patch propose de gérer les ticks de deux manières : soit en effectuant une variation de la fréquence des ticks en fonction de la charge du système (analogue au précédent patch de Con Kolivas, mais dont l’implémentation est bien moins intrusive) ou sinon en supprimant l’utilisation périodique des ticks (architecture tickless). Dans ce dernier cas (
CONFIG_NO_HZ), il n’y a plus de réveil systématique du système. Les interruptions du timer du système (
PIT ou
HPET) sont programmées à la demande en fonction des dates d’expiration des timers logiciel. Lorsqu’un processeur se trouve en état oisif (
idle), il n’a plus à se réveiller pour exécuter le gestionnaire d’interruption du timer. Le processeur se trouve donc réellement au repos, d’où un gain significatif au niveau de la consommation électrique et du refroidissement du processeur (nous évitons ainsi le déclenchement systématique de la procédure de gestion du tick, 250 fois par seconde – fréquence standard de l’horloge pour le 2.6).
En ce qui concerne la thématique de la virtualisation qui a été très présente lors du Kernel Summit, nous vous réservons pour le mois prochain toute une brève sur ce sujet.
La synchronisation avec les spinlocks
a synchronisation permet d’éviter les problèmes de concurrence lorsque de multiples traitements peuvent s’imbriquer (corruptions de données, corruption fonctionnelle). Il s’agit souvent de sérialiser les activités lorsque nous rentrons dans une section critique, autrement dit, lorsque nous nous apprêtons à modifier et/ou lire des données partagées entre différents acteurs pouvant s’exécuter parallèlement ou pouvant prendre le pas l’un sur l’autre de façon non prévisible.
Pour résoudre ces problèmes de concurrence, nous utilisons souvent le principe des verrous. Prenons un exemple simple de scénario d’exclusion mutuelle. Un processus souhaite accéder à un ensemble de données partagées, mais doit acquérir préalablement le verrou correspondant. Il y reste bloqué, car celui-ci est déjà pris par un autre processus. Quand ce dernier a fini son travail, il relâche le verrou ce qui permet au premier de le verrouiller à son tour et d’accéder aux données partagées.
La synchronisation est un moyen de rendre atomique des opérations. Autrement dit, il s’agit de rendre ces opérations indivisibles au sens de l’ordonnanceur ou du matériel (cas SMP). Certaines opérations sont déjà atomiques et ne nécessitent donc pas de mécanismes supplémentaires. Il s’agit des instructions assembleurs qui font, au plus, un accès mémoire. Des instructions comme
add sont atomiques pour un système uniprocesseur, mais ne le sont pas en environnement multiprocesseur. Dans ce dernier cas, il est possible de les rendre atomiques via l’instruction
lock placée devant. Celle-ci verrouille le bus mémoire jusqu’à la fin de l’instruction. Les autres processeurs ne peuvent donc pas accéder à la mémoire pendant ce temps. Notons que le kernel définit le type
atomic_t ainsi qu’un ensemble de macros afin d’effectuer des opérations atomiques.
Lorsque nous souhaitons protéger des ensembles de données, il est nécessaire de mettre en place des mécanismes assurant l’atomicité des opérations sur ces ensembles afin d’en prévenir la corruption. Pour cela, le kernel a mis en place divers mécanismes (per-CPU variables, spinlocks, sémaphores, etc.). Nous allons nous intéresser dans cette brève à celui des spinlocks.
Les spinlocks sont des verrous conçus pour fonctionner avec des systèmes multiprocesseurs. Si un processus bloque sur un spinlock, il effectue une attente active dessus, autrement dit, il boucle (spin) jusqu’à obtenir le verrou. En conséquence, le processeur est occupé à attendre ;) Aussi, les temps d’acquisition des spinlocks sont, en règle générale, suffisamment faibles pour être plus performants que l’utilisation de sémaphores. Lors de l’emploi de ces derniers, un processus bloquant est placé dans une liste d’attente (waitqueue) et le processeur lui est dérobé afin d’exécuter un autre processus. Bien que ce mécanisme soit plus performant qu’une attente active, leur gestion prend plus de temps que l’attente active effectuée dans les situations où sont utilisés les spinlocks.
A première vue, les spinlocks sont inutiles pour les systèmes à un seul processeur. En fait, cela n’est pas tout à fait vrai. Dans le cas où le noyau est préemptif, une opération d’acquisition de verrou se traduira par une désactivation de la préemption. Un noyau est qualifié de préemptif lorsqu un processus effectuant un traitement en kernel land peut être remplacé à tout moment par un autre processus lorsque survient une interruption (sauf dans le cas où le premier processus traite déjà une interruption). La désactivation de la préemption est donc nécessaire afin de conserver la synchronisation avec d’autres traitements concurrents en mode noyau. Pour mieux comprendre cela, il suffit de transposer le cas d’une préemption à un cas équivalent en multiprocesseur (considérez le processus préemptant le premier comme un traitement débutant au même moment sur un autre CPU). Notons cependant qu’il ne s’agit bien évidemment plus de la sémantique du spinlock, puisque aucune attente active (ou même passive) ne pourrait se produire.
Avant de rentrer dans le code relatif au spinlock, précisons sa constitution. Il est représenté par une structure contenant un champ
slock, lequel vaut 1 lorsque le verrou est libre et est négatif ou nul lorsque celui-ci est pris. Elle contient un autre champ (
break_lock) sur lequel nous revenons plus tard. L’acquisition et la libération d’un verrou va donc se traduire par la modification de la valeur de
slock.
Nous allons à présent expliquer le fonctionnement de la prise d’un spinlock dans un environnement SMP avec un noyau compilé avec la préemption. Dans cette situation, la primitive de verrouillage peut se décrire de la manière suivante. Avant d’essayer de prendre le verrou, nous désactivons la préemption. Ensuite, si la prise est un succès, nous effectuons la suite du traitement, sinon nous bouclons jusqu’à ce que le verrou soit libéré, auquel cas nous essayons de nouveau de l’acquérir. Notez que la préemption est réactivée lors de l’attente afin de rendre possible l’exécution d’un processus de priorité supérieure. Cela n’a toutefois aucune incidence sur la synchronisation avec les processus concurrents.
La fonction de verrouillage des spinlocks en SMP préemptif est la suivante (Linux 2.6.17). Vous devriez retrouver la description qui vient d’être énoncée avec quelques éléments supplémentaires que nous allons détailler.
|
|
1: void __lockfunc _spin_lock(spinlock_t *lock)
2: {
3: for (;;) {
4: preempt_disable();
5: if (likely(_raw_spin_trylock(lock)))
6: break;
7: preempt_enable();
8:
9: if (!(lock)->break_lock)
10: (lock)->break_lock = 1;
11: while (!spin_can_lock(lock) && (lock)->break_lock)
12: cpu_relax();
13: }
14: (lock)->break_lock = 0;
15: } |
Tout d’abord, faisons un petit commentaire sur le modificateur
__lockfunc. Il permet de demander au compilateur de placer ensemble dans une section spécifique du code, toutes les fonctions préfixées de la sorte. Pour chaque type de verrou, une section du code regroupe toutes ses fonctions associées. Pour le cas des spinlocks, il s’agit de la section
.spinlock.text. __lockfunc est définie de la façon suivante :
|
|
#define __lockfunc fastcall __attribute__((section(".spinlock.text"))) |
Notons également que lors de l’exécution de
preempt_enable, la fonction vérifie si une préemption est nécessaire auquel cas elle fait appel directement au scheduler.
Revenons à notre description. La fonction qui va essayer d’acquérir le verrou se traduit par quelques lignes en langage d’assemblage.
|
|
static inline int __raw_spin_trylock(raw_spinlock_t *lock)
{
char oldval;
__asm__ __volatile__(
"xchgb %b0,%1"
:"=q" (oldval), "=m" (lock->slock)
:"0" (0) : "memory");
return oldval > 0;
} |
Il s’agit ici d’assembleur
inline étendu, laissant au compilateur les soucis d’initialisation et d’écriture des résultats finaux. La syntaxe s’entend de la façon suivante :
|
|
__asm__ __volatile__(
"... bloc d’instructions ..."
: sorties
: entrées
: liste des "registres" dont leurs valeurs en sortie du bloc peuvent ne pas correspondre
avec celles avant exécution du bloc. Les "registres" précisés en E/S n’ont pas besoin
d’être ajoutés à cette liste); |
A l’intérieur du bloc, nous faisons référence aux entrées/sorties via une numérotation partant de %0. Des contraintes peuvent être précisées à ce niveau. Dans notre cas de figure, la variable
oldval contient à la sortie du bloc, la valeur du registre référencé par
%0, lequel est laissé au choix du compilateur (
"q"). Ce registre est d’ailleurs initialisé avec la valeur 0 (
"0" (0)). La référence
%1 correspond, quant à elle, à un emplacement en mémoire (
"m").
Dans notre fonction, Le bloc d’instructions contient uniquement
xchgb. Cette instruction va replacer atomiquement la valeur de
lock->slock par 0. L’instruction
xchgb affecte alors la valeur 1 au registre
%0 (premier attribut) si
slock était strictement positif (i. e. si le spinlock était libre), sinon la valeur 0. Au final, ce résultat est écrit dans la variable
oldval.
La fonction
__raw_spin_trylock retourne donc une valeur positive dans le cas où le verrou est libre. La fonction de verrouillage se termine alors (ligne 5). Notons que la macro
likely permet au compilateur d’optimiser la structure conditionnelle en lui indiquant le résultat le plus probable.
Si le verrou est déjà pris, nous réactivons la préemption (ligne 7) et nous affectons 1 au champ
break_lock du spinlock, afin d’avertir celui qui détient le verrou qu’il devrait se dépêcher de le relâcher (la macro
need_lockbreak permet au détenteur du verrou de connaître la valeur du champ). La boucle d’attente correspond aux lignes 11 et 12. Elle est constituée de la fonction
cpu_relax qui se développe en l’instruction
"pause;" (même opcode que
"rep;nop;") apparue avec le Pentium 4 et optimisant la consommation énergétique du processeur comparé à un
"rep;nop;". Finalement, nous sortons de la boucle dès que le verrou est libre ou que
break_lock est à 0.
La dernière clause ne vous paraît pas très claire (j’ai mis un moment à comprendre également pourquoi ;), alors approfondissons. Lorsqu’un verrou vient d’être acquis,
break_lock est remis à 0 (ligne 14). Cela est dû à un patch d’Ingo Molnar sur le 2.6.12-rc2, car
break_lock n’était remis à zéro que dans la primitive
cond_resched_lock. Or les utilisateurs de la macro
need_lockbreak ne font pas forcément appel à cette primitive. Nous pouvions donc nous retrouver pre-2.6.12 dans des situations où les utilisateurs de
need_lockbreak relâchaient hâtivement le spinlock alors qu’aucun processus n’était en attente dessus. Voilà donc la raison de la ligne 14. Cependant dans le cas où d’autres processus sont en attente sur ce spinlock, il est nécessaire de repositionner à 1
break_lock, c’est la raison de cette fameuse condition dans le
while de la ligne 11. En effet, lorsque
break_lock vaut 0, nous sortons de la boucle et pouvons réexécuter la ligne 10 afin d’affecter de nouveau la valeur 1 à la variable. Notons que la fameuse clause était déjà présente pre-2.6.12, mais n’était mise à profit que dans le cas où
cond_resched_lock était utilisée en milieu concurrentiel. Maintenant, cette clause est pleinement pertinente.
Voyons maintenant l’implémentation de la primitive de verrouillage lorsque la préemption n’est pas activée. Je vous laisse méditer sur le code suivant qui ne devrait pas poser de problèmes de compréhension.
|
|
void __lockfunc _spin_lock(spinlock_t *lock)
{
preempt_disable();
__asm__ __volatile__(
“\n1:\t”
"lock ; decb %0\n\t"
„jns 3f\n“
„2:\t“
"rep;nop\n\t"
“cmpb $0,%0\n\t”
“jle 2b\n\t”
“jmp 1b\n”
“3:\n\t”
“=m” (lock->slock) : : “memory”);
} |
Notez que la préemption permet d’obtenir des latences plus faibles (en environnement SMP) que le code ci-dessus. L’ajout en outre du champ
break_lock (patch d’Ingo Molnar sur le 2.6.9 de la branche
-mm) a permis de supprimer le problème de latence sur les systèmes SMP dû à l’utilisation de spinlocks imbriqués à différents niveaux.
Finalement, la fonction de libération d’un spinlock consiste en l’affectation de la valeur 1 à son champ
slock et en la réactivation de la préemption.
|
|
void __lockfunc _spin_unlock(spinlock_t *lock)
{
__asm__ __volatile__(
"movb $1,%0" \
:"=m" (lock->slock) : : "memory");
preempt_enable();
} |
ouvent critiqué pour sa " mauvaise gestion du matériel ", Linux s’est vu accusé d’être un OS antipathique à configurer dès qu’il s’agissait d’ajout et de détection de périphériques. Si ce type de paramétrages a en effet été réservé à un public initié dans le passé, la série 2.6 de notre noyau adoré franchit un nouveau cap et peut enterrer tous les préjugés portés précédemment. Nous allons découvrir comment s’y passe la détection d’un périphérique, de son branchement à son utilisation. Je me concentrerai volontairement sur les périphériques de type courant (PCI, USB) tels que les périphériques USB, les disques durs, les cartes réseau...
Préambule : les interruptions matérielles
L’organe central de l’ordinateur est le processeur. Il doit dialoguer avec l’ensemble du matériel présent ; pour cela il est relié aux différents bus de la machine : PCI, IDE, AGP et/ou SATA pour les matériels internes (disque dur, carte graphique, carte réseau...), mais aussi USB, PCMCIA... Lorsqu’un périphérique souhaite communiquer, il génère un signal électrique envoyé au contrôleur d’interruptions (PIC ou I/O APIC) lequel émet un signal à la CPU (pour un PIC sur x86 via la broche INTR de la CPU).
Dans la pratique, ce dialogue est une tâche qui implique que le processeur interrompe son travail en cours et sauvegarde l’état de ses registres, d’où le nom d’interruption. Il existe plusieurs types d’interruptions, mais celles utilisées par les périphériques sont les interruptions matérielles masquables et non masquables, nommées IRQ (Interrupts ReQuests) sous Linux. Elles sont traitées par le gestionnaire d’interruptions (Interrupt Handler) de l’OS, qui les transmet à l’Interrupt Handler du driver associé au périphérique (note : depuis le kernel 2.6.18, l’API de gestion des IRQ est vraiment générique à toutes les architectures, cf. KC86).
Chaque périphérique a un numéro d’interruption défini au démarrage, selon l’architecture, par le BIOS (x86) ou par le système d’exploitation.
Sur plate-forme x86, certaines sont fixes, ce qui est lié historiquement à la norme ISA : ainsi, par défaut, le lecteur de disquettes utilise l’IRQ 6. A titre d’exemple, vous pouvez consulter les IRQ utilisées sur votre système via :
|
|
$ cat /proc/interrupts
CPU0
0: 8002770 XT-PIC timer
1: 44849 XT-PIC i8042
2: 0 XT-PIC cascade
7: 519827 XT-PIC Intel 82801DB-ICH4, eth0
8: 2 XT-PIC rtc
9: 0 XT-PIC acpi
11: 3421155 XT-PIC yenta, ehci_hcd:usb1, uhci_hcd:usb2, uhci_hcd:usb3, uhci_hcd:usb4, i915@pci:0000:00:02.0
12: 6045 XT-PIC i8042
14: 277668 XT-PIC ide0
15: 402662 XT-PIC ide1
NMI: 0
LOC: 0
ERR: 0
MIS: 0 |
Par colonne : numéro d’IRQ, nombre d’interruptions reçues par chaque CPU présent, type et périphérique utilisant cette adresse. A noter que, pour communiquer avec le périphérique, le driver peut employer deux méthodes (si le matériel propose les deux) : soit via les ports d’E/S (I/O ports), soit en mappant dans l’espace d’adressage la zone mémoire (et/ou des registres) que le périphérique met à disposition sur le bus (I/O Memory). Dans ce dernier cas, ces espaces d’adressage peuvent être consultés dans
/proc/iomem/.
Représentation des périphériques sous systèmes Unix
La représentation des périphériques sous les systèmes Unix est commune et identique depuis la nuit des temps. Vous avez sans doute déjà entendu que sous Unix tout est fichier ; ceci vaut aussi pour les périphériques. En fait, chaque périphérique a un fichier virtuel qui lui est associé, et qui permet d’y accéder, dans l’arborescence
/dev.
Les systèmes *nix les séparent en trois catégories : de type bloc, caractère et réseau. Ces notions ne sont pas compliquées à comprendre. Si on peut lire et écrire dans un périphérique en spécifiant l’endroit auquel on souhaite accéder, il est de type bloc. Les matériels de stockage (mémoire, disques durs, etc.) sont de type bloc, car on spécifie la portion (le bloc) qui nous intéresse.
La catégorie dite " caractère " de périphériques ne permet que d’y accéder à la volée, en lisant/écrivant au fur et à mesure. Exemple, le clavier, le port série, la carte son... Ainsi, vous pouvez " écrire " directement dans votre carte son et y envoyer un flux audio via :
|
|
# cat /mon/fichier.wav > /dev/dsp |
C’est rigolo de jouer à y envoyer différents types de fichiers et d’écouter ainsi la mélodie produite par un fichier texte ou binaire, mais gardez un peu de sérieux et de concentration pour lire la suite :-)
Les interfaces réseau, quant à elles, ne répondent pas vraiment à l’une de ces deux caractéristiques, et fondent donc une famille à part.
Détection d’un périphérique dit " Plug-and-Play "
Du temps des bus ISA, que vous avez peut-être connu si vous êtes un semi-dinosaure du PC, le matériel n’était pas détecté automatiquement. L’insertion d’une nouvelle carte dans l’unité centrale vous obligeait à lui attribuer manuellement une IRQ libre via des cavaliers situés sur la carte elle-même, ou sinon l’IRQ était fixée " en dur " dans le périphérique.
Le pilote allait donc systématiquement chercher son périphérique là où il devait se trouver, mais la découverte automatique et le listage du matériel relié au bus n’étaient pas possibles. La donne a été changée avec les bus PCI, USB, AGP...
C’est ce qui se cache sous ce que Microsoft n’a pas inventé, mais a nommé " Plug’n’Play " (PnP). Cette norme impose au périphérique de pouvoir transmettre de manière standard ses Vendor ID, l’identifiant du fabricant, et Product ID, identifiant unique du produit de ce fabricant ainsi que le dialogue lui permettant d’obtenir une IRQ et une plage mémoire, à la routine PnP (pouvant être contenue dans l’OS et/ou le BIOS).
Créée au départ pour le bus ISA, elle existe sous différentes formes ; son successeur, le bus PCI, a été conçu dès le départ pour offrir ces fonctionnalités. Le plug and play " complet " permet en plus l’ajout/suppression de périphériques à chaud, et on le retrouve entre autres dans les bus USB et FireWire.
L’échange d’informations PnP se reproduit à chaque démarrage de l’ordinateur.
Dans le cas d’une architecture avec BIOS (x86), celui-ci s’occupe d’une partie de la détection Plug and Play pour faire fonctionner en priorité clavier, souris, affichage et mémoire de masse contenant le système d’exploitation, et permettre dès cet instant leur usage via des drivers rudimentaires et génériques. Le BIOS n’est pas obligé de détecter tout le matériel présent dans l’ordinateur, l’OS se chargera plus tard de cette tâche.
C’est cette partie qui de toute façon nous intéresse le plus. Notre noyau Linux une fois chargé en mémoire via le chargeur de boot, j’ai nommé Grub (GRand Unified Boot loader) ou LiLo (Linux Loader), prend le relais et détecte les bus du système, puis les scanne afin d’obtenir la liste des périphériques rattachés.
Côté code noyau, si l’on s’intéresse aux périphériques PCI, les fichiers
drivers/pci/probe.c,
search.c, etc. sont très instructifs ; pour simplifier, la fonction
pci_alloc_bus(void) crée une structure de
type pci_bus, qui héberge en particulier la liste des bus fils eux-mêmes rattachés à ce bus et les périphériques connectés.
Ensuite,
pci_do_scan_bus(struct pci_bus *bus),
pci_scan_child_bus et
pci_scan_slot(struct pci_bus *bus, int devfn) scannent l’ensemble du bus et, pour chaque élément découvert, ce sont successivement
pci_scan_device(struct pci_bus *bus, int devfn), puis
pci_setup_device(struct pci_dev * dev) qui continuent le travail, avec la lecture des paramètres requis, comme l’IRQ, Vendor ID et Product ID. Le principe est similaire avec les périphériques détectés " à chaud " (hotplug).
Nous visons ici par exemple les matériels connectés sur un bus USB. Ici, c‘est le bus qui envoie une notification au système (via une IRQ), qui récupère là encore les identifiants du périphérique.
Cependant une caractéristique de l’USB est que seul le bus possède un numéro d’IRQ, et se charge de transmettre le nécessaire au lieu de laisser ses périphériques rattachés avoir chacun leur numéro d’interruption (les communications USB fonctionnent selon un modèle de paquets et de jetons d’accès).
La suite du travail consiste à créer les entrées sysfs correspondantes et à ajouter les périphériques dans la liste déjà existante. Sysfs est un système de fichiers virtuel existant depuis la série 2.6 du noyau.
Cette infrastructure permet le déploiement de systèmes en userland (udev) visant à remplacer l’ancien devfs ayant accompagné la série 2.4. Il décrit l’ensemble des périphériques détectés, sous la hiérarchie
/sys.
Beaucoup d’informations sont en doublon dans le système virtuel
/proc, mais la tendance est à la migration de tout ce qui concerne le matériel vers
/sys.
Sysfs a été conçu pour être facilement parsé par des outils, avant d’être lisible par un humain. Mais jusque-là, nous n’avons toujours pas indiqué ni chargé de pilote pour nos périphériques. Ils sont donc inutilisables en l’état.
Chaque pilote présente la liste des périphériques qu’il gère par la macro
MODULE_DEVICE_TABLE qui prend en paramètre une structure contenant entre autres les Vendor ID et Product ID des périphériques pris en charge :
|
|
static const struct usb_device_id mydriver_id_table = {
{ USB_DEVICE (0x9999, 0xaaaa), driver_info: QUIRK_X },
{ USB_DEVICE (0xbbbb, 0x8888), driver_info: QUIRK_Y|QUIRK_Z },
...
{ } /* end with an all-zeroes entry */
}
MODULE_DEVICE_TABLE (usb, mydriver_id_table); |
Les outils module-init-tools sont chargés d’extraire ces informations de chaque pilote afin de créer une " carte " (map), liste associant les identifiants du produit au nom du driver les gérant.
Cette liste est un fichier texte, vous pouvez le consulter dans
/lib/modules/uname -r/modules.alias. En fait, une chaîne appelée " modalias " est générée par la concaténation de plusieurs des identifiants du périphérique, par exemple
pci:v00008086d000024D1sv00001043sd000080A6bc01sc01i8f.
C’est cette chaîne qui est associée à un nom de module dans le
modules.alias. Le driver du bus hébergeant le périphérique enregistre celui-ci dans
/sys/bus/{type_du_bus}/ et crée une entrée
modalias dans cette hiérarchie (
/sys/bus/type_du_bus/devices/{emplacement_du_produit}/modalias).
Munis de nos identifiants de périphérique, soigneusement ramenés par les routines de détection de chaque type de bus, nous avons donc la possibilité d’associer un pilote au(x) périphérique(s) présent(s), et donc potentiellement de le charger comme avec un
modprobe manuel.
Pour compléter ce topo, il faut savoir qu’un driver peut nécessiter des fonctions fournies par un autre driver ou module, donc que ce dernier soit chargé...
Pas de problème, Linux gère les dépendances entre modules depuis longtemps, c’est d’ailleurs le rôle de la commande
depmod (celle que vous lancez après avoir (re)compilé un/des module(s)) de construire une représentation de ces dépendances.
Ajoutons à ceci le fait que la commande
modprobe, que vous aviez sans doute l’habitude d’utiliser avec un nom de module noyau en paramètre, peut accepter à la place un
modalias, et vous allez penser : on a presque fait le tour, tout est là, seulement...
Seulement nous n’avons pas pour autant chargé le module du pilote.
Les outils complémentaires en user-space
Après le boot (coldplug), ou lors de la détection d’un ajout/suppression de périphérique (hotplug), le noyau génère des messages appelés " uevents " (
kobject_uevent(), anciennement appelés " événements hotplug ") sur un socket, qui sont récupérés par le démon
udevd.
On quitte ici le mode noyau pour basculer dans l’espace utilisateur classique. Ce démon, lorsqu’il reçoit un uevent, exécute des règles udev spécifiées dans le dossier
/etc/udev/rules.d.
Depuis que udev arrive à maturité, de plus en plus de distributions commencent à s’appuyer exclusivement dessus pour la détection du matériel et le chargement de drivers, y compris lors du boot en chargeant udev et sysfs le plus tôt possible.
Une règle udev est relativement aisée à comprendre, nous ne détaillerons donc pas cela ici ; cependant regardons cet intéressant exemple de chez Mandriva :
|
|
ACTION=="add", SUBSYSTEM=="usb", MODALIAS=="*", RUN+="/sbin/modprobe $modalias"
La même chose chez Suse :
ACTION=="add", SUBSYSTEM=="usb", RUN+="/sbin/hwup usb-devpath-%p -o hotplug" |
Une règle udev peut contenir une commande à exécuter quand la règle s’applique (ici, ajout d’un périphérique USB). Mandriva effectue directement un
modprobe en passant le
modalias en paramètre, tandis que SuSe lance son outil de configuration de matériel,
hwup. Le driver, enfin chargé, crée des entrées sysfs au sujet du matériel qu’il prend en charge. Un autre rôle important d’udev, sur lequel nous ne nous attarderons pas ici, est de créer une entrée correspondant au périphérique dans
/dev, avec un nom personnalisable et plus parlant que
/dev/eth0,
/dev/sda1, etc.
Pour conclure, d’autres outils en espace utilisateur complètent notre arsenal afin d’apporter la touche finale au confort d’utilisation.
On peut citer HAL (Hardware Abstration Layer), qui fournit un framework générique concernant le matériel présent, ajouté et supprimé en lisant la hiérarchie
/sys et par probing, et génère des événements transmis via DBUS, récupérés par Gnome, KDE, ... qui vont alors pouvoir proposer des actions comme visualiser les photos de votre appareil numérique, parcourir votre clef USB, lire le CD audio inséré...