Following his internship at Smile in 2018 on LLVM/Clang integration into Buildroot [1], Valentin Korenblit still maintains these packages on his spare time (thanks to him!), up to the latest current version llvm/Clang 8.0.0.
At the same time the Linux kernel continues evolving to support Clang compiler thanks to Google engineers. See Phoronix article [2] and « Compiling the Linux kernel with LLVM tools » conference at FOSDEM 2019 [3].
Valentin stated in his internship report:
Clang was designed to offer GCC compatibility, so it accepts most of GCC’s command line arguments to specify the compiler options. However, GCC offers a lot of extensions to the standard language while Clang’s purpose is being standard-compliant. Because of this, Clang cannot be a replacement for GCC when compiling projects that depend on GCC extensions, as it happens with Linux kernel. In this case, Linux can’t be built because Clang does not accept the following kinds of constructs:
In order to check by ourselves if it is now possible to compile a kernel with Clang using buildroot, we will compile the aarch64 configuration for Qemu (qemu_aarch64_virt_defconfig). First, we will try with the GCC toolchain in order to verify that everything works fine. Then we will recompile the kernel with Clang.
$ make qemu_aarch64_virt_defconfig
In order to speedup the build, we use an prebuilt external toolchain (Arm AArch64 2019.03) instead of an internal toolchain. We could also use an aarch64 toolchain downloaded from toolchain-builder project (https://toolchains.bootlin.com) that provide several toolchains generated by Buildroot.
When the build is finished, we can test the system using Qemu as indicated in the file « board/qemu/aarch64-virt/readme.txt »
The system should boot correctly. (If not, check your Qemu version and make sure it’s at least at the same version as the one present in the readme.txt).
Now that we tested that the kernel build correctly with GCC, we are going to try again with Clang. To do that, we first clean the kernel’s build directory
$ make linux-dirclean
Note: We keep the user space content previously built by the GCC toolchain. Building user space programs using Clang is not supported yet by buildroot.
We then need to build the Clang compiler itself. This is done by the host-clang package
We cannot select host-clang in buildroot’s menuconfig, because host-clang can only be selected as a dependency of another package. We will simply trigger the host-clang build manually
$ make host-clang
In order to cross-compile the kernel, we have to modify the Linux’s package Makefile (linux/linux.mk) as suggested in the following patch.
Now it’s time to rebuild the kernel using Clang Note: By default the qemu_aarch64_virt_defconfig uses a kernel 4.19.x, it’s recommended to use the latest kernel version (5.2.7).
$ make linux
The kernel build takes some time to complete, you can check the list of processes running on you build machine thanks to the htop command. You should notice some activity with clang-8 process.
When the build is finished (and successful), restart Qemu using the same command line as before.
The system should boot successfully and you can check the dmesg output to know the compiler used to build the kernel.
Conclusion:
This is our first test with a Linux kernel with Clang cross-compiler, for now it’s just a hack in Buildroot’s linux package. Buildroot is not able to use Clang as a generic compiler for all user-space yet. Adding this feature requires discussion with the Buildroot community.
This support would also require to change some hard-coded references within the Buildroot’s package infrastructure. CMake and meson cross-compilation support, in particular, would need some work.
Buildroot probably needs a new toolchain-wrapper for Clang compiler to provide –sysroot path and other compiler options like it does for the GCC cross-toolchain. Since the Clang compiler take a lot of time to build, it would be interesting to be able to reuse a prebuilt Clang compiler and import it in the toolchain-external package infrastructure. This would require that LLVM/Clang binaries be relocatable. For now, only aarch64 kernel has been tested since it probably the most tested one with Clang [5]. It would be interesting to do further tests with other architectures like arm, x86, x86_64, mips, mips64, ppc, ppc64…
Dans cet article, nous allons discuter de l’intérêt ainsi que des avantages et inconvénients d’utiliser un noyau Linux temps réel. L’objectif de cet article n’est pas de décrire ce qu’est le temps réel mais pourquoi et comment l’utiliser. Aux lecteurs curieux et intéressés par le temps réel, je recommande le livre deChristophe Blaess, Solutions temps réel sous Linux.
Introduction
Historique
La notion de temps réel a commencé à apparaître dans les années 60 dans le domaine de l’aérospatial. En effet, l’un des premiers systèmes embarqués temps réel fut l’Apollo Guidance Computer conçu par le MIT permettant du traitement temps réel des données recueillies lors du vol. La notion de temps réel a cependant bien évolué jusqu’à maintenant.
De nos jours, de nombreux systèmes requièrent des performances dites temps réel. En effet, le marché des systèmes embarqués est en pleine croissance et le besoin de solutions embarquées temps réel augmente en conséquence. Le temps réel se retrouve en particulier dans les domaines suivants :
Automobile
Automatique industrielle
Télécommunications
Santé/Médical
Aéronautique/Aérospatial
Qu’est ce que le temps réel ?
Il ne faut pas confondre temps réel avec vitesse. Par exemple le système de commande d’un avion nécessitera un temps de réponse de l’ordre de la microseconde alors le système de contrôle d’une chaîne de production nécessitera un temps de réponse de l’ordre de la milliseconde. En revanche, il devront tous deux répondre dans un laps de temps défini et ne pas le dépasser.
Il existe plusieurs notions de temps réel : Le temps réel strict (hard real time) et le temps réel souple (soft real time).
Le temps réel strict pénalise le non-respect d’une échéance par l’émission d’une erreur. La réponse du système est donc considérée comme erronée. En revanche un temps réel souple tolère une certaine marge de dépassement.
Image 1 : Différence soft (droite) et hard (gauche) real time
Les solutions temps réel
Plusieurs solutions temps réel sont disponibles aujourd’hui, propriétaires comme libres. En voici quelques exemples :
FreeRTOS
QNX
VxWorks
On peut ensuite lister les solutions avec noyaux hybrides qui présentent d’autres avantages. Certaines de ces solutions permettent d’utiliser un noyau Linux et d’y installer à côté un noyau temps réel. On peut citer :
Xenomai (Cobalt), Xenomai Mercury est simplement l’utilisation de l’API Xenomai sur un noyau Linux patché PREEMPT_RT.
RTAI
Xenomai se distingue par ses performances ainsi que la possibilité d’utiliser son API sans avoir obligatoirement à utiliser son co-noyau Xenomai Cobalt. A cet effet, Xenomai se décline en deux versions : Cobalt (co-noyau) et Mercury.
Cobalt est la version la plus intéressante si l’on veut faire du temps réel strict. Cobalt utilise le patch I-pipe qui installe un pipeline redistribuant les interruptions entre le noyau linux (pour les interruptions non temps réel) et le noyau Cobalt (pour les interruptions temps réel). Attention cependant, il est important de regarder la compatibilité du patch avec le matériel utilisé.
Mercury lui, permet d’utiliser l’API Xenomai sur un noyau linux patché PREEMPT_RT. Mercury est plus simple à implémenter que Cobalt mais reste moins performant.
Le noyau Linux mainline quant à lui possède quelques briques de base nécessaire au temps réel, comme par exemple un scheduler qui propose des politiques de scheduling temps réel.
En effet, dans les options de kernel, on peut choisir la préemptibilité du noyau linux. Par défaut, seulement 3 options sont disponibles, la meilleure de ces trois options pour s’approcher d’un comportement temps réel étant la préemptibilité Low-Latency. Cependant si l’on veut vraiment faire du temps réel, il faudra se tourner vers d’autres solutions.
Il est possible d’ajouter deux autres options de configuration en patchant le kernel à l’aide du patch PREEMPT_RT. Ce patch n’est actuellement pas pris en charge par le kernel mainline mais est en bonne voie pour devenir partie intégrante du kernel dans les mois ou les années à venir.
Nous allons maintenant parcourir les changements introduits par le patch PREEMPT_RT, évaluer les performances des différentes solutions temps réel sous Linux et évoquer des exemples d’implémentation de temps réel sous linux.
Apports du patch PREEMPT_RT
Le patch PREEMPT_RT ajoute l’option de compilation du noyau CONFIG_PREEMPT_RT_FULL. Elle se traduit par l’ajout de lignes dans le code du kernel, de type :
Le principe du patch PREEMPT RT est d’autoriser la préemption partout même dans les interruptions, à l’aide de l’ajout des mécanismes que nous allons décrire ci-après.
Spinlock et Mutex
Dans le patch PREEMPT_RT, l’intérêt est de pouvoir préempter toutes les tâches, mêmes celles possédant un spinlock, pour laisser s’exécuter la tâche la plus prioritaire. Dans cette optique, le rôle du patch est donc de transformer les spinlocks actuels en sleeping spinlocks, soit en rt_mutex. En effet, les spinlocks ne sont pas préemptibles par défaut, ce qui peut poser problème lorsqu’on fait du temps réel.
On peut le voir dans le fichier <spinlock_types.h> :
#include <linux/spinlock_types_raw.h>
#ifndef CONFIG_PREEMPT_RT_FULL
# include <linux/spinlock_types_nort.h>
# include <linux/rwlock_types.h>
#else
# include <linux/rtmutex.h>
# include <linux/spinlock_types_rt.h>
# include <linux/rwlock_types_rt.h>
#endif
Voici ci-dessous le fonctionnement des spinlocks dans un kernel mainline. Prenons l’exemple d’un programme possédant deux threads, tout deux s’exécutant sur un même coeur. Le premier thread (VERT), se lance jusqu’à rencontrer une zone de code protégée par un spinlock. Pendant ce temps, le thread 2 (BLEU), plus prioritaire est prêt à s’exécuter mais comme le thread 1 est dans un spinlock, le thread 2 devra attendre la fin de la zone de code protégée par un spinlock.
Image 2 : Exemple spinlocks avant patch preempt RT
Maintenant avec le patch PREEMPT_RT, on voit que le scheduler donne la main au thread 2 possédant une priorité plus élevée. Ces changements peuvent être lus sur la documentation de la fondation Linux, on peut voir notamment qu’un spinlock se comporte donc comme un rt_mutex (“In order to minimize the changes to the kernel source the existing spinlock_t datatype and the functions which operate on it retain their old names but, when PREEMPT_RT is enabled, now refer to an rt_mutex lock”) :
Image 3 : Image 2 : Exemple spinlocks après patch preempt RT
Raw spinlock
Bien que les spinlocks deviennent des mutex, il reste des endroits dans le kernel où il est nécessaire d’avoir recours à de vrais spinlocks. En effet, certains endroits du kernel ne devraient pas être préemptibles car ils sont vraiment critiques.
De plus, les spinlocks ont l’avantage d’être plus rapides que les mutex. Pour cela, il existe les raw_spinlocks qui sont en réalité les spinlocks du kernel classique non patché.
Ils ont été ajoutés au kernel mainline mais ne sont d’aucune utilité dans un kernel non patché. Il faut cependant prendre garde à leur utilisation dans un système temps réel. En effet, les raw_spinlocks désactivent la préemption et les interruptions, ce qui peut engendrer des latences non désirées et donc dégrader l’aspect temps réel du système.
Threaded Interrupts
Comme l’objectif du patch PREEMPT_RT est de rendre le kernel aussi préemptible que possible, il paraît normal de modifier le fonctionnement des interruptions. Nous allons tout d’abord revoir le fonctionnement classique des interruptions.
Interruptions classiques : Dans le kernel linux, lorsque une interruption survient, c’est à dire lorsqu’un périphérique externe change d’état (des données sur le port ethernet, le changement d’état d’une broche GPIO, etc.), le périphérique envoie un signal au gestionnaire d’interruptions APIC (Advanced Programmable Interrupt Controler).
Le gestionnaire transmet ensuite une requête d’interruption IRQ (Interrupt Request) au processeur. Ce dernier s’arrête, sauvegarde son contexte puis traite l’interruption concernée. Pour traiter l’interruption, plusieurs méthodes existent, mais la plus courante est celle des top-half et bottom-half.
Top-half et bottom-half interrupts handler : Pour traiter une interruption en évitant de monopoliser une unité de calcul, le moyen le plus utilisé est celui des top-half et bottom-half.
Ce mécanisme consiste à exécuter le top-half au moment de l’interruption, qui effectuera le minimum vital au traitement de l’exécution. Il programmera ensuite dans une file d’exécution un handler bottom-half qui traitera l’interruption proprement une fois qu’elle sera démasquée dans l’APIC et que le processeur disposera de temps de travail disponible.
Cette méthode permet ainsi de pouvoir gérer une succession rapide d’interruptions vu que le bottom-half est programmé dans tous les cas.
Threaded interrupts : Pour permettre au système de gérer des contraintes temps réel, le patch PREEMPT_RT met en place des threaded interrupts.
Les threaded interrupts reprennent le concept top-half bottom-half, mais remplacent le handler du bottom-half par un thread. Cela permet de donner une priorité au thread et de le préempter si un thread avec une priorité plus élevé est runnable.
Héritage de priorité
Le dernier changement important à noter est l’ajout de l’héritage de priorité pour les mutex et les spinlock. Pour illustrer l’héritage de priorité et son importance, regardons le cas suivant.
Tout d’abord, sans héritage. On peut voir sur le schéma ci-dessous, que le thread 3 possède initialement un mutex. Le thread 1 devenant runnable, commence à exécuter son code jusqu’à ce qu’il demande le mutex tenu par le thread 3, ce qui provoque son endormissement. Le thread 3 reprend alors son exécution. Cependant, avant d’avoir pu relâcher le mutex, le scheduler le préempte en faveur du thread 2 qui s’exécute pour une période indéfinie.
On constate alors que le thread 1 qui a la priorité la plus élevée ne pourra pas s’exécuter, ce qui par conséquent pose un problème lorsqu’on fait du temps réel du fait que le thread avec la plus grande priorité ne s’exécute pas, on appelle ça une famine.
Image 4 : Exemple héritage de priorité avant patch preempt RT
Dans ce second exemple avec l’héritage de priorité, on peut voir comme tout à l’heure que le thread 3 possède initialement le mutex. Mais lorsque le thread 1 demande le mutex possédé par le thread 3, le thread 3 hérite de la priorité du thread 1 ce qui lui permet de relâcher le mutex pour permettre au thread 1 de s’exécuter, puis le thread 2 pourra s’exécuter.
Image 5 : Exemple héritage de priorité après patch preempt RT
Implémentation du temps réel sur noyau Linux
Les options du noyau Linux
L’implémentation du temps réel sur noyau linux est relativement simple, mais l’obtention de performances optimales est conditionnée à la modification d’options annexes. Durant les phases d’évaluation des différentes solutions temps réel et de leurs performances, nous avons constaté que certaines options impactaient les performances plus que d’autres.
Power Management
En contexte temps réel, l’important est d’avoir un système réactif qui puisse réagir à la moindre interruption externe au système. L’activation du power management sur le CPU cause un risque d’augmentation de la latence du CPU. En effet, lorsque le power management est activé, le CPU va adapter sa fréquence pour économiser de l’énergie.
Cette option reste cependant intéressante lorsque l’on fait de l’embarqué, au vu de la durée des batteries actuelles, mais empêche cependant d’obtenir des performances temps réel. Il peut être intéressant d’utiliser le power management sur certains cœurs (cela peut se faire au moment du boot comme pour les timers ci-après) et d’utiliser les autres cœurs pour toutes les tâches temps réels.
Pour désactiver le power management, il faut tout d’abord désactiver le multi-core scheduler support. En effet, ce dernier nous empêche de retirer le power management.
SCHED_MC [=n]
Une fois le multi-core scheduler support désactivé, on peut maintenant désactiver le CPU Frequency scaling et le CPU Idle. Le CPU Frequency scaling permet de choisir le governor à utiliser pour gérer la fréquence du CPU, ce qui est inutile dans notre cas : nous souhaitons que tous les cœurs tournent à 100% afin de maximiser les performances. En revanche, cela implique une consommation plus élevée. Il se peut, si vous avez un processeur qui supporte l’ACPI, que l’option CPU Idle ne soit pas désactivable, ce que traitera le paragraphe suivant.
CPU_FREQ [=n]
CPU_IDLE [=n]
Sur certains processeurs, comme ceux d’intel, l’ACPI (Advanced Configuration and Power Interface) gère le power management. Il faut donc désactiver l’ACPI seulement pour le processeur, car une désactivation pour d’autres composants pourrait empêcher le système de démarrer correctement. De plus, sa désactivation est un préalable à celle du CPU Idle.
ACPI_PROCESSOR [=n]
SMP
Si votre système ne possède qu’un seul cœur, cette section ne vous concerne pas, il faudra donc désactiver cette option.
Le SMP (Symmetric multi-processing), permet à un système possédant plusieurs cœurs de les utiliser et donc d’exécuter plusieurs tâches à la fois. Le problème de posséder plusieurs cœurs est qu’ils partagent des zones mémoires et notamment de la mémoire cache L2 (cela dépend de l’architecture du processeur). Le fait de partager de la mémoire augmente le temps d’accès à la zone mémoire. Pour éviter ce problème, il est conseillé de bien gérer les affinités des tâches et des processus. Pour activer le multi-processing il suffit de choisir l’option Symmetric multi-processing support.
SMP [=y]
Timers
Ensuite, nous devons paramétrer les timers pour obtenir une plus grande réactivité. Tout d’abord, il faut activer le timer haute résolution qui fournit une meilleure précision pour tous nos programmes user-space.
HIGH_RES_TIMERS [=y]
Ensuite, pour éviter au système de se mettre en veille, il faut laisser les timers interrompre le système périodiquement afin de ne pas manquer d’événements importants. Il faut donc modifier l’option Timer tick handling comme on peut le voir ci-dessous, et choisir l’option Periodic timer ticks. Il peut être intéressant de choisir cette option pour certains cœurs, dans ce cas il faudra donner les options de boot suivantes pour isoler les cœurs et les rendre tickless : « isolcpus=2,3 nohz_full=2,3 »
HZ_PERIODIC [=y]
Les impacts sur le développement applicatif et le système
Affinités
Lorsque l’on fait du temps réel sous linux, il est important de gérer l’affinité de ses tâches et des interruptions.
La première chose à faire, surtout si l’on est en SMP, est de modifier l’affinité des interruptions du système dans /proc/interrupts pour empêcher les migrations de cœur qui augmentent la latence. Il est préférable de regrouper certaines interruptions sur le même CPU pour réserver les autres CPUs à notre application temps réel.
Pour modifier l’affinité d’une interruption, il faut modifier le pseudo-fichier /proc/irq/<NumeroIRQ>/smp_affinity à l’aide de la commande :« echo 8 > /proc/irq/127/smp_affinity » pour par exemple mettre l’interruption 127 sur le CPU 4. Ce fichier contient en effet un masque binaire définissant le ou les cpus à utiliser en cas d’interruption. Par exemple 0001 représente le CPU 0 tandis que 1001 représente les CPUs 0 et 4. Il est important de noter que le fichier /proc/irq/NumeroIRQ/smp_affinity attend une valeur en hexadécimal, ce qui explique la valeur 8 précédente.
On peut voir ci-dessous les interruptions et leur nombre d’occurences sur chaque cpu de ma raspberry pi 4 à l’aide de la commande « cat /proc/interrupts ».
Image 6 : Résultat de la commande « cat /proc/interrrupts »
Lors du développement applicatif d’une solution temps réel, il est important de bien choisir l’affinité de ses threads et de son processus principal. Pour les threads, il existe la fonction pthread_attr_setaffinity_np() et pour le processus principal, on peut utiliser la commande taskset ou la fonction sched_setaffinity().
Pour vérifier l’utilisation de chaque thread de son programme, on peut utiliser la commande suivante : “watch -n 1 ps -p $(pidof monProgramme) -L -o pid,tid,psr,pcpu,comm”. Cela permet de lister pour un PID donné, tous les threads présents et d’afficher sur quel cœur ils s’exécutent. On peut voir ci-dessous le résultat de cette commande sur un programme personnel.
La colonne PID représente le PID du programme, le TID celui du thread, PSR indique sur quel cœur le thread s’exécute et %CPU sa consommation CPU. Enfin, la colonne COMMAND permet de connaître le nom du thread si vous avez utilisé la fonction pthread_setname_np().
Image 7 : Résultat de la commande « watch -n 1 ps -p $(pidof cam) -L -o pid,tid,psr,pcpu,comm«
Il est recommandé de bien connaître l’architecture de son processeur, et d’établir un plan d’affectation des ressources : laisser un cœur pour le système (le coeur 0), et répartir les activités sur les autres cœurs.
Real Time Throttling
Lorsque vous exécutez une application temps réel sur un système temps réel, par défaut le système ne donne pas accès à 100% du CPU. En effet, le scheduler temps réel ne permet à un processus que de consommer 95% du temps CPU. Ces paramètres sont régis par les pseudo fichiers suivants :
/proc/sys/kernel/sched_rt_period_us
/proc/sys/kernel/sched_rt_runtime_us
Ces paramètres permettent d’allouer un temps de sched_rt_runtime_us sur une période de sched_rt_period_us. Par défaut ce ratio vaut 950000 µs/100000 µs, soit 95%. Cela permet d’éviter qu’une application erronée ne prenne tout le CPU et empêche le système de réagir à d’autres événements.
En revanche, il peut être intéressant sur un système validé d’optimiser ce ratio, voire de désactiver cette option en mettant -1 dans /proc/sys/kernel/sched_rt_period_us ou en réglant sched_rt_period_us = sched_rt_runtime_us : « echo -1 > /proc/sys/kernel/sched_rt_period_us ».
Performances
Afin de mesurer les performances des différentes solutions temps réel, j’ai utilisé les outils suivants :
Un script lançant des cyclictest avec un ordonnancementSCHED_OTHER, SCHED_RR (round-robin) et SCHED_FIFO avec une priorité de 99, subissant une charge simulée par le programme stress. Chaque test a été joué durant une heure.
Un programme codé en C calculant le temps de commutation d’un thread à l’autre au moment de lâcher un mutex, sous charge et sans charge, sur même CPU. Chaque test prenant en compte 10000 commutations.
J’ai réalisé ces tests sur les systèmes suivants :
x86_64
Linux vanilla 4.14.71
Linux vanilla 4.14.71 PREEMPT_RT
Xenomai Cobalt 3.0.8 sous linux vanilla 4.14.71
Xenomai Mercury 3.0.8 sous linux vanilla 4.14.71 PREEMPT_RT
Raspberry pi 3B, arm 64bits
Linux rpi 4.14.71
Linux rpi 4.14.71 PREEMPT_RT
Xenomai Cobalt 3.0.8 sous linux rpi 4.14.71
Xenomai Mercury 3.0.8 sous linux rpi 4.14.71 PREEMPT_RT
Cyclictest
Tout au long de cette partie, je ne vais parler que de l’ordonnancement SCHED_FIFO car ses performances sont quasi équivalentes à celles de l’ordonnancement round-robin, et l’ordonnancement SCHED_OTHER ne concerne pas le temps réel.
Image 8 : Résultats cyclictest sur raspberry pi 3B
On peut voir sur cette première série de benchmarks les différences entre les différents systèmes. On remarque que seul le kernel linux classique dépasse les 400 µs de latences et que les autres ne dépassent pas les 100 µs. Attention cependant, sur le kernel normal, il y a des pics en dehors du graphique à plus de 10000 µs comme indiqué sur le graphique, ce qui pose donc le problème du déterminisme du système. Avec les trois autres systèmes, le max ne dépasse pas les 100 µs.
On peut ensuite voir que les performances sont sensiblement équivalentes entre un système sous Xenomai Mercury et un système patché PREEMPT RT. Enfin la meilleure performance vient du système sous Xenomai Cobalt qui affiche une latence maximale sous les 10 µs avec une moyenne bien plus basse que les 2 autres, et les latences semblent mieux bornées.
Image 9 : Résultats cyclictest sur x86_64
Passons maintenant sous x86_64, on peut voir que le même ordre est respecté, sauf pour Xenomai Mercury qui présente de moins bonnes performances que ses concurrents. Comme tout à l’heure le test sous linux 4.14.71 non préemptible révèle des pics à plus de 10000 µs comme indiqué sur le graphique qui sortent de la porté du graphique.
Commutations de threads
Afin de mesurer les performances de commutations, j’ai codé un petit programme qui calcule le temps que met le cpu pour changer de thread. Le principe du programme est le suivant : le thread 1 prend un mutex, puis le relâche. Le scheduler est appelé et le thread 2 se lance en prenant le mutex. Le temps séparant la fin du thread 1 du début du thread 2 est mesuré et stocké dans une variable globale protégée par le mutex. Au bout de 10000 mesures, le résultat est affiché.
Image 10 : Exemple temps de commutations entre deux threads
Image 11 : Résultats commutations de threads sur raspberry pi 3B
Les résultats ci-dessus montrent les différences de performances sur Raspberry Pi sous stress. On peut voir que les performances entre un linux non patché et patché PREEMPT_RT sont assez similaires, avec une valeur max de 90 µs. En revanche on peut voir un résultat assez étrange : les moins bonnes performances sont atteintes par Xenomai Mercury. Enfin, Xenomai Cobalt obtient les meilleures performances.
Image 12 : Résultats commutations de threads sur x86_64
Passons maintenant sur x86_64, nous observons sensiblement les mêmes résultats que sur arm64, avec toujours un retard de performance pour Xenomai Mercury.
Différences entre POSIX et Alchemy (Xenomai)
Xenomai propose des skins, qui sont de petits wrappers permettant d’utiliser des APIs venant d’autres systèmes comme VxWorks ou POSIX avec Xenomai. La librairie native à xenomai est la librairie Alchemy qui est assez complète et fournit des outils très utiles comme des queues optimisées, des buffers, des pipes, des sémaphores etc…
Nous allons voir la différence de performance entre les deux skins POSIX et Alchemy, grâce à un portage du programme réalisé pour la commutation de threads sous les deux librairies. Voici ci-dessous les performances sous Xenomai Mercury. On peut voir un petit gain de performance avec la librairie native.
Image 13 : Résultats commutations de threads sur raspberry pi 3B, différences APIs POSIX vs Alchemy
Conclusion
Pour conclure, nous constatons avec évidence que Xenomai Cobalt est la meilleure des solutions pour faire du Linux temps réel avec des contraintes hard real time. Cependant, nous pouvons voir que le patch PREEMPT_RT propose une solution plus facile à mettre en place que Xenomai et présente de très bonnes caractéristiques temps réel, qui ont vu une nette amélioration récemment.
On remarque par contre des résultats un peu étonnants sur Xenomai Mercury qui affiche des performances plus faibles que ses concurrents. Encore une fois, les tests réalisés ne sont pas précis à 100% au vu de leur durée et du nombre de tests réalisés, en raison d’un manque de temps.
Pour ce qui est de Xenomai et comment l’implémenter de manière optimale, voici en lien le wiki de Xenomai qui est très bien documenté :
De plus, j’ai exposé ici quelques options à activer/désactiver au niveau du kernel, mais la configuration optimale dépendra de l’architecture utilisée et des contraintes liées au produit. Si vous êtes intéressé, vous pouvez consulter le site ci-dessous décrivant toutes les subtilités et les améliorations à faire pour mettre en place du temps réel :
Les outils de profilage permettent lors de l’exécution d’un logiciel de contrôler la liste des fonctions appelées, le temps passé dans chacune d’elle, l’utilisation des ressources processeur ou l’utilisation mémoire par exemple. Sous Linux une multitude d’outils sont disponibles et si vous avez déjà utilisé Perf ou eBPF vous avez sans nul doute remarqué que la quantité de log générée peut rapidement devenir gargantuesque et donc difficilement interprétable.
Cet article va vous présenter les FlameGraph : un outil très pratique de visualisation des logs d’applications profilées qui a été développé par Brendan Gregg, ingénieur chez Netflix et spécialiste de l’analyse de performance. Les FlameGraph sont une une représentation des logs de n’importe quel outil de génération de données de profiling comme eBPF et Perf qui sont également des traceurs déjà introduits par les excellents articles de Jugurtha :
Cet article n’est qu’un exemple d’utilisation des FlameGraph précédé de quelques notions. Tout le mérite revient évidemment à Brendan Gregg. Vous pouvez retrouver son blog qui sert de référence aux méthodes de profilage, au lien suivant : http://www.brendangregg.com/overview.html
Génération d’un FlameGraph on-CPU
Une des manières de profiler une application revient à déterminer pourquoi le CPU est occupé. Une façon efficace de faire cela est le profilage par échantillonnage : on envoie à une certaine fréquence une interruption au CPU pour récupérer la stack trace, l’adresse en mémoire de l’instruction en cours d’exécution (Program Counter) ainsi que l’adresse de la fonction. Nous allons dans notre exemple utiliser la commande Perf pour ce faire.
Vous pouvez installer Perf sur votre distribution via votre gestionnaire de paquet : linux-perf sous debian, perf sous CentOS et Arch, linux-tools sous Ubuntu… De plus si vous voulez qu’un utilisateur non root puisse collecter des données dans votre terminal courant, il est possible de modifier la valeur de la variable perf_event_paranoid :
echo -1 > /proc/sys/kernel/perf_event_paranoid.
NB : les programmes que vous profilez doivent comporter des symboles de debug nécessaires à la traduction des adresses mémoire en nom de fonction.
Si vous voulez profiler une application intégrée dans votre distribution via Yocto il faut installer la version « -dbg » du paquet que vous souhaitez analyser. Vous pouvez également utiliser l’image feature « dbg-pkgs » pour créer une version de votre image intégrant tous les paquets de debug ce qui peut être utile pour profiler le système complet.
Sur Debian pour installer des paquets avec les symboles de debug il faut ajouter la source debhttp://debug.mirrors.debian.org/debian-debug/ buster-debug main (pour debian buster) dans votre source.list d’apt. Après ça vous pouvez installer les paquets de debug qui ont en général comme suffixe -dbgsym.
Une autre source potentielle de problème peut être que la stack trace retournée est incomplète pour les applications qui sont compilées avec des optimisations de compilation. Dans ce cas il faut recompiler l’application avec l’option –fno-omit-frame-pointer.
De la même manière il est possible que la stack trace du kernel soit incomplète si l’option CONFIG_FRAME_POINTER est désactivée (Kernel hacking/Compile-time-checks and compiler option)
La procédure pour générer les FlameGraph CPU est très simple, il suffit dans un premier temps de lancer la commande suivante pour profiler pendant 30 secondes et à une fréquence de 99Hz (99 interruptions par seconde) une application qui a un PID valant 12345 par exemple :
perf record -F 99 -p 12345 -g -- sleep 30
On peut également profiler le système complet et donc tous les coeurs de la CPU avec l’option -a :
perf record -F 99 -a -g -- sleep 30
Cela va générer un fichier perf.data qui contient les échantillons qui peuvent être lus via la commande perf report :
perf report -n --stdio
Perf est un outil très puissant mais en lançant les 2 dernières commandes sur ma machine, le rapport généré fait plus d’un millier de lignes. Et c’est bien là l’intérêt des FlameGraph. Ils sont très facile à générer et très faciles à interpréter rapidement.
Dans mon cas j’ai généré mes Flame Graphs sur une cible dont la distribution a été générée via Yocto ; voici une recette très simple qui va récupérer les sources du projet et les installer dans la cible :
Le projet consiste en une multitude de scripts perl ainsi que des exemples de FlameGraph déjà générés et présentés sur le blog de Brendan Gregg. A partir d’un fichier perf.data généré par perf on peut le copier dans le répertoire du projet et lancer la commande suivante pour générer un flamegraph :
perf script va chercher dans le répertoire local un fichier perf.data (généré par perf record) et afficher la trace. Attention néanmoins cette commande est dépendante de l’architecture de la plateforme. Il faut donc générer les Flamegraph directement sur la cible.
stackcollapse-perf va formater la trace en une seule ligne pour qu’elle puisse être traitée par le script flamegraph.pl
flamegraph.pl transforme le fichier out.perf-folded en une image de flamegraph.
On génère donc un fichier SVG qui peut facilement être ouvert depuis un navigateur. Je vais vous présenter ici un exemple très simple de FlameGraph issu d’un petit programme.
D’une part le programme va dans un premier thread ouvrir un fichier, écrire dedans, le refermer en boucle et va dans un autre thread lancer une boucle vide qui va faire mouliner le processeur. Si on profile le système entier pendant 60 secondes et qu’on lance pendant cette période le programme pendant 30 secondes on obtient le résultat suivant :
Interprétation du résultat :
Chaque boite représente l’appel à une fonction dans la pile
L’axe des ordonnées présente la profondeur de la pile
La largeur des frame en abscisse correspond au temps passé (nombre d’échantillons) par un CPU à exécuter la fonction correspondante.
Dans notre cas il est très facile de déceler les tâches gourmandes en CPU. Pour interpréter un Flame Graph on va chercher les boites larges tout en haut de la pile et voir par quelles fonctions elles ont été appelées. Ici on remarque que la case « cpu_moulineur » est extrêmement large, c’est elle qui correspond à la fonction contenant une boucle vide.
Si on positionne le curseur de la souris sur la boite « cpu_moulineur » un champ affiche le nombre d’échantillons (et donc le temps passé dans la fonction) ainsi que le pourcentage correspondant par rapport à la mesure complète.
Le bloc situé à sa droite correspond à la fonction qui ouvre et ferme un fichier en boucle. En plus d’observer le nombre d’échantillons pour chaque boite, il est intéressant de voir l’enchaînement des fonctions appelées de l’userspace jusqu’aux strates les plus enfouies du kernel.
Ici l’exemple est relativement trivial mais dans un plus gros projet cela peut s’avérer très utile car les FlameGraph offrent une vision globale des fonctions appelées par une application ! On peut voir par exemple si l’appel à une fonction userspace provoque l’appel à un kmalloc côté kernel.
NB : on ne sait néanmoins pas à quel moment les fonctions sont appelées car il n’y a pas de notion temporelle dans les flamegraph CPU.
D’autres types de FlameGraph intéressants
FlameGraph off-CPU
Les FlameGraph on-CPU permettent de comprendre l’usage CPU mais ne permettent pas de voir les problèmes de latence présents quand un thread est en attente d’une I/O bloquée, d’un timer ou quand il y a un changement de contexte. Cela constitue une forme d’analyse à part entière que Brendan Gregg appelle analyse off-CPU (en opposition à on-CPU).
En résumé l’analyse off-CPU est un moyen de localiser de la latence introduite par le blocage de thread, cette analyse est complémentaire à l’analyse on-CPU et est nécessaire à la compréhension du cycle de vie d’un thread.
Leur génération peut être réalisée via le script offcputime de bcc (je vous renvoie vers l’article de Jugurtha) qui permet de trouver pourquoi et pendant combien de temps un thread est bloqué et ce quelque soit le type de blocage.
Une approche est de tracer les appels aux fonctions malloc et free et afficher sur un Flame Graph le nombre de fois où les fonctions ont a été appelées ou le nombre de bytes qui ont été alloués pour chaque frame.
Conclusion
En résumé le Flame Graph est un puissant outil de visualisation de logs d’outils d’analyse de performance qui peut vous permettre de gagner un temps précieux. J’ai simplement voulu vous partager cette découverte dans cet article qui n’est qu’une rapide présentation, je vous invite une nouvelle fois à vous rendre sur le blog de Brendan Gregg pour beaucoup plus de détails !
Tout code est susceptible au changement, avec pour objectif d’ajouter des fonctionnalités, de résoudre des BUGS ou même d’aller jusqu’a modifier les interfaces (altérer les prototypes des fonctions).
Généralement plus un code est utilisé par la communauté, plus il est déconseillé de modifier les interfaces lors d’une évolution. Cependant, la rétrocompatibilité reste floue pour certains développeurs surtout lors de la mise à jour des bibliothèques partagées.
Dans cet article, nous découvrirons comment maintenir les bibliothèques pour pouvoir les distribuer et les modifier sans avoir à recompiler les anciens binaires. Ces derniers pourront ainsi bénéficier d’une amélioration de performances ou de corrections de failles de sécurité.
Pourquoi la gestion de version des bibliothèques?
Pour répondre à cette question il est important de connaître quelques concepts liés aux bibliothèques partagées.
Quelques conventions à retenir
Une bibliothèque partagée doit être compilée comme ceci :
L’argument « -shared » est un flag linker utilisé pour créer les bibliothèques partagées.
Remarque : le flag du compilateur « -fPIC » ne doit pas être associé à la création des bibliothèques partagées (il est possible de l’utiliser pour créer des bibliothèques statiques).
Par convention, le nom d’une bibliothèque partagée prend la forme : libnombibliotheque.so.<M>.<m>.<p> (par exemple : libssl.so.1.0.0).
N.B : les symboles M, m et p désignent les nombres Majeur, mineur et patch respectivement. Ces derniers précisent l’impact du changement de version sur le code, par exemple : la modification de la signature d’une fonction est un changement de type majeur alors que l’optimisation d’un calcul dans une routine a des conséquences mineures.
Alors pourquoi versionner?
Pour démontrer l’importance du versionnage, nous allons créer une simple bibliothèque partagée (et suivre son évolution).
Exemple pratique
Pour les besoins d’un outil de monitoring du système, nous implémentons une bibliothèque qui permet de lister les différents utilisateurs connectés sur le système.
Solution proposée
Sous Linux, il existe deux fichiers qui se chargent de cette tâche :
/var/run/utmp : qui liste les utilisateurs connectés (utilisé par l’utilitaire who).
/var/log/wtmp : sauvegarde chaque connexion et déconnexion établie par un utilisateur (utilisé par l’utilitaire « last »).
Première release : libusrmgr.so.1.0.0
usrmgr.h : fichier header de la bibliothèque « libusrmgr.so.1.0.0 » :
#include <stdio.h>#include <stdlib.h>#include <utmpx.h>#include <time.h>/* Retourne la liste des utilisateurs connectés */voidgetLoggedUsers();
usrmgr.c : implémentation de la bibliothèque « libusrmgr.so.1.0.0 » :
#include "usrmgr.h"/* Retourne la liste des utilisateurs connectés */voidgetLoggedUsers(){
struct utmpx *utmpUser;
setutxent(); // Ouverture du fichier /var/run/utmp/* Parcours de la liste des utilisateurs */while ((utmpUser = getutxent()) !=NULL) {
printf("Utilisateur : %s <=> PID : %ld at : %s", utmpUser->ut_user,
(long) utmpUser->ut_pid, ctime((time_t*) &(utmpUser->ut_tv.tv_sec)));
}
}
premierBinaire.c : binaire client (qui va utiliser notre bibliothèque partagée).
#include "usrmgr.h"intmain(){
printf("La liste des utilisateurs connectés : \n");
getLoggedUsers();
return EXIT_SUCCESS;
}
Il ne reste plus qu’à réaliser la compilation et le test :
Au fil du temps, on se rend compte des besoins suivants :
Le fichier utmp (ouvert avec setutxent()) n’est pas fermé après avoir obtenu la liste des utilisateurs (l’ancien binaire doit bénéficier de cette mise à jour).
Pour des raisons de sécurité, l’utilisateur « Pierre » ne doit pas être affiché (l’ancien binaire doit bénéficier de cette mise à jour).
La fonction getLoggedUsers() doit retourner le nombre d’utilisateurs. Pour cela l’ABI de la fonction doit changer (obligation de passer à la version 2.0.0 de la bibliothèque).
Introduire la possibilité de filtrer sur un seul utilisateur.
Avec ce simple exemple, on se retrouve contraint de maintenir plusieurs versions de la bibliothèque, ce qui n’est pas possible (les anciens binaires ne vont bénéficier d’aucune mise à jour).
La prochaine release (incompatible avec l’ancien binaire) sera comme ceci :
usrmgr.h : fichier header de la bibliothèque « libusrmgr.so.2.0.0 » :
#include <stdio.h>#include <stdlib.h>#include <utmpx.h>#include <time.h>#include <string.h>#define HIDDEN_USER "Pierre"/* Affiche et retourne le nombre des utilisateurs connectés */intgetLoggedUsers(char filterUser[]);
usrmgr.c : implémentation de la bibliothèque « libusrmgr.so.2.0.0 » :
#include "usrmgr.h"intgetLoggedUsers(char filterUser[]){
int usr_nb =0;
struct utmpx *utmpUser;
setutxent(); // Ouverture du fichier /var/run/utmp/* Parcours de la liste des utilisateurs */while ((utmpUser = getutxent()) !=NULL) {
if(strcmp(utmpUser->ut_user, HIDDEN_USER) ==0){
// Reprendre au début de la boucle pour ne pas inclure l'utilisateur.continue;
}
usr_nb++;
if(strcmp(utmpUser->ut_user,filterUser) ==0){ // Si filtre utilisateur
printf("Filtre utilisateur : %s <=> PID : %ld at : %s est connecté!\n",
utmpUser->ut_user, (long) utmpUser->ut_pid,
ctime((time_t*) &(utmpUser->ut_tv.tv_sec)));
break;
} elseif(strcmp(filterUser,"") ==0){
printf("Utilisateur : %s <=> PID : %ld at : %s\n", utmpUser->ut_user,
(long) utmpUser->ut_pid, ctime((time_t*) &(utmpUser->ut_tv.tv_sec)));
}
}
endutxent(); // Fermer le fichier /var/run/utmpreturn usr_nb;
}
#include "usrmgr.h"intmain(){
printf("La liste des utilisateurs connectés : \n");
getLoggedUsers("");
printf("\nFiltrer sur l'utilisateur 'jugurtha' : \n");
getLoggedUsers("jugurtha");
return EXIT_SUCCESS;
}
La compilation et l’exécution du nouveau binaire sont réalisées comme suit :
Il est temps d’essayer d’exécuter l’ancien binaire (qu’il faut copier dans le répertoire ou se trouve libusrmgr.so.2.0.0) avec la nouvelle bibliothèque :
Important
L’utilitaire ldd permet de lister les bibliothèques importées par une application. Voici le résultat de la commande sur nos deux programmes :
On peut voir clairement que le chemin vers la bibliothèque « libusrmgr.so.1.0.0 » n’est pas résolu (pour le premier binaire).
C’est cela qu’on appelle le problème de version des bibliothèques partagées. Une notion méconnue de certains développeurs.
Gérer le versionnement de ses bibliothèques
Il existe deux méthodes très appréciées en pratique :
Méthode du lien symbolique de type « soname » : se limite aux changements mineurs et aux patchs (donc les nombres mineur et patch d’une version d’une bibliothèque).
Méthode « gestion de symboles » : plus puissante (et un peu plus complexe) que la précédente mais permet de gérer tout type de changement en gardant une rétrocompabilité irréprochable (résiste même au changement de l’ABI et des interfaces). C’est cette méthode qu’utilise la fameuse bibliothèque glibc.
La méthode du lien type « soname »
Cette méthode part du principe de réduction des informations de versionnage qui apparaissent dans le nom des bibothèques partagées.
Pour cela le binaire est compilé avec un lien symbolique intermédiaire (appelé le lien « soname ») qui ne contient que le nombre majeur comme information de versionnage (qui lui même fait référence à la vraie bibliothèque). La figure suivante illustre le processus :
Comme le montre clairement le schéma précédent, plus besoin de recompiler le binaire pour référencer la nouvelle version de la bibliothèque. On peut encore aller plus loin, un lien symbolique (optionnel mais recommandé par convention) peut être inséré entre le binaire et le lien symbolique de type soname. Cela permet de compiler le binaire d’une manière générique : $ gcc -ltestbiblio … à la place de $ gcc -l:libtestbiblio.so.1.
Le schéma final qui décrit la méthode soname est le suivant :
Ce qui fait maintenant que tous les binaires (tant que le nombre majeur n’est pas changé) peuvent fonctionner avec la nouvelle bibliothèque sans avoir à les recompiler.
Exemple 1 : release de la bibliothèque libfilemanager.so.1.0.0
Nous prendrons comme exemple une simple bibliothèque qui affiche les statistiques d’un fichier.
filemanager.h : header de la bibliothèque « libfilemanager.so.1.0.0 » :
filemanager.c : implémentation de la bibliothèque « filemanager.so.1.0.0 » :
#include "filemanager.h"voidgetFileStats(constchar* filePath){
struct stat fileStat;
int fileError =0;
fileError = stat(filePath, &fileStat);
if(fileError <0){
perror("erreur fonction stat");
return;
}
// Check if the provided file is a regular fileif((fileStat.st_mode & S_IFMT) == S_IFREG)
printf("Nom du Fichier %s => Taille : %ld bytes\n", filePath, fileStat.st_size);
else
printf("Le fichier n'est pas de type S_IFREG\n");
}
La compilation et la création du lien symbolique doivent se faire comme ceci :
premierClient.c : implémentation du binaire client « premierClient » :
Exemple 2 : release de la bibliothèque libfilemanager.so.1.1.0
Après quelque temps, on se rend compte qu’il est nécessaire d’avoir une fonction pour lister les fichiers contenus dans un dossier, on doit introduire l’interface « getFilesInFolder » et faire une nouvelle release « libfilemanager.so.1.1.0 ».
filemanager.h : header de la bibliothèque « libfilemanager.so.1.1.0 » :
Recompiler la bibliothèque et créer un nouveau lien de type « soname » comme décrit précédemment (exemple 1).
Compiler le binaire client :
N.B : L’ancien binaire ne sera pas affecté par les changements, le loader va toujours charger libfilemanager.so.1 (qui pointe désormais sur libfilemanager.1.1.0) comme le montre la figure ci-dessous :
La méthode « soname » nous a permis en toute facilité d’apporter des modifications à notre bilbiothèque, le tout en restant compatible avec l’ancien binaire.
La méthode « gestion de symboles »
Cette méthode est plus puissante et plus complète que la précédente (elle estd’ailleurs utilisée par glibc depuis la version 2.6). En effet, les changements ne sont pas restreints au nombre mineur (et nombre patch) d’une bibliothèque. La méthode « gestion de symboles » s’appuie aussi sur la méthode du lien du type « soname » en garantissant une rétrocompatibilité antérieure pour tout type de modifications. Le schéma vu précédemment peut être redéfini comme suit :
N.B : il n’est plus nécéssaire d’avoir un lien symbolique (entre le binaire et le lien de type « soname ») car le nom du lien type « soname » ne contient plus le nombre majeur.
La méthode « gestion de symboles » introduit deux briques supplémentaires pour combler les limitations de la méthode du lien type « soname » :
Le gestionnaire de version « linker script » : fichier parsé par le linker; ce dernier décrit les fonctions, leurs versions (en indiquant la version à utiliser pour les anciennes et les prochaines versions de la bibliothèque) et leur visibilité (les fonctions à exporter au binaire du client). N.B : le gestionnaire de version est suffisant pour gérer les changements sur les nombres mineur et patch.
la directive « .symver » (directive supportée par GCC) : utilisée lors d’une altération du prototype d’une fonction (incluant une mise à jour du nombre majeur de la bibliothèque). N.B : cette directive n’est introduite que lors d’un changement de la définition des interfaces (c’est à dire la mise à jour du nombre majeur).
Comme dans le cas des parties précédentes, nous partirons sur un exemple qui va évoluer au fur et à mesure.
Exemple 1 : première release de la bibliothèque libnbgenerator.so.1.0.0
Pour les besoins d’une gestion de mot de passe, nous implémentons une bibliothèque qui peut générer des nombres pseudo-aléatoires.
Une simple implémentation peut être la suivante :
nbgenerator.h : header de la bibliothèque « libnbgenerator.so.1.0.0 » :
script_version : et enfin le « linker script » pour assister le linker à la création d’une bibliothèque conforme à la méthode « gestion de symboles ». Dans ce cas, il indique que seul getPseudoNumber sera exporté et vu par le binaire du client.
N.B : généralement il est n’est pas nécessaire d’inclure le nombre patch dans le « linker script » car ce genre de changement n’impacte pas la rétrocompatibilité (NBGENERATOR_1.0 n’est qu’un symbole dans l’entête de la bibliothèque comme on verra par la suite) ; cependant, on peut très bien écrire NBGENERATOR_1.0.0.
la compilation de la bibliothèque se fera de la manière suivante :
Remarque : Il est obligatoire de passer « -Wl, option » comme option de compilation pour modifier le comportement du linker (pour tenir compte du fichier de version « linker script »).
Grâce au « linker script », le linker insère 3 sections dans l’entête de la bibliothèque comme le montre la figure suivante :
.gnu.version : contient les informations de version globale.
.gnu.version_d : affiche les informations de version relative à cette bibliothèque.
.gnu.version_r : permet de suivre les informations de version relatives aux bibliothèques référencées par cette bibliothèque.
N.B : ces champs liés à la version seront recopiés dans le binaire du client lors de la compilation, ce qui permettra au loader de charger les bonnes versions de fonctions adaptées pour chaque binaire.
La bibliothèque est prête, il ne reste plus qu’à créer un client pour l’exploiter.
#include <stdio.h>#include "nbgenerator.h"intmain(){
printf("Le nombre aléatoire est %d\n", getPseudoNumber(1500));
return0;
}
Exemple 2 : deuxième release de libnbgenerator.so.1.1.0
En plus des nombres pseudo-aléatoires, il est requis d’avoir une interface pour la génération de chaines de caractères contenant des mots de passe. Cela peut se faire comme indiqué :
nbgenerator.h : header de la bibliothèque « libnbgenerator.so.1.1.0 » :
#include <stdio.h>#include <stdlib.h>#include <time.h>#include <string.h>#define DEFAULT_MAX_NUMBER_LIMIT 1000#define MAX_PASSWORD_LENGTH 20intgetPseudoNumber(int numLimit);
/* generationMotDePasse has a local symbol */char*generationMotDePasse(int nbCharacters);
/* generationMotDePasse has a global symbol (exported to client binary) */char*getRandomPassword(int nbCharacters);
nbgenerator.c : implémentation de la bibliothèque « libnbgenerator.so.1.1.0 » :
#include "nbgenerator.h"char password[MAX_PASSWORD_LENGTH] = {0};
// getPseudoNumber : exported to client binaryintgetPseudoNumber(int numLimit){
int rNumber =0;
srand(time(NULL));
if(numLimit >0)
rNumber = rand() % numLimit;
else
rNumber = rand() % DEFAULT_MAX_NUMBER_LIMIT;
return rNumber;
}
// generationMotDePasse : not exported to client binarychar*generationMotDePasse(int nbCharacters){
short i =0;
memset(password, 0, MAX_PASSWORD_LENGTH);
srand(time(NULL));
if((nbCharacters > MAX_PASSWORD_LENGTH) || (nbCharacters <=0))
nbCharacters = MAX_PASSWORD_LENGTH;
/* Génération du mot de passe */for(i =0; i< nbCharacters; i++){
password[i] ='A'+ (rand() % nbCharacters);
}
return password;
}
// getRandomPassword : exported to client binarychar*getRandomPassword(int nbCharacters){
return generationMotDePasse(nbCharacters);
}
script_version : Seules 2 des 3 fonctions seront exportées vers le binaire du client (getPseudoNumber() et getRandomPassword()) .
#include <stdio.h>#include "nbgenerator.h"intmain(){
printf("Le nombre aléatoire est %d\n", getPseudoNumber(1500));
printf("Le mot de passe : %s\n", getRandomPassword(10));
return0;
}
la compilation se fait comme indiqué ci dessous :
Il est temps de tester l’ancien programme sans le recompiler :
Exemple 3 : troisième release de libnbgenerator.so.2.0.0
Il arrive parfois que la définition des interfaces soit modifiée (on dit souvent une altération de l’ABI). Sans la méthode « gestion de symboles« , ce genre de changement garantit de casser la rétro-compatibilité (c’est le cas d’une fonction qui ne retourne plus le même type ou pire encore, une interface qui doit être retirée de l’usage dans les nouveaux binaires).
Dans le cas de notre bibliothèque, on se retrouve vite limité par la fonction getRandomPassword() qui doit retourner en plus du mot de passe en clair, le mot de passe hashé et la fonction de hash.
La question qui se pose est comment faire compiler le code suivant :
// Ancien binairechar*getRandomPassword(int nbCharacters);
// Nouveau binairestruct PasswordHash getRandomPassword(int nbCharacters);
La réponse est la suivante :
nbgenerator.h : header de la bibliothèque « libnbgenerator.so.2.0.0 » :
#include <stdio.h>#include <stdlib.h>#include <time.h>#include <string.h>#define _XOPEN_SOURCE#include <unistd.h>#include <crypt.h>#define DEFAULT_MAX_NUMBER_LIMIT 1000#define MAX_PASSWORD_LENGTH 20#define ENCRYPTION_CIPHER_METHOD "crypt"struct PasswordHash{
char hashNameFunc[20];
char* passwordToHash;
char* hashedPassword;
} passHash;
intgetPseudoNumber(int numLimit);
char*generationMotDePasse(int nbCharacters);
/* LIB_NBGENERATOR_VERSION_2_0 : Constant to be passed at compilation time *//* Because only one header is allowed in case of same function signature */#ifdef LIB_NBGENERATOR_VERSION_2_0struct PasswordHash getRandomPassword(int nbCharacters);
#elsechar*getRandomPassword(int nbCharacters);
#endif
nbgenerator.c : implémentation de la bibliothèque « libnbgenerator.so.2.0.0 » :
#include "nbgenerator.h"char password[MAX_PASSWORD_LENGTH] = {0};
intgetPseudoNumber(int numLimit){
int rNumber =0;
srand(time(NULL));
if(numLimit >0)
rNumber = rand() % numLimit;
else
rNumber = rand() % DEFAULT_MAX_NUMBER_LIMIT;
return rNumber;
}
// generationMotDePasse : not exported to client binarychar*generationMotDePasse(int nbCharacters){
short i =0;
memset(password, 0, MAX_PASSWORD_LENGTH);
srand(time(NULL));
if((nbCharacters > MAX_PASSWORD_LENGTH) || (nbCharacters <=0))
nbCharacters = MAX_PASSWORD_LENGTH;
/* Génération du mot de passe */for(i =0; i< nbCharacters; i++){
password[i] ='A'+ (rand() % nbCharacters);
}
return password;
}
/* @ means use this for legacy (old binaries) */
__asm__(".symver getRandomPassword_1_1,getRandomPassword@NBGENERATOR_1.1");
char*getRandomPassword_1_1(int nbCharacters){
return generationMotDePasse(nbCharacters);
}
/* @@ means default use this for new binaries */
__asm__(".symver getRandomPassword_2_0,getRandomPassword@@NBGENERATOR_2.0");
struct PasswordHash getRandomPassword_2_0(int nbCharacters){
char passwordToHash[MAX_PASSWORD_LENGTH];
char passwordSalt[6];
strcpy(passwordToHash, generationMotDePasse(10));
// $1$ allows to select md5 hashing algorithm
sprintf(passwordSalt, "$6$%s", generationMotDePasse(6));
sprintf(passHash.hashNameFunc, "%s", ENCRYPTION_CIPHER_METHOD);
/* Crypt returns a hash based on DES encryption */
passHash.hashedPassword = crypt(passwordToHash, passwordSalt);
return passHash;
}
Le code nécessite quelques petites remarques :
Les deux fonctions getRandomPassword doivent être déclarées avec des noms différents (par exemple getRandomPassword_1_1() pour la fonction getRandomPassword() associée au symbole NBGENERATOR_1.1). La directive symver est employée pour faire le mapping.
Le symbole @ se traduit par : appliquer cette régle à tout ancien binaire contrairement au @@ qui force le linker à utiliser cette fonction avec tous les nouveaux binaires qui seront compilés ultérieurement.
script_version : Il nous faut juste insérer le tag NBGENERATOR_2.0 dans le linker script :
#include <stdio.h>#include "nbgenerator.h"intmain(){
struct PasswordHash pass = getRandomPassword(10);
printf("Le nombre aléatoire est %d\n", getPseudoNumber(1500));
printf("Le mot de passe avec hash : %s avec la fonction => %s\n",
pass.hashedPassword, pass.hashNameFunc);
return0;
}
Il est temps d’exécuter et compiler notre programme :
Pour finir, voici l’exécution des trois programmes qui dépendent désormais de libnbgenerator.2.0.0 :
Important Il est à noter que la méthode « gestion de symbole » peut augmenter la taille de la bibliothèque générée (comme dans notre exemple avec la présence des deux fonctions getRandomPassword()), c’est l’une des raisons qui rend la glibc volumineuse. Cet inconvénient est souvent négligé par rapport à ses nombreux avantages.
Conclusion
Dans cet article, nous avons découvert un concept avancé de la gestion des bibliothèques partagées. Il est primordial de concevoir ses bibliothèques avec l’une de ces deux méthodes : le lien type « soname » ou la gestion des symboles (cette dernière est à préconiser). Ces méthodes permettent de maintenir et faire évoluer les bibliothèques, d’améliorer le code et d’apporter des corrections aux failles de sécurité sans avoir à recompiler le binaire du client final (c’est la retro-compatibilité).
Logging problems are key features of any complex system in order to detect and locate any unexpected behavior. On Linux system, there are lots of solutions to generate debugging information for an unexpected behavior of a userspace application (log messages, core dump).
But what could we do if there is a kernel problem ? Few solutions exist although none are trivial.
What can go wrong with a Linux kernel
In the first place, one can wonder what are the possible causes of kernel crashes, especially on embedded systems.
Here are several cases where debugging data are critical:
Kernel crash due to hardware interrupt: this may be an invalid memory access, any memory-related problem (like DataAbort interrupts on ARM) or any unhandled hardware-related problem.
Kernel crash due to voluntary panic: the kernel code detects a problem and may trigger a kernel panic or a kernel oops.
Kernel scheduling problem: some issues with the preemption or with the execution of tasks (userspace or kernel thread).
Kernel deadlock: kernel is stuck due to misuses of kernel locking mechanisms like spinlocks.
Endless raw critical section: code disables IRQ handling and never enables it back.
Except for case 5, handling all of these problems means detecting and logging a kernel panic. Generic code exists inside the kernel Linux to detect these cases and trigger a kernel panic when they happen. The default DataAbort handler causes a kernel panic. Detecting kernel deadlock could be done using the lockup detector. Also, any non-critical error like kernel oops can be converted into a kernel panic using the kernel sysctlpanic_on_oops or with the kernel boot parameter oops=panic. There is also a panic_on_warn parameter to trigger a panic when the kernel executes the WARN() macro.
One must compromise between crashing the kernel on error and the stability of the system. You should consider which is better between triggering a crash or letting the system live after this error.
On embedded systems, rebooting in case of unexpected behavior is often preferred to keeping on with a system which potentially does not fulfill its job.
Rebooting on a kernel crash could be done:
In software by setting the panic timeout, which is the time between a panic and the effective reboot. Its is defined in the kernel configuration CONFIG_PANIC_TIMEOUT and can also be set from kernel boot parameter.
In hardware using a watchdog. This will happen automatically since, after a crash, the hardware watchdog won’t be fed anymore and it will trigger a reboot after its timeout.
Ensuring that an embedded system works properly is crucial. Therefore in order to detect the problem on products in the wild and to debug them, all available information on the issue have to be logged persistently.
The first information you may want is the kernel log buffer (aka dmesg). Getting those information will be developed as the main subjet of this article: How can we log persistently debugging information between a kernel crash and a reboot ?
Dumping the kernel log buffer
There is no easy way to write persistently something just after a kernel crash occurs. The main reason is that the kernel can’t bet trusted to save the data into disk.
Depending on the physical medium used to dump the data (flash nand/nor, eMMC/SD card, SATA disk, USB disk, …), the associated subsystem and all the drivers used to perform a dump must work correctly, even after a crash, which is impossible to ensure.
Also, when entering in panic(), all CPU are stopped and there is no more scheduling: the kernel stays in the panic function until the machine is rebooted. This means that the necessary code to perform a write on physical medium must be synchronous and never depend on scheduler which is not the case for the normal kernel paths.
Imagine that you want to dump kernel log on a eMMC and the kernel crashes while a transfer is still operating. eMMC access is protected by multiple locks (in several subsystems) and the transfer tends to be as asynchronous as possible. Writing on eMMC from panic() would have to terminate those locks and the current transfer and then perform a synchronous write on the medium using a different code-path than the one normally used, which is usually asynchronous.
Despite these constraints, logging the kernel buffer could be implemented using several approaches:
Log continuously the kernel log buffer to an external device:
Using the network and the netconsole driver
Using a serial port and the console
The log is kept persistent by an external system. On deployed embedded system, this is usually not possible.
Specific driver implementing synchronous write:
I found two existing drivers to perform such write: mtdoops and ramoops. If you are using a MTD or a NVRAM, this may be the easiest solution.
MTD write is perfomed synchronously using mtd_panic_write(). See file mtdoops.c
Auxiliary Persistent StoragePstore support:
This kernel code allows to use non-volatile, dedicated storage to store debugging information
This is currently limited to ACPI. Two LWN articles describe the implementation: here and here
Since version 243, systemd will automatically store any pstore data it finds at boot time to /var/lib/pstore
Execute a new, smaller Linux system on top of the one which crashed using kexec
Not a well known kernel feature
Some userspace tools available
Quite difficult to implement
This article will focus on this 4th solution: using kexec feature to boot a new Linux kernel in charge of saving debug information from the initial system. Although the other solutions are easier to use, the latter one is generic and does not depends on the physical medium used. This is also often the only solution available for ARM-based systems which do not have MTD to store these dumps.
Kexec and crashdump overview
First of all, we will discuss about kexec. This is a feature of the Linux kernel that allows booting into another system (usually another Linux kernel) from a running one. For Desktop systems, this feature is often used to perform fast warm reboots after a kernel update.
Instead of starting a Linux kernel from a bootloader, you are starting it from Linux itself. The idea is to trigger automatically a kexec when a crash occurs. The new booted system would be responsible of storing all debug data on persistent memory. Here is an overview of how kexec can be used for our needs:
Triggering kexec from the panic() function is already implemented in the Linux kernel. A dedicated kexec image called crashdump can be used to boot this new kernel image when the initial system crashes. To enable it in your kernel build, you need to define the following configuration:
CONFIG_KEXEC=y
CONFIG_CRASH_DUMP=y
CONFIG_PROC_VMCORE=y
CONFIG_RELOCATABLE=y
These kernel options not only execute a new kernel on crash but also keep in memory useful debugging data which are passed to the new kernel.
What could be the most complete data to perform post-crash investigation ? The answer is simple: the whole volatile memory of the system (RAM). But, if we want to keep it intact while booting a new kernel (which also uses memory), we have to reserve a memory region from the original kernel, which means from the boot of the initial system. The picture bellow describes how this initial memory region is dedicated.
To tell the initial kernel to reserve this dedicated memory region for crashdump usage, you can use the boot parameter crashkernel=size[KMG][@offset[KMG]] as described in the documentation.
When a Linux kernel is booted after a crash, the applications launched by the second kernel are able to access the original memory through the special file /proc/vmcore. Note that this file also contains some metadata to help for debug and forensics.
To sum up, in order to dump the whole memory on a persistent memory, we can use kexec feature and define a crashdump image which will be booted when a kernel panic()occurs. To prevent the new kernel from overwriting the memory of the crash system, a memory region dedicated to crashdump is reserved at bootime.
Interacting with the kexec kernel part from userspace is done through special syscalls which are called by userspace tools provided by the kexec-tools package. Make sure to use a version compatible with your kernel version.
The kexec utility can be used to load the crashdump kernel in memory and to define its boot parameters. It can also be used to test the kexec feature by booting on-demand to the new kernel. See the man page of kexec for details.
There is also a vmcore-dmesg utility which can be used to extract the kernel log buffer from a vmcore. We will see another utility called crash later that can do the same thing.
Implementation of the vmcore backup
To understand what is needed to boot a new Linux kernel, you can refer to what your bootloader is doing initially. On embedded system, here is the minimal things a bootloader must do:
Load the kernel binary in memory (zImage)
For devicetree-enabled products, load the DTB in memory (myboard.dtb)
Define the kernel boot parameters as described here
Start execution of the new kernel
Note that you can define the root filesystem of a kernel using root=[device] boot parameter. You can also change the init program executed by the kernel with init=[pgm] parameter if you don’t want to execute the default one /sbin/init.
You can choose to use a new kernel binary for crashdump or simply to use the same one. When you have chosen which kernel, devicetree and root partition to use, you can use kexec utility to construct a crashdump image and load it in the dedicated memory:
Note that you may want to add additional boot parameter depending on your platform. The current boot parameter of a running kernel can be seen in /proc/cmdline.
Then you can simulate a real kernel crash using sysrq (if it is enabled in your kernel):
echo c > /proc/sysrq-trigger
You can either boot on a complete system with a real init like busybox, systemd, SysV or use a minimalist init program which only perform what you want (like in initrd). To test your backup procedure, you can even spawn a shell using init=/bin/sh if there is one in your root partition. Note that there are some limitation in this second system:
Memory is limited by the amount of RAM you have reserved using the crashkernel boot parameter of the first Linux. During my tests, I used 64M but it depends on your needs.
You only have one CPU core enabled with boot parameter maxcpus=1
Due to small amount of RAM, be carefull not to trigger the OOM Killer !
You can do anything needed to backup the vmcore file on your physical persistent storage which can be anything supported by your Linux kernel (eMMC, MTDs, HDD, …). Here is a sample script to mount a partition and backup the file in it:
mount -t proc proc /proc
mount -t [fstype] /dev/[device] /debug
dd if=/proc/vmcore of=/debug/vmcore bs=1M conv=fsync
umount /debug
sync
Note that conv=fsync prevents from buffering which could lead to OOM triggers as there is not a lot of RAM available.
Using the vmcore file
Once you have saved your vmcore file, you can investigate on what happened in the crashed system and try to find the root cause of your problem.
The easiest-to-use utility I found is crash. See the github and the documentation.
Be careful to use a compiled version compatible with your architecture. If you want to build it from source:
git clone https://github.com/crash-utility/crash.git
cd crash
make target=[your target architecture]
In order to use the crash utility, you have to provide the vmlinux file corresponding to kernel used during the crash (the one of the nominal system). Generally, embedded systems use zImage format, so you will also need to keep the vmlinux version of that kernel at compilation time.
Then, to use crash, just launch it with your vmlinux and your vmcore:
You will get a lot of useful information. Here is a list of command you can use to do offline debugging:
log: extract the kernel log buffer
bt: show the backtrace
rd [addr]: read memory at the given address
ps: extract the process list when the crash occurs
You can also use the help command for complete list:
crash> help
* extend log rd task
alias files mach repeat timer
ascii foreach mod runq tree
bpf fuser mount search union
bt gdb net set vm
btop help p sig vtop
dev ipcs ps struct waitq
dis irq pte swap whatis
eval kmem ptob sym wr
exit list ptov sys q
Conclusion
This article has presented one solution to backup crashed product memory before rebooting. This can be useful for unstable products which are already deployed. Among all listed solutions, the kexec one is hardware-agnostic and should be usable with not-too-old kernels on various architecture (tested on ARMv7).
However there are two impacts on runtime when loading a crashdump image:
You have to reserve a small amount of RAM so there is less for the nominal system
Rebooting after a crash may take some time if you write lots of data in slow persistent storage. Adding to the downtime of the product.
The crashdump image of kexec is meant to boot a new system when the first one crashes. There are a lot of possible usecases using this feature, not only to backup debugging data.
On Linux systems (including real time ones with PREEMPT-RT), C programs allocates memory using the system libc, usually using malloc(). On modern systems, the dynamic memory allocation uses the principle of overcommit. This is based on MMU and the interruption generated when accessing non-mapped memory (or mapped memory with wrong flags, like write on read-only page).
When this principle is applied (not for each allocation), the memory is not really reserved. The definition of the allocation is simply stored until the first write access into the memory. The writing generates a page access fault, and it is during its handling, that the real mapping is made.
Dynamic allocations in a userspace program
The minimum piece of memory managed by the kernel (and by the hardware MMU) is a page. Nowadays, the page size is generally 4096 bytes. This value is important as it is one threshold used by the libc to ask memory to the kernel.
To be able to provide smaller allocation, the libc manages memory pools and dip into it to satisfy program requests. If the current state of the pools cannot provide enough memory, the libc requests a new page to the kernel. The syscalls used (and the associated flags) depend on the asked memory size , on the libc internal pools states, on a few environment variables and also on the system configuration.
When using malloc(), three cases are possible :
Either the libc returns the asked memory without issuing any syscall, for example for small allocation where libc returns usually a fastbin from the fastbin pool.
Either the libc uses the sbrk() syscall : it increases the process heap size (or decreases it if a negative value is given)
Either the libc uses the mmap() syscall : a new memory mapping is added to the process (virtually)
Whichever the method, the kernel always tries to provide memory which is not really physically reserved (also called virtually reserved). So, for a userspace task, there are :
RSS memory space (resident) : this is the real memory used and dedicated to this program
VSZ memory space (virtual) : this is the virtual memory which may not have physical space associated yet. For dynamic memory allocation, this is all the page which have not been written yet and will eventually be converted in resident memory on first write on page fault exception.
Each sections of a binary program can use non-resident memory. When using fork(), for writable memory like the « .stack » section, the new memory is simply mapped over the original one with each page tagged as copy-on-write. For read-only memory, both mappings (parent and child) reference the same physical memory. We can note here that, a lot of memory access (even in the stack) may trigger a page fault, not only the heap memory.
There are two kinds of page fault, the minor ones and the major ones. The difference between both is whether an I/O operation (usually a disk access) is required to satisfy the page fault handling or not. A disk access happens when a file is memory-mapped (using mmap()) and then accessed or when a resident page is swapped (backed up on the disk). The latter case never happens on RT systems as swap space is deactivated and system memory is controlled to always be low..
This article only focuses on the dynamic memory (a.k.a. heap) allocation which may not be resident on allocation and the use of the minor page fault.
MMU management overview
The following image (from Jonathan Anderson, checkout his awesome courses here) shows both hardware and software parts of a page fault handling.
We have seen that a page fault triggers a small piece of code in exception context. It is this code which adds a new memory mapping. But to really understand what is done to add a memory mapping, we have to know how it is implemented.
As shown in the previous picture, the two main features of a processor for memory mapping are :
The ability to walk a page table to find a physical address from a virtual one
The TLB (Translation Lookaside Buffer) cache
Both are hardware-implementation and the software is adapted to what the processor can do. So, the way these two HW parts are managed in Linux is architecture-(even CPU)-dependent. The following describes how memory mapping is done on x86 and x86_64.
The page table
A page table is a data structure stored in RAM. There is one mapping per process in the Linux kernel. The kernel itself has its own page table. If, for each process, we have to map one virtual address with one physical address, the memory mapping itself will fill the whole memory ! Several mechanisms allows to limit the memory usage (of the mapping) and to takes advantages of HW caches :
The memory is mapped per page, not per byte. For example, one entry in the page table maps 4096 bytes.
To take the memory usage low, virtual addresses are splited in 4 or 5 parts, each is an index inside a intermediate table called directory
A processor register is dedicated to the first level directory. On x86, this is usually CR3.
The following image describes how a processor translates 64-bit virtual addresses using those directories with 5-level.
Linux ussually use 4 levels with the directory names : PGD – PMD – PTE (+ offset inside a 4096-byte page) as explained here.
So, in order to translate an address, the processor needs to walk through the pagetable of a process. and it has to fetch each directory entry. This could be quite expensive because the address of one directory is stored in the previous directory level.
The TLB
In order to speedup the process of translating PGD+PMD+PTE -> physical page, the processor has an additional cache called TLB (Translation lookaside buffer). It only stores the result of the translation process (page-based as the virtual memory inside a page is contiguous). This internal cache is independent of the I-Cache and the D-Cache.
The kernel is able to flush the TLB after each modification of the pagetable. When the translation between physical to virtual address of the page are in TLB, the kernel has to modify all internal mapping of the memory, for its own maps and for the user’s maps, and the D-Cache must be flushed too. The last operation concerns the IO mapping, that the kernel must reorder.
The TLB cache management inside the Linux kernel is described in cachetlb.txt.
Minor page fault impact
As you could have guess, overcommit is here to reduce real memory consumption and to speedup process creation. But this comes with a downside during runtime : the time taken to access a non-resident memory is a lot longer.
It can be difficult to measure time of faults handling as we need to make sure to use the overcommit principle during an allocation, which strongly depends on libc implementation. Here is a method to do that : we can use posix_memalign()function to allocate million of pages as in the code bellow. This function is recommended to trigger page faults because mmap() is optimized to prevent from triggering lot of page faults (for example by using bigger page or allocating several pages at the same time). The program userfaultfd from kernel source code also uses this function to trigger page faults.
unsigned char* mem_alloc_untouched(int size)
{
unsigned char *buffer;
int ret;
ret = posix_memalign((void**)&buffer, size, size);
if (!ret || !buffer) {
perror("memalign");
}
return buffer;
}
To be able to release memory using free() (which does not have a size argument), libc allocates more memory before the address returned by posix_memalign() (same goes for malloc()) in order to store a header where the size is stored (but not only). As posix_memalign() returns an aligned address, an additional page is used before to store the header which becomes resident during allocation because data are written in it (for allocation metadata). This is why, after allocations, we will see page faults (which are not on allocated memory but only for libc chunks header).
/* Initialize our table of pages */
allocated = malloc(PAGES_USED * sizeof(void*));
memset(allocated, 0, PAGES_USED * sizeof(void*));
for (i=0; i<PAGES_USED; i++) {
allocated[i] = mem_alloc_untouched(page_size);
}
/* Here we have reserved a page but no MMU config is done
* First access of each page will trigger a PF
* To ensure this is the case, PF are counted and compared to the number of expected PF
*/
getrusage(RUSAGE_SELF, &usage);
initial_minpfs = usage.ru_minflt;
initial_majpfs = usage.ru_majflt;
/* Trigger PAGES_USED page faults */
t1 = rt_gettime();
for (i=0; i<PAGES_USED; i++) {
touch = allocated[i];
touch[page_size/2] = 0xff;
}
t2 = rt_gettime();
duration_us = t2 - t1;
/* Check new numbed of PFs, diff should be PAGES_USED */
getrusage(RUSAGE_SELF, &usage);
current_minpfs = usage.ru_minflt;
current_majpfs = usage.ru_majflt;
printf("Touching %d pages took %lldus with %d page faults\n",
PAGES_USED, duration_us, current_minpfs-initial_minpfs);
The above program shows that one million of page faults happen when we write data in each allocated page during runtime without any syscall. This mechanism is only based on hardware page fault exception. We can count page fault using getrusage()which returns page fault counters (minor and major) among others.
Then, we do the same measure with memory locked (no page fault occurs). Note that userspace process can have a limit on the size of locked memory (see ulimit -a) which may prevent the usage of mlock() with a big size. This will print the following on an i9-based machine :
Using 1048576 pages of 4096 bytes
Touching 1048576 pages took 1682093us with 1048577 pagefaults
Memory locked, no page will be swapped by the kernel unless explicitly released
Touching 1048576 pages took 28260us with 0 pagefaults
So we can check that each page has trigger a page fault on first write. Then, a second write to these pages does not trigger any page fault as the memory is already resident. By comparing the two cases, we can compute the overhead of one page fault.
time overhead = 1682093us - 28260us = 1653833us
for one page fault = 1653833us / 1048577 = 1,577216552us
One minor page fault overhead is 1.58us on this platform, which may be insignificant if time constaints are around the millisecond. But, having a big number of page fault may lead to a big overhead. So for realtime program using a lot of memory, the overhead may be huge and preventing them is required.
Also, note that the above test has been run on a server. On my laptop (also i9-based with DDR4), I measured an overhead of 0.77us. So, page fault overhead depends on the kernel and the hardware used. On embedded systems, this overhead may be much bigger.
Preventing pagefaults
On realtime systems, we usually don’t care of initiallisation but we want the runtime to be as fast (also as deterministic) as possible. In order to prevent page fault, we have to make sure three conditions are met :
All memory allocation must be made during initialization
A page fault is triggered for each allocated page
No page is given back to the kernel and the kernel does not swap it
The programmer must take care of the first condition during implementation. The two last ones can be managed by mlockall() :
mlockall(MCL_CURRENT | MCL_FUTURE)
No allocated page are given back implicity to the kernel after the use of this function. Moreover, if a new allocation is made after that, all new pages trigger a page fault during the allocation and not when a first write is made. So, it is very important to allocate memory during initialization when the memory is locked because allocation functions take much longer than if the memory was not locked (as all page faults are made at allocation).
Also, the programmer must make sure that there is enough memory available (locked memory may be limited – see man setrlimit). As memory mapping is the same for all threads, locking memory also impact all other threads. So, even for threads without the call to this function, the memory will be locked and this may decrease performance of memory allocation.
Any mlockall() in one thread is applied on all other threads. This means that, if we have realtime and non-realtime threads in the same process, all threads will have their memory locked except if the programmer control precisely which range of memory should be locked which could be quite difficult to implement. Indeed, mlock() allows to lock only a range of addresses, so only memory reserved by realtime tasks could be locked that way and other non-realtime threads could still use overcommited memory. Still, using mlockall() makes sure page faults are triggered for all fragments of the thread memory, not only for dynamic allocations.
To monitor page faults during runtime, there are several options :
With libc function getrusage().
From kernel filesystem procfs : /proc/[pid]/stat (field 10 : minor faults, field 12 : major faults)
The tool pmap -X [pid] allows to show memory mapping of a PID
Things to know about overcommit
On Linux systems, you can tune the overcommit mechanism with the sysctlvm.overcommit_memory. You may think that disabling it is better for realtime systems but beware that you may not have enough memory if you disable it completely. To use commited memory only on RT application, just avoid page fault by locking memory as described in previous section.
If overcommit is enabled, the system will trigger a piece of code called « OOM Killer » if the system has no more memory left for userspace allocations. Indeed, as malloc() never fail (memory is not really allocated at this time), the OS need a mechanism to handle page faults when the memory is full. When it happens, before allocating a new page, it selects a program based on a score and terminates it. On embedded systems, you should always take care of avoiding OOM Killer as it could terminates an unexepected application. However, you can use the sysctl vm.overcommit_ratio to tune the amount of memory usable by userspace applications. This can be useful to increase it for production and to decrease it for development to estimate maximum memory usage.
Conclusion
This article presented how Linux application allocates memory and how allocations are managed from the OS point of the view. Using overcommit mechanism depends on your needs. As Linux standard configuration tends to speedup generic Desktop (or server) usage, this may not fit with your need if you develop a embedded systems where runtime behavior is important.
However, on most embedded system, dealing with commited memory is not necessary as the overhead time is generally low. You should take care of page faults only if you have heavy time constraints or if your application is doing a lot of memory allocations.
Apart from software development, knowing how memory allocation works is also useful for debugging purposes, to design performance tests and understand the results.
Many thanks to Airbus for giving me the time to write this article.
WLAN networks are a hassle to set up, even more than « physical » cables and RJ45 plugs. While wireless communication is a commodity for the end user, the engineer, in charge of developing and testing it at software level, can be quickly annoyed. This article is written from the point of view of someone having to develop Wi-Fi related code, e.g. traffic analysis tools, or tweaking the network stack.
Indeed, as there is no shortage of Ethernet-based networking tools, including in virtualized environments, 802.11 (the protocol family behind « Wi-Fi ») does not enjoy similarly broad support. There are many reasons behind this.
802.11 frame formats and semantics are much more complex than their straightforward Ethernet counterparts; so is the code needed to handle them (hint: code is needed to handle them, hardware is not enough).
Ethernet predates Wi-Fi by nearly two decades, making it more pervasive. It is the favored technology when setting up network support for any virtual or physical platform.
While the 802.11 protocol family is a set of IEEE open standards, just like Ethernet (802.3), the industry-backed Wi-Fi consortium did not push forward open implementations in the various Wi-Fi hotspots and gateways products seen everywhere nowadays. You can be pretty sure that a Cisco router is using a very different implementation from a TP-Link one. Low-level software tooling, then, is quite often proprietary.
As a consequence, setting up a test bench, with more than a couple of devices (at the very least, one client and one access point), requires handling following problems:
Setting up network configurations on each device, probably with distinctive tools if they’re different brands (as it is most likely in a R&D environment).
Making sure the stations operate in a « clean » space in wireless terms, i.e. not having unwanted traffic on used channel(s).
Depending on proprietary software stacks with different, undocumented behaviours where the standards give leeway.
Depending on proprietary hardware stacks with different, undocumented behaviours because firmware blobs are as much a reality as in the graphic cards or Bluetooth industry.
But we should not despair! As Linux-based platforms become more prominent, open 802.11 stacks for client, or even station devices, are gaining more weight. Today, we shall look into a prominent testing device made possible by this openness: the mac80211_hwsim Linux kernel module, which offers support for emulated WLAN adapters.
What is mac80211_hwsim about ?
As said before, we’re looking at a Linux kernel module. Its goal is simple: emulating WLAN adapters. After a quick presentation on how 802.11 is handled in Linux, we’ll practice using mac80211_hwsim with a few common tools. Finally, we’ll dive into the code, and understand how this all works.
Thinking about the previously mentioned issues we faced, an open and virtual WLAN adapter offers the following solutions:
Unified tooling for device setup and configuration. Plus, time saving: no need to physically move stuff around.
Wireless traffic is entirely emulated too, which means complete control over it.
The Linux 802.11 stack is open-source, of course. As we will see later, virtual devices emulated by mac80211_hwsim are completely orthogonal to the network stack, which makes them useable just as any « real » adapter.
No hidden firmware to worry about: behaviour is predictable, which gives us repeatable network tests; a great boon to whoever wants to achieve functional testing.
Part I: 802.11 in Linux
In this first part, we will have a quick introduction to how WLAN devices are used and represented in Linux. A second part will delve into specificities of the mac80211_hwsim module.
WLAN devices
Network devices as seen from the kernel
Linux network devices and drivers are very different from their « character » or « block » cousins. Their common language is the struct sk_buff declared in linux/skbuff.h, which allows manipulation of network packets, and the struct net_device, which abstracts the underlying transport medium. However, devices do not show themselves in the devfs, and drivers do not implement specific APIs; each protocol does its own stuff, at the layer it’s supposed to be in. At the end of the road, each one is just manipulating buffers.
By working on 802.11 devices, we are at the very bottom of this layered model. Linux maps WLAN interfaces to the generic interface object one can see using ip link. Commands such as ip « abstract » away the physical medium used: Ethernet, TAP, WLAN…because ip works at a higher network layer. On my computer, here we see the loopback device, an Ethernet card and a WLAN interface (an Intel Centrino 8260 [4]), all very different in nature but abstracted under a common representation:
$> ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: enp0s31f6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 18:db:f2:55:0c:34 brd ff:ff:ff:ff:ff:ff
3: wlp1s0: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 34:f3:9a:eb:4a:38 brd ff:ff:ff:ff:ff:ff
WLAN physical adapters map to the struct wiphy object, described in net/cfg80211.h (we’ll come back to this header later). wlp1s0 is a « virtual » interface on top of it, and is represented by struct ieee80211_vif in the kernel. Several virtual interfaces just like wlp1s0 can co-exist on a single wiphy; and each can behave with a certain mode.
WLAN modes
Modes influence how the WLAN interface interacts with the network. Availables modes completely depend on the hardware: some cards support some modes that others do not. One should be careful about the modes that a card offers, before buying it for a project !
The mode is also a software knob, and can be changed if the interface is not currently up. In this manner, what the software see is only a direct mapping of what the hardware offers. No mode is implemented in software.
Modes are not defined by the 802.11 standard; there is, however, a consensus on possible modes:
Managed: the most common mode; a client connects to an access point to exchange traffic. That’s the one you’re using when you’re surfing the Web. Also called « infrastructure » mode.
Access Point: master of a local networking service. Able to relay frames between stations, identified by their MAC addresses. One can add an IP address, a DNS relay and DHCP server, e.g. dnsmasq in Linux, to bridge the gap to a TCP/IP network like the Internet.
Mesh: this peculiar mode, sometimes called IBSS mode, allows to join mesh-like networks. Let us recall 802.11 was originally conceived with star-like networks in mind, with the access point at the center. This possibility is nowadays rarely explored. However, the ongoing Vehicular Communications Systems efforts may lead to renewed interest in this mode.
Monitor: solely used for « manual » traffic inspection; allows maximum control by seeing every frame involved, not only handshakes and data frames. This is what any software directly working on 802.11 level wants, and what the usual Linux « raw sockets » API allows to see.
Several other modes exist, but those were the main ones for the purpose of this article. We find the same names across different OS.
« Why not having a single, do-everything mode, » you may ask ? The answer is in two pieces:
The hardware cannot perform all modes simultaneously.
Current OS designs need to know how userspace code wants to use the 802.11 stack. Managed and access point modes order your kernel to enable certain codepaths responding to clearly defined roles. Again, acknowledging the complexity of the protocol sheds some light on this: data frames are only one of several types of possible frames, all playing some role in collision avoidance, client authentication, and more. I count about 25 different types in my copy of the (2016-revised) base 802.11 standard [3], without worrying about encryption methods (WEP, WPA, WPA-2…) or cipher suites (TKIP, CCMP…).
A mindful note for the curious: the monitor mode is essential to traffic analysis and understanding, but its availability depends on several factors (yet another reason to want an emulated adapter :)). As explained in the Wireshark docs:
Unfortunately, changing the 802.11 capture modes is very platform/network adapter/driver/libpcap dependent, and might not be possible at all (Windows is very limited here).
Just as ip acts on interfaces at the IP level, we use iw for WLAN ones. This handy command-line tool allows us to list, inspect, and configure wiphys and their associated virtual interfaces. Here is its output for me:
# Note: you may need to have elevated privileges to use iw
# Some distributions, like Debian, put it in /sbin/
$> iw dev
phy#0
Interface wlp1s0
ifindex 3
wdev 0x1
addr 34:f3:9a:eb:4a:38
type managed
We can see I have a single wiphy, with one interface on top: wlp1s0. It is also in managed mode, and is not currently active. Notice the generic ifindex that Linux uses for pretty much all network interfaces, needed whenever interacting through the socket API.
I can switch to monitor mode on channel 11 (2,4Ghz band) like this:
#> ip link set dev wlp1s0 down
#> iw dev wlp1s0 set type monitor
# If your WLAN adapter does not fully support monitor mode, which is
# pretty common for embedded cards in laptops, this may not work.
#> iw dev wlp1s0 set channel 11
#> ip link set dev wlp1s0 up
You might have noticed wlp1sOis put under phy#0. This is the underlying wiphy device; typing iw phy phy0 info (omit the ‘#’) tells me a lot of information on what this card can actually do. (Output cut for clarity)
For instance, we observe the complete list of WLAN « modes » available; and there’s quite a few !
We can also see my WLAN adapter can listen to all possible 14 channels defined for the 2,4GHz band, even though some might not be useable depending on the country I’m currently in (if you’re wondering why, may I suggest taking a look at [2]).
Finally, I included part of the « Supported commands » paragraph, to introduce how the kernel « talks » to these interfaces. All these commands are linked to the nl80211 module, which forms the « upper » part of the 802.11 stack, and exposes an actual API for devices. As you might guess, this is also what a tool like iw uses to grab all its data. When I typed iw dev wlp1s0 set channel 11, I really invoked the set_channel command with the appropriate frequency.
Manipulating WLAN devices: high-level tools
Of course, nobody wants to connect to her home Wi-Fi by launching a few commands in a shell. Network managers, equipped with GUI, are there to fill the need for user-friendly tooling. Those vary with the distro, as all tools do in the Linux world, but we can mainly cite conman and NetworkManager.
As backends, however, there are terribly few tools: wpa_supplicant if you want to connect, and hostapd if you want to be an access point. The reason ? Complexity again. Linux does not actually try to implement everything in kernel, because it would be too big, too difficult to maintain, and most likely out of date at the moment it will be shipped. Linux offers crypto and keys storage subsystems, so that companies wishing to implement complex corporate networks (Wikipedia counts more than a dozen EAP methods alone [5]) can ship customized versions of wpa_supplicant (and perhaps hostapd) precisely for their use case. In fact, that is the way those tools evolve in Linux, as Marcel Holtmann describes very well in his 2018 talk at ELCE [6], which both enumerates issues with Linux tooling, and why he is hacking away new ones with the help of Intel colleagues.
The 802.11 stack
The Linux 802.11 stack is split into three parts, each one implemented with a different module in the kernel. We shall study what are the responsibilities of each of them. Device drivers follow, of course, and tie the knots by implementing whatever APIs the base stack imposes.
Let us not forget that hardware offloading is common, and this trend is even rising. Offloading is supported for a number of operations, even though we will not touch this subject; after all, we’re about to see a full software device.
The stack from afar
Let us keep in mind the different aspects of WLAN communication we have to juggle with. The three parts of the stack tackle those problems.
First, sending and receiving data; the end need. This is the job of « data frames ».
802.11 is a protocol that offers encrypting data frames; for this, an authentication mecanism framework is built into the standard, and special frames named « management frames » are dedicated to this job. From the moment a client is searching for an access point to the moment it actually starts sending data to one, this preliminary work has to be done.
As for all wireless mediums, 802.11 has to offer sufficient collision handling so that communications on the same channel are not complete chaos; this is the responsibility of « control frames ». In this case, 802.11 uses collision avoidance with time windows for « talking ».
Reconfiguring our adapter at runtime, the simplest example being changing channel.
Finally, the userland should know about WLAN events, like losing connection to the BSS because the access point is too far.
Overview of the 802.11 stack in Linux
Now here are the three 802.11 stack modules, and what they do:
mac80211: This is the lower layer, and the most associated to hardware offloading. In fact, by definition, whatever code needed here is not provided by the hardware, or is preferred in software to give better control for developers. In short, the 802.11 protocol state machine lives here, for every type of frame mentioned earlier. Also called the « Soft MAC » module, as opposed to « Hard MAC » (i.e. the device’s firmware does it all). Not surprisingly, reality is not black and white: a compromise is often used.
cfg80211: This middle-layer handles everything configurable for your WLAN adapters; contrary to mac80211, it is mandatory for drivers to interface with it. The « current channel » earlier example is maintained here. The regulatory domain is also implemented here, which is a good example on how this module really acts as a bridge between the kernel and userspace. However, it is not cfg80211 that defines the command set seen earlier through iw phy; this is where the last module comes in.
nl80211: The API between user-land and kernel-land. Relies on the netlink protocol to exchange messages between the two worlds; basically a front-end for cfg80211. This will also allow to generate appropriate « events »: messages that consumers will have to listen to. Netlink is a Linux-specific socket type used for events needing to cross the kernel-user border. A well-known example of its use is the Udev daemon, which is the program that manages device hotplug and relays hardware events; for instance, telling your file explorer that an USB key has just been inserted. As nl80211 constitutes « just » an interface, it is mostly about packaging data into appropriate formats and tunneling them down or up. We’re not going to discuss it much further.
mac80211
Let’s start with the bottom: mac80211. As mentionned just before, the WLAN driver writer that wants to handle some logic in software can use this module’s interface; we find it in net/mac80211.h.
The header file is huge, but we do not need to look at it all; 802.11 is a family of standards, with a base and a multitude of extensions. For example, the quality of service is an extension (802.11e) that aims at prioritizing some data over data, using « tags » that describe its nature and thus, its latency sensitivity (voice and video come to mind as demanding). I call to your attention on a number of structures seen here, that we will find central to the implementation of mac80211_hwsim.
struct ieee80211_vif is the « virtual » interface configuration, one per interface, that we mentionned earlier. As expected, it is passed as a parameter to a whole lot of functions. Here is a simplified version, that goes to the point.
struct ieee80211_vif {
// "Type" == "Mode": monitor, managed...
enum nl80211_iftype type;
// The dynamic configuration of the access point we're
// currently connected to: authentication status, association
// status, capabilities, beacon interval...
struct ieee80211_bss_conf bss_conf;
// Channel context: what central frequency am I on ? What is
// the channel's width ? WLAN allows several ones, from 20MHz
// to 160 MHz...
struct ieee80211_chanctx_conf __rcu *chanctx_conf;
// A debugfs entry, if fancied by the driver author
#ifdef CONFIG_MAC80211_DEBUGFS
struct dentry *debugfs_dir;
#endif
};
struct ieee80211_sta is a station we are talking to; it evokes the struct ieee80211_bss_conf (storing the configuration about some other node), except it’s about a regular « client » node, not an access point. Recall we have to negotiate systematically « how » to talk with any node: what bitrate to use, with which capabilities; if it supports sending A-MSDU frames (akin to « Jumbo » frames in the Ethernet world); etc.
struct ieee80211_hw is the low-level representation of a physical device. It is, in fact, linked to a struct wiphy device, and both complete each other. We’re more interested in practical HW limitations here, like bitrate, number of transmit queues, etc. Again, most of the fields have been omitted for clarity.
struct ieee80211_hw {
struct wiphy *wiphy;
unsigned long flags[BITS_TO_LONGS(NUM_IEEE80211_HW_FLAGS)];
// Number of hardware transmit queues
u16 queues;
// The number of rates we can try out when negotiating with
// a peer station, and how many retries we tolerate
u8 max_rates;
u8 max_rate_tries;
// On ciphering data frames: TKIP, CCMP...recall other Linux
// subsytems will do the bulk of the job, however
u8 n_cipher_schemes;
const struct ieee80211_cipher_scheme *cipher_schemes;
// Maximum Transfer Unit
u32 max_mtu;
};
Most importantly, the struct ieee80211_ops defines the actual API any soft MAC driver can implement here, at the mac80211 level. If you ever wrote down a simple character mode Linux device driver, this is the same logic as the struct file_operations object. It is quite enormous; in Linux 5.6, I listed 102 functions. Of course, here we take note of the most basic ones, just to get a feel for them:
One question might still remain: why would anyone want to do work in mac80211, if it can be done in hardware ? Let us be more explicit about the advantages:
Written-once, quality code maintainted in Linux
An interface reworked over time to fit everyone’s usecases
Good debugging support through sysfs, debugs, possibly ftrace
Support in hardware does not necessarily mean faster code; the bottleneck remains wireless issues (i.e. collisions and tx/rx problems) and antenna access
cfg80211
We arrive at the bridge between the device and userspace. If the job of mac80211 is to allow listing all capabilities of the hardware and interacting with it, cfg80211 will keep track of the actual state your WLAN adapter is currently in.
But there’s more: some concepts are made more user-friendly here, like the struct ieee80211_supported_band that could allow one to define in one object the whole range of 2,4GHz channels available with his device. This looks easier than reasoning purely with frequencies and channel width. It is also here that regulatory domains are defined, a concept that is absolutely not part of the 802.11 standard but imposed by telecommunications rules across the globe. Finally, note that it is this layer that really makes a distinction between concurrent virtual interfaces, as will be illustrated by some of the important objects below.
Remember struct wiphy ? Well, it is actually defined here ! This « high-level » companion to struct ieee80211_hw is used almost everywhere in the functions composing the cfg80211 API. In short, it looks like this:
struct wiphy {
// A wiphy can have one "official" MAC address, but the
// virtual addresses have their own and in practice, have
// the last word. An address mask allows to generate a number
// of addresses for new interfaces, however:
// wlp1s0 - ca:fe:ca:fe:00:01
// wlp2s0 - ca:fe:ca:fe:00:02
// ...
u8 perm_addr[ETH_ALEN];
u8 addr_mask[ETH_ALEN];
struct mac_address *addresses;
// Supported interface modes (mask)
u16 interface_modes;
// There is always some firmware blob creeping in, might as
// well acknowledge it
char fw_version[ETHTOOL_FWVERS_LEN];
u32 hw_version;
u32 available_antennas_tx;
u32 available_antennas_rx;
// Our "per-band list of channels" array: 2,4GHz, 5GHz...
struct ieee80211_supported_band *bands[NUM_NL80211_BANDS];
/* dir in debugfs: ieee80211/<wiphyname> */
struct dentry *debugfsdir;
};
At the beginning of the article, I told you iw showed you wiphy and ieee80211_vif objects. I lied… It does manipulate wiphy, but the more generic struct net_device objects is actually used instead of ieee80211_vif; though it might seem a bit confusing at first. We will find it almost everywhere in the set of functions below.
And here is the API (I count 112 functions here) in struct cfg80211_ops; as usual, shortened for clarity. The most observant might start to recognize some of the commands that iw offers…
struct cfg80211_ops {
// Will be called by: iw dev phy0 add wlp1s0 type managed
struct wireless_dev * (*add_virtual_intf)(struct wiphy *wiphy,
const char *name,
unsigned char name_assign_type,
enum nl80211_iftype type,
struct vif_params *params);
// Will be called by: iw dev wlp1s0 del
int (*del_virtual_intf)(struct wiphy *wiphy,
struct wireless_dev *wdev);
// Might by called by a plethora of commands, including
// those changing the type/mode
int (*change_virtual_intf)(struct wiphy *wiphy,
struct net_device *dev,
enum nl80211_iftype type,
struct vif_params *params);
// We also find functions clearly oriented towards making
// us an access point on our own
int (*start_ap)(struct wiphy *wiphy, struct net_device *dev,
struct cfg80211_ap_settings *settings);
int (*change_beacon)(struct wiphy *wiphy, struct net_device *dev,
struct cfg80211_beacon_data *info);
int (*stop_ap)(struct wiphy *wiphy, struct net_device *dev);
// Steps to the authentication mecanism of 802.11; challenges
// and extra handshakes might happen inbetween
int (*auth)(struct wiphy *wiphy, struct net_device *dev,
struct cfg80211_auth_request *req);
int (*assoc)(struct wiphy *wiphy, struct net_device *dev,
struct cfg80211_assoc_request *req);
int (*connect)(struct wiphy *wiphy, struct net_device *dev,
struct cfg80211_connect_params *sme);
};
As noted earlier, delving into nl80211 would not be that interesting as it is mostly reformatting what we saw in cfg80211.
In part II, we’ll dive into mac80211_hwsim and see how it uses these APIs to implement a simple, virtual WLAN adapter. We’ll show practical examples, from iw to the driver, and see how it fits together.
À en croire les forums de différentes distributions GNU/Linux ; le Wi-Fi sous Linux est lent, difficile à configurer, la documentation est pauvre et globalement il est bien trop compliqué à utiliser. Si une si grande partie de la communauté tend à avoir des difficultés avec le Wi-Fi c’est que ces reproches sont en partie fondés. Une des cibles de ceux-ci est wpa_supplicant : le logiciel userspace historique permettant de connecter son PC en temps que client à un Access Point Wi-Fi.
Conscient des ces lacunes, Intel a pris la décision de développer une nouvelle solution nommée iwd (pour iNet wireless daemon) qui viendrait remplacer et pallier les lacunes de wpa_supplicant.
Le but de cet article est en premier lieu de démystifier wpa_supplicant. Nous y évoquerons son principe de fonctionnement, sa compilation et sa configuration. De la même manière iwd sera également présenté, il sera alors l’occasion de faire un comparatif des forces en présence.
Dès le début des années 2000 le mécanisme de sécurité d’origine des normes IEEE 802.11, l’algorithme WEP (Wired Equivalent Privacy) s’est avéré insuffisant pour la plupart réseaux. La Wi-Fi alliance a donc été contrainte de mettre à jour ses normes pour corriger les failles de sécurité existantes.
Cela a conduit à la création d’un nouveau mécanisme de sécurisation des réseaux Wi-Fi : le Wi-Fi Protected Access (WPA). WPA utilise un protocole de communication appelé TKIP (Temporal Key Integrity Protocol) dont l’idée est de chiffrer chaque paquet avec une nouvelle clé plutôt qu’avec une clé constante ou partiellement constante.
Les clés peuvent être gérées à l’aide de deux mécanismes différents. WPA peut soit utiliser :
un serveur d’authentification externe comme RADIUS (WPA-Enterprise) et utiliser EAP comme protocole d’authentification
des clés pré-partagées sans avoir besoin de les serveurs externes (WPA-PSK)
Les deux mécanismes généreront une clé de session à usage unique pour l’Authenticator (AP) et le Supplicant (client).
C’est à la suite de ces évolutions de normes que wpa_supplicant a été développé. En résumé celui-ci est une implémentation du composant « WPA Supplicant », c’est-à-dire la partie qui s’exécute dans les stations clientes. Il implémente la négociation de clé avec un authentificateur (AP), le roaming et l’authentification/association du pilote wlan.
Compilation, intégration et utilisation
Compilation
Les normes Wi-Fi ne cessent d’évoluer et de la même manière le code de wpa_supplicant n’a cessé de s’étoffer. Avec environ 400 000 lignes de code on peut considérer wpa_supplicant comme un projet imposant au premier abord.
Heureusement tout comme le kernel la compilation de wpa_supplicant est modulaire et l’utilisateur pourra choisir de compiler uniquement les composants qui lui semblent nécessaires/supportés par son driver. Cela passe par un fichier de configuration « .config » qui permet de customiser wpa_supplicant de façon assez fine : des options de debug, support de certains cipher ou non, support de dbus, support de certaines bandes de fréquences ou non etc..
Note : attention, même si un fichier .config existe, Kbuild/Kconfig n’est pas utilisé par wpa_supplicant ce qui peut sembler assez déroutant. La description des options n’est donc pas faite dans un fichier kconfig mais directement dans le .config. Celles-ci sont alors utilisées par un gros Makefile (non généré par un système de construction de Makefile comme autotools/cmake).
La compilation de wpa_supplicant mène à la production de plusieurs binaires dont :
wpa_supplicant : le démon
wpa_cli : un exemple de client permettant de piloter wpa_supplicant via dbus ou un interface de contrôle (socket unix)
libwpa_client.so : une librairie partagée permettant d’écrire sa propre version de client pour piloter wpa_supplicant
Vous pouvez le cloner ici :
git clone git://w1.fi/srv/git/hostap.git
Vous l’aurez sans doute remarqué : le répertoire ne s’appelle pas « wpa_supplicant » mais hostap. Cela est certainement lié au driver Linux « hostap » maintenant obsolète et développé par Jouni Malinen également développeur de wpa_supplicant. Le répertoire contient aussi les sources d’hostapd, le démon permettant d’utiliser une interface wlan en Access Point Wi-Fi. Une bonne partie du code est commun avec wpa_supplicant et l’utilisation est semblable.
Intégration
Wpa_supplicant et bien évidemment disponible sur Buildroot et Yocto mais il est à noter que les recettes Yocto de wpa_supplicant / hostapd sont séparées dans Poky et meta-openembedded. Celles-ci permettent d’intégrer directement un fichier de configuration de service systemd si votre distribution en dépend (wpa_supplicant intègre également des scripts de démarrage pour d’autres systèmes d’init comme sysVinit ou openRC).
Note : Avec le système de .config de wpa_supplicant / hostpad il n’est pas possible de choisir quelles options de wpa_supplicant compiler en passant une option de compilation au Makefile. Il faut nécessairement aller modifier/remplacer le .config dans le répertoire où les sources sont décompressées.
Utilisation
Wpa_supplicant est un démon qui peut fonctionner sans action du userspace à condition qu’un fichier de configuration soit fourni. Il peut également être piloté par un client, dans ce cas il utilise une boucle événementielle pour recevoir /envoyer des signaux au userspace via dbus ou un socket unix. Cette boucle permet également de recevoir des notifications du kernel (via une interface générique).
Dans le cas des distributions utilisant NetworkManager wpa_supplicant utilise dbus comme le montre la configuration du service systemd :
Dans le cas d’une distribution qui n’utilise pas dbus, wpa_supplicant peut alors utiliser un socket unix standard et doit être lancé à minima avec les options suivantes -C <path> sur le filesystem de l’interface de contrôle, -i <interface> sur laquelle écouter. Par exemple :
Les opérations du démon wpa_supplicant sont contrôlables depuis cette interface de contrôle qui peut être utilisée par des programmes externes. Wpa_cli est un exemple de client qui utilise cette interface de contrôle pour configurer wpa_supplicant.
Il peut être appelé en mode interactif ou non, pour déclencher un scan par exemple :
sudo wpa_cli -i wlan0 scan
Note : Le sudo est uniquement nécessaire ici pour l’accès au socket si wpa_supplicant est lancé par l’utilisateur root. Wpa_cli n’a besoin sinon, d’aucune capability particulière.
Ce qui doit provoquer la réception d’un événement informant le succès de la connexion :
<3>CTRL-EVENT-CONNECTED - Connection to 30:7c:b3:5a:c4:q4 completed [id=0 id_str=]
Mini-TP : création d’un « dummy client »
Une librairie partagée peut être compilée pour le développement d’un client custom. C’est ce que je vous propose avec ce mini TP, création d’un « Dummy » client qui va demander à wpa_supplicant de scanner en boucle et qui va lister tous les événements envoyés par wpa_supplicant au user space. Le code de wpa_cli fait figure d’exemple au développement d’un client custom.
On clone pour cela le repo et compile la librairie partagée :
git clone git://w1.fi/srv/git/hostap.git
cd hostap/wpa_supplicant/ && make libwpa_client.so
mkdir ~/test_wpa
cp libwpa_client.so ../src/common/wpa_ctrl.h ~/test_wpa
Les principales étapes de création d’un client de wpa_supplicant sont les suivantes :
1 ) Ouverture de l’interface de contrôle :
La commande wpa_ctrl_open() crée une structure wpa_ctrl qui permet d’ouvrir une connexion à l’interface de contrôle afin d’envoyer et de recevoir le résultat des commandes.
Ici on créé une première instance que nous allons utiliser pour que wpa_supplicant demande au driver de lancer un scan.
std::string itfName = "/var/run/wpa_supplicant/wlan0";
struct wpa_ctrl * commandCtrl = nullptr;
// Open a control interface to wpa_supplicant/hostapd used to send commands.
commandCtrl = wpa_ctrl_open(itfName.c_str());
if(commandCtrl == nullptr)
{
return 1;
}
Wpa_supplicant envoie régulièrement des notifications via l’interface de contrôle pour prévenir le userspace de différents types d’événements comme par exemple le lancement d’un scan, la connexion et la déconnexon à un Access Point.
Même si ce n’est pas obligatoire, il est donc plus simple d’ouvrir une deuxième connexion utilisée pour récupérer les notifications de wpa_supplicant. Cela permet d’éviter que l’attente d’une réponse par la première connexion soit interrompue par une notification.
struct wpa_ctrl * eventCtrl = nullptr;
//Open a control interface to wpa_supplicant/hostapd used to receive notif.
eventCtrl = wpa_ctrl_open(itfName.c_str());
if(eventCtrl == nullptr)
{
return 1;
}
2) Fonction d’envoi des requêtes de scan à wpa_supplicant :
La fonction qui permet d’envoyer des requêtes à wpa_supplicant est wpa_ctrl_request, c’est cette commande qui permet notamment d’ajouter un nouveau réseau et de le configurer en ajoutant par exemple son ssid, bssid, psk, type de sécurité, etc. Ici nous l’utilisons pour envoyer un scan.
Ici on vérifie si des messages reçus sont en attente ou non avec wpa_ctrl_pending, si c’est le cas la commande wpa_ctrl_recv permet de récupérer le message.
// Register as an event monitor for the control interface.
if(wpa_ctrl_attach(eventCtrl) != 0)
{
return false;
}
while(1)
{
//check whether there are any pending control interface message available to be received
if(wpa_ctrl_pending(eventCtrl) == 0)
{
std::this_thread::sleep_for (std::chrono::seconds(1));
continue;
}
char reply[256];
size_t replyLen = 255;
// Receive a pending control interface message.
if(wpa_ctrl_recv(eventCtrl, reply, &replyLen) != 0)
{
std::this_thread::sleep_for (std::chrono::seconds(1));
continue;
}
std::string replyStr(reply, replyLen);
std::cout << "Event received " << replyStr << "\n";
std::this_thread::sleep_for (std::chrono::seconds(1));
}
Après un peu de mise en forme, on peut alors compiler et tester notre programme :
On remarque que lorsqu’un scan envoyé, un événement informant la bonne réception de la demande est reçue : CTRL-EVENT-SCAN-STARTED.
Le bssid des réseaux scannés est alors retourné via l’événement CTRL-EVENT-BSS-ADDED. En quelques centaines de lignes de code il est alors possible de réécrire votre propre version de wpa_cli qui va se charger de configurer wpa_supplicant.
Limites
Même si il est relativement aisé de créer son propre client, wpa_supplicant est un logiciel qui souffre d’un manque de documentation. Les fonctions les plus utilisées que je vous ai décrites dans le mini TP sont documentées sur la « Developers’s documentation for wpa_supplicant and hostapd » https://w1.fi/wpa_supplicant/devel/ctrl_iface_page.html . Néanmoins certaines fonctions moins courantes ou certains événements, ou paramètres sont partiellement – voire non – documentés.
Une autre limite de wpa_supplicant est que sans frontend de type Network Manager ou Connman la configuration peut paraître complexe pour un utilisateur encore non initié. Certains forums présentent heureusement un guide partiel d’utilisation de wpa_cli.
La limite la plus importante de wpa_supplicant est sa dépendance avec des logiciels tiers. Dans le cadre d’une distribution desktop classique l’UI de Network Manager va se charger de récupérer la configuration des réseaux connus, va appeler wpa_supplicant qui va appeler des librairies de cryptographies comme GnuTls ou Openssl, puis Network Manager va devoir appeler systemd-resolved ainsi que dhclient qui peuvent tout deux envoyer des requêtes au driver wlan en même temps que wpa_supplicant. Cela peut provoquer des problèmes de performances dont des baisses de débit, ou des déconnexions.
Et c’est justement pour éviter tous ces problèmes qu’iwd a été développé.
IWD
Iwd (iNet wireless daemon) est un wireless démon pour Linux écrit par Intel et qui vise à remplacer wpa_supplicant. L’objectif principal du projet est d’optimiser l’utilisation des ressources en ne dépendant d’aucune bibliothèque externe à part dbus et en utilisant au maximum les fonctionnalités fournies par le noyau Linux. Iwd mémorise les paramètres, ce sans l’aide de Network Manager ou équivalent. Un des buts est donc de centraliser au maximum tout ce dont le Wi-Fi a besoin au sein d’iwd.
Par exemple iwd supporte la configuration des interfaces Wi-Fi en standalone. Le but ici est de se passer de :
systemd-networkd/netplan/networking
dhclient
systemd-resolved/resolvconf
Un autre exemple est la configuration d’un réseau entreprise. Pour configurer un réseau entreprise sous Linux il faut que l’utilisateur fournisse un certificat CA, un certificat utilisateur ainsi que sa clé privée et son identifiant (dans le cas EAP-TLS) :
UI de l’applet de Network Manager pour la configuration d’un réseau entreprise
L’idée d’iwd est de tout centraliser au sein d’un seul et unique fichier qui pourrait être importé en un clic. L’utilisateur n’a donc plus qu’à demander à son administrateur réseau de lui envoyer le fichier qu’il peut alors importer simplement (un peu de la même manière qu’un fichier ovpn pour la configuration d’un client openvpn). Toute la configuration dont le certificat CA, le certificat utilisateur et sa clé privée est alors formatée dans un seul fichier au format standard.
Par ailleurs, la décision de ne pas se soucier de fonctionner sur des distributions non Linux a été prise. Dbus est utilisé pour la communication et les interfaces cryptographiques du noyau sont utilisées pour la fonctionnalité de chiffrement.
Compilation et utilisation
Compilation
Avant toute chose, pour que iwd puisse être exécuté sur votre système certaines options de compilation de votre noyau sont nécessaires :
De la même manière que wpa_supplicant, la compilation iwd génère plusieurs binaires : le démon iwd, le client iwctl et également iwmon, un utilitaire qui permet de lire des fichiers .pcap..
Vu que iwd est bien plus minimaliste que wpa_supplicant, il n’a pas à être configuré comme wpa_supplicant via un fichier .config. La plupart des options de compilation possibles concernent uniquement la compilation et l’installation d’outils tiers, de fichier de config de systemd ou l’utilisation de iwmon.
Note : Comme tout bon projet iwd utilise un générateur de Makefile, ici autotools. La compilation du projet est claire et sans bavure, la structure du projet est simple et une recette Yocto est déjà présente dans meta-openembedded. Néanmoins, il manque la ligne suivante dans le recette de Yocto Warrior (corrigé depuis sur Dunfell) :
RDEPENDS += "dbus"
Utilisation
La configuration d’iwd en standalone se veut bien plus simple que celle avec wpa_cli et c’est le cas. Iwctl ne présente qu’une dizaine de commandes (plus d’une centaine pour wpa_cli), la configuration et la selection d’un réseau est extrêmement simplifiée. Par exemple :
iwctl
>> station wlan0 connect Livebox-titi
Permet de se connecter à la Livebox-titi (il faut 4 commandes pour wpa_cli) et le psk est demandé de manière interactive.
La connexion est d’ailleurs très rapide et c’est dû notamment à l’optimisation du scan faite par iwd. En effet, avant une connexion, plutôt que de scanner tous les Access Points sur l’ensemble des bandes de fréquences Wi-Fi, iwd va uniquement scanner le channel utilisé lors de la précédente connexion.
On remarquera néanmoins que certaines options de connexion ne sont pas possibles via iwctl. Par exemple la connexion à une bande de fréquence particulière, la priorité d’un réseau, l’utilisation de PMF (Protection Management Frame) ou la configuration d’un réseau entreprise passent obligatoirement par des fichiers de configurations.
Un fichier de configuration global main.conf est présent dans /etc/iwd et les fichiers de configuration spécifiques aux réseaux sont présents dans /var/lib/iwd . Fort heureusement iwd met à disposition un grand nombre d’exemples de fichiers de configuration dans son répertoire de test iwd/autotests.
Une option très intéressante configurable depuis une fichier de configuration est le paramètre « EnableNetworkConfiguration »
Celle ci permet de remplacer systemd-networkd/networking/netplan en assignant les paramètres de l’interface directement dans le fichier de configuration de l’Access Point.
Une fois systemd-networkd désactivé, ces paramètres vont être pris en compte lors de la connexion. Cela peut être extrêmement pratique dans le cadre d’un système embarqué qui ne possède que des interfaces wifi. Il sera alors possible de laisser iwd « se charger de tout ».
Limites
De ce que j’ai pu en tester iwd tient clairement ses promesses : la configuration est simplifiée, le logiciel est bien plus performant que wpa_supplicant. Néanmoins il est clair que le public visé par Intel est le desktop Linux. Même si il permet une solution ultra minimaliste, la dépendance obligatoire à dbus pourrait être un facteur limitant.
Un autre facteur limitant est le fait qu’il ne soit pas aussi facile d’écrire son propre client avec iwd. Même si c’est un cas assez spécifique, si vous travaillez sur une gateway par exemple et que vous devez développer votre propre version de NetworkManager il sera alors obligatoire de développer votre propre client wpa. Bien que cela soit possible en prenant l’exemple d’iwctl la documentation manque probablement de quelques exemples triviaux.
Conclusion
Encore utilisé de base sur la plupart des distributions, il est clair que wpa_supplicant va bientôt céder sa place à iwd après plus de 15 ans de bons et loyaux services. Plus performant et plus facilement configurable, iwd a pour vocation non seulement d’améliorer le Wi-Fi sous Linux mais également toute la stack réseau.
Même si la dépendance à dbus peut être un frein, un projet de iwd sans cette dépendance est en cours de développement : https://github.com/dylanaraps/eiwd . Iwd et iwmon sont parfaitement fonctionnels mais iwd ne peut pas être configuré via iwctl, la configuration passe alors obligatoirement par ses fichiers de configuration.
Le DNS pour Domain Name System, est un protocole ancien qui permet de retrouver une adresse d’un serveur depuis son nom. Il est pour la première fois décrit dans les RFC1034, RFC1035 et RFC2782, en 1987. Ce protocole est basé sur un serveur qui répond sur des requêtes envoyées sur le port 53 du protocole UDP (et TCP si il est impossible d’avoir de réponse sur le premier). Comme ce protocole doit s’appliquer sur le réseau internet, il utilise des adresses Unicast.
Le protocole mDNS est une version du même protocole mais en Multicast sur l’adresse 224.0.0.251 et le port 5353. Il permet à ce que tous les appareils sur un réseau puissent faire le lien entre le nom et l’adresse d’une autre machine, sans connaître le serveur DNS. En fait n’importe quelle machine voire plusieurs machines peuvent faire office de serveur DNS. La principale limitation est son usage exclusif aux réseaux locaux. Nous n’aborderons pas l’aspect sécurité du réseau dans cette article, mais nous rappelons qu’un mauvais usage des protocoles sur une adresse Multicast peut avoir des répercussions importantes sur le réseau concerné.
Accolé à ce protocole, le DNS-SD pour DNS Based Service Discovery, permet d’ajouter des informations sur les services disponibles sur chacune des machines présentes sur le réseau. Ce protocole est défini par la RFC6763 qui date de 2013. Il se base sur le protocole précédent en ajoutant un usage spécifique des champs disponibles dans le paquet de données DNS.
Le protocole DNS-SD permet ainsi de déclarer un service, tel que la présence d’un serveur d’impression ou de page web sur une machine, mais aussi de demander les machines ayant de tels services.
Présentation du protocole DNS-SD :
Un paquet DNS est défini comme suit :
C’est donc 4 listes d’enregistrements ayant tous la même structure :
qn : pour les questions, sont les requêtes envoyées par une machine ;
ans : pour les réponses à des questions reçues ;
auth : pour la gestion des autorités de résolution (non abordé car cela correspond au DNS) ;
add : pour les informations additionnelles aux réponses.
Une des forces du protocole est de ne pas redonder les informations. Ainsi chaque QName n’est copié qu’une seule fois et c’est une référence sur une partie du message, codée sur 16 bits qui est placée en lieu et place de la chaîne de caractères.
Chaque enregistrement (record) peut être de différents types :
A : pour une adresse IPv4 ;
PTR : pour un message nommé dans ce message (très souvent un message vide) ;
TXT : pour une liste de chaînes de caractères (attention nous disons bien une liste de chaînes) ;
AAAA : pour une adresse IPv6 ;
SRV : pour les informations d’un service ;
NSEC : pour le transfert de clé de sécurité ;
D’autres types de messages existent comme MX, SOA, CNAME, HINFO… mais ne seront pas abordés ici.
À chaque question, un répondeur doit retourner la question et une liste de réponses. A chaque réponse il est possible d’ajouter des informations additionnelles attachées à celle-ci. Un cas particulier est l’annonce, celle-ci est envoyée au démarrage pour avertir les autres utilisateurs de sa présence avant même qu’une demande soit faite. Elle ne contient donc pas de question mais seulement des réponses.
Frame 41: 236 bytes on wire (1888 bits), 236 bytes captured (1888 bits) on interface 0 Ethernet II, Src: Dell_18:c4:00 (c8:f7:50:18:c4:00), Dst: IPv4mcast_fb (01:00:5e:00:00:fb) Internet Protocol Version 4, Src: 192.168.1.19, Dst: 224.0.0.251 User Datagram Protocol, Src Port: 5353, Dst Port: 5353 Multicast Domain Name System (response) Transaction ID: 0x0000 Flags: 0x8400 Standard query response, No error Questions: 0 Answer RRs: 2 Authority RRs: 0 Additional RRs: 5 Answers _http._tcp.local: type PTR, class IN, mytest._http._tcp.local _services._dns-sd._udp.local: type PTR, class IN, _http._tcp.local Additional records mytest._http._tcp.local: type TXT, class IN, cache flush mytest._http._tcp.local: type SRV, class IN, cache flush, priority 0, weight 0, port 8080, target p-gnb-montagne.local _http._tcp.local: type PTR, class IN, mytest._http._tcp.local p-gnb-montagne.local: type A, class IN, cache flush, addr 192.168.1.19 p-gnb-montagne.local: type NSEC, class IN, cache flush, next domain name p-gnb-montagne.local [Unsolicited: True]
L’annonce indique que :
il y a un service dns-sd de nom « _http._tcp.local. ». Celui-ci a une information additionnelle dans l’emplacement #2.
il y a un service _http nommé « mytest._http._tcp.local. ». Celui-ci a des information additionnelles dans les emplacements #0 et #1.
l’information additionnelle #1 indique que le service « mytest._http._tcp.local. » est disponible sur le serveur « p-gnb-montagne.local. » et le port 8080
l’information additionnelle #0 indique que le service « mytest._http._tcp.local. » est attaché à une donnée textuelle « name=toto ».
l’information additionnelle #3 indique une adresse de résolution pour le nom « p-gnb-montagne.local. » soit 192.168.1.19.
L’intérêt est de pouvoir associer un nombre important d’informations, tout en limitant la taille du message. Ainsi l’annonce ici présente ne fait que 236 octets, alors qu’un autre système de découverte de service comme SSDP (utilisé par UPnP) qui utilise une forme de HTTP, va vite dépasser le double pour une simple annonce.
Les outils
Bonjour
C’est la référence du protocole. Développé par Apple sous licence Apache 2.0, les sources sont disponibles à l’adresse suivante :
La version actuelle date de mai 2020, mais il est difficile de suivre les apports d’une version à une autre.
Construction
Bonjour est aussi disponible sur Linux et tout système Posix. Pour cela il suffit de télécharger l’archive, de la décompresser et de lancer « make » dans le répertoire « mDNSPosix ».
$ wget https://opensource.apple.com/tarballs/mDNSResponder/mDNSResponder-1096.40.7.tar.gz $ tar -xzf mDNSResponder-1096.40.7.tar.gz $ cd mDNSResponder-1096.40.7/mDNSPosix $ make
Hélas sur certains environnements il est nécessaire d’ajouter un patch :
mDNSResponderPosix, mDNSNetMonitor, mDNSClientPosix et mDNSProxyResponderPosix qui permettent un usage indépendant.
libdns_sd.so, libnss_mdns-0.2.so, mdnsd et dns-sd qui est un démon et son(ses) client(s), pour développer un service dans une application.
Il est nécessaire de faire une installation à la main, mais tout cela fonctionne bien, l’ensemble des binaires faisant tout de même plus de 2,5Mo. L’usage de serveur « mdsnd » et de son client ne prend que 800ko et n’a aucune dépendance.
Usage
./mDNSPosix/build/prod/mDNSNetMonitor permet de suivre les services qui sont distribués sur le réseau :
Le serveur « mdnsd », est livré avec un script de démarrage compatible initV. Une fois lancé, l’intégrateur peut soit utiliser la bibliothèque « libdns_sd.so » pour communiquer avec le serveur, soit utiliser simplement le client « dns-sd ».
Bonjour est peu utilisé sous Linux, mais il fonctionne très bien et il est bien maintenu par Apple. Il est possible de lui reprocher sa licence mais il permet une intégration dans un produit sans modification de code.
Avahi
Celui-ci est LE serveur de diffusion de Linux. Démarré par Trent Lloyd et Lennart Poettering en 2004, il est depuis sous la tutelle de freedesktop.org.
La dernière version 0.8 date de février 2020 et se trouve toujours dans le giron de Trent Lloyd :
Cette configuration garde les dépendances à libdaemon, libevent et expat, et offre la possibilité de mettre à jour la résolution des adresses du réseau local, ainsi que la mise en place de services de manière statique. L’ensemble des binaires selon la plateforme n’est que d’environ 1,5 Mo. Et le paquet est compatible avec systemd et initV.
Usage
La résolution des adresses par avahi-dnsconfd est accessible sans aucune modification de l’intégrateur.
C’est la résolution des services qui devra être décrite de manière statique dans les fichiers de configuration. Un service se définit dans le répertoire « /etc/avahi/services » par un fichier xml (d’où l’usage de expat).
Voici un premier exemple pour un partage de fichiers en HTTP
Nous y retrouvons les informations décrites dans notre introduction :
Frame 271771: 140 bytes on wire (1120 bits), 140 bytes captured (1120 bits) on interface 0 Ethernet II, Src: Dell_18:c4:00 (c8:f7:50:18:c4:00), Dst: IPv4mcast_fb (01:00:5e:00:00:fb) Internet Protocol Version 4, Src: 192.168.1.19, Dst: 224.0.0.251 User Datagram Protocol, Src Port: 5353, Dst Port: 5353 Multicast Domain Name System (query) Transaction ID: 0x0000 Flags: 0x0000 Standard query Questions: 1 Answer RRs: 0 Authority RRs: 2 Additional RRs: 0 Queries mytest._http._tcp.local: type ANY, class IN, « QM » question Authoritative nameservers mytest._http._tcp.local: type SRV, class IN, priority 0, weight 0, port 8080, target p-gnb-montagne.local mytest._http._tcp.local: type TXT, class IN [Retransmitted request. Original request in: 271702] [Retransmission: True]
La plupart des outils fournis par avahi dépendent de D-Bus, il n’y a donc pas de possibilité de trouver un service sur le réseau sans écrire une ligne de code.
Avahi propose des librairies permettant de décrire un service ou de trouver un service depuis son application sans passer par D-Bus. Si le fait d’écrire un annonceur de service par ce biais peut gêner le démon, il est aussi possible à notre application de découvrir un service sur le réseau. Le code d’exemple d’avahi « examples/core-browse-services.c » montre son usage pour retrouver tous les services HTTP.
Conclusion
Avahi est la référence sur Linux. Même réduit au strict minimum cet outil est tout de même lourd à embarquer sur un système. Mais la communauté qui l’entoure en fait l’un des systèmes les plus stables au point que Apple serait prêt à remplacer son outil « Bonjour » par celui-ci. Nous ne pouvons que le recommander en industrie si la place le permet.
mDns de OpenBSD
OpenBSD a sa propre implémentation de mDns mais celle-ci dépend de la librairie « util » qui est propre à ce système et incompatible en l’état avec Linux.
Un certain nombre d’utilisateurs existe tout de même. Ceux-ci mettent à jour le produit pour des corrections et des améliorations. Il est entre autre utilisé par « shairport », une implémentation du système « airplay » sous Linux et TizenRT l’OS de Samsung pour ses montres. Sa licence BSD lui permet aussi d’être intégré dans un certain nombre de solutions propriétaires ou non.
Construction
En l’état il est nécessaire de modifier le fichier testmdsnd.c pour ajouter un service.
L’utilisation de l’application tinysvcmdns est très simple :
$ ./testmdnsd mdnsd_start OK. press ENTER to add hostname & service
added service and hostname. press ENTER to exit
On préférera l’utiliser sous la forme d’une librairie pour l’intégrer dans une application.
Conclusion
Hormis sa taille et sa facilité d’intégration dans un produit, son usage est tout de même aléatoire. Il nécessiterait la création d’une nouvelle communauté.
L’effort de Samsung est peu récompensé, mais vous trouverez de nombreux patches importants ici:
mDns est un protocole très intéressant dans un contexte de M2M, il permet l’intégration de machines dans un réseau hétéroclite pour un moindre coût.
Certains inconvénients subsistent :
Il n’est pas le seul protocole d’exposition de services qui exist (SSDP, SLP, WS-Discovery et plus). Cet hétérogénéité ralentit l’adoption d’un standard.
Il est rarement intégré aux services déjà existants. Par exemple, il permettrait un usage simplifié à un protocole comme NFS. Il est donc nécessaire de l’intégrer dans son système indépendamment du service lui-même.
Une liste importante de services est déjà enregistrée par IANA. Celle-ci est disponible sur le site de dns-sd.org:
L’usage des services peut rendre notre maison vraiment connectée sans être contraint à une solution propriétaire. Apple a ouvert la voie à mDNS et DNS-SD. UPnP qui était en temps très en pointe dans les objets connectés des années 2000, a perdu de son usage pour des raisons de sécurité, entre autres.