Solutions Temps Reel Sous Linux Ed2 v1
Solutions Temps Reel Sous Linux Ed2 v1
Solutions Temps Reel Sous Linux Ed2 v1
C. Blaess
Solutions temps réel 2e édition
temps réel
sous Linux 2e édition
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
C. Blaess
Avec 50 exercices corrigés Expert reconnu de Linux
dans l’industrie, Christophe
sous Linux
Comprendre le fonctionnement de l’ordonnanceur et du noyau Blaess conçoit et met
en œuvre des systèmes
Pour concevoir un système équilibré, stable et réactif aux événements externes, il est indispensable de bien embarqués industriels,
comprendre le rôle et l’organisation de ses divers composants. C’est l’un des premiers buts de ce livre, qui détaille et propose au sein de la
et commente les interactions, les activations et les commutations des tâches. De très nombreux exemples illustrant société Logilin qu’il a créée
le propos permettront au lecteur de réaliser ses propres expériences sur son poste Linux. en 2004 des prestations
d’ingénierie et de conseil
Bâtir un système temps réel sous contraintes temporelles fortes dans différents domaines
Pour construire une application temps réel sous Linux, l’architecte logiciel doit choisir entre différentes solutions, un liés à Linux. Soucieux de
partager ses connaissances
2e édition
choix crucial qui influera sensiblement sur les limites de fonctionnement de son application. Dans cet ouvrage, l’auteur
et son savoir-faire, il dispense
étudie les environnements libres pouvant répondre à des contraintes temporelles plus ou moins fortes et propose également des formations
des outils pour valider le comportement des tâches face à des charges logicielles ou interruptives importantes.
sous Linux
– Aux développeurs, architectes logiciels et ingénieurs devant mettre en œuvre des applications temps réel sous Linux
Christophe Blaess
– Aux décideurs et industriels souhaitant installer un système temps réel sous Linux
– Aux étudiants en informatique
@
ISBN : 978-2-212-14208-2
35 €
C. Blaess
Solutions temps réel 2e édition
temps réel
sous Linux 2e édition
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
C. Blaess
Avec 50 exercices corrigés Expert reconnu de Linux
dans l’industrie, Christophe
sous Linux
Comprendre le fonctionnement de l’ordonnanceur et du noyau Blaess conçoit et met
en œuvre des systèmes
Pour concevoir un système équilibré, stable et réactif aux événements externes, il est indispensable de bien embarqués industriels,
comprendre le rôle et l’organisation de ses divers composants. C’est l’un des premiers buts de ce livre, qui détaille et propose au sein de la
et commente les interactions, les activations et les commutations des tâches. De très nombreux exemples illustrant société Logilin qu’il a créée
le propos permettront au lecteur de réaliser ses propres expériences sur son poste Linux. en 2004 des prestations
d’ingénierie et de conseil
Bâtir un système temps réel sous contraintes temporelles fortes dans différents domaines
Pour construire une application temps réel sous Linux, l’architecte logiciel doit choisir entre différentes solutions, un liés à Linux. Soucieux de
partager ses connaissances
2e édition
choix crucial qui influera sensiblement sur les limites de fonctionnement de son application. Dans cet ouvrage, l’auteur
et son savoir-faire, il dispense
étudie les environnements libres pouvant répondre à des contraintes temporelles plus ou moins fortes et propose également des formations
des outils pour valider le comportement des tâches face à des charges logicielles ou interruptives importantes.
sous Linux
– Aux développeurs, architectes logiciels et ingénieurs devant mettre en œuvre des applications temps réel sous Linux
Christophe Blaess
– Aux décideurs et industriels souhaitant installer un système temps réel sous Linux
– Aux étudiants en informatique
@
Sur le site http://christophe.blaess.fr
– Téléchargez le code source des exemples
– Consultez les corrigés des exercices et de nombreux documents complémentaires
– Dialoguez avec l’auteur
G00000_TempsReel-PDT_2e.indd 1
2e édition
sous Linux
temps réel
Solutions
29/10/15 10:44
Dans la collection Les guides de formation Tsoft
J.-F. Bouchaudy. – Linux Administration. Tome 4 : installer et configurer des serveurs Web, mail ou FTP
sous Linux.
N°13790, 2e édition, 2013, 420 pages.
Autres ouvrages
G00000_TempsReel-PDT_2e.indd 2
2e édition
Christophe Blaess
sous Linux
temps réel
Solutions
29/10/15 10:44
ÉDITIONS EYROLLES
61, bd Saint-Germain
75240 Paris Cedex 05
www.editions-eyrolles.com
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
En application de la loi du 11 mars 1957, il est interdit de reproduire intégralement ou partiellement le présent ouvrage,
sur quelque support que ce soit, sans l’autorisation de l’Éditeur ou du Centre Français d’exploitation du droit de copie,
20, rue des Grands Augustins, 75006 Paris.
© Groupe Eyrolles, 2012, 2016, ISBN : 978-2-212-14208-2
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Avant-propos
La mise au point d’un système temps réel, construit sur des solutions logicielles libres peut sem-
bler a priori assez compliquée.
Quel noyau choisir ? Quelle version ? Faut-il ajouter des extensions ? Quelles performances
peut-on espérer en retour ? Quel sera le comportement du système en cas de pics de charge
logicielle ? De charge d’interruption ? Toutes ces questions – qui se poseront à un moment ou un
autre – peuvent décourager l’architecte système face à la multitude d’offres libres ou commer-
ciales se réclamant de « Linux temps réel ».
Dans ce livre, j’ai souhaité aider le concepteur, le chef de projet, le développeur, à bien saisir les
éléments à prendre en considération lors de la mise au point d’un système où les performances
d’ordonnancement et les temps de réponse aux interruptions sont importants. Le propos s’articu-
lera donc sur le fonctionnement et les possibilités temps réel de Linux et de ses extensions libres.
Nous n’aborderons ni les spécificités des distributions Linux (Ubuntu, Debian, Fedora, etc.) qui
encadrent le noyau standard, ni les solutions commerciales (comme Suse Linux Enterprise Real
Time, ou Red Hat Enterprise MRG) construites autour de Linux et de ses extensions temps réel
auxquelles elles apportent essentiellement un support technique et une validation sur des plates-
formes définies.
Dans un premier temps (chapitres 1 et 2), nous examinerons quelques concepts de base concer-
nant le multitâche sous Linux et les interactions entre l’espace utilisateur (dans lequel les
applications s’exécutent) et l’espace kernel (contenant le code du noyau proprement dit).
Pour comprendre les enjeux de l’ordonnancement, nous commencerons par observer dans
les chapitres 3 et 4 les principes et les limites du temps partagé (le type d’ordonnancement
employé par défaut pour toutes les tâches usuelles).
Pour un comportement prédictible, le temps partagé n’est pas satisfaisant, aussi basculerons-
nous aux chapitres 5, 6 et 7 sur un ordonnancement temps réel souple (soft real time). Ces
chapitres seront également l’occasion d’aborder des points importants pour les systèmes temps
réel : préemptibilité du noyau, granularité et précision des timers, inversions de priorités…
Dans le chapitre 8, nous examinerons les limites du temps réel souple de Linux, ainsi que
certaines améliorations possibles, comme l’utilisation du patch PREEMPT_RT. Nous y
étudierons également des outils de mesure des performances sous une charge très élevée en
processus comme en interruptions.
Pour obtenir un comportement temps réel encore plus prédictible, relevant du temps réel strict
(hard real time), nous aborderons aux chapitres 9 et 10 l’utilisation de l’extension Xenomai.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Enfin, le chapitre 11 nous permettra d’écrire du code s’exécutant dans le noyau Linux, avec un
bref aperçu de l’écriture d’un driver et surtout de la gestion des interruptions. Nous découvri-
rons à cette occasion une seconde interface de programmation, nommée RTDM, proposée par
Xenomai.
Lors de la mise au point d’un système temps réel avec Linux, on s’aperçoit d’une très grande
variation des performances en fonction du matériel utilisé. Les fluctuations d’un timer peuvent
facilement évoluer dans un rapport de 1 à 10 entre deux architectures différentes (processeur,
périphériques, bus, contrôleur d’interruptions ), de même que le temps de déclenchement d’une
interruption ou la durée de préemption d’une tâche. Il n’est donc pas possible de fournir des
résultats chiffrés absolus indépendamment de la plate-forme cible envisagée. Toutes les valeurs
que nous observerons dans ce livre seront donc à prendre relativement les unes par rapport aux
autres et non pas comme des valeurs absolues applicables sur une autre architecture.
La plupart des exemples ont été exécutés sur un petit PC portable aux performances plutôt
limitées. On trouvera aussi régulièrement des références à la plate-forme Raspberry Pi 2 : je
trouve en effet que grâce à ses ports d’entrées-sorties très accessibles, cette carte bon marché se
prête particulièrement bien à des expérimentations et à des premiers prototypages (je suis plus
réservé sur son utilisation en tant que plate-forme de déploiement final). J’encourage le lecteur
à s’en procurer un exemplaire (ou une carte dans le même esprit comme les Beaglebone Black,
Banana Pro, CubieBoard, etc.) et à se familiariser avec les GPIO, interruptions, compilation de
noyaux Linux, etc.
J’insiste beaucoup sur la nécessité de réaliser des expériences directement sur la plate-forme
cible prévue (ou un équivalent) et, pour cela, nous allons construire de nombreux outils
de mesure et de vérification. Plus de 60 exemples (disponibles sur mon site web à l’adresse :
http://christophe.blaess.fr) sont présentés dans le livre ; je vous encourage à les télécharger, les
compiler, les tester et me faire part de vos remarques éventuelles. Chaque chapitre se termine
par quelques exercices permettant de mettre en application les concepts abordés. Leur niveau de
difficulté est indiqué par un nombre d’étoiles et leurs corrigés sont disponibles avec les exemples
du livre à l’adresse mentionnée ci-dessus.
J’anime fréquemment des sessions de formation et des séminaires sur les aspects industriels
de Linux (embarqué, temps réel, drivers ) et de nombreux participants m’ont aidé, par leur
motivation et leurs questions, à améliorer mes exemples, à approfondir les expérimentations
et finalement à mieux comprendre les mécanismes internes de Linux et Xenomai. Je les en
remercie chaleureusement.
CHAPITRE 1
Multitâche et commutation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
Multitâche sous Linux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
Création de processus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
Parallélisme multithreads . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
Systèmes multiprocesseurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
Multiprocesseurs, multicœurs et hyperthreading . . . . . . . . . . . . . . . . . . . . . . . 8
Affinité d’une tâche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
États des tâches . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
Ordonnancement . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
Préemption . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
Points clés . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
Exercices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
Exercice 1 (*) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
Exercice 2 (**) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
Exercice 3 (**) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
Exercice 4 (***) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
CHAPITRE 2
CHAPITRE 3
CHAPITRE 4
Limitations
de l’ordonnancement temps partagé . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
Mesure du temps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
Heure Unix avec gettimeofday() . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
Précision des mesures . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
Horloges Posix . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61
Tâches périodiques . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
Timers Unix classiques . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
Timers Posix . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66
Granularité . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68
Précision . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70
Préemption des tâches . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82
Points clés . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
Exercices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
Exercice 1 (*) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
Exercice 2 (**) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
Exercice 3 (**) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
Exercice 4 (***) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
Exercice 5 (***) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 84
CHAPITRE 5
CHAPITRE 6
CHAPITRE 7
CHAPITRE 8
CHAPITRE 9
CHAPITRE 10
CHAPITRE 11
Conclusion
ANNEXEA
ANNEXE B
Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 291
Livres . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 291
Articles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 292
Sites web . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 292
Index . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 295
Blaess.indb 16
22/10/2015 14:15
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
1
Multitâche et commutation
L’ordonnancement sous Linux est avant tout une affaire de gestion des tâches en attente. Sur
un poste de travail courant, il tourne en permanence une bonne centaine de processus, dont
la plupart sont endormis, en attente d’événements extérieurs (actions de l’utilisateur, données
provenant du réseau, etc.), et quelques-uns seulement sont actifs à un moment donné. Dans ce
chapitre, nous allons examiner la représentation et l’état des tâches, ainsi que la notion d’ordon-
nancement préemptif.
Figure 1-1
Processus et threads
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
On peut noter également sur cette figure une frontière entre l’espace utilisateur – celui dans
lequel s’exécutent toutes les applications –, et l’espace noyau. Cette frontière est très importante
et repose sur une transition du mode de fonctionnement du processeur. Linux s’exécute sur des
microprocesseurs qui disposent d’au moins deux modes de fonctionnement.
• Un mode privilégié, dans lequel le processeur peut exécuter n’importe quelle opération de
son jeu d’instructions défini par le constructeur. Il peut également y réaliser des entrées-
sorties directes vers les périphériques matériels externes. Enfin, il lui est possible de modifier
la configuration de la mémoire virtuelle à travers le composant MMU (dont nous reparlerons
plus loin).
• Un mode restreint, dans lequel le processeur est limité à un sous-ensemble de son jeu d’ins-
tructions. Dans ce cas, il ne peut accéder qu’à certaines opérations d’entrées-sorties et à
certaines plages de mémoire virtuelle qui ont été explicitement configurées depuis le mode
privilégié.
Le code du noyau s’exécute toujours en mode privilégié, celui des applications (même celles qui
disposent des droits de l’administrateur root) uniquement en mode restreint. Ainsi, un processus
en mode utilisateur ne pourra ni accéder indûment au matériel, ni toucher des pages de mémoire
qui ne lui auraient été volontairement et explicitement accordées par le noyau. Toute tentative de
violer ces limites (par exemple, en essayant d’exécuter une instruction assembleur réservée au
mode privilégié ou en accédant à une page mémoire non attribuée) se solderait immédiatement
par la levée d’une exception, c’est-à-dire une interruption interne – aussi appelée une « trappe »
sur certains systèmes d’exploitation – qui rendrait immédiatement le contrôle au noyau afin qu’il
prenne des dispositions adéquates (comme tuer le processus coupable ou au contraire lui attri-
buer les ressources demandées).
Lorsqu’un processus s’exécutant en mode utilisateur désire obtenir un accès à un périphérique
matériel, par exemple, il devra demander au noyau de réaliser pour lui les opérations voulues
(lecture, écriture, paramétrage, projection en mémoire...) en invoquant un appel système.
Ces appels système sont des routines d’assistance, où le processus utilisateur sous-traite au
noyau certaines opérations nécessitant des privilèges. Avant de réaliser le travail demandé, ce
dernier vérifiera que le processus dispose bien de toutes les autorisations adéquates.
Création de processus
La création d’un nouveau processus s’effectue via l’appel système fork(). Celui-ci duplique
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Enfin, un processus peut charger dans sa mémoire un nouveau code exécutable, abandonnant
totalement son programme précédent pour commencer le déroulement d’une nouvelle fonction
main(). Ceci s’effectue avec l’une des fonctions de la famille exec(), dont seul execve() est
réellement un appel système, les autres étant des fonctions de bibliothèques qui arrangent leurs
arguments avant de l’invoquer.
int execve (const char *fichier, char *const argv[],
char *const envp[]);
int execl (const char *fichier, const char *arg,...);
int execle (const char *fichier, const char *arg, ...,
char * const envp[]);
int execlp (const char *fichier, const char *arg, ...);
int execv (const char *fichier, char *const argv[]);
int execvp (const char *fichier, char *const argv[]);
Avec cet ensemble de primitives système – fork(), execve(), exit(), waitpid() –, on peut
organiser tout le multitâche classique Unix fondé sur des processus. Bien sûr, des fonctions
de bibliothèques comme system() ou posix_spawn() simplifient le travail du programmeur en
encadrant ces appels système et rendent plus aisé le démarrage d’un nouveau processus.
Voici un petit programme qui se présente comme un shell (très) minimal, il lit des lignes de
commandes et les fait exécuter par un processus fils.
exemple-processus :
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
char ligne[LG_LIGNE];
while(1) {
// Afficher un symbole d'invite (prompt)
fprintf(stderr, "--> ");
// Lire une ligne de commandes
if (fgets(ligne, LG_LIGNE, stdin) == NULL)
break;
// Supprimer le retour chariot final
ligne[strlen(ligne)-1] = ‘\0';
// Lancer un processus
if (fork() == 0) {
// --- PROCESSUS FILS ---
// Exécuter la commande
execlp(ligne, ligne, NULL);
// Message d'erreur si on échoue
perror(ligne);
exit(EXIT_FAILURE);
} else {
// --- PROCESSUS PÈRE ---
// Attendre la fin de son fils
waitpid(-1, NULL, 0);
// Et reprendre la boucle
}
}
fprintf(stderr, "\n");
return EXIT_SUCCESS;
}
Lors de son exécution, on peut lui faire réaliser quelques commandes simples :
$ ./exemple-processus
--> ls
exemple-processus exemple-processus.c Makefile
--> date
dim. mars 27 22:29:06 CEST 2011
--> who
cpb tty1 2011-03-27 19:12 (:0)
cpb pts/0 2011-03-27 21:52 (:0.0)
cpb pts/1 2011-03-27 21:56 (:0.0)
-->
Toutefois, dès que l’on essaie de passer des arguments sur la ligne de commandes, la fonction
execlp() recherche un fichier exécutable du nom complet (y compris les espaces et arguments)
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
et échoue :
--> ls -l
ls -l: No such file or directory
--> (Contrôle-D)
$
Si l’on souhaitait écrire un vrai interpréteur de commandes, il faudrait analyser la ligne saisie,
découper les mots, etc. Ce petit programme est toutefois intéressant dans sa gestion des pri-
mitives de base du multitâche Unix. Ce sont les seules dont on disposait de manière standard
jusque dans les années 1990 environ.
Parallélisme multithreads
Au cours des années 1980, plusieurs implémentations ont été proposées pour obtenir un méca-
nisme multitâche léger, fonctionnant à l’intérieur de l’espace mémoire d’un processus. Certaines
s’appuyaient sur une commutation entre tâches organisées au sein même du processus – par une
bibliothèque –, tandis que d’autres réclamaient une extension des primitives Unix classiques
pour permettre à plusieurs tâches de partager le même espace mémoire. Dans les années 1990,
une volonté d’uniformisation de l’API des systèmes Unix a donné naissance à la norme Posix,
dont une section (Posix.1c) était consacrée aux threads. Cette série de fonctions permet de gérer
des Posix Threads, aussi appelés « Pthreads ».
La création d’un nouveau thread s’obtient en appelant pthread_create() à qui on indique la fonc-
tion sur laquelle le thread nouvellement créé devra démarrer. L’identifiant du thread (de type
pthread_t) sera renseigné durant cet appel. On peut également préciser des attributs spécifiques
pour le thread et un argument pour la fonction à exécuter. Nous verrons ultérieurement des attri-
buts (enregistrés dans l’objet pthread_attr_t) ; pour le moment nous nous contenterons de passer
un pointeur NULL en second argument de pthread_create().
int pthread_create (pthread_t * thread,
pthread_attr_t * attr,
void * (*fonction) (void *),
void * argument);
Dès que la fonction pthread_create() se termine avec succès, nous savons qu’un nouveau fil
d’exécution se déroule dans notre processus.
La fin de ce thread se produira lorsqu’il invoquera pthread_exit() ou terminera sa fonction prin-
cipale par un return, en renvoyant un pointeur (éventuellement NULL s’il n’a rien de particulier à
retourner).
void pthread_exit (void * valeur);
Le pointeur renvoyé lors de la terminaison peut être récupéré par n’importe quel autre thread qui
invoque pthread_join().
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Dans l’exemple suivant, le thread main() de notre programme va démarrer autant de nouveaux
threads qu’on lui a passé d’arguments sur sa ligne de commandes. Chacun d’entre eux recevra
en paramètre de sa fonction principale un nombre (passé – à travers un cast – dans un pointeur).
Chaque thread calculera alors la factorielle de son nombre en effectuant des boucles et en nous
affichant sa progression. Le résultat du calcul sera renvoyé à la fin de la fonction du thread, et
récupéré dans le thread main(). L’intérêt de ce programme est d’utiliser les différentes primitives
que nous avons présentées précédemment.
exemple-threads.c :
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
$ ./exemple-threads 3 5 7
main(): lancement des threads
main(): tous threads lances
7! : en calcul...
5! : en calcul...
3! : en calcul...
7! : en calcul...
5! : en calcul...
main(): 3! = 6
7! : en calcul...
5! : en calcul...
7! : en calcul...
main(): 5! = 120
7! : en calcul...
main(): 7! = 5040
main(): tous threads termines
$
Systèmes multiprocesseurs
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
vendor_id : GenuineIntel
cpu family : 6
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
model : 58
model name : Intel(R) Core(TM) i3-3110M CPU @ 2.40GHz
stepping : 9
cpu MHz : 1200.093
cache size : 3072 KB
physical id : 0
siblings : 4
core id : 1
cpu cores : 2
[…]
$
Nous voyons que nous disposons de quatre processeurs virtuels (lignes processor : 0 à proces-
sor : 3). Toutefois, il s’agit du même processeur physique (lignes physical id : 0 identiques)
qui contient deux cœurs distincts (lignes core id : 0 et core id : 1). Chacun des cœurs est
hyperthreadé. On peut remarquer que la fréquence CPU est différente sur des deux cœurs affi-
chés, en effet sur de nombreux processeurs cette fréquence est modifiable dynamiquement. Ici,
le noyau Linux la fait évoluer en fonction de la charge du système. Nous reviendrons sur ce sujet
dans le chapitre 8.
La commande lscpu, disponible sur de nombreux systèmes Linux, présente de manière plus
lisible le contenu de /proc/cpuinfo :
$ lscpu
Architecture: x86_64
Mode(s) opératoire(s) des processeurs : 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 4
On-line CPU(s) list: 0-3
Thread(s) par cœur : 2
Cœur(s) par socket : 2
Socket(s): 1
Nœud(s) NUMA : 1
Identifiant constructeur : GenuineIntel
Famille de processeur : 6
Modèle : 58
Révision : 9
Vitesse du processeur en MHz : 1199.906
BogoMIPS: 4789.00
Virtualisation : VT-x
Cache L1d : 32K
Cache L1i : 32K
Cache L2 : 256K
Cache L3 : 3072K
NUMA node0 CPU(s): 0-3
$
Parfois lscpu est moins volubile, en voici un exemple d’exécution sur Raspberry Pi 2 :
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
$ lscpu
Architecture: armv7l
Byte Order: Little Endian
CPU(s): 4
On-line CPU(s) list: 0-3
Thread(s) per core: 1
Core(s) per socket: 4
Socket(s): 1
$
Toutefois, avec l’argument _SC_NPROCESSORS_ONLN, il est important de savoir que cette fonction-
nalité n’est pas normalisée et ne fonctionnera peut-être que sous Linux. En voici un exemple :
exemple-sysconf.c :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("Nombre de CPU: %ld\n",
sysconf(_SC_NPROCESSORS_ONLN));
return EXIT_SUCCESS;
}
$ ./exemple-sysconf
Nombre de CPU: 4
$
remarque un bref passage sur le cœur 1 pendant quelques secondes et un retour prolongé sur
le cœur 2.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Figure 1-2
Migration de tâches
qui nous renvoie le numéro de processeur depuis lequel elle a été invoquée, ou -1 si elle ne peut
le déterminer.
Le petit programme suivant va vérifier en permanence son emplacement et nous indiquer
lorsqu’il détectera des migrations :
exemple-sched-getcpu.c :
#define _GNU_SOURCE // sched_getcpu() extension GNU
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
int main(void)
{
int n;
int precedent = -1;
time_t heure;
struct tm * tm_heure;
while (1) {
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
n=sched_getcpu();
if (n == -1) {
perror("sched_getcpu");
exit(EXIT_FAILURE);
}
if (precedent == -1)
precedent = n;
if (n != precedent) {
heure = time(NULL);
tm_heure = localtime(& heure);
print("%02d:%02d:%02d migration %d -> %d\n",
tm_heure->tm_hour, tm_heure->tm_min,
tm_heure->tm_sec,
precedent, n);
precedent = n;
}
}
return EXIT_SUCCESS;
}
Nous voyons que le noyau déplace le processus alternativement sur les quatre cœurs en fonction
de la charge du système.
$ ./exemple-sched-getcpu
08:29:40 migration 3 -> 0
08:30:01 migration 0 -> 2
08:30:07 migration 2 -> 0
08:30:12 migration 0 -> 1
08:30:12 migration 1 -> 2
08:30:15 migration 2 -> 3
08:30:21 migration 3 -> 0
(Contrôle-C)
$
Il nous est également possible de contrôler l’emplacement d’un processus, avant son lancement
ou pendant son exécution. Pour cela, la commande shell taskset est très utile. Toutefois, son
utilisation n’est pas vraiment intuitive. En voici quelques exemples :
$ taskset -c 1 ./commande
// Lance la commande sur le CPU 1
$ taskset 0,2 ./commande
// Autorise la commande à s’exécuter sur les CPU 0 et 2
$ taskset -pc 0 1234
// Migre le processus 1234 sur le CPU 0
$ taskset -p 1234
// Affiche l’affinité du processus 1234
L’affinité d’une tâche est la liste des CPU sur lesquels elle peut s’exécuter. On peut la consulter
ou la fixer à l’aide des fonctions suivantes :
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Ces fonctions sont des extensions GNU – non portables sur d’autres systèmes Unix – qui néces-
sitent donc de définir la constante symbolique _GNU_SOURCE avant d’inclure <sched.h>.
Le second argument de ces fonctions correspond à la taille du type de donnée cpu_set_t pour
le système.
Les listes de CPU sont représentées par les variables de type cpu_set_t, que l’on manipule avec
les fonctions suivantes :
void CPU_ZERO (cpu_set_t * ensemble);
void CPU_SET (int cpu, cpu_set_t * ensemble);
void CPU_CLR (int cpu, cpu_set_t * ensemble);
int CPU_ISSET (int cpu, cpu_set_t * ensemble);
CPU_ZERO(& cpuset);
CPU_SET(cpu, &cpuset);
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
// Fixer l'affinité
if (sched_setaffinity(0, sizeof(cpuset), &cpuset)!=0){
perror(argv[1]);
exit(EXIT_FAILURE);
}
while (1) {
printf("Je suis sur le CPU %d\n", sched_getcpu());
sleep(1);
}
return EXIT_SUCCESS;
}
$ ./exemple-sched-setaffinity 0
Je suis sur le CPU 0
Je suis sur le CPU 0
Je suis sur le CPU 0
Je suis sur le CPU 0
Contrôle-C
$ ./exemple-sched-setaffinity 3
Je suis sur le CPU 3
Je suis sur le CPU 3
Je suis sur le CPU 3
Je suis sur le CPU 3
Contrôle-C
$ ./exemple-sched-setaffinity 4
4: Invalid argument
$
On peut aussi fixer l’affinité d’un futur thread avant sa création. Pour cela, on initialise un objet
pthread_attr_t qui contient les attributs du thread à créer et on passe cette structure en second
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
argument de pthread_create().
Pour remplir la structure d’attributs, on utilise les fonctions :
int pthread_attr_setaffinity_np (
pthread_attr_t attr,
size_t taille,
const cpu_set_t * cpuset);
int pthread_attr_getaffinity_np (
pthread_attr_t attr,
size_t taille,
cpu_set_t * cpuset);
Dans l’exemple suivant, nous allons lancer autant de threads en parallèle qu’il y a de proces-
seurs disponibles. Nous commençons par le CPU 0, puis incrémentons le numéro jusqu’à ce que
l’appel pthread_create() échoue. Notons que la variable de type pthread_t est écrasée à chaque
appel avec le nouvel identifiant affecté au thread créé.
exemple_pthread_attr_setaffiniy.c :
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sched.h>
int main(void)
{
cpu_set_t cpuset;
pthread_t thr;
pthread_attr_t attr;
long num;
num = 0;
while (1) {
// Initialiser avec les attributs par défaut
pthread_attr_init(& attr);
// Préparer le cpuset
CPU_ZERO(&(cpuset));
CPU_SET(num, &(cpuset));
// Fixer l'affinité
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
pthread_attr_setaffinity_np(& attr,
sizeof(cpu_set_t),
& cpuset);
// Lancer le thread
if (pthread_create(& thr, & attr, fonction,
(void *) num) != 0)
break;
num++;
}
// Terminer le thread main en continuant les autres
pthread_exit(NULL);
}
On peut remarquer que l’ordre d’affichage des messages n’est pas figé. Les threads faisant des
accès simultanés à la console, le noyau est obligé de se livrer à un arbitrage qui peut évoluer en
fonction de l’activité du système. Nous allons examiner les différents états dans lesquels peuvent
se trouver les tâches et les transitions possibles entre ces états.
Il est important de noter que l’affinité ainsi fixée ne présente pas de caractère obligatoire pour la
tâche. À tout moment, il lui est possible de modifier son affinité et de se déplacer ainsi vers un
autre processeur. Ce principe est tout à fait suffisant pour les applications temps réel, où toutes
les tâches d’un système sont habituellement configurées pour un fonctionnement optimal. Si
on désire verrouiller impérativement une tâche sur un CPU – ou un ensemble de CPU – sans
qu’elle puisse en sortir ensuite, on utilisera plutôt le mécanisme des cpuset. Un exemple clair est
présenté dans la page de manuel cpuset(7) accessible ainsi :
$ man 7 cpuset
Nous observons la présence de quelques dizaines de processus, décrits par divers champs. Les
lignes précédentes ont été éditées pour supprimer quelques champs peu intéressants pour le
moment.
Comme nous souhaitons des informations sur l’ordonnancement, nous préférons obtenir des sta-
tistiques sur les tâches du système, c’est-à-dire des threads. Pour cela, la commande ps maux est
beaucoup plus utile car elle décrit l’activité des différents threads au sein de chaque processus.
$ ps maux
USER PID %CPU %MEM [...] STAT START TIME COMMAND
root 1 0.0 0.0 - 14:17 1:14 /sbin/init
root - 0.0 - Ss 14:17 1:14 -
root 2 0.0 0.0 - 14:17 0:12 [kthreadd]
root - 0.0 - S 14:17 0:12 -
root 3 0.0 0.0 - 14:17 0:36 [ksoftirqd/0]
root - 0.0 - S 14:17 0:36 -
[...]
cpb 2760 0.2 0.6 - 20:47 0:01 gnome-term
cpb - 0.2 - Sl 20:47 0:01 -
cpb - 0.0 - Sl 20:47 0:00 -
cpb - 0.0 - Sl 20:47 0:00 -
cpb 2767 0.0 0.0 - 20:47 0:00 gnome-pty-h
cpb - 0.0 - S 20:47 0:00 -
cpb 2768 0.0 0.0 - 20:47 0:00 bash
cpb - 0.0 - Ss 20:47 0:00 -
cpb 3015 4.0 0.0 - 20:52 0:00 ps maux
cpb - 4.0 - R+ 20:52 0:00 -
$
Nous voyons, pour chaque processus, une première ligne – avec le champ PID rempli – réca-
pitulant les informations globales, puis une ou plusieurs lignes pour chacun des threads. Les
champs n’ayant pas de valeur spécifique pour le thread – par exemple, le PID – contiennent un
tiret.
Nous pouvons voir que tous les processus disposent au moins d’un thread (celui qui exécute la
fonction main()) et que certains d’entre eux (par exemple, gnome-terminal pour ses onglets) dis-
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
S Sleeping Endormi. Le thread est inactif, il est bloqué dans un appel système,
probablement en attente de données extérieures. Dès que les données
seront disponibles, il sera réveillé et passera en état Running. Si un
signal est envoyé au thread, il sera réveillé prématurément pour traiter
le signal. En l’absence de traitement correct du signal, le thread sera
tué.
D Down Bloqué. Cet état très rare signifie que le thread est endormi dans
un sommeil profond en attente d’événements externes. Il n’est pas
possible de le réveiller prématurément en lui envoyant un signal, même
le fameux signal 9 (SIGKILL).
T Traced Suivi. Ce mode est utilisé par les débogueurs pour geler le thread. Il ne
s’exécute plus mais ne disparaît pas pour autant. Le passage à l’état
Traced s’obtient la plupart du temps sur l’arrivée d’un signal SIGSTOP.
Le thread reprendra son exécution normale sur réception d’un signal
SIGCONT.
Z Zombie Le thread s’est terminé (volontairement avec pthread_exit() ou
exit(), involontairement sur réception d’un signal). Il a laissé un code
de terminaison qui persiste jusqu’à ce qu’il soit lu par un autre thread ou
par le processus père.
Les transitions entre les états d’une tâche sont toujours assurées par le noyau. La plupart du
temps, elles s’expriment comme la conséquence de l’arrivée d’une interruption. Nous revien-
drons sur ce sujet dans le prochain chapitre.
La figure 1-3 présente les différents états possibles, ainsi que leurs transitions habituelles. Par
souci de simplification, on n’y distingue pas l’état Sleeping de l’état Down, la tâche étant en som-
meil dans les deux cas (dans le second état, elle ne peut toutefois pas être réveillée par l’arrivée
d’un signal).
Figure 1-3
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Ordonnancement
Bien entendu, lorsque nous disposons de quatre CPU, par exemple, il peut y avoir jusqu’à quatre
tâches actives (Running) simultanément. Toutefois, sur la plupart des systèmes, il arrive que le
nombre de tâches demandé excède le nombre de processeurs disponibles. Il faut donc organiser
des commutations entre ces tâches, c’est le rôle de l’ordonnanceur (scheduler).
L’ordonnanceur profite de la mise en sommeil d’une tâche (qui attend un événement externe, par
exemple) pour en activer une autre. Mais il faut également disposer d’un nouvel état pour repré-
senter une tâche qui est prête à s’exécuter, et en attente de la libération du processeur. C’est l’état
Runnable (Prêt) qui s’intercale entre les états Sleeping (ou Down) et Running.
En zoomant sur la partie centrale de la figure 1-3, celle-ci devient donc la figure ci-après.
Lorsqu’une tâche est réveillée (ou lorsqu’elle démarre, même si ce n’est pas visible sur la
figure 1-4), elle passe d’abord dans un état d’attente d’où l’ordonnanceur l’extraira pour la pla-
cer sur le processeur. Il peut y avoir plusieurs tâches prêtes simultanément (on peut prendre
l’image d’une salle d’attente), et le scheduler va devoir choisir une tâche à activer. On parle d’un
mécanisme d’élection. Avec un ordonnancement temps partagé, toute la qualité du scheduler,
toute son intelligence, résidera dans la pertinence de ce choix. Nous reviendrons sur ce point au
chapitre 3.
Notons que depuis l’extérieur du kernel, il n’est pas possible de distinguer une tâche qui est
Runnable d’une tâche qui est effectivement Running. Ainsi, ps nous les présente-t-il toutes deux
avec la lettre ‘R'. Lorsque plusieurs tâches apparaissent avec cet état ‘R' sur un système unipro-
cesseur, il n’y en a évidemment qu’une seule dans l’état Running, les autres étant en Runnable.
Le nombre de tâches prêtes à un moment donné est appelé « charge du système ». On effectue
généralement une moyenne sur quelques dizaines de secondes pour avoir une valeur significa-
tive. S’il y a eu en moyenne 2,5 tâches Runnable sur une période donnée, on dit que la charge fut
de 250 %. Naturellement, il faut rapporter cette valeur au nombre de processeurs présents pour
qu’elle soit représentative.
Figure 1-4
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Préemption
Lorsqu’une tâche s’est exécutée pendant un temps prolongé au détriment des autres, le système
peut décider de lui retirer temporairement le processeur et de la réinjecter dans la liste des
tâches Runnable. On dit que la tâche a été préemptée, et plus généralement qu’il s’agit d’un sys-
tème multitâche préemptif.
À l’inverse, un ordonnancement peut être collaboratif, c’est-à-dire que chaque tâche va volon-
tairement (lors d’un appel à la boucle d’événements de l’interface graphique, par exemple) céder
régulièrement le CPU – quitte à le retrouver immédiatement si elle est la seule en activité. Un
exemple typique était le système Microsoft Windows 3.1.
La plupart des ordonnancements temps partagé actuels sont préemptifs. En revanche, la pré-
emption peut être gênante pour des systèmes temps réel, aussi verrons-nous qu’il est possible de
configurer le comportement des tâches temps réel.
Conclusion
Dans ce chapitre, nous avons mis en place les éléments essentiels du multitâche, vu de l’espace
utilisateur. En ce qui concerne l’API disponible sous Linux pour la programmation multithreads
ou multiprocessus, on trouvera des éléments détaillés dans [BLAESS 2011], Développement
système sous Linux. Le prochain chapitre va nous permettre de mieux comprendre les transitions
entre les états des tâches et les interactions entre l’espace applicatif (espace utilisateur) et celui
du noyau.
Points clés
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Les processus représentent avant tout des espaces mémoire indépendants. Il y a une filiation
entre processus. La fin d’un processus est notifiée à son créateur. Les threads d’un processus
s’exécutent dans le même espace mémoire et partagent les variables globales.
Le noyau Linux gère de manière homogène les systèmes multiprocesseurs, multicœurs et les
processeurs hyperthreads. Il fait migrer des tâches d’un processeur à l’autre en fonction de la
charge du système. On peut fixer l’affinité d’une tâche (thread ou processus complet), c’est-à-
dire son attachement à un ou plusieurs processeurs.
Pour partager le temps processeur disponible, les tâches sont ordonnancées et alternent entre les
états Sleeping (endormie, en attente de réveil), Running (active, en exécution) et Runnable (prête
à s’exécuter, attendant la disponibilité du processeur).
Exercices
Les quelques exercices présentés en fin de chapitre permettent de revenir sur les concepts abor-
dés précédemment. Les explications et codes sources des solutions sont disponibles sur le site
web de l’auteur :
http://christophe.blaess.fr/
Exercice 1 (*)
En consultant le fichier /proc/cpuinfo, déterminez l’architecture des différents systèmes Linux à
votre disposition (uniprocesseur ? multiprocesseurs ? nombre de processeurs physiques, nombre
de cœurs, présence d’hyperthreading ?)
Exercice 2 (**)
Écrivez un petit programme qui démarre autant de threads qu’il y a de processeurs (au sens
large, en comptant les cœurs et l’hyperthreading). Chaque thread devra se placer sur un proces-
seur distinct, puis ils entameront à tour de rôle des séquences de boucles actives comme :
for (i = 0 ; i < 1000000000 ; i ++)
;
sleep(10);
Exercice 3 (**)
Créez un programme qui exécute une boucle active en incrémentant un compteur, puis se met en
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
sommeil pendant quelques secondes – avec l’appel système sleep(). En exécutant ps aux depuis
un autre terminal, observez bien les états Running et Sleeping. Envoyez à votre processus un
signal STOP avec la commande :
$ kill -STOP (pid_du_processus)
Exercice 4 (***)
Écrivez un programme qui crée un processus fils avec l’appel fork(). Ce processus fils doit se
terminer au bout de dix secondes, tandis que le père va boucler autour de l’appel sleep(1). Véri-
fiez le déroulement depuis un autre terminal avec ps aux. Que devient le processus fils après sa
terminaison ? Comment s’en débarrasser et quel est le mécanisme sous-jacent ?
2
Interruptions, exceptions
et appels système
Le principe des interruptions est un élément clé des systèmes temps réel car il permet quasi
instantanément de consacrer temporairement les ressources du processeur à la réponse à un
événement externe. Du temps de réponse à une interruption et de la constance de ce temps de
réponse dépendra la qualité d’un système temps réel.
Mode noyau
Nous avons vu dans le chapitre précédent que Linux fait une grande distinction entre deux
modes de fonctionnement : le mode utilisateur (restreint) et le mode noyau (privilégié) – aussi
appelé « mode superviseur ». Dans le premier mode, les possibilités (jeu d’instructions assem-
bleur, accès à la mémoire, entrées-sorties) sont restreintes, tandis que dans le second mode,
aucune limite n’est imposée au logiciel. Il faut bien comprendre que cette séparation entre mode
utilisateur et mode superviseur a une véritable représentation matérielle. Le processeur dis-
pose d’un bit dans son registre d’état qui décrit le mode dans lequel il se trouve et c’est donc le
processeur lui-même qui limitera ses possibilités lorsque le bit sera dans l’état « utilisateur » et
ignorera les limitations si le bit est en l’état « superviseur ». Bien sûr, le détail de cette implé-
mentation varie suivant les processeurs, mais le principe reste le même.
Sous Linux, toutes les applications s’exécutent dans le mode utilisateur (même celles qui ont les
droits root) et seul le code du noyau s’exécute en mode superviseur.
lorsqu’il se trouve en mode noyau, il lui est possible de modifier à volonté son registre d’état.
Lorsque le processeur se trouve en mode utilisateur, il peut revenir en mode superviseur dans
trois cas.
• Le programme utilisateur a besoin de sous-traiter au noyau la réalisation de certaines tâches
privilégiées (entrées-sorties, configuration de la mémoire virtuelle, etc.) : il réalise un appel
système.
• Le processeur détecte une erreur de fonctionnement due au programme en cours d’exécution
(mauvais accès mémoire, tentative d’utiliser une instruction assembleur interdite en mode
utilisateur, opération arithmétique impossible, etc.). Il lève alors une exception qui va redon-
ner instantanément le contrôle au noyau et lui permettre d’envoyer un signal (probablement
fatal) au processus fautif.
• L’environnement extérieur (périphériques) doit indiquer au processeur l’occurrence d’une
situation spécifique (données disponibles, erreurs, modification d’état d’un capteur, etc.) et
déclenche une interruption qui va donner également le contrôle au noyau pour qu’il puisse
répondre à l’événement survenu avant de reprendre son travail habituel.
Suivant les processeurs, le détail exact de fonctionnement varie, mais pour la plupart d’entre eux,
ces trois cas seront traités de manière similaire en utilisant le principe général des interruptions.
Interruptions
Principe
Supposons qu’un périphérique externe (un bouton, par exemple) ait besoin de signaler au sys-
tème une modification de son état (un utilisateur vient d’appuyer sur le bouton). Il va envoyer
un signal électrique à l’APIC (Advanced Programmable Interrupt Controler) du système. Ce
composant – dont les fonctionnalités sont plus ou moins élaborées suivant la complexité de
l’architecture matérielle – identifiera le changement d’état (par exemple, une transition montante
de 0V à 5V ou, au contraire, un front descendant de 5V à 0V) et enverra à son tour une impul-
sion électrique sur une broche IRQ (Interrupt Request – demande d’interruption) du processeur.
Ce dernier va interrompre son travail en cours, mémoriser dans la pile l’état de ses registres et
l’adresse de l’instruction en cours d’exécution, puis demander au contrôleur d’interruption ce
qu’il se passe. L’APIC lui indiquera alors le numéro de l’IRQ survenue (numéro qui dépend du
périphérique) et le processeur exécutera le code se trouvant à l’adresse mémoire correspondant
à ce numéro. Pour cela, il consultera une table dite « table des vecteurs d’interruptions » qui
contient l’adresse des routines à exécuter en fonction du numéro d’IRQ.
Figure 2-1
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Déclenchement
d’une interruption
On imagine que les fonctions appelées dans cet exemple sont définies un peu plus haut dans le
programme.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Ce principe que l’on rencontrait couramment dans les automates programmables industriels
(Programmable Logic Controler – PLC), et qui est encore adopté parfois dans la programma-
tion sur microcontrôleurs présente des avantages et des inconvénients.
En ce qui concerne les avantages, on notera la solidité du code. La modification d’une entrée
déclenche automatiquement un traitement, de façon lisible et facilement extensible. Les états
peuvent être prévus et répertoriés sous forme de graphes, et des outils et méthodologies d’ana-
lyse permettent d’envisager les transitions entre chaque situation d’entrée.
Par ailleurs, on distingue deux inconvénients principaux : le temps d’exécution et la consom-
mation électrique. Le délai qui s’écoule entre la modification d’une entrée et la réponse par le
système est difficilement prévisible. Cette durée est limitée au maximum par l’exécution de
toute la boucle mais celle-ci est fonction du nombre de capteurs présents. L’ajout d’une nou-
velle entrée peut modifier le temps de réponse à un événement détecté par un autre capteur. En
d’autres termes, la prédictibilité du temps de réponse à un élément externe n’est pas assurée. Ce
critère est pourtant, nous le verrons plus loin, essentiel pour les applications temps réel.
Le second problème avec ce type de programmation est la boucle incessante que parcourt le
programme. Ne s’interrompant jamais, le processeur consomme de l’énergie en permanence et
s’échauffe. Ceci nécessite un système de refroidissement (ailettes ou ventilateurs) qui occupe
du volume et consomme éventuellement de l’électricité à son tour. Tout ceci s’avère fortement
problématique dans le cas d’un système embarqué fonctionnant sur batteries. On notera en outre
que le processeur – comme tout composant électrique – s’use et vieillit d’autant plus vite que sa
température reste élevée.
int main(void)
{
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
initialisation_du_système();
installation_handler(INTERRUPT_1, handler_1);
installation_handler(INTERRUPT_2, handler_2);
while(1) {
sommeil();
}
}
Cette fois, dès que les gestionnaires d’interruption ont été installés (c’est-à-dire inscrits dans la
table que consulte le processeur pour savoir quel traitement réaliser lorsque survient une IRQ),
le programme se compose d’une simple boucle infinie autour d’une fonction que j’ai nommée
sommeil() qui arrête le processeur jusqu’à l’arrivée d’une nouvelle interruption.
Suivant les processeurs, il existe différents types de sommeils, pour les plus courants d’entre
eux, on retrouve des états typiquement nommés C0, C1, C2 et C3.
Nom Signification
C1 L’horloge interne du processeur est arrêtée, ce dernier n’exécute plus d’instructions. Il consomme
donc moins d’énergie et peut se réveiller très vite.
C2 Les horloges interne et externe (celles du bus) sont arrêtées, le redémarrage sera un peu plus long
qu’en mode C1.
C3 Les horloges sont arrêtées et le processeur vide éventuellement ses caches (mémoire statique).
Il consomme très peu d’énergie mais son redémarrage sera plus long.
Lorsque survient une interruption, le processeur est donc réveillé et se branche sur le handler
associé, qui pourra acquitter l’interruption (c’est-à-dire la valider dans le contrôleur APIC) et
procéder au traitement en conséquence.
Dans ce schéma, nous voyons que la consommation énergétique est moindre, le processeur
n’étant actif que s’il doit réellement réaliser un travail. Ceci est particulièrement important pour
les systèmes embarqués. De plus, le temps s’écoulant entre le déclenchement d’une interruption
et le traitement correspondant ne dépend que de la vitesse du processeur et pas du nombre
d’entrées qu’il est capable de gérer. Nous aurons de meilleures performances en ce qui concerne
l’aspect temps réel.
Le fichier /proc/interrupts est très intéressant car il présente la liste des interruptions gérées
par le noyau, ainsi que le nombre de déclenchements de chacune d’elles, répartis sur chaque
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
$ cat /proc/interrupts
CPU0 CPU1 CPU2 CPU3
0: 233 2750 211 52 IO-APIC-edge timer
1: 1 0 1 0 IO-APIC-edge i8042
8: 0 0 0 1 IO-APIC-edge rtc0
9: 0 0 0 0 IO-APIC-fasteoi acpi
12: 0 2 1 1 IO-APIC-edge i8042
16: 600778 29099 129 130 IO-APIC-fasteoi uhci_hcd:u
17: 0 0 0 0 IO-APIC-fasteoi uhci_hcd:u
18: 8912 5325 2 0 IO-APIC-fasteoi firewire_o
20: 2026994 2200738 1043 1033 IO-APIC-fasteoi ata_piix,
22: 175070 11321 6 4 IO-APIC-fasteoi ehci_hcd:u
23: 43770059 2302203 163 175 IO-APIC-fasteoi ehci_hcd:u
43: 4705950 25 23 25 PCI-MSI-edge em1
44: 78 79 30868 14149 PCI-MSI-edge hda_intel
[...]
$
Pour chaque interruption (dont le numéro figure en première colonne), on trouve le nombre
d’occurrences sur chaque CPU, puis le type d’interruption (ici, on remarque le contrôleur APIC
des entrées-sorties et le contrôle des interruptions du bus PCI). Viennent ensuite la liste des
drivers chargés de gérer chaque interruption. Il est possible que le même numéro d’interruption
soit utilisé par plusieurs périphériques différents. Dans ce cas, les handlers des drivers seront
invoqués successivement, chacun devant déterminer – en interrogeant son matériel – si l’inter-
ruption le concerne ou non.
Notons qu’il s’agit ici des interruptions pour lesquelles un gestionnaire a été installé (par un
driver, par exemple), mais que l’APIC peut être capable de gérer bien d’autres lignes d’IRQ non
mentionnées ici.
Rappelons que tout le contenu de /proc est entièrement virtuel, le noyau nous présente ainsi
sous forme de pseudo fichiers des informations internes. Si l’on examine en boucle le contenu
de /proc/interrupts, on verra les valeurs évoluer dynamiquement au gré des interruptions qui
surviennent.
Le kernel Linux lui-même peut offrir une répartition des interruptions pour essayer d’équilibrer
la charge de travail entre les différents processeurs. Cette solution n’est plus trop utilisée pour
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
les systèmes récents car l’arbitrage effectué par le noyau était trop simpliste.
Il existe un démon – un processus utilisateur – nommé irqbalance, qui peut effectuer la réparti-
tion en optimisant la charge des différents processeurs en fonction de l’architecture sous-jacente.
Par exemple, sur un système de type Quad-Core Intel, les cœurs 1 et 2 d’une part, et 3 et 4
d’autre part, partagent leurs caches ainsi que l’accès à la mémoire. Il est donc plus judicieux de
traiter des interruptions simultanées sur les cœurs 1 et 3.
Le démon irqbalance est présent sur la majorité des distributions Linux actuelles. Toutefois,
dans le cas de systèmes embarqués particuliers, il peut être intéressant de diriger spécifique-
ment certaines interruptions sur des processeurs donnés. On peut ainsi imaginer de conserver
un cœur qui ne fait que du traitement de type calcul sans être interrompu, un cœur qui traite
uniquement certaines interruptions bien précises pour lesquelles un temps de réponse garanti
est nécessaire, et enfin deux cœurs réalisant le reste du travail et répondant aux interruptions
classiques (réseau, disque, etc.) pour lesquelles aucune contrainte absolue n’est imposée.
Voyons pour cela le contenu du répertoire /proc/irq/.
# cd /proc/irq/
# ls
0 1 10 11 12 13 14 15 16 17 18 2 20 22 23 3 4 43 44 5 6 7 8 9
default_smp_affinity
#
Il existe donc un sous-répertoire par interruption que l’APIC est susceptible de gérer. Le contenu
exact du sous-répertoire peut varier suivant les versions du noyau Linux. Dans les noyaux assez
récents, on trouve entre autres un fichier smp_affinity qui contient le masque binaire des proces-
seurs vers lesquels l’interruption peut être dirigée.
# cat 23/smp_affinity
03
#
À partir de cet instant, l’interruption 23 ne sera traitée que sur les processeurs 3 et 4, ceci
jusqu’au redémarrage du système, sauf si le démon irqbalance se déclenche et vient modifier
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
# cat 0/smp_affinity
ff
# echo 1 > 0/smp_affinity
-bash: echo: erreur d'écriture : Erreur d'entrée/sortie
#
Toutefois, pour l’essentiel des lignes d’IRQ, le réglage de l’affinité est possible. Aussi, nous pour-
rons choisir précisément quels cœurs traiteront les interruptions et quels cœurs seront dédiés à
des tâches de traitement ou de calcul.
Exceptions
Principe
Un second mécanisme d’interaction du processeur avec son environnement existe : il s’agit des
exceptions. Très semblables aux interruptions, les exceptions – appelées parfois « interruptions
synchrones » – surgissent lorsque certaines conditions d’erreur sont rencontrées à cause du code
en cours d’exécution.
Les exceptions sont générées par le processeur lui-même, sans l’intervention de périphérique
externe.
Imaginons par exemple qu’un processus, durant une phase de calcul en espace utilisateur, effec-
tue une tentative de division par zéro.
Figure 2-2
Déclenchement d’une
exception
La FPU (Floating Point Unit), partie du processeur chargée de réaliser les calculs, va détec-
ter une erreur irréparable et lèvera une exception (Floating Point Exception). Le processeur
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Fichier core
Lorsqu’une exception entraîne l’émission d’un signal fatal (comme SIGSEGV, SIGILL ou SIGFPE)
qui tue un processus, le noyau peut enregistrer dans un fichier une copie intégrale de la mémoire
virtuelle du processus afin de permettre une analyse post-mortem à l’aide d’un débogueur. Ces
fichiers sont traditionnellement nommés core, du nom des petits anneaux employés dans les
mémoires magnétiques encore en utilisation lorsque les premiers systèmes Unix sont apparus
dans les années 1970.
Le support qu’offre un fichier core pour assister le développeur lors des phases de débogage
est très précieux. En effet, c’est une image exacte du processus qui est enregistrée à l’instant
même où le matériel signale une erreur incorrigible. Dans le cas d’un accès mémoire illégal, par
exemple, le fichier core nous indiquera la ligne du code source où l’erreur s’est produite, et nous
permettra d’inspecter les pointeurs et variables pour déceler l’accès erroné. Voyons un exemple
de programme qui tente d’écrire dans un pointeur NULL :
exemple-crash.c :
#include <stdio.h>
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
char * pointeur = NULL;
Bien entendu, nous ne voyons que le premier message lors de l’exécution car le programme
reçoit un signal SIGSEGV dès sa tentative de déréférencement du pointeur.
$ ./exemple-crash
Avant ecriture dans le pointeur
Erreur de segmentation
$
Le message « Erreur de segmentation » provient du shell, qui lit le code de terminaison de son
fils et voit qu’il a été tué par le signal SIGSEGV. Il nous avertit donc de cette condition d’erreur.
Toutefois, aucun fichier n’a été généré.
En fait, les fichiers core peuvent être très encombrants (plusieurs dizaines voire centaines
de mégaoctets parfois). D’autant que sur certaines distributions Linux, le fichier a pour nom
« core » suivi d’un point et du PID du processus qui a été tué (ceci est configurable via /proc/
sys/kernel/core_uses_pid). Aussi, il peut y en avoir plusieurs dans le même répertoire. En outre,
un fichier core n’est jamais utile à l’utilisateur (qu’il a plutôt tendance à exaspérer), ni à l’admi-
nistrateur système. Seul le développeur peut lui trouver une utilité, mais seulement dans des
périodes de débogage bien précises.
Aussi, sur la plupart des systèmes Linux, les fichiers core ne sont pas créés par défaut, et il fau-
dra les demander explicitement. La commande ulimit du shell permet de fixer des limitations
de ressources pour les commandes lancées à partir de ce shell. Entre autres, on peut restreindre
l’usage de la mémoire, du temps CPU, du nombre de fichiers ouverts simultanément, etc. Avec
l’option -c de cette commande, c’est la taille maximale des fichiers core qui est configurée. Or,
celle-ci est généralement fixée à zéro à l’initialisation du shell. Essayons de la modifier et de
relancer la commande précédente :
$ ulimit -a
core file size (blocks, -c) 0
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Cette fois, nous avons obtenu un fichier core. Celui-ci peut être examiné à l’aide du débogueur
GDB (ou d’un environnement de développement servant de frontal graphique à GDB comme
Eclipse, NetBeans, Code::Blocks, etc.)
$ gdb ./exemple-crash core
GNU gdb (Ubuntu/Linaro 7.2-1ubuntu11) 7.2
Copyright (C) 2010 Free Software Foundation, Inc.
[...]
Core was generated by `./exemple-crash'.
Program terminated with signal 11, Segmentation fault.
#0 0x08048451 in main () at exemple-crash.c:10
10 pointeur [0] = ‘X';
(gdb) print pointeur
$1 = 0x0
(gdb) quit
$
Nous voyons que GDB nous indique précisément la ligne de code source où s’est produit l’accès
ayant levé l’exception de faute de page. De plus, nous pouvons examiner le contenu de variable
comme pointeur. La présentation de GDB pour afficher le contenu des variables est a priori
surprenante : $1 indique qu’il s’agit de la première évaluation d’expression, et 0x0 correspond à
un pointeur – donc présenté en hexadécimal – de valeur nulle.
Si vous ne réussissez pas à produire de fichier core même après configuration de ulimit, assurez-vous
que le fichier /proc/sys/kernel/core_pattern ne contienne que le mot core.
Le principe des exceptions est donc un moyen simple et efficace pour rendre la main au noyau
lorsqu’une situation anormale est détectée mettant en cause le code de l’espace utilisateur. On
l’utilise également pour organiser le support de la mémoire virtuelle des processus, nous en
reparlerons plus loin.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Appels système
Lorsqu’un processus désire réaliser une opération réclamant des accès aux ressources maté-
rielles du système (par exemple, écrire dans un fichier ou lire un octet depuis un port série), il
doit sous-traiter le travail au noyau qui vérifiera les autorisations dont dispose le processus et
effectuera – ou non – l’action demandée.
Pour cela, le kernel Linux propose ses services par l’intermédiaire de fonctions spécifiques
appelées « appels système ».
Dans l’espace utilisateur, l’appel système est implémenté dans la bibliothèque C, ce qui explique
que cette dernière soit indispensable sur tout système Linux même si les applications ne sont pas
écrites en C. Lors de la construction d’un système embarqué, l’installation de la bibliothèque
GlibC (ou uClibC) vient immédiatement après la génération du noyau. Par exemple, l’appel
système write(), qui sert à écrire dans n’importe quel descripteur de fichier, est implémenté
directement dans la libC et s’occupera d’invoquer le noyau.
Au sein de ce dernier, l’appel système est une routine nommée sys_write() qui se trouve dans le
fichier fs/read_write.c des sources du noyau.
Dans les noyaux récents, le nom sys_write n’apparaît plus directement dans l’en-tête de la fonction, il est
construit par la macro SYSCALL_DEFINE3().
Pour passer de la fonction write() de la bibliothèque C – exécutée par du code en mode uti-
lisateur – à la fonction sys_write() implémentée dans le noyau, on utilise sur la plupart des
systèmes des interruptions logicielles.
Une interruption logicielle est une instruction assembleur que le programme exécute normale-
ment depuis l’espace utilisateur. Toutefois, lorsque l’instruction est décodée par le processeur,
tout se passe comme si une interruption matérielle était survenue : le processeur sauvegarde ses
registres, passe en mode superviseur et se branche à l’adresse indiquée dans la table des vec-
teurs d’interruption. Cette routine se trouve naturellement dans l’espace kernel et va réaliser le
travail demandé, après avoir vérifié les permissions du processus appelant. Une fois l’opération
terminée, au retour du gestionnaire d’interruption, le contrôle repasse au processus en mode
utilisateur et l’exécution reprend juste après l’instruction de l’interruption logicielle.
exemple-appel-systeme.c :
#include <stdio.h>
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
char * message = "Hello\n";
write (STDOUT_FILENO, message, strlen(message));
return EXIT_SUCCESS;
}
Nous allons centrer notre suivi sur l’appel système write(). Il est important de compiler le
programme avec l’option -static de gcc, ce qui peut nécessiter, suivant les distributions,
l’installation d’un package supplémentaire libc-static. Ainsi, nous sommes certains que l’implé-
mentation de write() côté utilisateur est entièrement intégrée dans le fichier exécutable et nous
pourrons l’examiner avec objdump.
Le code assembleur produit dépend des versions de la bibliothèque C employée et peut donc
varier légèrement. Il s’agit ici d’un exemple réalisé sur un PC embarqué 32 bits.
Nous allons rechercher successivement plusieurs chaînes de caractères dans la sortie de objdump.
Commençons par main :
080482c0 <main>:
80482c0: 55 push %ebp
80482c1: 89 e5 mov %esp,%ebp
[...]
80482d8: e8 b3 6f 00 00 call 804f290 <strlen>
80482dd: 89 44 24 08 mov %eax,0x8(%esp)
80482e1: 8b 44 24 1c mov 0x1c(%esp),%eax
80482e5: 89 44 24 04 mov %eax,0x4(%esp)
80482e9: c7 04 24 01 00 00 00 movl $0x1,(%esp)
80482f0: e8 bb ad 00 00 call 80530b0 <_libc_write>
80482f5: b8 00 00 00 00 mov $0x0,%eax
80482fa: c9 leave
80482fb: c3 ret
On remarque l’appel à la fonction libc_write, précédé de quelques instructions qui placent suc-
cessivement dans la pile du procesus : le retour de la fonction strlen (à l’adresse 80482dd), le
pointeur sur la chaîne de caractères (en 80482e5) et le numéro du descripteur STDOUT de valeur 1
(en 80482e9), tout ceci représentant les arguments de la fonction write().
Comme nous avons compilé notre processus de manière statique, nous retrouvons l’implémenta-
tion de libc_write un peu plus loin :
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
080530b0 <_libc_write>:
[...]
80530bb: 8b 54 24 10 mov 0x10(%esp),%edx
80530bf: 8b 4c 24 0c mov 0xc(%esp),%ecx
80530c3: 8b 5c 24 08 mov 0x8(%esp),%ebx
80530c7: b8 04 00 00 00 mov $0x4,%eax
80530cc: ff 15 98 f0 0c 08 call *0x80cf098
80530d2: 5b pop %ebx
80530d3: 3d 01 f0 ff ff cmp $0xfffff001,%eax
80530d8: 0f 83 02 20 00 00 jae 80550e0 <_syscall_error>
80530de: c3 ret
Après avoir rempli les registres ebx, ecx et edx avec les valeurs extraites de la pile, et le registre
eax avec la valeur 4 (représentant l’appel système write()), le programme va se brancher sur une
routine dont l’emplacement est inscrit à l’adresse 0x080cf098.
080cf098 <_dl_sysinfo>:
80cf098: d0 45 05 rolb 0x5(%ebp)
80cf09b: 08 00 or %al,(%eax)
Ici, ce ne sont pas les instructions assembleur qui nous intéressent mais bien les valeurs stockées
à cet emplacement : d0, 45, 05 et 08, qui correspondent à une adresse (stockée poids faible en
premier sur architecture PC) : 0x080545d0.
080545d0 <_dl_sysinfo_int80>:
80545d0: cd 80 int $0x80
80545d2: c3 ret
À cette adresse, nous trouvons bien l’instruction int qui déclenche une interruption logicielle. Il
s’agit de l’interruption 0x80 – sur un PC – qui est utilisée pour entrer dans le noyau quel que soit
l’appel système invoqué. La valeur stockée précédemment dans eax, qui correspond au numéro
de l’appel système, servira, une fois dans le noyau, à sélectionner la routine à appeler.
Le lecteur intéressé pourra suivre la suite de l’appel système en consultant les fichiers suivants
du kernel Linux (je considère ici le code source du noyau 3.0) :
• au boot, la fonction trap_init() du fichier arch/x86/kernel/traps.c installe la routine sys-
tem_call() comme gestionnaire de l’interruption 0x80 (appelée SYSCALL_VECTOR ) ;
• la routine system_call implémentée dans le fichier assembleur arch/x86/kernel/entry_32.S
appelle la fonction se trouvant au rang correspondant à la valeur eax dans la table
sys_call_table ;
• l’entrée 4 (valeur chargée dans eax avant l’interruption) de la table arch/x86/kernel/syscall_
table_32.S est un pointeur vers la fonction sys_write() mentionnée plus haut.
Ainsi, le passage du mode utilisateur au mode noyau par interruption logicielle peut être résumé
par la figure 2-3.
Figure 2-3
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
À noter que sur certaines architectures modernes, des instructions assembleur spécifiques
(SYSENTER/SYSEXIT) permettent de s’affranchir du mécanisme d’interruption logicielle en effec-
tuant un branchement direct sur un gestionnaire dans le noyau de manière plus rapide et
efficace ; néanmoins le principe reste globalement le même.
Threads du noyau
Une fois le boot achevé, le noyau lance le premier processus, init, dont le PID vaut 1, pour conti-
nuer l’initialisation. Celui-ci lance les premiers services, les démons, les connexions utilisateur,
etc.
Ensuite, le contrôle ne reviendra au noyau que sur les événements suivants.
• Un périphérique externe doit signaler quelque chose au système (expiration d’un timer,
disponibilité de données reçues, échec d’envoi, modification de l’état d’un capteur, etc.) et
déclenche une interruption.
• Un processus utilisateur a commis une erreur nécessitant l’intervention du noyau, le matériel
signale le problème par l’intermédiaire d’une exception.
• Une application de l’espace utilisateur doit sous-traiter une opération au mode superviseur et
effectue donc un appel système qui se traduit habituellement par une interruption logicielle.
Dans tous ces cas, c’est le mécanisme des interruptions (ou assimilé) qui est utilisé. Il s’agit du
seul point d’entrée dans le noyau et nous lui accorderons une attention particulière lorsque nous
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
$ ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 3040 1752 ? Ss Aug30 0:03 /sbin/init
root 2 0.0 0.0 0 0 ? S Aug30 0:00 [kthreadd]
root 3 0.0 0.0 0 0 ? S Aug30 1:59 [ksoftirqd/0]
root 6 0.0 0.0 0 0 ? S Aug30 0:00 [migration/0]
root 7 0.0 0.0 0 0 ? S Aug30 0:00 [migration/1]
root 9 0.0 0.0 0 0 ? S Aug30 1:08 [ksoftirqd/1]
root 11 0.0 0.0 0 0 ? S Aug30 0:00 [migration/2]
root 13 0.0 0.0 0 0 ? S Aug30 0:10 [ksoftirqd/2]
root 14 0.0 0.0 0 0 ? S Aug30 0:00 [migration/3]
root 16 0.0 0.0 0 0 ? S Aug30 0:11 [ksoftirqd/3]
root 17 0.0 0.0 0 0 ? S< Aug30 0:00 [cpuset]
root 18 0.0 0.0 0 0 ? S< Aug30 0:00 [khelper]
root 19 0.0 0.0 0 0 ? S< Aug30 0:00 [netns]
root 21 0.0 0.0 0 0 ? S Aug30 0:01 [sync_supers]
[...]
On peut remarquer que les valeurs des colonnes VSZ (Virtual Memory Size, taille de la mémoire
virtuelle) et RSS (Resident Set Size, portion de la mémoire virtuelle présente en mémoire phy-
sique) sont à 0 pour tous ces threads. En effet, ils s’exécutent directement dans la mémoire
kernel et ne disposent pas d’espace mémoire propre.
Comme nous le verrons plus loin, il est possible sur les noyaux récents de faire traiter les
handlers des interruptions matérielles par des threads du noyau. Ces derniers étant ordonnancés,
ils disposent d’une priorité d’exécution configurable que nous pourrons régler en fonction des
autres tâches temps réel du système.
La seule différence d’exécution notable entre un kernel thread et une tâche de l’espace utilisa-
teur, est que certains kernel threads peuvent être attachés à des processeurs – ou cœurs – bien
précis (par exemple, ksoftirqd/0, ksoftirqd/1, migration/1, migration/3, etc., dans l’exemple
précédent) et ne pourront pas être déplacés à notre guise.
Conclusion
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Dans ce chapitre, nous avons observé les points d’entrées du noyau Linux. Plus particulièrement,
nous avons remarqué l’importance des interruptions (matérielles ou logicielles) pour l’activation
de l’espace kernel.
Dans le chapitre suivant, nous étudierons les méthodes utilisées par le scheduler pour détermi-
ner quelle tâche activer.
Points clés
Pour passer de l’espace utilisateur à l’espace noyau, trois possibilités : l’interruption matérielle
– provenant de l’extérieur du système –, l’exception signalant une erreur d’exécution et l’appel
système qui permet à un processus utilisateur de demander au noyau de réaliser une opération
privilégiée pour son compte.
Linux peut lancer l’exécution de tâches (les kernel threads) fonctionnant avec les privilèges et
l’espace mémoire du noyau, tout en étant ordonnancées comme les autres tâches du système.
Il est possible de fixer l’affinité d’une interruption afin que le gestionnaire se déroule sur un
processeur spécifique. Ceci permet d’isoler un traitement temps réel des fluctuations liées aux
interruptions.
Exercices
Exercice 1 (*)
Observez les interruptions de votre système, en utilisant la commande :
qui affiche le contenu du fichier /proc/interrupts tous les dixièmes de secondes (suivant la loca-
lisation de votre système, il vous faudra peut-être utiliser 0,1 plutôt que 0.1).
Voyez-vous une interruption timer se produire régulièrement ? Avec quelle fréquence ?
Exercice 2 (*)
Déterminez par l’observation quelle interruption se produit lorsque vous déplacez la souris de
votre système ou lorsque vous pressez une touche.
Exercice 3 (**)
Si votre système est multiprocesseur, modifiez l’affinité des interruptions identifiées précédem-
ment et vérifiez si elles se déplacent bien sur le processeur choisi.
Exercice 4 (**)
Écrivez un programme qui tente de réaliser une division par zéro. Exécutez-le, que se passe-t-il ?
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Exercice 5 (***)
Comment pouvez-vous écrire un programme qui déclenche l’interruption SIGILL ?
Ordonnancement temps
partagé et priorités
Les tâches s’exécutant sur un système Linux se déroulent typiquement sous un ordonnancement
temps partagé. Bien sûr, nous verrons par la suite comment les forcer à s’exécuter en temps réel,
mais il est important de comprendre le fonctionnement de l’ordonnancement temps partagé pour
ajuster au mieux les performances d’un système interactif réalisant des traitement en temps réel.
Temps partagé
Principes
Linux, en tant que descendant d’Unix, est un système multitâche et multi-utilisateur ordonnancé
en temps partagé. L’article original de Dennis Ritchie et Ken Thomson, « The Unix Time-Sha-
ring System », datant de 1974 décrit le premier système Unix et l’aspect temps partagé de ce
système multitâche1.
Le temps partagé, que l’on confond parfois avec le multitâche préemptif, est un mode d’ordon-
nancement essayant de répartir le plus équitablement possible le temps CPU disponible, afin que
plusieurs tâches puissent s’exécuter de manière apparemment simultanée sur le même proces-
seur. Pour cela, un composant du noyau, le scheduler (ordonnanceur), est chargé de commuter
l’exécution du CPU entre les différentes tâches prêtes (Runnable) comme nous l’avons évoqué
au chapitre 1.
Examinons les situations dans lesquelles l’ordonnanceur peut cesser l’exécution d’une tâche T1
pour activer une autre tâche. Il existe principalement trois cas de figure.
1. On trouve aisément des copies de cet article sur Internet en saisissant son titre dans un moteur de recherche. Il est
intéressant de voir comment les concepts exposés sont toujours aussi présents dans Linux plus de 40 ans après sa
publication.
• La tâche T1 invoque un appel système durant lequel elle est mise en sommeil en attente d’un
événement futur (réception de données, lecture d’un fichier, timer, etc.). Le noyau pourra alors
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
activer une autre tâche en attente. Nous pouvons observer cette commutation sur la figure 3-1
où le temps s’écoule de gauche à droite. Initialement, la tâche T1 est Running (active) et
la tâche T2 est Runnable (prête). Lorsque T1 invoque un appel système durant lequel elle
s’endort, l’ordonnanceur va alors choisir d’activer T2 qui passe à l’état Running. Si aucune
tâche n’est prête quand T1 s’endort, l’ordonnanceur peut invoquer une routine spécifique du
noyau, que l’on nomme traditionnellement la boucle idle (inactive) au sein de laquelle il est
possible sur certaines architectures d’arrêter le processeur jusqu’à la prochaine occurrence
d’une interruption matérielle.
Figure 3-1
Commutation sur appel
système bloquant
• Enfin, le dernier type de commutation se présente ainsi : lorsque le noyau a activé T1, il lui
a accordé une certaine tranche de temps (timeslice), dont la longueur varie en fonction de la
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
priorité de la tâche. Au bout de cette durée, si T1 n’a pas cédé le processeur volontairement,
elle sera préemptée par le noyau. Si une autre tâche T2 est dans l’état Runnable, elle sera
activée à son tour. Sinon, T1 sera réactivée. La figure 3-3 illustre cette situation : la tâche T1
est activée et conserve le processeur jusqu’à l’expiration d’un timer qui invoque l’ordonnan-
ceur. Celui-ci juge qu’il est temps d’activer la tâche T2 et de laisser T1 dans l’état Runnable
jusqu’à l’expiration de la nouvelle tranche de temps accordée à T2, où elle sera probablement
réactivée.
Figure 3-3
Commutation sur expiration
de timer
Ce dernier exemple de commutation est probablement le mieux connu, bien qu’il ne représente
en réalité qu’un cas relativement rare. Il se produit lorsque plusieurs tâches sont actives simulta-
nément pour consommer du temps processeur (jobs de calcul, par exemple). Sur la plupart des
systèmes Linux courants, les commutations 1 et 2 sont les plus fréquentes puisque c’est sur l’oc-
currence d’événements externes via les interruptions que la majorité des commutations a lieu.
En résumé, une tâche placée sur le processeur pourra être interrompue soit volontairement en
invoquant un appel système bloquant, soit involontairement lors de l’arrivée d’une interrup-
tion réveillant une tâche plus prioritaire ou lors de l’expiration d’un timer indiquant qu’elle a
consommé toute la tranche de temps qui lui était allouée.
Dans tous les cas, l’ordonnanceur de Linux est mis à contribution pour déterminer quelle sera
la prochaine tâche à activer. Dans un environnement à ordonnancement temps partagé, c’est de
la qualité de cet ordonnanceur, de la pertinence de ses choix et de sa rapidité de décision que
dépendront la souplesse et la fluidité du système pour l’utilisateur.
Dans un environnement temps réel, il n’y a pas réellement de choix laissé à l’ordonnanceur, il se contente
– comme nous le verrons par la suite – de toujours activer la tâche la plus prioritaire.
Sous Linux, plusieurs ordonnanceurs temps partagé ont été implémentés, avec des améliora-
tions successives. Nous allons examiner quelques versions marquantes.
Ordonnanceur historique
Depuis les premières versions de Linux, l’ordonnanceur est implémenté par la fonction
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Pour étudier confortablement les sources de Linux, je conseille le site web suivant, qui permet de naviguer
aisément entre les versions, les répertoires et les fichiers :
▸▸ http://lxr.free-electrons.com
Le premier ordonnanceur a été implémenté dès le noyau 0.0.1 en 1991. Il n’a pas fondamen-
talement changé avant la version 2.6 diffusée fin 2003 (plus exactement avant le noyau de
développement 2.5.0, mais je préfère considérer les versions stables du kernel).
La fonction schedule() de cet ordonnanceur passe en revue toutes les tâches dans l’état Runnable,
calculant pour chacune d’entre elles un poids indiquant à quel point il est intéressant d’activer
cette tâche. À partir de la version 1.3.36 du kernel, une fonction spécifique nommée goodness()
a été introduite pour calculer cette valeur.
Le calcul de la qualité d’une tâche s’effectue ainsi.
• Une tâche déjà activée sur un autre CPU reçoit un poids de -1 000 qui l’empêche d’être
sélectionnée.
• Si une tâche est ordonnancée en temps réel, elle reçoit immédiatement un poids de 1 000
additionné à sa priorité. Ceci permet de sélectionner instantanément la tâche temps réel la
plus prioritaire, sans qu’elle soit concurrencée par les tâches temps partagé (dont le poids
ne peut dépasser 140 environ). Cette prise en compte des ordonnancements temps réel est
apparue dans le noyau 1.4 (1.3.43 pour la branche de développement).
• Pour les tâches temps partagé, le poids est initialisé avec le nombre de ticks système non
consommés dans sa tranche de temps. Nous reviendrons sur les ticks dans le prochain cha-
pitre, la valeur maximale était environ de 80.
• Si la tâche s’exécutait précédemment sur le même CPU, son poids est augmenté d’une valeur
arbitraire (15 sur un PC) pour prendre en compte le coût de la migration des processus entre
CPU.
• Si la configuration de la mémoire virtuelle de la tâche est déjà chargée dans la MMU du pro-
cesseur, le poids est incrémenté de 1 pour favoriser très légèrement les commutations entre
threads du même processus.
• On ajoute au poids une valeur de priorité fournie par l’utilisateur, allant de 1 à 40, nous la
décrirons plus loin.
Cet ordonnanceur fonctionnait bien, mais il posait un problème sur les serveurs comportant
un grand nombre de tâches : la fonction schedule() devait à chaque invocation passer en revue
toutes les tâches prêtes pour choisir celle à activer.
Le temps pris pour se décider était ainsi directement proportionnel au nombre de tâches dans
l’état Runnable. Si l’on a N tâches prêtes, la fonction schedule() prendra un temps proportionnel
à N pour se décider. En algorithmique, on dit que l’ordonnanceur est donc d’une complexité
O(N).
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Par ailleurs, le comportement des premiers noyaux n’était pas aussi souple que l’on pouvait
l’espérer et lors d’une grosse phase de calcul ou de compilation, le fonctionnement de l’interface
utilisateur était sensiblement ralenti.
Figure 3-4
Premier ordonnanceur du
noyau 2.6
Lorsque schedule() est appelée, elle n’aura qu’à prendre la première tâche prête en partant du
haut de la table active. Ceci est implémenté avec un algorithme qui garantit que le temps d’exé-
cution reste constant, quel que soit le nombre de tâches dans la table.
La tâche choisie (P1 sur la figure 3-4) va être placée sur le processeur, et le noyau lui accordera
une tranche de temps dont la durée variera en fonction de sa priorité (plus une tâche est priori-
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
taire, plus longue est sa tranche de temps). Comme nous l’avons vu chapitre 2, trois cas mènent
à l’éviction de P1.
• P1 s’endort dans un appel système bloquant : elle sort donc des listes de tâches actives et
schedule() passe à la suivante.
• Une autre tâche plus prioritaire se réveille suite à une interruption : la tâche P1 va donc
regagner la table active, le noyau notant qu’elle a déjà consommé une partie de sa tranche de
temps.
• P1 consomme tout le temps qui lui était imparti et un timer indique au noyau qu’il faut la
préempter : elle va donc passer dans la table expired et schedule() activera la suivante.
Peu à peu, active va se vider au profit de expired, à chaque fois qu’une tâche consomme toute
sa tranche de temps. Une fois active entièrement vide, le noyau intervertit les deux tables et
reprend sa progression.
Cet algorithme présente de nombreux avantages. Nous avons déjà évoqué le fait qu’il soit rapide
à exécuter et que sa durée n’augmente pas en fonction du nombre de tâches prêtes. Un autre
point important est l’absence totale de « famine » : même la tâche la moins prioritaire est sûre
de pouvoir s’exécuter une fois (pendant une durée très courte, il est vrai) entre deux exécutions
successives des tâches plus prioritaires (dont les tranches de temps sont nettement plus longues).
Lorsqu’un processus est placé dans la table expired, le noyau peut jouer sur sa position en fonc-
tion de son comportement précédent. Ainsi, une tâche fortement consommatrice de temps CPU
(du calcul, par exemple) sera-t-elle « punie » modérément par l’ordonnanceur, tandis qu’une
tâche passant beaucoup de temps endormie dans des appels système bloquants (attente de
déplacement de la souris, de pression sur le clavier, etc.) sera considérée comme interactive
et légèrement favorisée par rapport aux autres. De cette manière, dès qu’une interruption de
déplacement de la souris se déclenchera, l’interface graphique sera activée et pourra réagir ins-
tantanément, au détriment des jobs de compilation, par exemple.
Il faut noter que la complexité de la fonction d’insertion dans une table est O(log(N)) – seule
l’extraction d’une tâche dans la table est en O(1) – ce qui reste tout à fait raisonnable, même pour
un nombre conséquent de tâches parallèles.
Ordonnanceur CFS
Pour le noyau 2.6.23, Ingo Molnár a développé un nouvel ordonnanceur, dont les performances
sont jugées meilleures que celles de son prédécesseur. Il s’agit du CFS (Completely Fair Sche-
duler, ordonnanceur totalement équitable), dont le but est de partager le plus équitablement
possible le temps CPU disponible entre les tâches.
Le CFS ne gère plus les tâches à travers des listes d’attente classées par priorité. Il les trie
en se basant sur la valeur de virtual runtime de chaque tâche, correspondant au temps CPU
consommé. À chaque invocation de l’ordonnanceur, celui-ci active la tâche ayant le plus manqué
de temps CPU (celle dont le virtual runtime est le plus court). Si cette tâche s’endort ou si elle est
préemptée, on passe à la suivante ayant la valeur de virtual runtime la plus faible.
Pour organiser les tâches en attente, l’ordonnanceur CFS s’appuie sur une structure de données
nommées red-black tree. Il s’agit d’arbres binaires dont chaque nœud est coloré en rouge ou
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
en noir. Quelques règles permettent d’ajouter, de supprimer des éléments dans l’arbre ou de le
trier avec une complexité O(log(N)), ce qui signifie que pour un nombre N de tâches en attente,
l’extraction ou l’insertion prendront au maximum une durée proportionnelle à log(N). Ceci reste
très raisonnable même lorsque N croît de manière importante.
Les avantages de l’ordonnanceur CFS sur l’ordonnanceur en O(1) précédent sont essentiellement
liés aux priorités entre les tâches. Le CFS les prend en considération dans le calcul du temps
attribué à la tâche avant préemption. Comparativement à son prédécesseur, le CFS offre les
bénéfices suivants.
• L’influence de la priorité fixée par l’utilisateur sur la durée accordée à la tâche avant sa pré-
emption est uniforme : augmenter d’une unité la priorité multiplie le temps disponible par 1,1.
• Les durées avant préemption varient nettement avec les priorités temps partagé, ce qui rend
ces dernières beaucoup plus sensibles qu’avec l’ordonnanceur précédent.
• En conséquence, il est possible de faire fonctionner des applications imposant des contraintes
temporelles (lecteur vidéo, par exemple) en temps partagé avec une priorité élevée, alors
qu’auparavant il était indispensable de les exécuter selon un ordonnancement temps réel.
Une autre évolution de l’ordonnanceur Linux est d’être devenu relativement générique et d’ap-
peler des routines qui dépendent de modules chargés dans le noyau. Ceci devrait permettre
d’expérimenter plus facilement les évolutions du scheduler.
Groupes de processus
Les premières versions de l’ordonnanceur CFS assuraient une répartition équitable du temps
CPU entre les tâches (en prenant bien sûr en considération les priorités temps partagé que nous
décrirons plus loin). Toutefois, ceci pose un problème dans le cas d’un serveur multi-utilisateur
ou même du poste de travail d’un développeur : si on lance un gros job de calcul ou de compi-
lation avec de nombreuses tâches en parallèle (par exemple, un make -j 64 dans la compilation
du noyau Linux), cela va pénaliser le fonctionnement interactif du système ou des applications
multimédias (lecteur vidéo, par exemple).
Des améliorations proposées par Srivatsa Vaddagiri ont permis de prendre en compte les
groupes de processus. Cette notion de groupe (qui jusqu’alors ne servait principalement qu’au
shell pour envoyer des signaux à des ensembles de processus lancés en arrière-plan) va per-
mettre de répartir plus équitablement le temps CPU entre les différentes activités du système.
Si cinq processus de calcul appartiennent à un groupe et deux processus à un autre groupe,
ceux du premier groupe recevront 10 % du temps CPU et ceux du second 25 %. Ainsi, chaque
groupe bénéficiera de 50 % du temps et la répartition se fera équitablement entre les processus
du groupe.
Le lecteur intéressé pourra trouver de la documentation sur les groupes de processus dans le répertoire
Documentation/cgroups des sources du noyau Linux. Ce mécanisme permet de contrôler les ressources
(mémoire, temps processeur, CPU, etc.) qui sont affectées à chaque groupe de processus.
La gestion des groupes de processus étant assez complexe à organiser pour l’administrateur,
une option est apparue dans le noyau 2.6.38, implémentée par Mike Galbraith. Elle permet de
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
On notera que les mesures sont ici très grossières, avec une précision de l’ordre de la seconde. On
peut si besoin affiner les calculs en employant gettimeofday() qui fournit le complément horaire en
microsecondes.
./exemple-taux-cpu.c
#include <stdio.h>
#include <stdlib.h>
2. L’option à activer lorsque l’on compile soi-même son kernel est Automatic process group scheduling du menu
General setup.
#include <unistd.h>
#include <time.h>
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
debut = time(NULL);
nb_boucles = 0;
while (time(NULL) < (debut + 10)) {
nb_boucles ++;
}
printf("[%d] nb_boucles : %ld ", getpid(), nb_boucles);
if ((argc == 2)
&& (sscanf(argv[1],"%ld", & nb_boucles_max) == 1)){
printf("(%.0f)",
100.0 * nb_boucles / nb_boucles_max);
}
printf("\n");
return EXIT_SUCCESS;
}
Comme nous voulons étudier les partages de temps CPU, il est important que tous les processus
que nous lançons s’exécutent sur le même processeur (même cœur). Aussi, commençons-nous
par fixer l’affinité du shell et celle de ses descendants :
$ taskset -pc 0 $$
pid 20545's current affinity list: 0-3
pid 20545's new affinity list: 0
$
Puis, nous exécutons à plusieurs reprises le programme tout seul pour avoir une estimation du
nombre maximal de boucles en dix secondes :
$ ./exemple-taux-cpu
[4666] nb_boucles : 81483838
$ ./exemple-taux-cpu
[4673] nb_boucles : 76501287
$ ./exemple-taux-cpu
[4678] nb_boucles : 76312723
$ ./exemple-taux-cpu
[4681] nb_boucles : 81475481
$
Sur ce système, on réalise donc environ 81 000 000 itérations par dizaines de secondes. Je
répète que je ne recherche pas une valeur précise – sinon j’aurai appelé gettimeofday() – mais
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Les résultats ont été légèrement édités pour supprimer les messages parasites du shell lors du lancement
de processus en parallèle et l’affichage de son prompt alors qu’un processus tourne encore à l’arrière-plan.
Nous voyons bien que la répartition du temps est de 50 % pour chaque processus lorsqu’il y en a
deux, et de 33,3 % lorsqu’il y en a trois.
Vérifions la répartition entre deux terminaux. Dans un nouveau terminal, nous fixons également
l’affinité du shell :
$ taskset -pc 0 $$
pid 527’s current affinity list: 0-3
pid 527’s new affinity list: 0
$
Le sommeil d’une seconde avant démarrage des processus laisse le temps de commuter sur
l’autre terminal pour lancer simultanément les deux commandes.
Nous voyons bien que l’ordonnanceur a attribué 50 % du temps CPU à chaque terminal, ce qui
se traduit par 5 % pour chacune des dix tâches du premier et 25 % pour les deux processus du
second.
Autres ordonnanceurs
Il existe d’autres ordonnanceurs qui n’ont pas été intégrés dans la version standard de Linux :
Con Kolivas en a notamment développés plusieurs. Son algorithme RSDL (Rotating Stair-
case Deadline Scheduler) a été supplanté au dernier moment par le CFS qui s’en inspirait, en
octobre 2007, ce qui l’a poussé à abandonner le développement noyau pendant plusieurs mois.
De retour en septembre 2009, il a présenté un nouvel ordonnanceur, spécialisé pour les postes
de travail mono-utilisateur, nommé par provocation BFS (Brain Fuck Scheduler) montrant son
animosité envers le CFS d’Ingo Molnár. Cet algorithme n’est a priori pas destiné à être inclus
dans le noyau officiel, mais existe sous forme de patch pour modifier les sources du kernel
standard.
Attention à ne pas confondre ces priorités, qui concernent uniquement l’ordonnancement temps partagé
avec les priorités temps réel que nous examinerons dans la seconde partie de ce livre.
Suivant la tradition des systèmes Unix, l’ordonnanceur Linux propose à l’utilisateur d’indiquer
la courtoisie, la gentillesse (nice) du processus, c’est-à-dire une valeur qui représente la facilité
avec laquelle le processus laisse passer les autres. Traditionnellement, cette valeur – qui pro-
gresse donc inversement à la priorité du processus – va de -20 pour un processus très agressif
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
à +19 pour un processus très gentil. Généralement, les processus sont lancés par défaut avec la
valeur 0 et il est nécessaire d’avoir les privilèges root pour pouvoir diminuer la gentillesse (aug-
menter la priorité) d’un processus.
On voit bien, à ce propos, qu’Unix s’est développé essentiellement dans des laboratoires et
centres de recherches, où il était important, lorsqu’on lançait un long job de calcul intensif sur
le serveur, de ne pas pénaliser les autres utilisateurs connectés. Pour cela, il était très facile
d’utiliser la commande nice depuis le shell pour rendre notre processus plus gentil au niveau de
l’ordonnancement. Ainsi, il s’exécutait lors d’un temps légèrement plus long, mais on évitait les
récriminations des autres utilisateurs présents.
Depuis le shell, on utilise :
pour lancer la commande (et ses arguments) avec une valeur de gentillesse augmentée de l’in-
crément précisé.
Seul root peut faire descendre la valeur de gentillesse en dessous d’une limite – configurable –
valant généralement zéro. Cette limite peut être consultée (et modifiée) avec la commande ulimit
-e. Toutefois, cette commande ne fournit pas directement la valeur limite, mais 20-limite. Si
ulimit -e renvoie 15, cela signifie qu’un processus doit avoir les privilèges root pour descendre
sous la valeur nice 20-15 = 5. Par défaut, ulimit -e renvoie 20, donc la limite est 20-20 = 0.
Reprenons notre exemple précédent, en lançant deux tâches en parallèle, avec des valeurs nice
différentes.
Nous constatons qu’il y a de nettes différences de temps CPU accordés aux tâches dont les
valeurs nice sont différentes, même lorsque l’incrément ne vaut que 5. Voyons ce que donne
l’exécution de deux processus, dont les priorités sont aux deux extrêmes de l’échelle permise.
Pour cela, nous prenons les droits root :
Le premier processus (le plus agressif) a pu réaliser près de 5 000 fois le nombre d’itérations du
second !
Il est possible de modifier, ou de consulter, la courtoisie d’un processus avec l’un des appels
système suivants :
#include <unistd.h>
int nice (int increment);
qui ajoute l’incrément précisé à la gentillesse du processus appelant (et renvoie sa nouvelle
valeur) :
#include <sys/time.h>
#include <sys/resource.h>
Dans ces deux derniers appels système, on indique à travers l’argument type les processus
concernés. S’il vaut PRIO_PROCESS, le second argument est le PID du processus visé, s’il vaut
PRIO_PGRP, l’identifiant est un PGID (Process Group Identifier), et si le type est PRIO_USER, le
deuxième argument est un UID (User Identifier).
Comme précédemment, on indique en réalité une valeur de courtoisie allant de -20 (le plus
prioritaire) à +19 (le plus gentil).
exemple-nice-threads.c
#include <pthread.h>
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
nice(nice_value);
debut = time(NULL);
#define NB_THREADS 5
int main(void)
{
int i;
void * resultat;
long nice_value[NB_THREADS];
pthread_t thread[NB_THREADS];
long total_iterations = 0;
long nb_iterations[NB_THREADS];
L’exécution confirme bien que les threads de la NPTL peuvent recevoir des priorités temps par-
tagé différentes :
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
$ ./exemple-nice-threads
[4] 33857744 (60)
[8] 13777677 (24)
[12] 5601120 (10)
[16] 2320758 (4)
[20] 1204927 (2)
$
Notez bien que ce comportement est spécifique et ne sera pas portable sur d’autres Unix, ni sur
des systèmes Linux (embarqués, par exemple) employant d’autres bibliothèques C.
Conclusion
Nous avons étudié le fonctionnement de l’ordonnancement temps partagé sous Linux. Dans
le prochain chapitre, nous allons voir ses limitations, afin de savoir quelles restrictions pour-
ront nous pousser à préférer un ordonnancement temps réel pour développer une application
interactive.
Points clés
L’ordonnancement temps partagé a beaucoup évolué au fil des versions du noyau Linux. Souple,
fluide, équitable, le modèle actuel est performant, tant pour les stations de travail personnelles
que pour les serveurs à haute charge.
La répartition du temps processeur s’effectue d’abord au niveau des groupes de processus, puis
tâche par tâche dans chaque groupe.
On peut fixer la courtoisie – c’est-à-dire l’opposé de la priorité – d’un processus et même d’un
thread (spécificité de Linux).
Exercices
Exercice 1 (*)
Utilisez le programme exemple-taux-cpu présenté précédemment pour déterminer le nombre de
boucles que peut effectuer votre processeur en 10 secondes. Lancez-en deux, puis trois exem-
plaires en parallèle. Vérifiez les variations sur le nombre de boucles effectuées. Pensez à fixer
l’affinité de vos processus sur le même CPU.
Exercice 2 (*)
Utilisez la commande nice pour préfixer un exemplaire du programme, tandis que vous lancerez
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Exercice 3 (**)
Écrivez un script shell pour lancer successivement le programme de mesure des boucles aux
différents niveaux de nice entre 1 et +19 (ou -20 et +19 avec les droits root) en parallèle avec
une autre instance au niveau nice 0. Observez l’échelle des priorités en fonction du niveau de
courtoisie des processus.
Exercice 4 (***)
Comparez les résultats de l’exercice précédent sur des noyaux Linux de différentes générations
et avec différentes options de compilation :
• Noyau Linux 2.6 avant le 2.6.23 ;
• Linux postérieur au 2.6.23 et antérieur au 2.6.38 ;
• Noyau postérieur au 2.6.38 compilé avec ou sans l’autogroupement des processus ;
• ...
Exercice 5 (***)
En vous appuyant sur la documentation du noyau, placez plusieurs tâches dans des cgroups dis-
tincts dont les affinités seront disjointes. Peut-on toujours déplacer les processus avec taskset ?
avec sched_setaffinity ? d’un cgroup à l’autre ?
4
Limitations
de l’ordonnancement
temps partagé
Dans le chapitre précédent, nous avons examiné les fondements de l’ordonnancement temps
partagé. Efficace et performant, il est parfaitement adapté aux tâches courantes d’un poste
de développement ou de bureautique. Pourtant, certaines applications (media player, entrées-
sorties, etc.) s’en satisfont difficilement. Nous allons essayer de comprendre les limites qui
handicapent le scheduler temps partagé de Linux pour ces applications.
Mesure du temps
Tout d’abord, nous allons devoir nous appuyer sur des mesures horaires pour vérifier la durée
d’opérations critiques. Aussi, devons-nous trouver des outils capables de nous fournir l’heure
assez précisément.
La fonction time() bien connue fournit l’heure dans un type entier time_t qui contient le nombre
de secondes écoulées depuis le 1er janvier 1970. Pratique pour mesurer des opérations à l’échelle
humaine (uptime depuis le boot, durée d’exécution d’une grosse compilation, etc.), elle n’est pas
assez pointue pour des tâches informatiques précises.
time_t time (time_t * heure);
un champ tv_sec avec la même valeur que time() précédemment, mais également un champ
tv_usec qui contient le complément en microsecondes.
#include <sys/time.h>
int gettimeofday (struct timeval * tv,
struct timezone * tz);
Le second argument de gettimeofday() permet de lire le fuseau horaire pour lequel le système
est configuré. En pratique, cette valeur est rarement utilisée et lors de la plupart des appels de
gettimeofday(), le second argument est NULL.
#define NB_MESURES 30
$ ./exemple-gettimeofday-01
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
0 : 1436801387.696325
1 : 1436801387.696326
2 : 1436801387.696326
3 : 1436801387.696326
4 : 1436801387.696326
5 : 1436801387.696326
6 : 1436801387.696326
7 : 1436801387.696326
8 : 1436801387.696326
9 : 1436801387.696326
10 : 1436801387.696326
11 : 1436801387.696326
12 : 1436801387.696326
13 : 1436801387.696327
14 : 1436801387.696327
15 : 1436801387.696327
16 : 1436801387.696327
17 : 1436801387.696327
18 : 1436801387.696327
19 : 1436801387.696327
20 : 1436801387.696327
21 : 1436801387.696327
22 : 1436801387.696327
23 : 1436801387.696327
24 : 1436801387.696327
25 : 1436801387.696327
26 : 1436801387.696328
27 : 1436801387.696328
28 : 1436801387.696328
29 : 1436801387.696328
[chapitre-04]$
Nous pouvons remarquer que les appels successifs renvoient des valeurs parfaitement régu-
lières, les microsecondes se déroulent sans saut (donc notre programme n’a pas été préempté).
Par ailleurs, on réussit à faire treize appels à gettimeofday() par microseconde. Sur une machine
plus puissante, on observera couramment une vingtaine d’appels par microseconde.
Cet appel système est bien adapté pour horodater des événements avec une précision de l’ordre
de la microseconde sur ce système.
Voici à présent un extrait d’une exécution sur un Raspberry Pi modèle 1 :
4 : 1411876365.577818
5 : 1411876365.577818
6 : 1411876365.577819
7 : 1411876365.577820
8 : 1411876365.577821
9 : 1411876365.577821
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
10 : 1411876365.577822
11 : 1411876365.577823
12 : 1411876365.577824
13 : 1411876365.577824
14 : 1411876365.577825
15 : 1411876365.577827
16 : 1411876365.577827
17 : 1411876365.577828
18 : 1411876365.577828
19 : 1411876365.577829
20 : 1411876365.577830
Cette fois, nous constatons que la précision des mesures n’est plus aussi régulière. Parfois deux
invocations successives (4 et 5, 8 et 9, etc.) se déroulent durant la même microseconde, parfois il
peut s’écouler plus d’une microseconde entre deux appels (14 et 15).
Nous allons encore exécuter ce même programme sur une autre plate-forme à processeur Arm
pour Linux embarqué (Igep v.2). Voici un extrait du résultat :
# ./exemple-gettimeofday-arm
0 : 225.572906
1 : 225.572906
2 : 225.572906
3 : 225.572906
4 : 225.572906
5 : 225.572906
6 : 225.572906
7 : 225.572906
8 : 225.572937
9 : 225.572937
10 : 225.572937
11 : 225.572937
[...]
25 : 225.572937
26 : 225.572967
27 : 225.572967
28 : 225.572967
29 : 225.572967
#
Les valeurs sont nettement moins précises : en fait, nous voyons que le noyau ne peut nous
fournir l’heure qu’avec une granularité de 30 microsecondes environ, même si l’appel système
proprement dit dure moins longtemps (environ 1,7 microsecondes).
Il n’est donc pas inutile de vérifier, avec le petit programme précédent, la précision de
gettimeofday() directement sur le système cible.
Horloges Posix
La norme Posix a introduit quelques fonctions permettant de mesurer l’heure avec une précision
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Valeur Signification
CLOCK_MONOTONIC Une horloge non modifiable, dont la valeur initiale au boot est laissée à
la discrétion du noyau.
int main(void)
{
affiche_caracteristiques("CLOCK_REALTIME",
CLOCK_REALTIME);
affiche_caracteristiques("CLOCK_MONOTONIC",
CLOCK_MONOTONIC);
affiche_caracteristiques("CLOCK_PROCESS_CPUTIME_ID",
CLOCK_PROCESS_CPUTIME_ID);
affiche_caracteristiques("CLOCK_THREAD_CPUTIME_ID",
CLOCK_THREAD_CPUTIME_ID);
affiche_caracteristiques("CLOCK_MONOTONIC_RAW",
CLOCK_MONOTONIC_RAW);
return EXIT_SUCCESS;
}
L’exécution montre que sur ce système, la résolution est la nanoseconde ; les différentes horloges
ont des origines variées : 01/01/1970 pour CLOCK_REALTIME, heure de boot pour CLOCK_MONOTONIC
et CLOCK_MONOTONIC_RAW, et heure de démarrage du processus ou du thread pour les deux autres.
$ ./exemple-clock-gettime-01
CLOCK_REALTIME :
resolution : 0.000000001
heure : 1316672165.783063186
CLOCK_MONOTONIC :
resolution : 0.000000001
heure : 142870.818423848
CLOCK_PROCESS_CPUTIME_ID :
resolution : 0.000000001
heure : 0.001803095
CLOCK_THREAD_CPUTIME_ID :
resolution : 0.000000001
heure : 0.001821238
CLOCK_MONOTONIC_RAW :
resolution : 0.000000000
heure : 142870.818356852
$
Pour vérifier si la précision des mesures est meilleure que la microseconde, nous allons réaliser
le même travail qu’avec gettimeofday() : appel en boucle, puis affichage des valeurs.
exemple-clock-gettime-02.c :
#include <stdio.h>
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
#include <stdlib.h>
#include <time.h>
int main(void)
{
int i;
struct timespec mesure[30];
for (i = 0; i < 30; i ++)
clock_gettime(CLOCK_REALTIME, &(mesure[i]));
for (i = 0; i < 30; i ++)
fprintf(stdout, "%ld.%09ld\n",
mesure[i].tv_sec, mesure[i].tv_nsec);
return EXIT_SUCCESS;
}
En exécutant ce programme sur le PC portable qui nous donnait treize appels par microseconde
avec gettimeofday(), nous obtenons :
$ ./exemple-clock-gettime-02
1436801515.063343311
1436801515.063343480
1436801515.063343559
1436801515.063343642
1436801515.063343721
1436801515.063343804
1436801515.063343883
1436801515.063343965
[...]
1436801515.063345499
1436801515.063345582
1436801515.063345661
1436801515.063345744
$
En fait, ces résultats ne sont pas très parlants, mais nous pouvons afficher sur chaque ligne,
grâce à un petit script nommé Awk, la différence par rapport à la ligne précédente :
0.000000000
0.000000000
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
0.000000238
0.000000000
0.000000000
0.000000238
[...]
$
Les mesures ont une précision inférieure à 0,25 microseconde, ce qui est correct pour un
système de ce type. Avec une machine plus puissante, on pourrait atteindre une précision de
50 nanosecondes.
Tâches périodiques
La plupart des systèmes devant répondre à des contraintes temporelles relativement fortes
s’appuient sur des tâches périodiques, qui doivent s’exécuter avec la meilleure granularité et la
moindre variabilité possibles.
Il existe plusieurs appels système permettant de réaliser des actions périodiques. La première
méthode est basée sur l’appel système setitimer(), classique sous Unix, la seconde utilise
timer_create(), normalisé par l’extension de Posix consacrée au temps réel. Ces deux fonctions
s’appuient sur les mêmes mécanismes dans le noyau et offriront donc des performances simi-
laires. Notons toutefois que setitimer() ne permet d’enregistrer qu’un seul timer par processus
– ou plutôt trois timers, mais ils sont complémentaires – tandis que timer_create() permet d’en
utiliser plusieurs (la limite dépend du système).
Champs Contenu
Le premier argument de cette fonction précise les conditions dans lesquelles les durées indi-
quées ci-dessus sont décomptées.
Type Signification
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
ITIMER_REAL Les durées sont considérées comme réelles, mesurées à partir de l’horloge
système. Le signal envoyé à expiration sera le signal SIGALRM.
ITIMER_VIRTUAL Les durées ne sont décomptées que lorsque le processus est en cours
d’exécution. Le signal est SIGVTALRM.
À l’exécution, un astérisque est affiché toutes les secondes jusqu’à l’arrêt par l’utilisateur :
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
$ ./exemple-setitimer-01
*************************************** (Contrôle-C)
$
Timers Posix
Les timers proposés par la norme Posix reposent sur un principe légèrement différent, même si
les fonctionnalités sous-jacentes sont les mêmes dans le noyau.
On commence par créer un timer avec la fonction :
#include <time.h>
Cette fonction initialise et remplit le pointeur passé en troisième argument. Le premier argument
indique au noyau sur quel type d’horloge système il doit construire notre timer – les horloges
sont identiques à celles que nous avons vues pour clock_gettime() – et le deuxième argument
décrit comment notre processus gérera l’occurrence du timer.
Les champs de la structure sigevent ont les significations suivantes.
Champs Signification
sigev_value Si notification par signal temps réel : valeur qui accompagne le signal.
Si notification par thread : valeur passée en argument de la fonction.
La notification par démarrage d’un thread est assurée par la GlibC alors que l’envoi d’un signal
est réalisé par le noyau. Cette dernière méthode est peut-être légèrement plus efficace.
Une fois le timer créé, on le configure en indiquant sa période et le délai avant le premier déclen-
chement à l’aide de l’appel :
Le premier argument est le timer obtenu auparavant, le second argument un indicateur précisant
si la structure itimerspec contient une durée ou une heure absolue de déclenchement (en général,
on utilise une durée, et le second argument est nul).
La structure itimerspec ressemble à la structure itimerval vue précédemment, à la différence
que les temps sont compris dans des structures timerspec dont la résolution est en nanosecondes
plutôt qu’en microsecondes.
Voici un petit exemple où l’on programme deux timers dans le même processus, le premier avec
une période d’une seconde, le second avec une période d’un quart de seconde.
exemple-timer-create-01.c :
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#include <sys/time.h>
// Allouer le timer
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
if (timer_create(CLOCK_REALTIME, &event_1,
&timer_1) != 0) {
perror("timer_create");
exit(EXIT_FAILURE);
}
À l’exécution, nous voyons bien que le processus reçoit les deux timers :
$ ./exemple-timer-create-01
2 2 2 2 1 2 2 2 2 1 2 2 2 2 1 2 2 2 2 1 2 2 2 2 1 2 2 2 2 1 2 2 2 2 1 2 2 2 2 1 2 2 2
(Contrôle-C)
$
Granularité
La granularité des timers sous Linux a beaucoup évolué au cours de son histoire, et dépend
encore sensiblement des architectures et des options de compilation. Il est donc intéressant de
s’arrêter un instant sur les étapes de la gestion des timers par le noyau Linux.
Dans les premières versions de Linux, les timers logiciels étaient gérés par une interruption
périodique – aussi nommée tick –, configurée au démarrage du système. Sur l’architecture PC,
cette interruption était déclenchée par un composant timer matériel, que le noyau programmait
au boot avec une fréquence de 100 Hz. Ainsi, le handler de l’interruption (IRQ 0) était invo-
qué toutes les 10 ms. Son rôle était alors de gérer toutes les opérations temporelles revenant au
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
noyau :
• maintien de l’heure système et mise à jour du compteur des jiffies (ticks écoulés depuis le
boot) ;
• décompte des tranches de temps accordées aux processus avant préemption ;
• vérification des délais restants lorsqu’un processus est bloqué dans un appel système tempo-
risé – comme sem_timedwait() pour les sémaphores ;
• envoi des signaux aux processus dont les timers logiciels ont expirés ;
• etc.
La granularité des timers logiciels était donc de 10 ms. Toutes les durées des opérations pério-
diques devaient être des multiples de cette durée.
Le manque de finesse dans la configuration des timers a poussé les développeurs du noyau à faire
évoluer la fréquence de l’interruption sous-jacente dans les premières versions du noyau 2.6 en
2004. Les améliorations matérielles survenues permettaient d’utiliser une fréquence de 1 kHz,
et les timers pouvaient programmer des périodes multiples de 1 ms.
Néanmoins, cette fréquence devenait trop élevée pour certaines machines, notamment les sys-
tèmes embarqués pour lesquels Linux représentait une alternative de plus en plus intéressante. À
partir du noyau 2.6.10 (fin 2004), la fréquence est devenue configurable au moment de la com-
pilation du noyau. On pouvait choisir entre 100 Hz, 250 Hz et 1 kHz. Rapidement une valeur
supplémentaire 300 Hz est apparue car c’est un multiple entier de 50 Hz et de 60 Hz, les fré-
quences utilisées par les applications multimédias en Europe et aux États-Unis. Avec un timer
matériel à 300 Hz, réaliser un traitement périodique à 50 Hz ou 60 Hz est simple et efficace.
Lors de la configuration du noyau avant compilation (voir annexe A), le choix de la fréquence
s’effectue dans le menu Processor Type and Features (ou suivant les architectures : Kernel
Features, Kernel Options, etc.), sous-menu Timer Frequency.
L’inconvénient principal du tick, tel qu’il a été employé dans toutes ces versions de Linux, est
lié à la consommation du processeur. Lorsque le système n’a rien à faire, il est possible d’endor-
mir le processeur en attente d’une interruption (chapitre 2). Ainsi, le processeur consomme
beaucoup moins d’énergie et refroidit. Il sera automatiquement réveillé par une interruption
dès qu’un utilisateur pressera une touche ou déplacera la souris, dès qu’un paquet réseau sera
reçu sur une interface ou un événement détecté sur une carte de communication externe, etc.
Malheureusement, avec le principe du tick, le noyau ne reste jamais endormi plus d’une mil-
liseconde, puisque l’interruption timer le réveille pour vérifier s’il doit réaliser une des tâches
décrites dans la liste vue plus haut.
Avec les améliorations du matériel, il est alors devenu envisageable de ne plus utiliser d’inter-
ruption périodique, mais de programmer le timer physique à chaque fois que l’on endort le
processeur, en planifiant un réveil pour la prochaine tâche à réaliser. Ainsi, le sommeil peut
durer plus longtemps, jusqu’à l’expiration du premier timer logiciel ou l’arrivée d’une interrup-
tion externe. Ce principe (nommé les dynamic ticks) a été intégré en 2006.
Jusqu’alors, ce mécanisme n’était pas possible en raison du temps (trop long) de programmation
des timers matériels et des fluctuations entre l’instant de leur programmation et le déclenche-
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
L’option tickless est parfois déconseillée pour les systèmes temps réel car elle laisse le processeur s’en-
dormir et descendre dans des états de sommeil profond (comme nous en avons parlé dans le chapitre
précédent), ce qui augmente le temps de réveil et donc de réponse aux interruptions. Suivant les situations,
il faudra réaliser un arbitrage entre les besoins temps réels (rapidité de réveil après occurrence d’une inter-
ruption) et les contraintes liées aux systèmes embarqués (consommation la plus économique possible).
Précision
La granularité des timers est aujourd’hui inférieure à la milliseconde, mais qu’en est-il de leur
précision ? Quelle sont les fluctuations à attendre dans le déclenchement des opérations pério-
diques ? On utilise pour nommer cette imprécision dans l’instant exact de déclenchement le
terme de « gigue », ou plus couramment le terme anglais jitter.
Nous allons construire un petit exemple qui programme un timer Posix avec une période fournie
en argument sur la ligne de commandes. À chaque réception de signal, nous lirons l’heure et
calculerons la différence avec le signal précédent. Au bout de cinq secondes, nous afficherons
sur la sortie standard toutes les mesures.
exemple-timer-create-02.c :
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#include <sys/time.h>
if ((argc != 2)
|| (sscanf(argv[1], "%ld", & periode) != 1)){
fprintf(stderr, "usage: %s periode_en_microsec\n",
argv[0]);
exit(EXIT_FAILURE);
}
// Configurer le timer
signal(SIGRTMIN, handler_signal);
event.sigev_notify = SIGEV_SIGNAL;
event.sigev_signo = SIGRTMIN;
Les résultats que fournit ce programme (exécuté sur un Raspberry Pi 2) ne sont pas très intéres-
sants tels quels, il nous faut les traiter à nouveau pour obtenir des informations pertinentes. Nous
allons donc sauvegarder les valeurs dans un fichier par une redirection de la sortie du processus.
945
994
997
1006
994
998
999
1000
998
1000
[...]
$
Nous pouvons à présent traiter ces données pour en extraire quelques informations statistiques.
Le programme suivant travaille sur un flux quelconque de valeurs, et affiche finalement les
valeurs minimale, maximale et moyenne ainsi que l’écart-type. Nous le réutiliserons à nouveau
dans les chapitres à venir.
calculer-statistiques.c :
#include <math.h>
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
char ligne[LG_LIGNE];
double mesure;
int nb_mesures = 0;
double minimum;
double maximum;
double moyenne = 0.0;
double variance = 0.0;
double delta;
double ecart_type;
ecart_type = sqrt(variance);
moyenne, ecart_type);
return EXIT_SUCCESS;
}
Nous observons que les déclenchements du timer sont bien espacés en moyenne de 1 milli-
seconde, ce qui correspond parfaitement à la période demandée. La précision de chaque
déclenchement semble relativement bonne, puisque l’écart-type par rapport à cette valeur de
consigne est de 7 microsecondes. De même, les valeurs extrêmes ne sont pas exagérément éloi-
gnées, il ne s’est jamais écoulé plus de 1,22 milliseconde entre deux déclenchements successifs
du timer.
Ces informations sont utiles, mais il est généralement préférable en ingénierie temps réel de
disposer d’une représentation graphique où l’on voit en abscisse les valeurs mesurées et en
ordonnée le nombre de mesures concernées. Pour cela, le petit programme suivant va prendre
nos données mesurées et sortir un tableau permettant de construire un histogramme.
calculer-histogramme.c :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
double mesure;
double minimum = -1;
double maximum = 0;
double largeur_echantillon;
int * echantillons;
int i;
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
if ((argc != 4)
|| (sscanf(argv[1], "%d", & nb_echantillons) != 1)
|| (sscanf(argv[2], "%lf", & minimum) != 1)
|| (sscanf(argv[3], "%lf", & maximum) != 1)) {
fprintf(stderr, "usage: %s nb_echantillons minimum"
" maximum\n", argv[0]);
exit(EXIT_FAILURE);
}
if (nb_echantillons <= 0) {
fprintf(stderr, "Valeur invalide : %d\n",
nb_echantillons);
exit(EXIT_FAILURE);
}
nb_mesures = 0;
while (fgets(ligne, LG_LIGNE, stdin) != NULL) {
if (sscanf(ligne, "%lf", & mesure) != 1) {
fprintf(stderr, "Erreur de lecture, ligne %d\n",
nb_mesures+1);
exit(EXIT_FAILURE);
}
i = (int) (mesure - minimum )/ largeur_echantillon;
if ((i < 0) || (i >= nb_echantillons)) {
fprintf(stderr, "Valeur invalide ligne %d\n",
nb_mesures + 1);
exit(EXIT_FAILURE);
}
echantillons[i] ++;
}
for (i = 0; i < nb_echantillons; i ++)
printf("%.0lf %d\n",
minimum + (i + 0.5) * largeur_echantillon,
echantillons[i]);
return EXIT_SUCCESS;
}
Les valeurs fournies sur la ligne de commandes (nombre d’intervalles et limites minimale et
maximale) sont déterminées arbitrairement à partir des résultats du calcul statistique précédent.
Pour visualiser graphiquement le résultat, je vous propose d’utiliser l’outil gnuplot grâce à ce
petit script :
afficher-histogramme.sh :
#! /bin/sh
if [ $# -lt 2 ]
then
echo "usage: $0 <fic-histogramme> <titre> [<sortie>]"
exit 1
fi
if [ $# -eq 3 ]
then
output="$3"
else
output="|display gif:-"
fi
set logscale y 10
# Afficher le contenu du fichier
plot [] [0.1:] "$1" notitle with boxes
# Attendre click de souris
pause mouse
EOF
Nous relançons le programme précédent avec une redirection de sa sortie standard dans un
fichier, que nous soumettons à notre script.
L’histogramme représenté à la figure 4-1 s’affiche alors. Les durées entre déclenchements
successifs du timer (mesurés en nanosecondes) sont indiquées en abscisse, leurs nombres
d’occurrences en ordonnée. Notez que l’échelle des ordonnées est logarithmique afin de laisser
apparaître les occurrences uniques des valeurs extrêmes.
Figure 4-1
Timer 1 kHz non perturbé
Ce graphique nous présente un résultat correct pour un système temps partagé : une large majo-
rité des mesures sont très proches de la valeur de consigne et quelques écarts existent, en nombre
limité. En outre, la dispersion est relativement faible, puisque la totalité des mesures se trouve
dans un intervalle dont la largeur est d’environ 50 % de la période demandée.
Nous allons à présent stresser un peu notre programme en chargeant son processeur par d’autres
tâches. Le petit programme suivant perturbe le fonctionnement du système en effectuant régu-
lièrement des boucles actives, puis en dormant quelques microsecondes.
Attention, sur certains systèmes, les mesures avec un processeur chargé au maximum peuvent être trom-
peuses. Nous avons évoqué dans le chapitre 2, section « Entrées-sorties avec interruptions », les modes
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
de sommeil des processeurs. Si un CPU est endormi dans un sommeil profond, son temps de démarrage
après réveil par un signal peut être plus long que celui d’un CPU chargé par une boucle active moins prio-
ritaire que le gestionnaire de signal. Il convient donc, pour perturber efficacement un processeur, d’alterner
des périodes d’activité intense et des périodes de sommeil.
exemple-perturbateur.c :
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/time.h>
int main(void)
{
struct timeval heure;
struct timeval debut_boucle;
struct timeval debut_programme;
long long int difference;
while (1) {
gettimeofday(& debut_boucle, NULL);
do {
gettimeofday(& heure, NULL);
difference = heure.tv_sec - debut_boucle.tv_sec;
difference *= 1000000;
difference += heure.tv_usec - debut_boucle.tv_usec;
} while (difference < 100);
usleep(20);
Nous lançons le perturbateur sur un terminal et la mesure des timers sur un autre, en s’assurant
– grâce à taskset – qu’ils soient tous deux sur le même processeur.
$ taskset -c 0 ./exemple-perturbateur
$ taskset -c 0 ./exemple-timer-create-02 1000 > data-02.txt
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
$
$
$ ./calculer-statistiques < data-02.txt
Nb mesures = 5000
Minimum = 211
Maximum = 4489
Moyenne = 1000
Ecart-type = 67
$
Les résultats semblent moins bons. En les vérifiant sur l’histogramme, nous obtenons le gra-
phique représenté à la figure 4-2. Attention, les valeurs en abscisse ont été sensiblement étendues
vers la droite. En effet, on note non seulement une dispersion un peu plus large autour de la
valeur de consigne (1 milliseconde), mais également des valeurs extrêmes s’étendant jusqu’à
4,5 millisecondes.
Un timer dont le traitement est réalisé avec un ordonnancement temps partagé n’est donc visible-
ment pas capable de répondre de manière fiable à des contraintes temporelles.
Figure 4-2
Timer 1 kHz perturbé
observant l’évolution de l’heure, mesurée avec gettimeofday() – que l’on pourrait remplacer par
clock_gettime() avec des résultats similaires. Lorsque le programme détecte une durée supé-
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
rieure à un certain seuil (fourni en argument sur sa ligne de commande) entre deux lectures
successives, il note les valeurs. Au bout de cinq secondes (ou après 100 préemptions), il se
termine et affiche les résultats.
exemple-gettimeofday-02.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/time.h>
int nb_mesures = 0;
int i;
if ((argc != 2)
|| (sscanf(argv[1], "%ld", & seuil) != 1)) {
fprintf(stderr, "usage: %s seuil-en-microsec\n",
argv[0]);
exit(EXIT_FAILURE);
}
do {
gettimeofday(& heure, NULL);
difference = heure.tv_sec - precedente.tv_sec;
difference *= 1000000;
difference += heure.tv_usec - precedente.tv_usec;
if (difference >= seuil) {
avant_preemption[nb_mesures] = precedente;
apres_preemption[nb_mesures] = heure;
nb_mesures ++;
if (nb_mesures >= MAX_MESURES)
break;
}
precedente = heure;
} while (heure.tv_sec - debut.tv_sec <= 5);
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
return EXIT_SUCCESS;
}
Lorsque nous l’exécutons, même avec une priorité temps partagé très élevée (valeur de nice la
plus négative possible), nous observons des perturbations dans l’exécution de notre tâche.
Nous avons placé ici le seuil à 15 microsecondes. Les préemptions se présentent très régulière-
ment toutes les 10 millisecondes. Il s’agit bien entendu du tick du système. La mesure est faite
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
sur un Raspberry Pi 2, dont le tick est configuré avec une fréquence de 100 Hz.
L’exécution s’est faite sur le cœur numéro 1. Réitérons en plaçant le programme sur le cœur
numéro 0.
Nous voyons alors de très nombreuses préemptions de notre tâche. En effet, par défaut, l’essen-
tiel des interruptions (réseau, carte SD, SPI, GPIO...) est traité par le Raspberry Pi 2 sur son
cœur 0.
Conclusion
Dans ce chapitre, nous avons examiné les limites des ordonnancements temps partagé : très bien
adaptés pour organiser l’exécution des tâches courantes sur un poste de travail ou sur un serveur
réseau, ils ne permettent pas un contrôle fin des temporisations, ni une exécution ininterrompue
des tâches, aussi importantes soient-elles.
Pour améliorer les réponses temporelles de nos processus, il nous faudra donc nous tourner vers
les mécanismes temps réel.
Points clés
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
• Le noyau Linux actuel nous permet de mesurer l’heure avec une précision de l’ordre de la
microseconde (ceci dépend beaucoup du matériel).
• Avec une compilation adéquate (options High-Resolution Timers et Tickless System), nous
pouvons obtenir des timers logiciels dont la période se mesure en dizaine ou centaines de
microsecondes.
• L’ordonnancement temps partagé induit une fluctuation (jitter) des timers et des préemptions
inévitables des tâches.
Exercices
Exercice 1 (*)
Utilisez le programme exemple-gettimeofday.c pour vérifier la précision de la mesure horaire
sur votre ordinateur. Combien de temps dure l’appel système ?
L’utilisation de la commande nice pour modifier la priorité temps partagé influe-t-elle sur les
résultats ?
Exercice 2 (**)
Utilisez le programme exemple-timer-create.c pour créer un timer à la milliseconde.
Observez les résultats fournis par le programme, puis envoyez-les dans le programme calculer-
statistiques.c pour analyser le comportement du timer.
Exercice 3 (**)
Utilisez les programmes exemple-timer-create.c, calculer-histogramme.c et le script afficher-
histogramme.sh pour visualiser graphiquement les fluctuations du timer.
Jusqu’à quelle période pouvez-vous faire descendre le timer en conservant des résultats accep-
tables (par exemple, un écart-type inférieur au dixième de la période) ?
Exercice 4 (***)
Vérifiez le comportement du timer programmé dans l’exemple précédent en présence d’un
programme parasite, comme element-perturbateur.c ou en présence d’un grand nombre d’inter-
ruptions (ceci peut s’obtenir avec la commande hdparm ou en utilisant un ping flood depuis un
autre poste).
Exercice 5 (***)
Utilisez le programme exemple-gettimeofday-02 pour détecter les préemptions supérieures à
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
1 milliseconde.
S’en produit-t-il si vous laissez le programme seul sur un système au repos ? Si vous utili-
sez le système (mouvement de fenêtres graphiques, exécution de commandes shell...) pendant
son exécution ? Si vous lancez sur le même processeur une application gourmande en CPU au
démarrage (Eclipse, Open Office.org, Firefox, etc.) ?
5
Principes du temps réel
Ayant observé jusqu’à présent les limites de l’ordonnancement temps partagé, nous pouvons être
amenés à vouloir améliorer la fiabilité de nos tâches, et notamment leurs temps de réponse face
à un événement. Nous allons pour cela examiner dans ce chapitre et les suivants, les possibilités
de traitement temps réel offertes par le noyau Linux classique (sans extension particulière).
Définitions
Temps réel
Il existe de nombreuses définitions concernant les systèmes temps réel, certaines s’appuient
sur la prédictibilité du temps d’exécution d’une tâche, d’autres sur les priorités et les méca-
nismes d’ordonnancement, d’autres encore mettent l’accent sur la précision des caractéristiques
temporelles.
Je retiendrai une définition qui convient à l’idée que je me fais d’un système temps réel appliqué :
« Un système informatique est soumis à des contraintes temps réel si l’instant auquel il parvient
au terme d’une opération entre en considération dans la validité du résultat de cette opération. »
En pratique, on considère qu’une opération est initialisée par un événement et que l’on dispose
d’un certain délai pour la terminer. L’événement de départ peut être interne (déclenchement d’un
timer, détection d’une valeur spécifique d’un paramètre, message provenant d’une autre tâche...)
ou externe (interruption matérielle, changement d’état sur un capteur...). Si le système effectue
le traitement avant le délai qui lui était imparti, le résultat de l’opération est correct, sinon il est
considéré comme invalide.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Figure 5-1
Contrainte temporelle
Ceci est représenté sur la figure 5-1 où se trouvent placés sur l’axe temporel l’événement de
départ (timer, interruption, signal, etc.) et la limite au-delà de laquelle la réponse du système
n’est plus acceptable.
Nous pouvons représenter une courbe de validité de la réponse : entre l’événement déclencheur
et la limite, la réponse sera valide (si le traitement est correct bien sûr), après elle ne le sera plus.
Un exemple de cette courbe est représenté à la figure 5-2.
Figure 5-2
Validité en temps réel strict
nanosecondes, par exemple) et ne varie pas en fonction de l’activité du système. Les fluctuations
infimes pourraient être dues aux dérives thermiques des caractéristiques des composants.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Il s’agit essentiellement de systèmes purement électroniques (FPGA, PAL, PLD…), sans inter-
vention de programmation logicielle. Ce type de temps réel ne peut pas être atteint avec un
microprocesseur.
utilisateur. Personne n’est prêt à parcourir tous les chemins logiques possibles de ces millions de
lignes de code pour s’assurer qu’une limite temporelle ne sera jamais dépassée.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Les systèmes capables de répondre aux contraintes du temps réel strict certifiable sont généra-
lement construits autour de microcontrôleurs ; ils sont souvent monotâches ou ne comportent
qu’un nombre très limité (et figé) de tâches.
La possibilité de réaliser des systèmes temps réel stricts certifiables en employant des
microprocesseurs modernes est même sujet à controverse. De nombreux sous-systèmes de
ces microprocesseurs limitent la prévisibilité des temps de réponse : MMU, caches mémoire,
pipelines d’instruction, protocoles de synchronisation des caches pour les processeurs
multicœurs, réduction de consommation d’énergie, etc.
Si l’on souhaite développer une application temps réel stricte certifiable en utilisant un système
d’exploitation libre, les solutions seront à rechercher vers des systèmes comme FreeRTOS
(ordonnanceur minimal) ou RTEMS (Real Time Executive for Multiprocessor Systems)
implémentés sur des processeurs simples ou des microcontrôleurs.
1. Certains préfèrent parler de « temps réel mou » au lieu de « temps réel souple ». Je trouve personnellement l’adjectif
« mou » peu adapté à la notion de temps réel, malgré son clin d’œil à Salvador Dalí.
Figure 5-3
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Cette fois, la validité de résultat est d’autant meilleure qu’elle est rapide. Une réponse quasi
instantanée est considérée comme très bonne. Plus on se rapproche de la limite, moins la
réponse est acceptable. Toutefois, même après avoir dépassé notre limite, on attribuera une
« faible validité » à notre réponse.
Comment peut-on attribuer une valeur à la validité d’une réponse qui ne soit pas « vraie » ou
« fausse » ? Simplement, on s’intéressera à des conditions d’exécution moyennes, et non au pire
des cas. Prenons l’exemple d’un système devant diffuser des trames vidéo, par exemple pour une
application de vidéoconférence. Dans notre cas, l’événement déclencheur est un timer fourni par
le système et le logiciel répond en émettant son image dans un délai limité.
Nous souhaiterions que notre programme diffuse les trames avec une fréquence de 25 images/
seconde, soit 40 ms entre chaque émission, comme sur la figure 5-4.
Figure 5-4
Système non perturbé
Supposons que suite à une perturbation relativement rare (coïncidence d’interruptions multiples),
une image soit légèrement retardée, à 42 ms de la précédente au lieu de 40 ms (figure 5-5).
Figure 5-5
Temps réel souple perturbé
Dans un environnement temps réel souple, comme Linux, le système corrigera l’écart à l’image
suivante, quitte à l’avancer par rapport à celle en retard. Ainsi, le nombre moyen d’images par
seconde restera constant à 25.
Nous nous intéressons dans ce cas au comportement moyen du système et non au comportement
individuel de chaque événement.
Une chose importante également : si un système est considéré comme soumis à des contraintes
temps réel souples, le concepteur n’est plus obligé de prouver son bon comportement quelles
que soient les circonstances, mais il peut le vérifier. Il construit une maquette du système, la
chargeant au maximum, tant au niveau des applications et services logiciels qu’au niveau des
interruptions matérielles et trafics de données (réseau, liaison série…) et vérifie sa bonne tenue
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Rôles respectifs
La limite entre les domaines d’application des systèmes temps réel souples et stricts (non cer-
tifiables en ce qui concerne Linux) n’est pas facile à définir. Il s’agit avant tout des contraintes
imposées par l’environnement physique avec lequel le logiciel interagit et des tolérances aux
variations temporelles.
Si le temps de réponse demandé est à l’échelle humaine (allumage d’un voyant d’acquittement
suite à une pression sur un bouton), il ne fait nul doute qu’un système temps réel souple – voire
temps partagé dont les tâches ont des valeurs de priorité élevées – aura une réactivité largement
suffisante. Dans ce cas, les limites temporelles seront de l’ordre de la dizaine de millisecondes,
avec une tolérance de plusieurs millisecondes.
Lorsque le système doit superviser ou piloter des dispositifs électromécaniques externes, les
contraintes sont généralement beaucoup plus fortes. Même si les délais de réaction sont parfois
relativement longs (plusieurs millisecondes ou dizaines de millisecondes), les limites doivent
être impérativement respectées (imaginons le cas d’un moteur devant être arrêté avant qu’un axe
arrive en butée).
Naturellement, il est souvent possible d’utiliser un système offrant des possibilités de temps
réel souples, comme Linux, même lorsque le problème est clairement relatif aux concepts du
temps réel strict si le délai limite de terminaison d’une tâche est très nettement supérieur au plus
long délai de réponse observé. Toutefois, il convient d’être bien conscient que dans ce cas, nous
n’avons jamais de garantie absolue du respect de la limite. Il faut évaluer les conséquences d’un
éventuel retard d’exécution très rare (et très improbable) et en accepter le prix.
Figure 5-6
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Dans la première approche, schématisée sur la figure 5-6, la carte d’acquisition déclenche une
interruption sur réception d’une trame de données (image vidéo), qui est lue par le handler de
l’interruption, traitée par un algorithme d’encodage, et transmise dans le buffer de sortie d’une
carte de diffusion.
Ceci nécessite d’écrire deux drivers ou un seul gros driver encadrant tout le traitement, et pré-
sente plusieurs inconvénients.
• Le développement dans le kernel Linux n’est pas plus compliqué que dans l’espace utilisa-
teur, mais il convient de faire attention à de nombreux paramètres (protection des variables
globales, réentrance des appels système, etc.) qui compliquent rapidement le projet. En outre,
la mise au point et la validation du code sont rendues beaucoup plus complexes par l’absence
d’outils de débogage simples et le manque total de protection (isolation mémoire, accès aux
périphériques, etc.).
• Le noyau Linux est sous licence GPL. Lorsqu’on fournit un système à l’utilisateur, il doit
obligatoirement disposer des sources du kernel, ainsi que des éventuelles modifications qui
y ont été apportées. Or, dans le monde industriel, il est rare d’accepter de fournir les sources
des traitements centraux (par exemple, encodage vidéo) qui représentent une part importante
du travail de recherche et développement.
Il est possible d’insérer du code propriétaire dans le noyau Linux par l’intermédiaire des modules (fichiers
*.ko (kernel object) que l’on charge dynamiquement avec la commande insmod), mais il existe quand
même des restrictions de licences, et le module ne peut être utilisé qu’avec le noyau exact pour lequel il a
été compilé, ce qui limite les possibilités de diffusion du fichier binaire.
• S’il est possible, dans le noyau, d’assurer le déroulement ininterrompu d’une portion de code
(en coupant les interruptions, par exemple, et en interdisant temporairement la préemption),
ceci va à l’encontre des préceptes d’écriture des drivers et rend la maintenance difficile au gré
des évolutions de Linux.
• Enfin, ce principe permet de rendre une opération plus prioritaire que tout le système, mais
ne permet pas de programmer plusieurs tâches en profitant des mécanismes de priorité offerts
par Linux.
Pour ce genre de projet, je conseillerai plutôt une approche légèrement différente, représentée
sur la figure 5-7.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Un premier driver très simple gère les accès à la carte d’acquisition et implémente quelques
appels système, dont la méthode mmap(). Ce genre de driver est facile à écrire, de nombreux
exemples existent – nous en verrons un dans le chapitre 11 – et il ne contient aucun aspect
« intelligent » ou « secret », ce qui permet de le placer sous licence GPL sans problème.
Un second driver – tout aussi simple – gère la carte de diffusion en implémentant, entre autres,
l’appel système mmap().
Figure 5-7
Traitement en espace
utilisateur
Un processus, fonctionnant en espace utilisateur, va demander au premier driver de lui faire une
projection directe de la mémoire de la carte d’acquisition dans son espace d’adressage grâce à
mmap(). Symétriquement, il demandera une projection dans un autre emplacement de son espace
d’adressage de la mémoire de la carte de diffusion. Ensuite, le programme effectuera en boucle
les traitements, se mettant en sommeil sur un appel système bloquant comme select() jusqu’à
ce qu’une image soit disponible dans sa mémoire, puis il assurera l’encodage en écrivant dans la
mémoire du second driver, et préviendra ce dernier grâce à un appel comme ioctl().
Les avantages de ce système sont les suivants.
• Les codes sources des drivers sont simples et peuvent être diffusés sans aucune limitation. Ils
sont construit sur des exemples disponibles tant dans les sources du kernel Linux que sur des
sites Internet de développeurs.
• Le code contenant l’algorithme d’encodage se trouve dans l’espace utilisateur où aucune res-
triction de licence ne s’applique. Il peut donc se trouver sous une licence propriétaire sans
aucune divulgation de code.
• Le code principal du programme se situant dans l’espace utilisateur, sa mise au point est faci-
litée par les nombreux outils disponibles. De plus, les limitations encadrant le déroulement
des processus protègent le système et les autres applications de toute erreur de codage.
• Le processus s’exécutant sous le contrôle de l’ordonnanceur, il sera possible de lui donner
une priorité (temps partagé ou temps réel) qui garantira son interaction avec les autres tâches.
L’intérêt d’utiliser Linux comme système d’exploitation temps réel plutôt qu’un système spécia-
lisé comme VxWorks, VRTX, etc., réside à mon avis dans les quatre points suivants.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
• La gratuité et la liberté du système, liées à sa licence GPL : il est important à mon sens de
respecter l’esprit Open Source, et de ne placer dans le kernel Linux que du code dont on est
prêt à fournir les sources aux utilisateurs.
• La richesse de l’API, qui offre des mécanismes de communication et de synchronisation
entre threads (mutex, variables conditions...), entre processus (files de messages, mémoire
partagée, sémaphores, pipes...) et même entre systèmes distants (sockets TCP/IP ou UDP/
IP). Pour profiter de cette interface de programmation, il est préférable de l’aborder depuis
l’espace utilisateur.
• La protection offerte par la mémoire virtuelle, et plus particulièrement par le système de
pagination s’appuyant sur la MMU (Memory Managment Unit) du processeur. Seuls les pro-
cessus utilisateurs ont cette garantie d’étanchéité absolue de leur espace mémoire.
• Enfin, l’environnement GNU/Linux lui-même s’avère particulièrement riche et complet,
offrant de nombreux serveurs (FTP, HTTP, SSH, SNMP) très utiles dans des environne-
ments industriels ou scientifiques, ainsi qu’un nombre élevé de bibliothèques et de langages
de programmation.
Pour ces raisons, je conseille systématiquement de développer le cœur des applications temps
réel dans l’espace utilisateur, et de n’ajouter au noyau que le strict minimum nécessaire pour
gérer le matériel spécifique. Si une tâche doit être obligatoirement réalisée avec les privilèges
du noyau (pour la gestion des entrées-sorties physiques ou des interruptions), on peut se tourner
vers les kernel threads, qui présentent l’avantage d’être ordonnancés et de pouvoir ainsi bénéfi-
cier de priorités temps réel.
Le principe général est qu’une tâche d’une priorité donnée ne pourra jamais être préemptée ou
laissée en attente (Runnable) tandis qu’une tâche de priorité moindre dispose du processeur.
Une tâche de priorité 50 ne laissera jamais s’exécuter une tâche de priorité 49 et encore moins
une tâche temps partagé.
Attention, cette dernière affirmation n’est pas tout à fait exacte, nous en reparlerons plus loin.
Pour que la tâche de priorité 49 puisse s’exécuter, il faudra que celle de priorité 50 s’endorme
volontairement (ou se termine) comme représenté sur la figure 5-9.
Figure 5-9
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Cession de CPU
en temps réel
En revanche, notre tâche de priorité 50 sera immédiatement préemptée si une tâche de prio-
rité 51 se réveille (suite à une interruption ou au déclenchement d’un timer, par exemple), comme
nous pouvons le voir sur la figure 5-10. Il lui faudra attendre l’endormissement ou la terminaison
de la tâche 51 pour reprendre son exécution.
Figure 5-10
Préemption en temps réel
Il subsiste malgré tout une interrogation lorsque deux tâches se trouvent sur le même niveau
de priorité. Pour préciser le comportement, il existe deux types d’ordonnancements temps réel
normalisés par la norme Posix.
• Fifo (First In, First Out, premier arrivé, premier servi) : une tâche sous ordonnancement Fifo
ne peut être préemptée que par une tâche de priorité strictement supérieure qui devient prête.
• Round Robin (tourniquet) : lorsqu’une tâche sous ordonnancement Round Robin est activée,
on lui accorde un délai au bout duquel elle sera préemptée pour laisser passer les autres
tâches de même priorité en attente. Si aucune tâche n’est prête, la tâche initiale reprend son
exécution normalement.
Répétons-le : même en Round Robin, une tâche ne laissera jamais s’exécuter une tâche de
priorité inférieure.
Configuration de l’ordonnancement
Pour fixer ou consulter le type d’ordonnancement d’un processus, nous utilisons les appels
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
système :
#include <sched.h>
Le premier argument est l’identifiant du processus concerné. En effet, un processus peut tout à
fait modifier l’ordonnancement d’un autre processus. Si l’on indique 0 en premier argument, c’est
au contraire son propre ordonnancement que l’on modifie.
Le second argument de sched_setscheduler() ou le retour de sched_getscheduler() représente le
type d’ordonnancement parmi les trois valeurs normalisées par Posix :
Constante Signification
SCHED_FIFO Ordonnancement temps réel Fifo. La tâche ne peut être préemptée que par une
tâche de priorité strictement supérieure.
Champ Signification
sched_priority La priorité temps réel dans l’intervalle [1,99] pour les ordonnancements Fifo et
Round Robin, et valant 0 pour l’ordonnancement temps partagé Other.
de la structure, toutefois il est possible que d’autres systèmes ajoutent leurs propres paramètres
(on peut imaginer, par exemple, une notion de deadline pour d’autres ordonnancements).
Aussi, pour assurer la portabilité d’un programme, prendra-t-on soin, lorsque l’on voudra modi-
fier la priorité, de commencer par lire la structure avec sched_getparam(), de modifier le champ
sched_priority, puis de réécrire la structure avec sched_setparam().
J’ai indiqué que les valeurs limites pour les priorités temps réel étaient dans l’intervalle [1, 99].
Ceci est spécifique à Linux, et un programme se voulant portable devrait déterminer correcte-
ment l’échelle des priorités en invoquant :
int sched_get_priority_max (int ordonnancement);
int sched_get_priority_min (int ordonnancement);
Voici une vérification des valeurs indiquées précédemment :
exemple-sched-get-priorities.c :
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
printf("SCHED_FIFO : [%d - %d]\n",
sched_get_priority_min(SCHED_FIFO),
sched_get_priority_max(SCHED_FIFO));
printf("SCHED_RR : [%d - %d]\n",
sched_get_priority_min(SCHED_RR),
sched_get_priority_max(SCHED_RR));
printf("SCHED_OTHER : [%d - %d]\n",
sched_get_priority_min(SCHED_OTHER),
sched_get_priority_max(SCHED_OTHER));
return EXIT_SUCCESS;
}
$ ./exemple-sched-get-priorities
SCHED_FIFO : [1 - 99]
SCHED_RR : [1 - 99]
SCHED_OTHER : [0 – 0]
$
Il est important de savoir que pour soumettre un processus à un ordonnancement temps réel,
avec sched_setscheduler(), l’appelant doit disposer des droits root. En effet, cette opération est
potentiellement dangereuse, puisque la tâche temps réel peut se mettre à dévorer tout le temps
CPU disponible et créer ainsi un déni de service sur le système.
va lancer autant de processus fils qu’il y a de CPU disponibles sur le système. Chacun d’entre
eux fixera son affinité (comme nous l’avons vu au chapitre 1) afin d’occuper tous les proces-
seurs. Ensuite, ils passeront en temps réel Fifo de priorité 99, avant d’attaquer une boucle active
infinie :
while (1)
;
Bien sûr, si nous nous lançons directement dans cette boucle, tous les CPU seront saturés et
nous ne pourrons pas reprendre la main sur notre machine. Notre seul recours sera l’interrupteur
Marche/Arrêt. Pour éviter ceci, nous allons programmer une alarme de 15 secondes avant de
rentrer dans la boucle.
Au bout des 15 secondes, le noyau enverra automatiquement un signal SIGALRM à chaque proces-
sus qui a programmé l’alarme. Comme nous ne faisons rien pour gérer ce signal, les processus en
boucle seront tués, et nous reprendrons le contrôle de notre système. Voici une implémentation :
exemple-boucle-temps-reel.c :
#define _GNU_SOURCE // Pour avoir sched_setaffinity
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
int nb_cpus = sysconf(_SC_NPROCESSORS_ONLN);
int cpu;
for (cpu = 0; cpu < nb_cpus; cpu ++) {
if (fork() == 0) {
fonction_fils(cpu);
}
}
/* Attendre la fin des fils */
for (cpu = 0; cpu < nb_cpus; cpu ++)
waitpid(-1, NULL, 0);
return EXIT_SUCCESS;
}
CPU_ZERO(& cpuset);
CPU_SET(cpu, &cpuset);
if (sched_setaffinity(0, sizeof(cpuset),
& cpuset) !=0) {
perror("sched_setaffinity");
exit(EXIT_FAILURE);
}
Avant de lancer ce programme, je dois vous avertir que certains environnements graphiques
n’aiment pas être privés de temps CPU pendant plusieurs secondes et peuvent se comporter
bizarrement (envoyer des rafales de retours chariot dans les terminaux texte, par exemple). De
même, j’ai constaté des déconnexions d’adaptateur Wi-Fi si le temps dépasse 20 à 30 secondes.
Je vous conseille donc de tester le programme sur un poste de travail indépendant, que vous
pourrez réinitialiser sans gêner d’autres utilisateurs.
Un bon réflexe à acquérir lorsqu’on se prépare à démarrer un programme d’expérimentation en temps réel
est de lancer auparavant depuis le shell la commande sync. Celle-ci s’assurera que tous les contenus des
buffers de cache disque en attente ont été transmis aux pilotes de périphériques. On limite ainsi la perte de
données en cas de nécessité de réinitialisation abrupte.
Allons-y !
$ sync
$ ./exemple-boucle-temps-reel
sched_setscheduler: Operation not permitted
sched_setscheduler: Operation not permitted
sched_setscheduler: Operation not permitted
sched_setscheduler: Operation not permitted
$
Et oui, le passage en temps réel échoue si nous n’avons pas les droits root.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
$ su
Password:
# ./exemple-boucle-temps-reel
#
Après une période de 15 secondes (qui paraît beaucoup plus longue la première fois, c’est nor-
mal...) pendant laquelle tout le système était entièrement gelé, nous reprenons normalement la
main sur notre système.
Mais, est-ce bien vrai ? Le système est-il effectivement gelé pendant 15 secondes ? Recommen-
cez plusieurs fois l’expérience et vous pourrez remarquer que le pointeur de la souris arrive à se
déplacer (de façon très saccadée), que le curseur du shell continue à clignoter et que l’horloge
graphique avance (en sautant parfois quelques secondes).
Depuis de nombreuses années, je réalise cette petite expérience lors de sessions de formation
sur Linux temps réel. Imaginez ma surprise, au printemps 2008, en lançant un tel programme,
de voir les applications tournant en ordonnancement temps partagé (comme l’utilitaire GkrellM
dont nous avons parlé au chapitre 1) continuer à s’exécuter – ralenties, il est vrai – malgré les
boucles temps réel Fifo de priorités très élevées.
Figure 5-11
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
# sysctl kernel.sched_rt_runtime_us=-1
kernel.sched_rt_runtime_us = -1
# ./exemple-boucle-temps-reel
#
Cette fois, aucune activité ne peut s’exécuter pendant 15 secondes, le système est complètement
figé.
qui permettent de modifier le type d’ordonnancement et la priorité d’un thread existant, ainsi
que :
int pthread_attr_getschedparam (
const pthread_attr_t * attributs,
struct sched_param * parametres);
int pthread_attr_setschedparam (
pthread_attr_t * attributs,
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
int pthread_attr_getschedpolicy (
const pthread_attr_t * attributs,
int * ordonnancement);
int pthread_attr_setschedpolicy (
pthread_attr_t *attributs,
int ordonnancement);
Ces fonctions permettent de manipuler un objet « attributs », pour fixer les paramètres désirés
dès la création d’un nouveau thread. Schématiquement, le principe est le suivant :
pthread_t thr;
pthread_attr_t attributs;
struct sched param parametres;
[...]
L’objet attributs n’est pas modifié lors du pthread_create() et n’est plus utilisé par le système
une fois le thread lancé. Il est donc possible de le réutiliser, en le modifiant éventuellement, pour
créer d’autres threads. Ensuite, il est conseillé d’appeler :
void pthread_attr_destroy (pthread_attr_t * attributs);
pour libérer ses éventuelles ressources internes (cette fonction n’a aucun effet sous Linux, mais
garantit la portabilité du programme).
Attention, lorsqu’un thread est créé par cette méthode, il hérite par défaut de l’ordonnancement
de son thread créateur. Autrement dit, il ne tient pas compte des éléments d’ordonnancement
que nous avons fixés dans les attributs. Pour confirmer que nous voulons bien utiliser ces para-
mètres, il faut l’indiquer dans un attribut supplémentaire :
int pthread_attr_getinheritsched (
int pthread_attr_setinheritsched (
pthread_attr_t * attributs,
int heritage) ;
avant le pthread_create().
Il existe une dernière fonction concernant les threads et le temps réel :
int pthread_attr_getscope (
const pthread_attr_t * attributs,
int * portee);
int pthread_attr_setscope (
pthread_attr_t * attributs,
int portee);
portage d’une application développée sur un autre système) en s’appuyant sur les cgroups, mais cela sort
du cadre de ce livre.
Nous allons construire un petit exemple où quatre threads vont se concurrencer pour le temps
CPU. Chacun d’entre eux affichera son heure de démarrage, effectuera une boucle active de
calcul, puis affichera son heure de terminaison. Ils seront placés à des priorités temps réel diffé-
rentes. Toutefois, les threads les plus prioritaires démarreront après les autres, afin de laisser aux
moins prioritaires le temps de commencer avant d’être préemptés.
Les portées des deux boucles imbriquées dans la fonction_thread() devront être calibrées pour
durer quelques secondes.
exemple-threads-temps-reel.c :
#include <pthread.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/time.h>
#define NB_THREADS 4
sleep(numero*2);
int main(void)
{
int i;
int err;
pthread_attr_t attr;
struct sched_param param;
pthread_t thr[NB_THREADS];
SCHED_FIFO)) != 0) {
fprintf(stderr, "setschedpolicy: %s\n",
strerror(err));
exit(EXIT_FAILURE);
}
if ((err = pthread_attr_setinheritsched(& attr,
PTHREAD_EXPLICIT_SCHED)) != 0) {
fprintf(stderr, "setinheritsched: %s\n",
strerror(err));
exit(EXIT_FAILURE);
}
for (i = 0; i < NB_THREADS; i ++) {
param.sched_priority = (i + 1) * 10;
if ((err = pthread_attr_setschedparam(& attr,
& param)) != 0) {
fprintf(stderr, "setschedparam: %s\n",
strerror(err));
exit(EXIT_FAILURE);
}
if ((err = pthread_create(& (thr[i]), & attr,
fonction_thread, (void *) (i+1))) != 0) {
fprintf(stderr, "pthread_create: %s\n",
strerror(err));
exit(EXIT_FAILURE);
}
}
for (i = 0; i < NB_THREADS; i ++)
pthread_join(thr[i], NULL);
return EXIT_SUCCESS;
}
À l’exécution, nous constatons que les threads les plus prioritaires se terminent bien en premier,
bien qu’ils aient démarrés avant les autres. Bien entendu, pour qu’ils soient en concurrence, il
convient de tous les placer sur le même processeur.
# taskset -c 0 ./exemple-threads-temps-reel
Debut thread 1 a 1317907166
Debut thread 2 a 1317907168
Debut thread 3 a 1317907170
Debut thread 4 a 1317907172
Fin thread 4 a 1317907175
Fin thread 3 a 1317907176
Fin thread 2 a 1317907177
Fin thread 1 a 1317907178
#
de priorité. L’échelle proposée par Linux [1, 99] est suffisamment large pour la plupart des appli-
cations que l’on construira avec ce système.
Les tâches les plus urgentes, et celles tolérant le moins de fluctuations temporelles, seront pla-
cées à des niveaux de priorité les plus élevés. Les autres processus ou threads d’importance
moindre, ou acceptant plus facilement des jitters, se retrouveront à des niveaux de priorité plus
faibles.
Lorsque des tâches ont des niveaux équivalents de criticité ou d’urgence, on leur donne des
priorités proches. Toutefois, il existe des cas où l’on est obligé de fixer plusieurs tâches sur le
même niveau de priorité. C’est le cas notamment lorsque le nombre de tâches n’est pas connu
à l’avance, mais est déterminé à l’initialisation du système (nombre variable de ports de com-
munication à gérer, par exemple) ou même dynamiquement (à chaque connexion d’un nouveau
client distant).
Si les tâches de même niveau ont des traitements conséquents à réaliser, l’ordonnancement Fifo
n’est pas nécessairement adapté, car lorsqu’une tâche prend le CPU et commence son traite-
ment, toutes les autres seront gelées pendant toute la durée de son travail. Pour ces situations, on
préférera un ordonnancement Round Robin, où une tâche peut s’exécuter pendant une tranche
de temps au bout de laquelle elle est préemptée et placée à la fin de la liste des tâches de mêmes
priorités.
Nous allons reprendre l’exemple précédent avec quatre threads, placés cette fois au même
niveau de priorité. Les threads boucleront autour de l’appel système gettimeofday(). Comme
nous l’avons observé au chapitre 4, ceci nous permettra de détecter les préemptions de chaque
thread. Le seuil de détection est ici fixé arbitrairement à 1 milliseconde.
exemple-threads-rr.c :
#include <pthread.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/time.h>
#define NB_THREADS 4
sleep(2);
heure = debut;
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
while (1) {
do {
precedente = heure;
gettimeofday(& heure, NULL);
// Boucle limitée à 10 secondes
if ((heure.tv_sec - debut.tv_sec) > 10)
pthread_exit(NULL);
difference = heure.tv_sec - precedente.tv_sec;
difference *= 1000000;
difference += heure.tv_usec - precedente.tv_usec;
} while (difference < 1000); // 1 milliseconde
fprintf(stdout, "[%ld.%06ld] %d preempte\n",
precedente.tv_sec, precedente.tv_usec, numero);
fprintf(stdout, "[%ld.%06ld] %d active\n",
heure.tv_sec, heure.tv_usec, numero);
}
return NULL;
}
int main(void)
{
int i;
int err;
pthread_attr_t attr;
struct sched_param param;
pthread_t thr[NB_THREADS];
return EXIT_SUCCESS;
}
Lors de l’exécution, les affichages dus aux différents threads ne se produisent pas par ordre
chronologique, aussi utiliserons-nous l’utilitaire sort pour mieux visualiser les périodes :
Les quatre premières lignes sont incohérentes, ce qui est normal car les threads démarrent
simultanément. Ensuite, nous observons bien une exécution régulière 1 – 2 – 3 – 4 – 1 – 2 – 3 – 4
Les threads s’exécutent par tranches de 100 millisecondes. Cette durée dépend du processus lui-
même et peut être consultée par l’appel système :
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Toutefois, il n’existe pas de fonction standard pour fixer la tranche de temps utilisée dans les
ordonnancements temps réel Round Robin.
Nous pouvons observer également le temps de commutation entre deux threads du même pro-
cessus : environ 6 microsecondes sur ce système. Nous reviendrons sur ce point dans le prochain
chapitre.
Ce dernier a pour effet de suspendre la tâche courante et de la placer en fin de file des tâches en
attente de sa priorité. Si aucune tâche de même priorité n’est prête à s’exécuter, l’appel système
revient immédiatement sans modification pour la tâche appelante.
Sur certains systèmes temps réel classiques, l’habitude était d’utiliser un sommeil de durée nulle sleep(0)
pour réaliser cette opération.
Notre rotation entre tâches sera donc organisée « manuellement », chacune réalisant une boucle
du type :
while ( ! condition_de_sortie) {
// Réaliser le traitement critique
[...]
// Céder le CPU aux autres tâches de même priorité
sched_yield();
}
Naturellement, pour ne pas être préemptées intempestivement, toutes les tâches seront ordon-
nancées en SCHED_FIFO.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
J’ai découvert cet utilitaire tardivement – en 2008 je crois – alors que j’employais depuis des années un
équivalent « fait maison ». Convaincu de l’utilité de mon programme, je me suis amusé à l’intégrer dans le
package Busybox qui sert sur la plupart des plates-formes Linux embarqué. Une fois mon portage terminé,
et avant de le soumettre aux mainteneurs de Busybox, j’ai recherché un nom cohérent pour la commande
(je l’appelais set-rt-priority jusque là mais c’était un peu long). En parcourant la liste des applets de
Busybox, mon attention a été attirée par la commande chrt que je ne connaissais pas. J’ai alors découvert
que j’avais réinventé un utilitaire qui existait déjà !
L’interface de chrt n’est pas très intuitive. Elle fonctionne un peu comme taskset que nous avons
examinée au chapitre 1 et qui permet de fixer l’affinité d’un processus. Voici quelques exemples
d’utilisation :
# chrt -f 50 ./commande
lance la commande en ordonnancement Fifo priorité 50
# chrt -r 10 ./commande
lance la commande en Round Robin priorité 10
# chrt -o 0 ./commande
lance la commande en temps partagé (Other) priorité 0
# chrt -p 12345
affiche ordonnancement et priorité du processus PID 12345
# chrt -pf 50 12345
passe le processus de PID 12345 en Fifo priorité 50
# chrt -pr 30 12345
passe le processus de PID 12345 en RR priorité 50
Bien entendu, lorsqu’il s’agit de lancer ou passer une tâche en ordonnancement temps réel, les
droits root sont nécessaires pour exécuter chrt.
Il est possible, par exemple, d’utiliser chrt pour améliorer la fluidité de certaines applications
multimédias (vidéo, audio, etc.).
Voici un exemple :
# chrt -pf 80 $$
# mplayer video-01.avi
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
On remarquera que j’ai commencé par passer mon shell (dont le PID est toujours contenu dans
la variable spéciale $$) en temps réel Fifo, priorité 80. Ensuite, j’ai lancé le lecteur vidéo mplayer
qui hérite bien sûr de l’ordonnancement de son père et dispose dans ce cas d’une grande stabilité
de ses timers de synchronisation, même en cas de forte charge du système.
Figure 5-12
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Ordonnancement
SCHED_DEADLINE
L’algorithme EDF sert à sélectionner une tâche lorsque le scheduler est invoqué. En l’occur-
rence, il activera celle dont la limite d’exécution est la plus proche.
Pour basculer une tâche en ordonnancement SCHED_DEADLINE, nous ne pouvons pas utiliser le clas-
sique appel sched_setscheduler() car il ne permet pas de fixer les trois arguments précédents.
En principe, rien n’interdirait d’ajouter des champs dans la structure sched_param, mais comme le type
d’ordonnancement n’est pas standardisé par Posix, les développeurs de Linux ont préféré ne pas la modi-
fier et ajouter un appel système plus général, qui, par ailleurs, permet de configurer également les autres
ordonnancements.
int sched_setattr (pid_t tâche, const struct sched_attr * attr, unsigned int flags);
int sched_gettattr (pid_t tâche, struct sched_attr * attr, unsigned int size, unsigned int flags);
La structure sched_attr contient les champs nécessaires pour configurer tous les ordonnance-
ments actuels de Linux.
Champ Signification
Champ Signification
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
size Ce champ doit être initialisé avec sizeof(struct sched_attr) pour gérer
les évolutions futures des paramètres.
Le paramètre flags des appels système sched_setattr() et sched_getattr() doit être nul, le para-
mètre size de sched_getattr() est la taille de la zone mémoire passée en second argument, qui
doit être au moins égale à sizeof(struct sched_attr).
Pour l’ordonnancement SCHED_DEADLINE, le paramètre sched_period doit être supérieur à sched_
deadline, qui doit à son tour être supérieur à sched_runtime.
Au moment de la rédaction de ces lignes, la bibliothèque C installée sur la distribution que
j’utilise ne connaît pas encore les appels système sched_getattr() et sched_setattr(). Pour pou-
voir faire fonctionner l’exemple suivant, il faut les déclarer explicitement en employant l’appel
système syscall(). Il s’agit de la portion de code encadrée par un #ifndef SCHED_DEADLINE dans
l’exemple suivant. Les numéros des appels système varient d’une architecture à l’autre. J’ai
mentionné ici les valeurs pour les architectures les plus courantes. Il faudra éventuellement les
adapter pour de nouvelles plates-formes en consultant le fichier de définition des appels système
sous le répertoire arch/<architecture>/kernel du noyau.
L’exemple ci-dessous bascule le processus sous ordonnancement SCHED_DEADLINE, puis crée un
timer avec la période indiquée. Dans la fonction de traitement du timer, nous affichons en per-
manence l’écart minimal et l’écart maximal observés entre deux déclenchements successifs.
exemple-sched-deadline.c :
#define _GNU_SOURCE
#include <sched.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <sys/time.h>
#ifndef SCHED_DEADLINE
#define SCHED_DEADLINE 6
#include <sys/syscall.h>
struct sched_attr {
__uint32_t size;
__uint32_t sched_policy;
__uint64_t sched_flags;
/* SCHED_OTHER */
__int32_t sched_nice;
/* SCHED_FIFO, SCHED_RR */
__uint32_t sched_priority;
/* SCHED_DEADLINE */
__uint64_t sched_runtime;
__uint64_t sched_deadline;
__uint64_t sched_period;
};
#define SCHED_FLAG_RESET_ON_FORK 1
{
long int runtime;
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
if ((argc < 4)
|| (sscanf(argv[1], "%ld", &runtime) != 1)
|| (sscanf(argv[2], "%ld", &deadline) != 1)
|| (sscanf(argv[3], "%ld", &period) != 1)) {
fprintf(stderr, "usage: %s <runtime> <deadline> <period> (all in usec)\n", argv[0]);
exit(EXIT_FAILURE);
}
event.sigev_notify = SIGEV_SIGNAL;
event.sigev_signo = SIGRTMIN;
signal(SIGRTMIN, rtmin_handler);
return EXIT_SUCCESS;
}
Il faut noter une particularité pour un processus ordonnancé en SCHED_DEADLINE : son affinité
CPU ne doit pas être contrainte. En d’autres termes, il ne faut pas utiliser taskset par exemple
pour interdire l’exécution sur certains cœurs, sinon le noyau refusera le passage en ordonnance-
ment SCHED_DEADLINE.
Voici un exemple d’exécution sur Raspberry Pi 2, où nous demandons un timer toutes les dix
millisecondes, le handler associé doit s’exécuter dans les cinq premières millisecondes de
chaque cycle, avec une durée d’exécution maximale (largement surestimée) d’une milliseconde.
Les arguments de ligne de commande et les résultats sont en microsecondes.
Le programme n’affiche de nouvelles lignes que lorsque le minimum ou le maximum observés
changent.
Conclusion
Nous avons observé dans ce chapitre les bases de l’ordonnancement temps réel sous Linux.
J’encourage vivement le lecteur à se livrer à ses propres expériences, en réalisant des petites
boucles temporisées ou protégées par alarm(), afin de se rendre compte des effets sur la réacti-
vité du système.
Notre prochain chapitre va s’attacher à étudier les limites du temps réel souple sous Linux.
Points clés
• On peut ordonnancer des tâches (processus complets ou threads uniques) en temps réel dans
une échelle allant de 1 à 99. Les tâches temps partagé classiques ont l’équivalent d’une prio-
rité temps réel nulle.
• Il existe deux ordonnancements temps réel sous Linux : Fifo ou Round Robin. Une tâche Fifo
ne peut être préemptée que par une tâche de priorité strictement supérieure. En Round Robin,
une tâche s’interrompt périodiquement pour laisser s’exécuter les éventuelles autres tâches de
même priorité.
• Sous Linux, un garde-fou conserve une partie du temps processeur pour les tâches temps
partagé même quand une tâche temps réel s’exécute. Pour le désactiver, on configure /proc/
sys/kernel/sched_rt_runtime_us.
• L’utilitaire chrt permet de fixer l’ordonnancement d’un processus depuis la ligne de com-
mandes du shell.
• Le nouvel ordonnancement SCHED_DEADLINE apparu dans le noyau 3.14 permet de cadrer préci-
sément les limites temporelles d’exécution d’une tâche (généralement cyclique). Bien qu’il ne
soit pas classé dans les ordonnancements temps réel classiques, il a une priorité plus grande
que ces derniers, puisque la portion de temps CPU nécessaire est réservée. L’utilisation d’un
appel système spécifique est un handicap pour cet ordonnancement sur les systèmes où la
bibliothèque C n’en tient pas encore compte.
Exercices
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Les exercices suivants peuvent, en cas d’erreur, nécessiter d’éteindre « brutalement » votre
machine. Assurez-vous avant de les démarrer d’avoir sauvegardé toutes les données importantes
des applications en cours. Pensez également à lancer la commande sync depuis le shell avant de
démarrer un programme susceptible de boucler indéfiniment. Enfin, il est évident qu’il ne faut
pas effectuer ces manipulations si d’autres utilisateurs sont connectés sur le même système.
Exercice 1 (*)
Utilisez la commande chrt pour examiner l’ordonnancement des processus présents sur votre
système. En voyez-vous avec un ordonnancement temps réel ? Examinez plus particulièrement
les threads du noyau (présentés entre crochets au début des résultats de la commande ps aux).
Exercice 2 (*)
Essayez de passer votre shell en temps réel avec chrt. Quels sont les droits nécessaires ? Pour-
quoi ? Observez-vous des différences sur le comportement des commandes que vous passez
depuis ce shell ?
Exercice 3 (**)
Passez en temps réel (Fifo ou Round Robin) avec une priorité 10 tous les processus de votre
système précédemment identifiés comme étant ordonnancés en temps partagé.
Constatez-vous un fonctionnement différent de votre environnement ?
Exercice 4 (**)
Lancez le programme exemple-boucle-temps-reel. Le système est-il actif ? Gelé ? Totalement
gelé ? Pourquoi ?
Jouez (avec des redirections de echo) sur le contenu du pseudo-fichier /proc/sys/kernel/sched_
rt_runtime_us pour garantir un vrai ordonnancement temps réel à la boucle.
Exercice 5 (***)
Lancez une boucle temps réel avec une priorité Fifo 40 (après configuration correcte de
sched_rt_runtime_us).
Au préalable, montez à la priorité Fifo 50 les processus nécessaires pour conserver le contrôle
sur votre système (serveur X-Window, environnement graphique, etc.).
6
Performances
du temps réel souple
Nous avons vu que nous disposons d’outils nous permettant de fixer – tant depuis le shell qu’au
sein d’une application – l’ordonnancement des tâches en suivant des politiques Fifo ou Round
Robin, avec des priorités dans l’échelle [1, 99]. Nous allons maintenant essayer de mesurer les
performances de ce temps réel souple et d’en déterminer les limites d’application.
Précisions et fluctuations
Notre première expérience va consister à vérifier si les tâches temps réel bénéficient d’une meil-
leure précision pour réaliser des opérations périodiques.
Reprenons le programme exemple-timer-create-02.c du chapitre 4 et exécutons-le sous un
ordonnancement Fifo de haute priorité en sauvegardant ses résultats dans un fichier :
# taskset -pc 0 $$
pid 3189’s current affinity list: 0-3
pid 3189’s new affinity list: 0
# chrt -f 99 ./exemple-timer-create-02 1000 > data-rt-01.txt
# ./calculer-statistiques < data-rt-01.txt
Nb mesures = 5000
Minimum = 930
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Maximum = 1063
Moyenne = 999
Ecart-type = 5
#
Nous voyons que les statistiques sont meilleures que pour le temps partagé (sans perturbation),
où nous avions obtenu un écart maximal de 1 224 microsecondes entre deux déclenchements du
timer, et un écart-type de 7 microsecondes par rapport à la valeur moyenne d’une milliseconde.
Un graphique nous permettra de mieux voir les fluctuations :
Figure 6-1
Timer temps réel
non perturbé
Si on le compare à la figure 4-1 du chapitre 4, nous voyons que les écarts par rapport à la
moyenne sont du même ordre, mais qu’il n’y a plus d’occurrences déviant sur les côtés.
Essayons à présent la même expérience avec un programme perturbateur (en temps partagé bien
sûr) exécuté sur un autre terminal, sur le même processeur.
$ taskset -pc 0 $$
$ ./exemple-perturbateur
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Les valeurs sont bien stables et comparables aux précédentes. L’écart-type est de 4 micro-
secondes, alors qu’au chapitre 4 nous avions un écart-type quinze fois plus élevé dans les mêmes
circonstances.
Figure 6-2
Timer temps réel
avec perturbation
Je vous encourage à comparer ce graphique avec la figure 4-2 du chapitre 4, où nous avions été
obligé d’étirer l’axe des abscisses vers la droite jusqu’à 5 millisecondes.
Les résultats précédents ont été obtenus sur le cœur 0 d’un Raspberry Pi 2. C’est la pire des
situations puisque par défaut la plupart des interruptions sont traitées sur ce cœur. Comme nous
l’avons vu dans le chapitre 2, il serait possible de rediriger les traitements d’interruption sur
d’autres cœurs.
pas cherché à vérifier la finesse que nous pouvons attendre lors de déclenchements périodiques.
Cette fois, nous pouvons essayer de diminuer la période des timers temps réel et vérifier la
précision des déclenchements. Ceci se réalise ainsi :
Pour éviter les collisions avec les autres interruptions du système, j’ai choisi de placer ce test sur
le cœur numéro 2 d’un Raspberry Pi 2.
J’ai regroupé dans le tableau suivant les résultats de plusieurs essais successifs.
Période
Moyenne Mini Maxi Écart-type
demandée
1000 999 962 1014 3
100 99 54 197 1
50 49 13 100 3
10 21 12 163 10
5 25 12 195 12
Nous voyons que notre tâche périodique peut arriver à fonctionner raisonnablement jusqu’à des
périodes de quelques dizaines de microsecondes (à condition d’être ordonnancée en temps réel
avec une priorité suffisante). Les valeurs moyennes sont toujours bonnes et les écarts-types (qui
sont significatifs des fluctuations du timer) restent faibles. Toutefois, il faut noter que si un écart-
type de 3 microsecondes représente 0,3 % d’un timer à la milliseconde, il correspond également
à 6 % d’un timer à 50 microsecondes.
On notera que ces valeurs sont très différentes d’un processeur à l’autre. Il sera nécessaire dans
la phase d’étude d’un projet temps réel de mesurer sur le système cible les valeurs (à l’aide des
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Cela implique naturellement que le support pour le fichier /proc/config.gz ait été activé dans le noyau.
Temps de commutation
Un point important dans les systèmes temps réel est de connaître le temps de commutation entre
deux tâches synchronisées sur une ressource partagée (mutex, sémaphore, etc.) Les fournisseurs
de systèmes temps réels stricts propriétaires indiquent généralement ces valeurs dans les spéci-
fications de leurs produits, garantissant des durées maximales de commutation.
Dans les systèmes temps réels souples, nous n’avons pas de garantie aussi absolue sur les
durées de commutation. Nous pouvons toutefois les mesurer et valider ainsi par l’observation le
comportement de notre système.
Thread 1 Thread 2
Demande le mutex Attend 10 ms
Obtient le mutex
Attend 10 ms
Pour être sûr que le thread 2 ne récupère pas instantanément le mutex après l’avoir relâché,
nous rendons le thread 1 sensiblement plus prioritaire. Ce dernier devra alors utiliser un petit
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
sommeil (10 ms, par exemple) pour que le thread 2 ait le temps de reprendre le mutex lorsqu’il
le libère.
Nous étudierons au chapitre 7 une autre méthode permettant de mieux se synchroniser sur un mutex.
pthread_mutex_lock(& mutex);
usleep(200000); // 200 ms
while (nb_commutations < NB_COMMUTATIONS_MAX) {
gettimeofday(& avant_commutation, NULL);
pthread_mutex_unlock(& mutex);
pthread_mutex_lock(& mutex);
usleep(10000); // 10 ms
}
pthread_mutex_unlock(& mutex);
return NULL;
}
int main(void)
{
int i;
int err;
pthread_attr_t attr;
struct sched_param param;
pthread_t thr[2];
param.sched_priority = 20;
if ((err = pthread_attr_setschedparam(& attr,
& param)) != 0) {
fprintf(stderr, "setschedparam: %s\n",strerror(err));
exit(EXIT_FAILURE);
}
if ((err = pthread_create(& (thr[0]), & attr,
thread_basse_prio, NULL)) != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(err));
exit(EXIT_FAILURE);
}
param.sched_priority = 80;
if ((err = pthread_attr_setschedparam(& attr,
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
& param)) != 0) {
fprintf(stderr, "setschedparam: %s\n",strerror(err));
exit(EXIT_FAILURE);
}
if ((err = pthread_create(& (thr[1]), & attr,
thread_haute_prio, NULL)) != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(err));
exit(EXIT_FAILURE);
}
pthread_join(thr[0], NULL);
pthread_join(thr[1], NULL);
return EXIT_SUCCESS;
}
Nous lançons le programme (avec les deux threads sur le même CPU), qui affiche les temps de
commutation mesurés :
# taskset -pc 2 $$
# ./exemple-commutation-threads
12
13
12
13
12
13
13
13
13
12
[...]
#
Naturellement, les conditions qui nous intéressent pour le temps réel sont les cas extrêmes, aussi
réutilisons-nous nos outils développés au chapitre 4 :
Les résultats sont représentés sur l’histogramme de la figure 6-3. On y remarque que les temps
de commutation sont bien constants et plutôt rapides (deux commutations vers 50 micro-
secondes probablement dues à l’occurrence d’une interruption, une en 17 microsecondes et les
997 autres entre 11 et 15 microsecondes). Ceci est très satisfaisant, mais il faut noter que le sys-
tème n’était pas particulièrement chargé, et il serait sûrement nécessaire de valider ces données
sur un système cible avec une charge processeur importante.
Figure 6-3
Temps de commutation
entre threads
Effectivement, les durées moyennes et maximales sont plus longues, comme le montre l’histo-
gramme de la figure 6-4.
Figure 6-4
Temps de commutation
entre processus
non en mémoire physique. Cette correspondance est assurée par une sous-partie du processeur
nommée MMU (Memory Managment Unit), qui peut être imaginée comme une sorte de table
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
indiquant pour chaque page de mémoire virtuelle le numéro de la page de mémoire physique
correspondant, ou un repère précisant que cette page virtuelle n’a pas de correspondance en
mémoire physique. Toute tentative de lecture ou d’écriture dans une telle page déclencherait
une exception « faute de page » qui avertirait le noyau d’un accès mémoire illégal (se soldant
la plupart du temps par un signal fatal pour le processus, mais aussi parfois par de l’allocation
mémoire à la volée).
Chaque processus dispose de sa propre configuration pour la table MMU. Sauf cas particu-
lier (mémoire partagée), ces configurations sont organisées pour garantir qu’aucune page de
mémoire physique ne risque d’être modifiée par deux processus distincts. C’est ce qui assure
l’étanchéité de la mémoire d’un processus.
Figure 6-5
Mémoire virtuelle
d’un processus
Lorsque l’ordonnanceur commute entre deux tâches se trouvant dans deux espaces mémoire
(deux processus) distincts, il doit reconfigurer le contenu de la MMU. Or, cette modification
de la configuration de la MMU a un coût non négligeable, principalement en termes de cache
mémoire.
À l’inverse, deux threads d’un même processus partagent par définition la même mémoire vir-
tuelle. La seule différence dans la configuration de leur espace mémoire est que chaque thread
dispose d’une pile personnalisée pour stocker l’adresse de retour lorsqu’il entre dans une fonc-
tion, les paramètres de la fonction et pour allouer les variables automatiques (locales).
La commutation entre deux threads d’un même processus est donc beaucoup plus rapide
puisqu’elle ne sollicite que les registres du processeur (dont le pointeur de pile).
Lorsque l’on porte sous Linux des projets ayant été conçus initialement pour des systèmes temps
réel classiques (VxWorks, VRTX, pSOS, etc.), on est souvent amené à développer un gros pro-
cessus qui contient tout le projet, en répartissant toutes les tâches sous forme de threads. Ceci
présente l’avantage de projeter les concepts (mémoire partagée, mutex, etc.) que l’on trouve sur
ces systèmes embarqués, qui n’ont généralement pas de notion de mémoire virtuelle. En outre,
la commutation entre threads est une opération efficace et dont la durée est plus déterministe
que celle entre processus.
Un inconvénient toutefois : si un thread commet une erreur fatale liée à un bogue (accès
mémoire, division par zéro, etc.), c’est tout le processus – l’ensemble des threads – qui est inter-
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Cette méthode de gestion avancée de la mémoire, en s’appuyant sur l’exception « faute de page »,
est très performante et fonctionne parfaitement pour un poste de travail ou un serveur. Mais elle
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
peut s’avérer très gênante dans le cas d’une application temps réel.
Imaginez que notre processus vient à peine de démarrer. Il a basculé sous ordonnancement
temps réel et invoque une fonction de traitement critique. Toutefois, cette fonction peut se trou-
ver sur une page virtuelle qui n’est pas encore présente en mémoire physique. C’est lors de
l’exception « faute de page » que le noyau va lancer le chargement depuis le fichier exécutable.
Le temps de démarrage de notre routine critique vient subitement de changer d’ordre de gran-
deur pour passer de la microseconde à la milliseconde !
Le problème n’est pas tant la durée de chargement du code exécutable (qu’il faut bien réaliser à
un moment ou à un autre), mais l’imprévisibilité de la situation, due notamment au comporte-
ment des autres processus et au niveau de saturation de la mémoire physique.
Un second phénomène peut contrarier la prédictibilité du temps d’accès à la mémoire :
lorsqu’un processus crée un fils en appelant fork(), le noyau est censé réaliser une copie de
la mémoire du processus père. Toutefois, pour améliorer l’efficacité du système, le kernel
n’effectue pas de copie de la mémoire physique. Les pages de mémoire virtuelle des deux
processus, père et fils, pointent vers les mêmes pages de mémoire physique, mais elles sont
alors verrouillées en lecture seule dans la MMU. Tant que les deux processus ne font que de
la consultation de ces pages (code exécutable, tables de paramètres constants, etc.), il n’y a
aucune duplication de mémoire.
Si l’un des deux processus tente de modifier une donnée, la MMU voit un conflit avec la
configuration lecture seule de la page concernée et lève une exception « faute de page ». Le
kernel s’aperçoit que l’exception s’est produite lors d’un accès à une page qu’il avait notée
comme Copy-On-Write (copie à l’écriture) et va réaliser seulement à ce moment la dupli-
cation de la page de mémoire physique, puis l’attribution de chacune des copies à l’un des
processus et enfin le déverrouillage pour en autoriser l’accès lecture écriture.
Encore une fois, ce mécanisme très efficace peut perturber le déterminisme de l’exécution d’une
application temps réel. Pourtant, le noyau Linux nous propose une solution :
#include <sys/mman.h>
int mlockall (int flags);
int munlockall (void);
L’appel système mlockall() charge et verrouille en mémoire physique toutes les pages de
mémoire virtuelle qui y sont déjà (si flags contient MCL_CURRENT), ainsi que celles qui devraient
être chargées ultérieurement (si flags contient MCL_FUTURE) par le mécanisme de la copie à l’écri-
ture ou du chargement à la demande du code exécutable.
L’appel mlockall() peut échouer si la quantité de mémoire disponible n’est pas suffisante, ou si
les limites fixées pour le processus ont été atteintes.
Je conseille de l’employer systématiquement dans les processus temps réel, afin d’éviter les
retards imprévisibles dus à la mémoire virtuelle. Un petit souci se pose toutefois pour garantir
l’allocation de la pile du processus. J’utilise généralement une fonction d’initialisation qui va
réserver la taille voulue dans la pile et appeler mlockall() :
void verrouiller_memoire ()
{
unsigned char tableau [TAILLE_PILE_VOULUE];
memset(tableau, 0, taille_pile_voulue);
if (mlockall(MCL_CURRENT | MCL_FUTURE) != 0) {
perror("mlockall()");
exit(EXIT_FAILURE);
}
}
Cette fonction pourra être appelée après passage en temps réel, avant d’entamer les opérations
critiques.
Nous verrons dans les chapitres ultérieurs que cette opération de verrouillage en mémoire physique, qui
est importante pour le temps réel souple, pourra être indispensable pour les extensions temps réel strictes.
Préemptibilité du noyau
Il existe dans l’univers des systèmes d’exploitation temps réel une notion parfois mal comprise
mais très importante : celle de préemptibilité du noyau.
Avant tout, une précision de vocabulaire : Linux a toujours été – et demeure – un système
d’exploitation à ordonnancement préemptif. Le noyau est capable à tout moment de préempter
une tâche pour exécuter un traitement urgent (sur demande d’interruption, par exemple) ou
commuter sur une autre tâche plus prioritaire qui vient de se réveiller. Nous l’avions observé sur
la figure 3-2 du chapitre 3.
Depuis le noyau 2.6, une option a été intégrée permettant de rendre le noyau préemptible, c’est-
à-dire susceptible d’être lui-même préempté par une tâche urgente.
Principes
Prenons d’abord l’exemple d’un noyau non préemptible et reportons-nous à la figure 6-6. Nous
observons deux tâches : T1 (de faible priorité) et T2 (de haute priorité).
T2 est endormie en attente d’un événement extérieur, alors que T1 est active. Nous savons que si
T2 devient prête alors que T1 s’exécute en espace utilisateur, T1 sera immédiatement préemptée
(voir chapitre 3, figure 3-2).
Figure 6-6
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Ici, T1 va effectuer un appel système et entrer ainsi dans le noyau. Supposons qu’il s’agisse d’un
appel système relativement long (nécessitant de réorganiser des tables internes, par exemple),
mais durant lequel la tâche T1 n’est pas endormie.
À présent, une interruption survient, indiquant que l’événement attendu par T2 s’est produit. Le
handler gérant cette interruption fait passer T2 à l’état prête (Runnable). Toutefois, au retour de
ce handler, c’est l’appel système s’exécutant pour le compte de T1 qui doit d’abord se terminer.
Puis, au retour dans l’espace utilisateur, l’ordonnanceur est invoqué et la commutation se fait
vers T2 qui devient Running tandis que T1 est Runnable.
Voyons à présent le comportement d’un noyau préemptible face à la même séquence d’événe-
ments, comme représenté sur la figure 6-7.
Figure 6-7
Noyau préemptible
T1 débute toujours Running et T2 Sleeping ; T1 invoque un appel système durant lequel une
interruption se produit. Le handler fait passer T2 à l’état Runnable.
Cette fois, c’est au retour du handler de l’interruption que l’ordonnanceur est invoqué. Aussi
peut-on commuter tout de suite sur T2, en laissant T1 Runnable dans l’espace kernel. Ce n’est
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
que lorsque T2 s’endormira volontairement que l’appel système suspendu pourra continuer et T1
reprendre son exécution.
Ce second comportement – celui d’un noyau préemptible – est beaucoup plus adapté pour un
système temps réel. En effet, nous avons un événement externe (l’interruption) et une action en
réponse à ce résultat (l’exécution de T2). Il est manifeste en comparant les deux figures que dans
le second cas, la réponse à l’événement arrive plus rapidement que dans le premier cas. Toute-
fois, ce n’est pas la notion de rapidité qui compte pour une application temps réel mais celle de
prédictibilité.
Dans le premier cas, nous sommes incapables de donner une limite au temps s’écoulant entre
le déclenchement de l’événement et le début de la réponse, car ce temps ne dépend ni de l’appli-
cation temps réel (T2) ni du noyau lui-même (durée du handler), mais du comportement d’une
autre tâche totalement indépendante. On appelle ce genre de situation, dans laquelle une tâche
temps réel est soumise à l’exécution d’une tâche de moindre priorité, une « inversion de prio-
rité ». Nous reviendrons sur ce sujet dans le prochain chapitre.
Au contraire, dans la seconde situation, nous pouvons mesurer la latence, c’est-à-dire le temps
s’écoulant entre l’arrivée de l’interruption et le réveil de la tâche T2. Ensuite, sachant que cette
durée ne dépend que du handler et de l’ordonnanceur, nous pouvons estimer que la latence res-
tera constante par la suite.
Il faut toutefois reconnaître que la préemptibilité alourdit quelque peu le noyau.
• Tout le code des appels système doit être réentrant, même sur un système uniprocesseur :
la tâche T2 de notre exemple peut en effet invoquer le même appel système que celui dans
lequel T1 est en attente et utiliser des variables globales ou statiques qu’il faut protéger contre
les accès concurrents.
• L’appel de l’ordonnanceur doit se faire à chaque retour d’interruption et non plus à chaque
retour dans l’espace utilisateur.
• Pour maintenir une tâche en attente dans le code du kernel, et enregistrer son état (registres
processeur, etc.), il faut qu’elle dispose d’une pile personnelle dans l’espace noyau en plus de
sa pile dans l’espace utilisateur.
• Voluntary kernel preemption : il s’agit d’une option un peu « tiède », avec laquelle le noyau
n’est pas totalement préemptible, mais où des points de préemption sont insérés explicitement
autour des fonctions potentiellement longues invoquées depuis les appels système. C’est sou-
vent la configuration adoptée par les distributions ; elle est bien adaptée aux postes de travail
avec quelques périphériques externes.
Vous pouvez voir les points de préemption explicites dans le code du noyau Linux, il s’agit de
portions comme :
if (need_resched()) {
// nettoyage
[...]
// appel de l'ordonnanceur
schedule();
}
Ici, nous voyons que l’option VOLUNTARY est activée, comme sur la plupart des distributions clas-
siques. La commande uname -a nous confirme qu’il s’agit d’un noyau compilé pour la distribution
Ubuntu.
$ uname -a
Linux SRVR-Logilin 3.0.0-12-generic #20-Ubuntu SMP Fri Oct 7 14:50:42 UTC 2011 i686
➥ i686 i386 GNU/Linux
[~]$
Sur un système embarqué, le fichier de configuration du noyau n’est pas toujours fourni. Toute-
fois, il est possible d’intégrer son contenu directement dans la mémoire du noyau, et d’y donner
accès via le pseudofichier /proc/config.gz. En voici un exemple sur une carte à processeur Arm.
$ ssh root@Pandaboard
root@Pandaboard's password:
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Cette fois, l’option PREEMPT est activée, ce qui apparaît également dans le résultat de :
[Panda] # uname -a
Linux Pandaboard 3.0.0-rc7-cpb #1 SMP PREEMPT Thu Sep 29 14:49:25 CEST 2011 armv7l
➥ GNU/Linux
[Panda] #
uart-echo-timing.c :
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
#include <intrinsics.h>
#include <msp430g2553.h>
int main(void)
{
long int counter;
P1OUT |= BIT6;
// Wait for button press.
while ((P1IN & BIT3) != 0)
;
// Green led off.
P1OUT &= ~BIT6;
while (1) {
counter = 0;
// Red led on.
P1OUT |= BIT0;
// Send a request.
send_char(‘A');
// Wait for an answer.
while(!(IFG2 & UCA0RXIFG)) {
counter ++;
}
// Red led off.
P1OUT &= ~BIT0;
if (UCA0RXBUF != ‘A')
continue;
// Send the timing.
send_long_int(counter);
send_char(‘\r');
send_char(‘\n');
delay_ms(10);
}
return 0;
}
void send_char(char c)
{
// Wait for Tx empty.
while(!(IFG2 & UCA0TXIFG))
;
UCA0TXBUF = c;
}
void send_long_int(long l)
{
if (l > 10)
send_long_int(l / 10);
send_char((l%10) + ‘0');
}
On peut voir que ce programme, après une pression sur le bouton Start, envoie un octet (carac-
tère ‘A') sur le port série et boucle – en incrémentant une variable – tant qu’il n’a pas reçu de
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
réponse. La durée de la boucle importe peu. Chaque itération dure environ 1 microseconde, et
on pourrait la calibrer précisément mais cela ne présente pas de véritable intérêt. Ce n’est pas le
nombre absolu de boucles effectuées qui nous concerne, mais plutôt les variations de ce nombre
au cours des essais successifs.
Une fois la réponse obtenue, nous envoyons sur le même port série le nombre de boucles effec-
tuées, et le travail reprend inlassablement.
Le programme qui fonctionne sur le Raspberry Pi 2 sous Linux est le suivant :
exemple-reponse-irq-serie.c :
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <termios.h>
#include <unistd.h>
if (argc != 2) {
fprintf(stderr, "usage: %s port_serie\n",argv[0]);
exit(EXIT_FAILURE);
}
signal(SIGINT, handler_sigint);
while (! quitter) {
if (read(fd, & octet, 1) < 0) {
perror(argv[1]);
exit(EXIT_FAILURE);
}
if (octet!= ‘A')
continue;
if (write(fd, & octet, 1) < 0) {
perror(argv[1]);
exit(EXIT_FAILURE);
}
while (octet != ‘\n') {
read(fd, &octet, 1);
write(STDOUT_FILENO, &octet, 1);
}
}
tcsetattr(fd, TCSANOW, & original);
close(fd);
fprintf(stderr, "Bye !\n");
return EXIT_SUCCESS;
}
Le programme attend donc un caractère, le renvoie en écho, récupère les caractères représentant
son temps de réponse et les affiche sur sa sortie standard.
Voici un exemple d’exécution :
# ./exemple-reponse-irq-serie /dev/ttyAMA0
5938
5908
5918
5915
5923
5923
5926
5924
5923
5926
5927
5920
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
5922
[...]
(Contrôle-C)
Bye !
#
Pour perturber le fonctionnement du programme, j’ai réalisé un petit script shell qui charge
(avec insmod) et décharge (avec rmmod) toutes les cinq secondes ce petit module du kernel :
module-delay.c :
#include <linux/module.h>
#include <linux/delay.h>
module_init(module_delay_init);
module_exit(module_delay_exit);
MODULE_LICENSE("GPL");
Ce module effectue une boucle active de 100 millisecondes dès son chargement dans le kernel.
Ceci nous permet de disposer d’un appel système suffisamment long pour perturber le temps de
réponse à une interruption sur un système non préemptible.
Comme nous en avons l’habitude, étudions les résultats en les redirigeant dans un fichier et utili-
sons les outils développés au chapitre 4. Tout d’abord, un essai avec un noyau non préemptible :
# uname -a
Linux raspberrypi 3.18.16-cpb-no-preempt #1 SMP Fri Jul 17 09:11:35 CEST 2015 armv7l
GNU/Linux
# taskset -pc 1 $$
pid 3222's current affinity list: 0-3
pid 3222's new affinity list: 0-3
# chrt -f 90 ./exemple-reponse-irq-serie /dev/ttyAMA0 > /run/resultats-non-preempt.
txt
(Contrôle-C après quelques minutes)
Bye!
#
J’ai choisi d’enregistrer les résultats des mesures dans un fichier placé dans le répertoire /run
(système de fichiers tmpfs en mémoire RAM), afin d’éviter les perturbations apportées par les
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Les résultats statistiques paraissent assez catastrophiques : une variabilité entre 500 et 100 000
itérations de boucles, et un écart-type approchant la moyenne des valeurs ! Graphiquement, les
résultats sont plus compréhensibles, comme nous le voyons sur la figure 6-8.
Figure 6-8
Réponse en interruption
sur noyau non préemptible
Notre système fait un grand écart entre un temps de réponse faible (de l’ordre de 5 000 itérations
par boucle, environ 5 millisecondes) et une réponse très retardée par l’appel système perturba-
teur (100 000 itérations par boucle, à peu près 100 ms).
Voici à présent la réponse avec le même noyau compilé avec l’option préemptible :
# uname -a
Linux raspberrypi 3.18.16-cpb-preempt #1 SMP PREEMPT Fri Jul 17 13:19:13 CEST 2015
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
armv7l GNU/Linux
# chrt -f 90 ./exemple-reponse-irq-serie /dev/ttyAMA0 > /run/resultats-preempt.txt
# ../chapitre-04/calculer-statistiques < resultats-preempt.txt
Nb mesures = 4460
Minimum = 534
Maximum = 6167
Moyenne = 5928
Ecart-type = 115
#
Voilà qui est beaucoup mieux ! Le résultat est visible sur la figure 6-9 avec la même échelle que
précédemment. Un zoom au début de l’axe des abscisses est représenté sur la figure 6-10.
Figure 6-9
Réponse en interruption
sur noyau préemptible (1)
Cette fois, aucune préemption du processus de haute priorité, la réponse à l’interruption est de
l’ordre de 5 millisecondes au maximum. La réponse n’est pas réellement plus rapide (elle peut
même être très légèrement plus lente en raison de la complexité ajoutée au code noyau), mais
elle est plus fiable et prévisible.
Figure 6-10
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Nous avons indiqué précédemment que la préemptibilité du noyau impose aux appels système
d’être réentrants. Ceci signifie que l’appel système laissé en attente pendant l’activation d’une
autre tâche est susceptible d’être réinvoqué par cette dernière. Aussi, tout accès à des variables
globales ou statiques, par exemple, doit être protégé.
Dans le kernel Linux, plusieurs mécanismes cohabitent pour la protection des données parta-
gées. Les spinlocks sont les plus utilisés car ils permettent d’éviter les cas de concurrences entre
appels système et gestionnaires d’interruptions quelle que soit la configuration du système (uni-
processeur ou multiprocesseur, préemptible ou non).
Ceci implique que les portions de code durant lesquelles un spinlock a été verrouillé ne sont pas
préemptibles. Certes, ces moments doivent être les plus brefs possibles, mais il n’en demeure
pas moins que les réponses à certaines interruptions peuvent être légèrement retardées à cause
d’un spinlock tenu par une tâche de plus faible priorité. Et ceci, même si le spinlock en question
protège une variable qui n’a aucun rapport avec l’interruption.
Pour améliorer ce comportement, le projet PREEMPT_RT propose un patch qui rend préemp-
tible la plupart de ces portions de code en remplaçant les spinlocks par des mutex là où c’est
possible. Nous en reparlerons au chapitre 8.
Conclusion
Les mécanismes temps réel proposés par le noyau Linux standard offrent, ainsi que nous l’avons
observé, des performances intéressantes. Néanmoins, toute application temps réel peut être
confrontée à certains problèmes classiques et récurrents (inversion de priorité, reprise de mutex,
etc.) comme nous allons l’étudier dans le prochain chapitre.
Points clés
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
• Avec un ordonnancement temps réel souple proposé par le noyau Linux standard, une tâche
peut bénéficier de timers dont la période peut descendre à quelques dizaines de microse-
condes avec une variabilité, un jitter, de quelques microsecondes. Ceci dépend toutefois très
nettement des performances du matériel.
• La commutation entre deux processus prend quelques microsecondes. La commutation entre
deux threads du même processus est légèrement plus rapide.
• Pour éviter les délais imprévisibles dus à la gestion de la mémoire virtuelle, il est important
d’utiliser l’appel mlockall().
• La préemptibilité optionnelle du noyau, disponible depuis sa version 2.6, est un élément
important pour améliorer la fiabilité des systèmes temps réel dans leur réponse aux événe-
ments externes.
Exercices
Exercice 1 (*)
Reprenez le programme exemple-timer-create-02.c du chapitre 4 et exécutez-le sous un ordon-
nancement temps réel (avec chrt). Est-il plus rapide qu’en temps partagé ?
Exercice 2 (**)
Utilisez le programme exemple-perturbateur.c pendant l’exécution de exemple-timer-create-
02.c. Les résultats de ce dernier sont-ils différents ? Comparez-les avec ceux obtenus au
chapitre 4.
Jouez sur la priorité temps réel du processus perturbateur et observez la variabilité du timer.
Quelle est la priorité maximale que l’on peut employer sans augmentation du jitter ?
Exercice 3 (**)
Déterminez la fréquence maximale avec laquelle vous pouvez faire fonctionner un timer sur
votre système sans que l’écart-type par rapport aux déclenchements prévus dépasse 5 % de la
période du timer.
Exercice 4 (**)
Mesurez le temps de commutation entre deux threads du même processus synchronisés sur un
mutex. Recommencez la même opération sur deux processus synchronisés sur un sémaphore.
Placez les threads ou processus sur deux processeurs distincts, puis sur le même processeur.
Voyez-vous une différence ?
Exercice 5 (***)
Mesurez le temps de commutation entre deux processus en utilisant les différentes ressources
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
IPC classiques : files de messages, tubes de communication, signaux temps réels, etc.
Le principe est toujours le même : un processus récupère l’heure système et la fournit à l’autre
processus en le réveillant. Ce dernier calculera la différence et l’affichera.
7
Problèmes temps réel classiques
Il existe quelques problèmes que l’on rencontre régulièrement avec les systèmes temps réel et
auxquels la plupart des développeurs de ces systèmes sont confrontés un jour ou l’autre. Notre
premier souci va consister à lancer des threads en ordonnancement Round Robin avec une prio-
rité élevée, puis nous examinerons les problèmes d’inversion de priorités et enfin les prises de
mutex.
#define NB_THREADS 5
int main(void)
{
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
int i;
int err;
pthread_attr_t attr;
struct sched_param param;
pthread_t thr[NB_THREADS];
return EXIT_SUCCESS;
}
}
Lorsque nous exécutons notre programme, les threads affichent tous leurs heures de début et de
fin (en secondes comptées depuis le 01/01/1970).
# taskset -pc 0 $$
pid 2470's current affinity list: 3
pid 2470's new affinity list: 0
# ./exemple-threads-rr-01
[0] 1318412845 -> 1318412848
[1] 1318412848 -> 1318412851
[2] 1318412851 -> 1318412854
[3] 1318412854 -> 1318412857
[4] 1318412857 -> 1318412860
#
Trouvez-vous que cela ressemble à une exécution en Round Robin ? Moi, non ! Chaque thread
s’exécute pendant trois secondes, mais chacun commence uniquement après la fin du précé-
dent. Nous avons un déroulement qui correspond beaucoup plus à un ordonnancement Fifo que
Round Robin.
Le problème qui se pose est que notre thread main(), celui qui lance les cinq autres, est ordon-
nancé en temps partagé. À peine a-t-il démarré le premier thread temps réel que celui-ci le
préempte, et s’exécute en entier avant même que main() puisse créer le second thread.
Le problème serait le même si le thread main() était ordonnancé en temps réel avec une priorité
inférieure à celles des threads créés. Dans certains cas, on peut se permettre de placer le thread
de lancement à une priorité supérieure à celles des threads à créer, mais ce n’est pas toujours
possible.
Barrières Posix
Pour pallier ce genre de problème, il existe un mécanisme spécifique : les barrières. On initialise
la barrière en indiquant combien de threads elle doit retenir. Ensuite, chaque nouveau thread
créé vient d’abord se mettre en attente contre la barrière. Dès qu’ils sont tous présents, la bar-
rière se lève et laisse les tâches s’exécuter en parallèle.
Pour modifier le programme précédent, il suffit d’ajouter les lignes suivantes :
exemple-threads-rr-02.c
[...]
pthread_barrier_t barriere;
int main(void)
{
[...] // Déclaration des variables
# ./exemple-threads-rr-02
[4] 1318413001 -> 1318413016
[0] 1318413002 -> 1318413016
[1] 1318413002 -> 1318413016
[2] 1318413003 -> 1318413016
[3] 1318413003 -> 1318413016
#
Inversion de priorité
Il existe un problème récurrent dans les systèmes temps réel : les inversions de priorité. Ceci se
produit lorsqu’une tâche de haute priorité est en attente d’une ressource tenue par une tâche de
faible priorité (jusqu’ici, tout va bien) et que cette dernière se trouve préemptée par une tâche de
priorité moyenne. Tout se passe alors comme si cette tâche de priorité moyenne avait préempté
celle de haut niveau.
Principe
Imaginons que nous ayons trois threads T1, T2, et T3 de priorités strictement croissantes, ainsi
qu’un mutex M utilisé par certains de nos threads. Initialement, T2 et T3 sont endormis, T1 est
actif et le mutex M est libre.
T1 T2 T3 M
Running Sleeping Sleeping Libre
T1 T2 T3 M
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
T3 demande le mutex M. Celui-ci étant tenu par T1, T3 ne peut l’obtenir et s’endort. T1 reprend son exécution.
Nous sommes alors dans une situation où T3 est soumis à l’exécution de T2 qui doit relâcher
le processeur pour que T1 s’exécute jusqu’à libérer le mutex ce qui réveillera enfin T3. Le fait
que T3 soit soumis à l’exécution de T1 est parfaitement normal : ils partagent une ressource (le
mutex) sur laquelle ils doivent synchroniser leur progression. Ce qui est gênant c’est que T3 soit
soumis à T2, car ces tâches n’ont a priori rien en commun. La conception du système (probable-
ment erronée) a conduit à placer T2 entre T1 et T3.
Il n’est pas très facile de mettre en évidence ce comportement. Dans l’exemple suivant, le
thread T1 crée directement T2 et T3 plutôt qu’ils soient réveillés par des événements externes.
Comme d’habitude, il faut calibrer les boucles actives pour que les traitements durent quelques
secondes.
exemple-inversion.c :
#define _XOPEN_SOURCE 500
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/time.h>
Il faut que les trois threads se déroulent sur le même CPU. Voici un exemple d’exécution :
# ./exemple-inversion
T1 demarre
T1 demande le mutex
T1 tient le mutex
T1 cree T3
T3 demarre
T3 demande le mutex
T1 cree T2
T2 demarre
T2 se termine
T1 lache le mutex
T3 tient le mutex
T3 lache le mutex
T3 se termine
T1 se termine
#
Nous voyons bien que T2 a le temps de s’exécuter entièrement avant que T3 puisse faire son
travail nécessitant le mutex.
Que faire pour éviter ces situations ? Il n’y a pas de réponse absolue. La meilleure attitude est de
prévoir dès la conception du système une répartition des priorités telle que l’inversion ne puisse
pas se produire.
Il existe un cas célèbre, détaillé dans plusieurs articles techniques : celui de la mission Mars
Pathfinder. Un parallèle approximatif avec le tableau précédent consisterait à considérer que
la tâche T1 était une tâche de très faible priorité, chargée de collecter des données météorolo-
giques, et T3, la supervision du bus logiciel chargée d’envoyer les données vers la station. La
ressource partagée n’était pas un simple mutex, mais un sémaphore dissimulé dans l’implémen-
tation de deux mécanismes de communication entre les tâches de la bibliothèque VxWorks.
Le problème de préemption indirecte de T3 par des tâches de moindre priorité était détecté par
le watchdog qui, voyant que le bus logiciel n’était pas à jour, réinitialisait le système, redémar-
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
rant ainsi le programme de travail au début de la journée. Pour plus de précisions et de détails,
on se reportera à l’article [REEVES 1997].
La mission Pathfinder a pu être sauvée grâce à un mécanisme, nommé « héritage de priorité »,
que la NASA a activé à distance. Cet héritage de priorité consiste à élever momentanément la
priorité d’une tâche tenant une ressource si une autre tâche plus prioritaire demande cette même
ressource.
Héritage de priorité
Il existe une implémentation assez classique, nommée PIP (Priority Inheritance Protocol),
qui donne des valeurs de priorité aux mutex et fonctionne avec deux règles. Reprenons notre
exemple précédent. Initialement, M a une priorité nulle que j’indique entre parenthèses après
son état dans le tableau suivant.
T1 T2 T3 M
Running Sleeping Sleeping Libre (0)
La première règle de PIP intervient : « un mutex hérite de la priorité la plus élevée parmi celles
de tous les threads qui le demandent ». Sa priorité passe donc à 1.
T1 T2 T3 M
Running Sleeping Sleeping Tenu par T1 (1)
T3 demande le mutex M. Celui-ci étant tenu par T1, T3 ne peut l’obtenir et s’endort. T1 reprend son exécution.
En vertu de la première règle, cette fois la priorité du mutex monte à celle de T3. Puis, la
seconde règle intervient : « un thread s’exécute avec la priorité la plus élevée parmi celles de
tous les mutex qu’il tient ».
Ainsi, la priorité de T1 va brusquement monter à celle de T3.
T1 T2 T3 M
Running Sleeping Sleeping Tenu par T1, demandé par
Priorité T3 T3. (3)
T1 T2 T3 M
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
T1 relâche son mutex. Il retombe à sa priorité originale, mais la libération du mutex réveille T3.
[...]
pthread_mutexattr_init(& mutexattr);
pthread_mutexattr_setprotocol(& mutexattr,
PTHREAD_PRIO_INHERIT);
pthread_mutex_init(& mutex, & mutexattr);
[...]
# ./exemple-pip
T1 demarre
T1 demande le mutex
T1 tient le mutex
T1 cree T3
T3 demarre
T3 demande le mutex
T1 cree T2
T1 lache le mutex
T3 tient le mutex
T3 lache le mutex
T3 se termine
T2 demarre
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
T2 se termine
T1 se termine
#
Cette fois, nous observons que le thread T3 a pu s’exécuter entièrement avant de céder la place
à T2.
Il faut noter que si la plupart des systèmes temps réel actuels implémentent l’héritage de priorité,
il existe des détracteurs pour ce mécanisme. Entre autres, un célèbre article de Victor Yodaiken
Against Priority Inheritance (voir Bibliographie) présente plusieurs inconvénients de PIP.
Prise de mutex
Une autre question peut se poser quand plusieurs threads sont en attente pour tenter de prendre
un mutex alors que ce dernier est verrouillé : qui va l’obtenir lorsqu’il sera libéré par son actuel
détenteur ?
Effectuons un premier essai en temps partagé avec un ensemble de cinq threads en concurrence
pour obtenir le même mutex.
exemple-prise-mutex-01.c:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define NB_THREADS 5
int main(void)
{
int i;
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
pthread_t thread[NB_THREADS];
pthread_mutex_lock(& mutex);
for (i = 0; i < NB_THREADS; i ++)
pthread_create(& thread[i], NULL,
fonction_thread, (void *) (i+1));
sleep(1);
fprintf(stderr, "Liberation initiale du mutex\n");
pthread_mutex_unlock(& mutex);
for (i = 0; i < NB_THREADS; i ++)
pthread_join(thread[i], NULL);
return EXIT_SUCCESS;
}
Les sommeils dans l’exécution des threads ont plusieurs rôles : le premier usleep() assure que
les threads commencent leurs boucles dans l’ordre de création, le sleep(), alors que le mutex
est tenu par le thread, permet un affichage régulier et lisible des messages. Enfin, le dernier
usleep() garantit que le thread qui vient de lâcher un mutex ne le réclame pas à nouveau instan-
tanément. Nous nous concentrerons par la suite sur ce dernier point.
$ ./exemple-prise-mutex-01
[1] demande le mutex
[2] demande le mutex
[3] demande le mutex
[4] demande le mutex
[5] demande le mutex
Liberation initiale du mutex
[1] tient le mutex
[1] lache le mutex
[2] tient le mutex
[1] demande le mutex
[2] lache le mutex
[3] tient le mutex
[2] demande le mutex
[3] lache le mutex
[4] tient le mutex
[3] demande le mutex
[4] lache le mutex
[5] tient le mutex
[4] demande le mutex
[5] lache le mutex
[1] tient le mutex
(Contrôle-C)
$
L’appel pthread_mutex_lock() est avant tout une fonction de la bibliothèque C. Un système uti-
lisant la bibliothèque NPTL incluse dans la GlibC, tire parti des fonctionnalités offertes par le
kernel Linux depuis la version 2.6 : les Futex (Fast User Space Mutex). Lorsqu’un thread essaye
de verrouiller un mutex, et que ce dernier est libre, la prise du mutex s’effectue entièrement dans
l’espace utilisateur (à l’intérieur de la NPTL, sans passer par le kernel). Il n’y aura d’appel au
noyau – opération plus coûteuse en temps – que si le mutex est déjà verrouillé. C’est une très
bonne optimisation lorsque la protection des données partagées entre les threads n’entraîne que
rarement des situations de contention. Dans notre cas, toutefois, chaque appel pthread_mutex_
lock() entre dans le noyau et le thread s’endort dans une file d’attente.
De même, l’appel pthread_mutex_unlock() de la NPTL peut déverrouiller un mutex en restant
simplement dans l’espace utilisateur si aucun autre thread n’est en attente du même mutex.
Néanmoins, dans notre exemple, chaque libération du mutex entraînera un appel système qui
réveillera tous les threads en attente et les laissera « lutter » pour l’obtention du mutex lors de
l’ordonnancement suivant.
Dans un ordonnancement temps partagé, il est logique que les prises de mutex de l’exemple
précédent s’effectuent dans l’ordre d’arrivée initiale car tous les threads sont symétriques. Si
toutefois il y avait des différences entre eux (valeur de nice, consommation du CPU, etc.), l’ordre
ne serait plus garanti. Modifions, par exemple, la fonction des threads de manière à ce qu’un seul
d’entre eux (le numéro 2) se comporte « bien » vis-à-vis de l’ordonnanceur, les autres réclamant
le mutex avec insistance en bouclant autour de pthread_mutex_trylock().
exemple-prise-mutex-02.c:
[...]
void * fonction_thread (void * arg)
{
int numero = (int) arg;
usleep(10000 * numero);
while (1) {
fprintf(stderr, "[%d] demande le mutex\n",numero);
if (numero != 2)
while (pthread_mutex_trylock(& mutex) != 0)
;
else
pthread_mutex_lock(& mutex);
fprintf(stderr, " [%d] tient le mutex\n",
numero);
[...]
À l’exécution, le comportement est très différent. Le thread numéro 2 est visiblement favorisé
par l’ordonnanceur et retrouve le mutex plus souvent que les autres qui sont « punis » par le
scheduler pour leurs comportements hystériques autour du pthread_mutex_trylock().
$ ./exemple-prise-mutex-02
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
param.sched_priority = numero;
if (pthread_setschedparam(pthread_self(), SCHED_FIFO,
& param) != 0) {
perror("setschedparam");
exit(EXIT_FAILURE);
}
fprintf(stderr, "[%d] demande le mutex\n", numero);
pthread_mutex_lock(& mutex);
fprintf(stderr, " [%d] tient le mutex\n",
numero);
sleep(1);
fprintf(stderr, "[%d] lache le mutex\n", numero);
pthread_mutex_unlock(& mutex);
return NULL;
}
#define NB_THREADS 5
int main(void)
{
int i;
pthread_t thread[NB_THREADS];
pthread_mutex_lock(& mutex);
for (i = 0; i < NB_THREADS; i ++) {
pthread_create(& thread[i], NULL,
fonction_thread, (void *) (i+1));
usleep(10000);
}
sleep(1);
fprintf(stderr, "Liberation initiale du mutex\n");
pthread_mutex_unlock(& mutex);
for (i = 0; i < NB_THREADS; i ++)
pthread_join(thread[i], NULL);
return EXIT_SUCCESS;
}
# ./exemple-prise-mutex-03
[1] demande le mutex
[2] demande le mutex
[3] demande le mutex
Les prises successives du mutex se sont bien réalisées en fonction de la priorité du thread. Inver-
sons les priorités pour vérifier que l’ordre initial de demande du mutex n’influe pas.
exemple-prise-mutex-04.c:
[...]
param.sched_priority = 10 - numero;
[...]
À l’exécution, l’ordre de prise du mutex est bien celui des priorités décroissantes :
# ./exemple-prise-mutex-04
[1] demande le mutex
[2] demande le mutex
[3] demande le mutex
[4] demande le mutex
[5] demande le mutex
Liberation initiale du mutex
[1] tient le mutex
[1] lache le mutex
[2] tient le mutex
[2] lache le mutex
[3] tient le mutex
[3] lache le mutex
[4] tient le mutex
[4] lache le mutex
[5] tient le mutex
[5] lache le mutex
#
plusieurs threads sur la prise permanente du même mutex. Grossièrement, nous voudrions que
chaque thread puisse effectuer une boucle infinie :
while (1) {
pthread_mutex_lock(& mutex);
// Traitement critique
[...]
pthread_mutex_unlock(& mutex);
}
Dans ce cas, tous les threads (dont le nombre peut être variable en fonction de l’évolution du sys-
tème) seront placés au même niveau de priorité et ordonnancés en temps réel Round Robin, par
exemple. Nous espérons ainsi que chacun d’entre eux pourra successivement recevoir le mutex
et effectuer son traitement critique.
exemple-prise-mutex-05.c:
[...]
void * fonction_thread (void * arg)
{
int numero = (int) arg;
struct sched_param param;
param.sched_priority = 10;
if (pthread_setschedparam(pthread_self(), SCHED_RR,
& param) != 0) {
perror("setschedparam");
exit(EXIT_FAILURE);
}
while (1) {
fprintf(stderr, "[%d] demande le mutex\n",numero);
pthread_mutex_lock(& mutex);
fprintf(stderr, " [%d] tient le mutex\n",
numero);
sleep(1);
fprintf(stderr, "[%d] lache le mutex\n", numero);
pthread_mutex_unlock(& mutex);
}
return NULL;
}
[...]
# ./exemple-prise-mutex-05
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Comment se fait-il qu’un seul thread accède au mutex alors qu’ils sont cinq à tenter de l’obtenir
simultanément, et que nous les avons placés en ordonnancement Round Robin ? Pour com-
prendre le fonctionnement, reprenons les verrouillages et déverrouillages des mutex.
Notre thread numéro 1 a obtenu le mutex car il était le premier de son niveau de priorité à s’exé-
cuter. Il le libère ensuite, ce qui se traduit par un appel système car le mutex est réclamé par les
quatre autres threads (endormis pour le moment). Durant cet appel système, le kernel réveille
les threads en attente, afin qu’ils puissent à nouveau tenter leur chance en réclamant le mutex.
Avant de revenir dans l’espace utilisateur, l’ordonnanceur est invoqué, et ce dernier juge que le
thread numéro 1, étant en temps réel Round Robin, peut continuer à s’exécuter jusqu’à ce qu’il
ait consommé tout le temps CPU alloué à sa tranche de temps (100 millisecondes). Il faut être
conscient que le thread ne consomme que très peu de temps processeur, car il passe l’essentiel
de son temps en sommeil dans le sleep() au milieu de la boucle.
Le thread 1 reprenant son exécution, il peut à nouveau réclamer le mutex qu’il vient juste de
libérer, et l’obtenir car les autres threads n’ont pas pu être ordonnancés. Lorsque notre thread
va s’endormir dans le sleep(), ses confrères vont demander successivement le mutex – déjà ver-
rouillé – et s’endormir l’un après l’autre.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Pour que les autres threads arrivent à prendre le mutex, il faudra attendre que le thread 1 ait
consommé l’intégralité de sa tranche de temps CPU, ce qui n’adviendra qu’après un très grand
nombre d’itérations si nous conservons le sommeil d’une seconde. Dans l’exemple suivant, nous
allons supprimer ce sommeil afin de forcer le thread à consommer du temps CPU. Une variable
globale contenant le numéro du dernier thread ayant pris le mutex permettra de voir les transi-
tions horodatées.
exemple-prise-mutex-06.c:
[...]
static int dernier_thread = 0;
param.sched_priority = 10;
if (pthread_setschedparam(pthread_self(), SCHED_RR,
& param) != 0) {
perror("setschedparam");
exit(EXIT_FAILURE);
}
while (1) {
pthread_mutex_lock(& mutex);
if (dernier_thread != numero) {
gettimeofday(& tv, NULL);
fprintf(stderr,"%ld.%06ld: %d tient le mutex\n",
tv.tv_sec, tv.tv_usec, numero);
dernier_thread = numero;
}
pthread_mutex_unlock(& mutex);
}
return NULL;
}
Par ailleurs, pour permettre une sortie du programme, un appel système alarm() est ajouté dans
la fonction main(). Ainsi, le noyau enverra au processus le signal SIGALRM au bout de 15 secondes.
Ce signal n’étant pas géré, il va tuer notre processus.
int main(void)
{
[...]
sleep(1);
alarm(15);
fprintf(stderr, "Liberation initiale du mutex\n");
[...]
Les messages devront être redirigés dans un fichier pour être consultés tranquillement.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Les résultats ne sont guère concluants. Nous aimerions que les threads alternent correctement
leurs exécutions autour de ce mutex.
Solutions
La première solution, qui vient à l’esprit des programmeurs habitués aux systèmes embarqués
temps réel « classiques » et à la programmation sur microcontrôleurs est d’ajouter un petit
délai entre le relâchement du mutex et sa reprise par le même thread (comme dans l’exemple
exemple-prise-mutex-01.c).
Typiquement, on utilise un appel usleep(10) pour demander une mise en sommeil de 10 micro-
secondes, voire un appel usleep(0) pour forcer un appel au scheduler dans une condition
défavorable au processus appelant. En voici un exemple :
exemple-prise-mutex-07.c:
[...]
void * fonction_thread (void * arg)
{
int numero = (int) arg;
struct sched_param param;
param.sched_priority = 10;
if (pthread_setschedparam(pthread_self(), SCHED_FIFO,
& param) != 0) {
perror("setschedparam");
exit(EXIT_FAILURE);
}
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
while (1) {
fprintf(stderr, "[%d] demande le mutex\n", numero);
pthread_mutex_lock(& mutex);
fprintf(stderr, " [%d] tient le mutex\n",
numero);
sleep(1);
fprintf(stderr, "[%d] lache le mutex\n", numero);
pthread_mutex_unlock(& mutex);
usleep(10);
}
return NULL;
}
[...]
Cette fois, l’ordonnancement s’effectue en mode Fifo, afin d’éviter toute préemption involon-
taire. Cette solution fonctionne, en voici une démonstration.
# ./exemple-prise-mutex-07
[1] demande le mutex
[2] demande le mutex
[3] demande le mutex
[4] demande le mutex
[5] demande le mutex
Liberation initiale du mutex
[1] tient le mutex
[1] lache le mutex
[2] tient le mutex
[1] demande le mutex
[2] lache le mutex
[3] tient le mutex
[2] demande le mutex
[3] lache le mutex
[4] tient le mutex
[3] demande le mutex
[4] lache le mutex
[5] tient le mutex
[4] demande le mutex
[5] lache le mutex
[1] tient le mutex
[5] demande le mutex
[1] lache le mutex
[2] tient le mutex
[1] demande le mutex
[2] lache le mutex
[3] tient le mutex
param.sched_priority = 10;
if (pthread_setschedparam(pthread_self(), SCHED_FIFO,
& param) != 0) {
perror("setschedparam");
exit(EXIT_FAILURE);
}
while (1) {
fprintf(stderr, "[%d] demande le mutex\n", numero);
pthread_mutex_lock(& mutex);
fprintf(stderr, " [%d] tient le mutex\n",
numero);
sleep(1);
fprintf(stderr, "[%d] lache le mutex\n", numero);
pthread_mutex_unlock(& mutex);
sched_yield();
}
return NULL;
}
[...]
# ./exemple-prise-mutex-08
[1] demande le mutex
[2] demande le mutex
[3] demande le mutex
[4] demande le mutex
[5] demande le mutex
Liberation initiale du mutex
[1] tient le mutex
[1] lache le mutex
[2] tient le mutex
[1] demande le mutex
[2] lache le mutex
[3] tient le mutex
[2] demande le mutex
[3] lache le mutex
[4] tient le mutex
[3] demande le mutex
[4] lache le mutex
[5] tient le mutex
[4] demande le mutex
[5] lache le mutex
[1] tient le mutex
[5] demande le mutex
[1] lache le mutex
[2] tient le mutex
[1] demande le mutex
[2] lache le mutex
[3] tient le mutex
[...]
#
Conclusion
La mise au point d’une application temps réel est une chose complexe et de nombreux « pièges »
inattendus peuvent se présenter. Le premier réflexe est souvent d’utiliser des délais, des attentes,
pour synchroniser les opérations. Cette méthode, bien que fonctionnelle dans la plupart des cas,
n’est pas toujours optimale ni portable. En explorant l’API disponible, le programmeur pourra
souvent trouver de meilleures solutions (variables conditions, barrières, héritage de priorité, de-
scheduling volontaires, etc.).
Points clés
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
• En temps réel, pour éviter qu’un thread nouvellement créé préempte son créateur et l’em-
pêche de lancer les autres threads équivalents, on peut utiliser les barrières Posix.
• Pour empêcher l’inversion de priorité qui peut se produire sur les mutex, le noyau Linux pro-
pose une implémentation du Priority Inheritance Protocol, que l’on active à l’initialisation
d’un mutex.
• La prise d’un mutex se fait généralement suivant la priorité des threads qui le demandent. Si
un thread lâche et redemande le même mutex immédiatement, il est probable qu’il l’obtienne
au détriment des autres. Pour réguler cet accès, on peut employer sched_yield().
Exercices
Exercice 1 (*)
Comment mettre en œuvre un mécanisme équivalent à celui des barrières pthread_barrier_t
pour synchroniser le démarrage de plusieurs processus temps réel distincts ?
Exercice 2 (**)
Reprenez le programme exemple-inversion.c et modifiez-le pour employer des processus plutôt
que des threads. Le mutex sera remplacé par un sémaphore Posix.
Y a-t-il une inversion de priorité ? Peut-on configurer un héritage de priorité sur le sémaphore ?
Exercice 3 (**)
Sur un système ne disposant pas d’héritage de priorité sur les mutex, comment peut-on éviter les
inversions de priorité telles que nous les avons observées ?
Exercice 4 (***)
Quel est l’ordre de prise d’un sémaphore lorsque plusieurs processus de priorités différentes le
réclament simultanément ? Lorsqu’il s’agit de processus de même priorité en Round Robin ?
Exercice 5 (***)
Écrivez un programme qui travaille en boucle autour de la prise d’un sémaphore Posix. Lancez-
en plusieurs exemplaires en parallèle et assurez-vous que leurs exécutions soient cadencées de
manière homogène et régulière.
Blaess.indb 170
22/10/2015 14:15
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
8
Limites et améliorations
du temps réel Linux
Le temps réel souple de Linux tel que nous l’avons observé jusqu’ici offre de nombreuses possibi-
lités et des performances assez intéressantes. Il y a toutefois quelques limitations parfois gênantes,
comme les traitements longs d’interruption au détriment des tâches temps réel. Nous allons étu-
dier ce problème, ainsi qu’une solution offerte par le projet PREEMPT_RT. Nous verrons qu’il
est important de pouvoir comparer les performances en fonction des options de configuration, ce
que permettent certains outils de tests. Nous examinerons également des solutions pour amélio-
rer le comportement des systèmes embarqués, comme la régulation de la vitesse du processeur.
Attention, il s’agit d’un domaine très actif dans l’évolution actuelle du noyau Linux. Je réalise ci-après des
expériences avec un noyau bien particulier (3.18), mais ses successeurs n’auront peut-être pas le même
comportement.
Réalisons une petite expérience : sur un Raspberry Pi 2, nous allons faire tourner des boucles
temps réel de priorité maximale (Fifo 99) sur tous les CPU, et pendant ce temps nous enverrons
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
des pings vers cette machine depuis un autre ordinateur du même réseau. Pour que ce processus
soit indépendant de l’ordonnanceur, nous allons éviter d’invoquer un appel système et réali-
ser plutôt une boucle active de comptage. La valeur maximale du comptage dépendra bien sûr
des plates-formes. Pour un Raspberry Pi 2, un comptage jusqu’à 500 millions dure une petite
dizaine de secondes.
exemple-boucle-temps-reel-01.c :
#define _GNU_SOURCE // Pour avoir sched_setaffinity
#include <pthread.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
pthread_barrier_wait(& barrier);
# uname -r
3.18.11-v7+
#
$ ping 192.168.3.152
PING 192.168.3.152 56(84) bytes of data.
icmp_req=1 ttl=64 time=0.355 ms
icmp_req=2 ttl=64 time=0.306 ms
icmp_req=3 ttl=64 time=0.269 ms
icmp_req=4 ttl=64 time=0.389 ms
# ./exemple-boucle-temps-reel
icmp_req=5 ttl=64 time=0.281 ms
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Sur ce noyau Linux standard, le ping depuis une autre machine a continué de fonctionner même
pendant l’exécution d’une boucle temps réel de très haute priorité. Que s’est-il passé ?
Je précise que la réponse au ping n’est pas traitée directement par la carte réseau mais bien par
les couches de protocoles du noyau. Il suffit pour s’en convaincre de voir qu’un :
les rétablit.
Le processus exemple-boucle-temps-reel-01, placé en priorité Fifo 95 est a priori totalement
protégé contre les préemptions, et son exécution nous semble absolument déterministe et prévi-
sible. Il n’y a aucune autre tâche temps réel plus prioritaire. On a exécuté auparavant :
pour désactiver le garde-fou présenté dans le chapitre 5. Comme attendu, la console et l’environ-
nement graphique du système semblent totalement gelés.
Toutefois, une interruption matérielle survient. Rappelons-nous qu’il n’est pas possible de cou-
per les interruptions matérielles depuis l’espace utilisateur.
Même si sur certaines architectures comme le PC on peut effectivement couper les interruptions depuis un
programme avec des opérations comme iopl(3); asm("cli");...; asm("sti"), ce n’est ni élégant ni
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
L’interruption en question est celle du contrôleur Ethernet. Le noyau va donc activer le handler
inclus dans le driver de ce dernier, et celui-ci va enchaîner plusieurs opérations résumées sur la
figure 8-1.
• Il interroge le matériel pour vérifier si des données sont bien arrivées, récupère la trame
reçue, puis la transmet à la fonction netif_rx() du fichier net/core/dev.c qui représente le
point d’entrée « bas » de la pile réseau.
• Déterminant qu’il s’agit d’un paquet pour le protocole IPv4, cette routine l’envoie à la fonc-
tion ip_rcv() du fichier net/ipv4/ip_input.c, entrée de la couche IP.
• Cette dernière détermine qu’il s’agit d’un paquet pour le protocole ICMP et le transmet à la
fonction icmp_rcv() du fichier net/ipv4/icmp.c. Voyant qu’il s’agit d’une demande d’écho,
cette dernière invoque donc la fonction icmp_echo() du même fichier.
• Après vérification de l’autorisation de répondre (si /proc/sys/kernel/ipv4/icmp_echo_ignore_
all contient bien zéro), cette routine prépare la réponse et le paquet repart dans l’autre sens,
redescendant vers la couche IP – qui devra peut-être calculer une route vers la destination.
• Les données préparées par la couche IP vont redescendre vers la couche Ethernet, qui les
transmet au matériel en lui demandant de les poster sur le réseau.
N’oublions pas également l’implication éventuelle du firewall (Net-Filter), qui intervient à chaque transition
entre les couches réseau pour vérifier si les données sont autorisées à poursuivre leur chemin.
Enfin, l’interruption se termine et à l’issue de toutes ces opérations, notre processus temps réel
qui pensait s’exécuter de manière exclusive peut reprendre son déroulement...
Figure 8-1
Interruption d’une tâche
temps réel
En réalité, tout le travail n’est pas réalisé directement dans le gestionnaire d’interruptions ; le
noyau Linux considère qu’il faut distinguer deux parties dans les handlers d’interruption : la
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
moitié supérieure (Top Half) qui doit être effectuée le plus rapidement possible – et sans que la
même IRQ ait la possibilité de se déclencher à nouveau –, et la moitié inférieure (Bottom Half)
qui peut être légèrement retardée.
Pendant longtemps, les drivers pouvaient s’appuyer essentiellement sur deux mécanismes pour
scinder leurs traitements en interruptions : les tasklets, qui sont exécutées immédiatement après
le retour du handler d’interruption, et les workqueues, qui sont exécutées par un kernel thread
– donc ordonnancées au même titre que les processus. La situation a légèrement évolué depuis
les derniers noyaux 2.6, nous détaillerons cela plus loin.
Le traitement de la pile réseau est effectuée par une soft-IRQ, mécanisme très proche des tas-
klets. Ainsi, tout le traitement de réponse au ping est réalisé au détriment des tâches temps réel,
quelles que puissent être leurs priorités.
Pour vérifier ce comportement, et essayer de mesurer la durée pendant laquelle notre tâche
temps réel est interrompue, nous allons utiliser un petit programme, proche de ceux développés
au chapitre 4, qui détecte et affiche les préemptions d’une durée (en microsecondes) supérieure
à un certain seuil. Le programme tournant en temps réel, il s’endort volontairement à intervalle
régulier afin de laisser le système respirer (afficher les messages, traiter les tâches en attente,
etc.).
exemple-interruption-01.c :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/time.h>
if ((argc != 2)
|| (sscanf(argv[1], "%ld", & seuil) != 1)) {
fprintf(stderr,"usage: %s seuil-en-microsec\n",
argv[0]);
exit(EXIT_FAILURE);
}
Lançons ce programme sur un poste, en temps réel Fifo de priorité 99 ; simultanément, nous
allons exécuter un ping depuis une autre station du même réseau. Notre programme sera placé
sur le cœur 0 d’un Raspberry Pi 2. L’exemple tourne sur un noyau 3.18 :
# taskset -p -c 0 $$
Le choix du seuil (100 microsecondes) a été effectué arbitrairement, et peut nécessiter une modi-
fication pour réitérer l’expérience sur une autre machine. Ici, nous voyons que notre processus
temps réel est préempté pendant 100 à 150 microsecondes à chaque fois qu’un ping arrive sur
l’interface Ethernet !
Naturellement, ceci est contraire à tous les principes du temps réel puisque nous ne maîtrisons
absolument pas l’arrivée de cette interruption (qui n’a par ailleurs rien à voir avec l’application
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
temps réel). Ce genre de situation, qui correspond de fait à une inversion de priorité, peut se
retrouver dans d’autres circonstances :
• un processus de faible priorité a demandé une lecture depuis le disque et lorsque l’interrup-
tion indiquant que les données sont disponibles se déclenche, elle préempte une tâche de
haute priorité ;
• un utilisateur déplace la souris ou presse une touche du clavier, ce qui déclenche une inter-
ruption (via le contrôleur USB, éventuellement) au détriment d’un processus temps réel ;
• certains traitements réalisés par le noyau peuvent demander une consommation importante
de CPU : gestion de la mémoire virtuelle, systèmes de fichiers cryptés, etc.
Pour pallier cette faiblesse du noyau Linux, deux approches sont possibles :
• améliorer le traitement des interruptions dans le kernel en transférant une plus grosse part de
code dans les Bottom Halves ordonnancées ;
• ajouter sous le kernel Linux un cœur de noyau temps réel qui ne lui envoie les interruptions
que si aucune tâche prioritaire n’est prête.
La seconde approche est celle des systèmes tels que RTLinux, RTAI ou Xenomai dont nous par-
lerons dans les prochains chapitres. La première est celle adoptée par le projet PREEMPT_RT.
PREEMPT_RT
L’un des principaux projets destinés à améliorer les performances de Linux pour le temps réel
est le projet PREEMPT_RT de Thomas Gleixner, Ingo Molnár et de nombreux autres collabo-
rateurs. On le nomme également Linux-RT.
Attention à ne pas confondre Linux-RT, projet libre pour améliorer le noyau classique, et RTLinux, produit
commercial utilisant un micronoyau temps réel ordonnançant le noyau standard en tâche de fond.
Le projet PREEMPT_RT se présente sous la forme d’un patch que l’on peut appliquer sur un
noyau standard pour affiner ses fonctionnalités dans divers domaines touchant au temps réel. Il
convient de considérer ce patch à la fois comme une amélioration sensible du comportement de
Linux temps réel, mais également comme une sorte de laboratoire où les participants au projet
valident des optimisations en attendant qu’elles soient effectivement intégrées au noyau Linux.
De nombreuses contributions de PREEMPT_RT restent assez confidentielles (les diminutions des
latences dues au Virtual File System et à la gestion de la mémoire, par exemple, ou encore les spin-
locks préemptibles). D’autres sont plus visibles, comme la préemptibilité du noyau que nous avons
étudiée au chapitre 6 ou les threaded interrupts que nous allons observer à présent. Notez que ces
éléments ont d’ores et déjà été intégrés dans le noyau standard. Toutefois, la plupart des fonction-
nalités qui améliorent le comportement du point de vue temps réel présentent un revers gênant
dans d’autres domaines, en général un léger surcoût de temps CPU qui peut devenir prohibitif sur
les systèmes parallélisant un grand nombre de tâches (serveurs, calculs, bases de données, etc.).
Aussi, la progression des fonctionnalités de PREEMPT_RT dans le noyau standard est assez lente.
Ceci est dû pour une faible part au manque de conviction de Linus Torvalds dans l’utilité du temps réel
strict sous Linux, et pour une grande part à l’absence dans le développement du noyau des utilisateurs
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
industriels (embarqué, temps réel, etc.), ainsi qu’à leur maigre retour d’expérience sur les mises en œuvre
réalisées.
Il est donc souvent intéressant d’utiliser le patch de Thomas Gleixner pour bénéficier des
nouvelles améliorations qui n’ont pas encore été intégrées dans le noyau Linux. Ce patch, mis
à jour régulièrement, est disponible à l’adresse suivante : http://www.kernel.org/pub/linux/kernel/
projects/rt/.
En novembre 2014, Thomas Gleixner a annoncé que le manque de financement de la part du
monde industriel ne lui permettait plus de développer le projet PREEMPT_RT à plein temps et
qu’il devrait à présent le faire sur son temps libre. Ceci risquant d’impacter la disponibilité du
patch pour les nouveaux noyaux, des acteurs industriels ont proposé leur soutien. En octobre
2015, la Linux Foundation a annoncé la création d’une branche « Real Time Linux C ollaborative
Project » en embauchant Thomas Gleixner en tant que Linux Foundation Fellow au même titre
que Linus Torvalds, Greg Kroah-Hartman ou Richard Purdie.
Voici un exemple d’application de ce patch. Commençons par télécharger un noyau Linux
vanilla adapté à notre plate-forme (Raspberry Pi 2) :
# uname -r
3.18.11-v7+
# git clone https://github.com/raspberrypi/linux linux
Le clonage avec Git du noyau et de tout l’historique de ses modifications est une opération assez
longue, variable en fonction du système et du réseau, mais de l’ordre d’une heure. Extrayons la
version du noyau Linux qui nous intéresse et vérifions son numéro exact.
# cd linux
# git checkout rpi-3.18.y
# head Makefile
VERSION = 3
PATCHLEVEL = 18
SUBLEVEL = 16
EXTRAVERSION =
# cd ..
# wget https://www.kernel.org/pub/linux/kernel/projects/rt/3.18/older/patch-
3.18.16-rt13.patch.xz
# unxz patch-3.18.16-rt13.patch.xz
# cd linux
Attention à ne pas télécharger patches-3.18.16... qui contient tous les petits patchs compilés
par Thomas Gleixner pour obtenir le gros patch PREEMPT_RT.
Nous allons commencer par créer une branche de travail avant d’appliquer le patch.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Je conseille d’utiliser systématiquement l’option --dry-run lors du premier test d’un nouveau
patch. Cette option ne modifie pas les sources du noyau, elle vérifie simplement si l’application
du patch est possible sans erreur. Nous pouvons voir quelques messages « Hunk succeeded at
... » bénins qui indiquent un léger décalage entre le code source dont nous disposons (adapté
pour Raspberry Pi) et celui du noyau officiel à partir duquel le patch a été produit.
Si le résultat est correct, comme ici, nous pouvons relancer la commande patch sans l’option
--dry-run :
# uname -a
Linux raspberrypi 3.18.16-rt13-cpb+ #1 SMP PREEMPT RT Tue Jul 14 23:43:08 CEST 2015
armv7l GNU/Lin ux
Le point à remarquer est la présence du flag RT. Attention, l’option PREEMPT n’indique pas la présence
du patch, mais seulement que le noyau a été compilé avec une configuration préemptible.
Quel est l’intérêt d’utiliser un noyau modifié par le patch PREEMPT_RT ? Après avoir redé-
marré sur un kernel modifié par ce patch, nous allons reprendre notre expérience précédente
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
# uname -r
3.18.16-rt13-cpb+
$ ping 192.168.3.152
icmp_req=1 ttl=64 time=0.337 ms
# ./exemple-boucle-temps-reel-01 500000000
icmp_req=2 ttl=64 time=8754 ms
icmp_req=3 ttl=64 time=7755 ms
icmp_req=4 ttl=64 time=6755 ms
icmp_req=5 ttl=64 time=5755 ms
icmp_req=6 ttl=64 time=4755 ms
icmp_req=7 ttl=64 time=3756 ms
icmp_req=8 ttl=64 time=2756 ms
icmp_req=9 ttl=64 time=1756 ms
icmp_req=10 ttl=64 time=756 ms
#
icmp_req=11 ttl=64 time=0.411 ms
icmp_req=12 ttl=64 time=0.371 ms
(Contrôle-C)
$
Cette fois, notre processus temps réel n’est jamais préempté durant sa boucle active. La com-
mande ping continue inlassablement à envoyer des messages toutes les secondes, mais ils ne
sont pas traités car notre processus est plus prioritaire. Les réponses aux ping ne sont envoyées
qu’une fois la boule temps réel achevée. C’est très visible sur la trace précédente en observant les
temps de réponse. Une fois la boucle commencée, un premier ping a duré 8,7 secondes environ,
le suivant (dont la requête a été envoyée une seconde plus tard) 7,7 secondes, puis 6,7 secondes,
et ainsi de suite jusqu’à la fin du processus temps réel, et la reprise des réponses normales.
Threaded interrupts
Notre tâche temps réel est bien restée plus prioritaire que le traitement des interruptions. Mais
ceci signifie donc que les gestionnaires d’interruptions doivent différer une partie de leur travail
pour qu’il soit exécuté avec une priorité donnée.
Nous avons évoqué précédemment le principe des Top Half et Bottom Half, qui représentent
respectivement les traitements urgents – réalisés immédiatement – et ceux moins pressants, qui
sont réalisés soit en tasklet, soit par les workqueues dans un kernel thread. Ces dernières sont en
train de disparaître au profit des threaded interrupts.
Ce système, que l’on doit au projet PREEMPT_RT, est en cours de déploiement dans le noyau
standard depuis le 2.6.30. Lorsqu’un driver désire gérer une interruption, il fournit deux fonc-
tions : la première, appelée directement à l’arrivée de l’IRQ, doit vérifier si l’interruption
concerne bien son driver et renvoyer IRQ_WAKE_THREAD si c’est le cas – ou IRQ_NONE si l’interruption
ne vient pas de son matériel. La seconde fonction sera exécutée dans le contexte d’un kernel
thread, avec une priorité temps réel Fifo 50 par défaut.
Dans le noyau standard, les drivers doivent volontairement utiliser le mécanisme des threaded
interrupts, ce qui est encore rare car cela ralentit le traitement des interruptions. Sur un noyau
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
auquel le patch PREEMPT_RT a été appliqué, l’utilisation des threaded interrupts est beaucoup
plus généralisée. Pour chercher le numéro de l’interruption Ethernet sur notre système, il suffit
de lancer une commande suivante :
comme nous l’avons vu au chapitre 2, tandis qu’un ping s’exécute depuis une autre machine. On
observe alors qu’une ligne augmente régulièrement de deux interruptions par seconde (réception
de la requête et fin d’émission de la réponse). Sur mon Raspberry Pi 2, il s’agit de l’interrup-
tion 32. J’observe alors la sortie de la commande ps.
# ps aux
[...]
root 60 0.0 0.0 0 0 ? S< 03:51 0:00 [iscsi_eh]
root 61 0.0 0.0 0 0 ? S< 03:51 0:00 [dwc_otg]
root 62 0.9 0.0 0 0 ? S 03:51 0:05 [irq/32-dwc_otg ]
root 63 0.4 0.0 0 0 ? S 03:51 0:02 [irq/32-dwc_otg _]
root 64 0.8 0.0 0 0 ? S 03:51 0:04 [irq/32-dwc_otg _]
root 65 0.0 0.0 0 0 ? S< 03:51 0:00 [DWC Notificati o]
root 66 0.0 0.0 0 0 ? S 03:51 0:00 [irq/24-DMA IRQ ]
root 67 0.0 0.0 0 0 ? S 03:51 0:00 [irq/25-DMA IRQ ]
root 68 0.0 0.0 0 0 ? S 03:51 0:00 [irq/84-mmc0]
[...]
#
Nous voyons que sur ce système doté du patch PREEMPT_RT, les interruptions sont gérées par
des kernel threads, ceux de PID 62, 63 et 64 étant dédié à la gestion de l’interruption 32.
L’interruption est ici identifiée comme dwc_otg (qui dépend de l’USB), car sur cette carte embarquée, les
ports USB et l’interface Ethernet filaire sont gérés par le même contrôleur.
Il est possible de modifier la priorité de cette interruption pour la monter à 98, par exemple, et de
réexécuter notre programme de tests (qui fonctionne à la priorité 95) :
# chrt -pf 98 62
# chrt -pf 98 63
chrt -pf 98 64
Cette fois, le ping s’exécute normalement, notre programme étant préempté par le thread traitant
l’interruption réseau.
Le mécanisme des threaded interrupts fait lentement son chemin dans le noyau Linux standard.
Rappelons-nous que cela pénalise légèrement les traitements, aussi leur intégration complète ne
pourra se faire qu’à travers des options de compilation permettant aux utilisateurs n’ayant pas de
besoins spécifiquement temps réel de les désactiver.
Les résultats ne sont pas très différents, mais on sent néanmoins que les valeurs sont plus rap-
prochées autour de la moyenne. L’écart-type, notamment, est divisé par deux. La moyenne est
ici très légèrement meilleure, mais il faudrait prendre des mesures beaucoup plus longues pour
s’en assurer. On pourrait même s’attendre à ce que la moyenne soit très légèrement moins bonne
à cause de la légère surcharge de code induite par PREEMPT_RT.
le suivi (en termes de correctifs, par exemple) ne pourra être aussi réactif que celui du kernel
officiel, bien qu’un effort notable soit visible depuis le noyau 3.0.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Bien sûr, il serait appréciable que toutes ces extensions temps réel trouvent leur place au sein
du noyau classique – en étant activables ou non à la compilation du kernel – mais ça n’est pas
encore le cas, essentiellement à cause de la diminution de performances brutes qu’imposent
certains de ces traitements spécifiques.
Cyclictest
Cyclictest est le plus célèbre des outils de mesure pour le temps réel. Écrit à l’origine par Thomas
Gleixner, il est à présent maintenu par Clark Williams dans son projet rt-tests. Ce programme
mesure les fluctuations et les latences de divers paramètres.
Le téléchargement de rt-tests et sa compilation se réalisent ainsi :
Pour l’exécution du programme, il est conseillé d’avoir les droits root. L’appel de cyclictest
avec l’argument --help permet d’obtenir la liste des options disponibles. Voici celles que j’utilise
le plus fréquemment.
Options Signification
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
-D 1h Laisser le test tourner pendant une heure. On peut utiliser les suffixes m (minutes), h (heures)
voire d (days, jours).
En voici un exemple d’exécution avec un noyau Linux standard (non modifié avec le patch
PREEMPT_RT). Le test dure une heure.
# uname -r
3.18.11-v7+
# ./cyclictest -p 80 -m -D 1h -q
T: 0 ( 2392) P:80 I:1000 C:3600000 Min: 12 Act: 19 Avg: 20 Max: 182
Voici à présent un test de la même durée sur le même noyau après application du patch
PREEMPT_RT :
# uname -r
3.18.16-rt13-cpb+
# ./cyclictest -p 80 -m -D 1h -q
T: 0 ( 2380) P:80 I:1000 C:3600000 Min: 13 Act: 20 Avg: 21 Max: 63
#
Dans les deux cas, une tâche (T:0), lancée en priorité temps réel (P:99), programme un timer
avec un intervalle (I:1000) de 1 000 microsecondes, soit une milliseconde, ce qui conduit à
36 millions de déclenchements en une heure.
Dans le premier cas, les fluctuations vont de 12 à 182 microsecondes, et leur valeur moyenne est
de 20 microsecondes d’écart par rapport au déclenchement désiré.
Dans le second cas, avec le patch PREEMPT_RT, la moyenne est très légèrement dégradée à
21 microsecondes (pour que le système soit plus prédictible, il y a des vérifications supplémen-
taires), mais on observe surtout que l’écart maximal par rapport au timer programmé est de
63 microsecondes, ce qui est sensiblement mieux que précédemment. Ceci confirme rapidement
les résultats que nous avons obtenus précédemment. Bien sûr, il faudrait valider ces mesures sur
des durées encore plus longues, en parallèle avec des actions perturbatrices importantes.
Hwlatdetect, Hackbench...
D’autres outils de l’ensemble rt-tests sont très utiles.
• Hwlatency permet de détecter les latences dues au matériel. Il effectue des mesures en boucle
dans l’espace kernel pour vérifier si le noyau n’est pas préempté par des interruptions qu’il ne
peut pas gérer. Par exemple, sur les architectures de type PC, les SMI (System Managment
Interrupts) sont des interruptions qui sont gérées par le Bios sans que le noyau ne les voit.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
• Hackbench effectue des mesures de commutation entre threads et processus qui commu-
niquent par des tubes ou des sockets. Ce programme étant très intensif, il sert d’élément
perturbateur efficace pour les mesures avec cyclictest. Dans ce cas, il convient de le faire
fonctionner sous ordonnancement temps réel avec une priorité légèrement inférieure à celle
de cyclictest.
• Pi_stress permet de vérifier l’implémentation des héritages de priorités sur les mutex, en tes-
tant différents cas d’inversion de priorité.
• …
Je conseille, lors de la mise au point d’un système temps réel, de lancer des séries de tests de ce
package ou d’autres comme les Realtime Tests de LTP (Linux Test Package). Pour cela, il est
important de préparer des scripts shell permettant de relancer les tests à plusieurs reprises, sur
de longues périodes, après chaque modification du kernel, en sauvegardant systématiquement
les résultats dans des fichiers. On trouvera dans l’annexe A quelques pistes d’optimisation dans
la configuration du noyau pour ses aspects temps réel. Ce n’est qu’en comparant les perfor-
mances avec ou sans chacune des options que l’on peut optimiser le système final.
Économies d’énergie
Nous allons nous intéresser à présent à un paramètre important pour les systèmes embarqués :
la consommation d’énergie électrique. Sur la plupart des systèmes interactifs, la charge du pro-
cesseur varie en permanence entre des états d’intense activité (calcul, compilation, compression,
encodage, etc.) et des périodes de repos durant lesquelles aucune opération n’a lieu, jusqu’à la
prochaine sollicitation provenant de l’utilisateur.
En fait, sur un système de type bureautique, la charge est généralement très basse, et la puis-
sance complète du processeur n’est nécessaire que pendant des pics d’activité ponctuels. Voici,
par exemple, la charge de la station Linux sur laquelle je rédige ces lignes :
$ uptime
11:44:07 up 29 days, 2:26, 9 users, load average: 0.11, 0.10, 0.08
$
CPU (par exemple, grâce à la technologie SpeedStep d’Intel ou Cool’n’Quiet d’AMD). Lorsque
la charge est faible, on se contentera d’une basse vitesse d’exécution du code. Si la charge aug-
mente, on pourra élever (suivant divers critères) la fréquence d’interprétation des instructions
machine. La consommation électrique du processeur est proportionnelle à sa fréquence. Le
noyau Linux est tout à fait capable de prendre en charge cette variation de fréquence, en utilisant
un algorithme dont l’heuristique (le governor) est configurable par l’administrateur (par le biais
du pseudo-fichier /sys/devices/system/cpu/cpuNN/cpufreq/scaling_governor, NN étant le numéro
de CPU). La liste des heuristiques disponibles se trouve dans le pseudo-fichier /sys/devices/
system/cpu/cpuNN/cpufreq/scaling_available_governors. Il existe, par exemple, les heuristiques
suivantes :
• powersave : utiliser toujours la fréquence la plus basse pour limiter la consommation (utile
pour un ordinateur portable sur batterie) ;
• performance : utiliser toujours la fréquence la plus haute pour optimiser les temps de traite-
ment (centres de calcul, serveurs d’applications, etc.) ;
• ondemand : faire varier automatiquement la fréquence en fonction de la charge système pour
ajuster la puissance disponible aux demandes de l’utilisateur (poste de travail) ;
• conservative : comme ondemand, mais en minimisant les changements de fréquence ;
• userspace : laisser l’administrateur paramétrer la fréquence depuis l’espace utilisateur (en
fixant dans /sys/devices/system/cpu/cpuNN/cpufreq/scaling_cur_freq une des valeurs propo-
sées dans /sys/devices/system/cpu/cpuNN/cpufreq/scaling_available_frequencies).
Ainsi, des lignes de commandes telles que :
# cd /sys/devices/system/cpu/0/cpufreq
# echo powersave > scaling_governor
demande importante de charge CPU. Nous allons devoir effectuer quelques expériences pour
vérifier le comportement du noyau Linux avec cette heuristique.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Le programme suivant fixe le governor indiqué en argument sur le CPU 0, puis lance un thread
temps réel sur ce CPU. Le thread va mesurer une série de durées de boucles actives.
exemple-governor-01.c
#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/time.h>
if (argc != 2) {
fprintf(stderr, "usage: %s <governor>\n", argv[0]);
exit(1);
}
sprintf(filename,
"/sys/devices/system/cpu/cpu%d/cpufreq/scaling_governor",
sched_getcpu());
fd = open(filename, O_RDWR);
if (fd < 0) {
perror(filename);
exit(2);
}
if (write(fd, argv[1], strlen(argv[1])) < 0) {
perror(argv[1]);
exit(2);
}
close (fd);
param.sched_priority = 10;
if (sched_setscheduler(0, SCHED_FIFO, & param) != 0) {
perror("pthread_attr_setschedpolicy");
exit(2);
}
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
sleep(1);
En fin de programme, la durée des boucles est affichée sur la sortie standard. Nous nous en
servirons avec nos outils habituels.
Heuristique performance
Commençons par lancer notre programme avec le governor performance. Cela va nous per-
mettre de calibrer le nombre d’itérations par boucle active en fonction du processeur. Disons
qu’une durée de quelques centaines de microsecondes représente une valeur convenable. Le
programme doit être lancé depuis l’identité root pour modifier le governor et accéder à une
priorité temps réel.
Le test est réalisé sur un Raspberry Pi 2.
Sur ce poste, la durée des boucles à fréquence maximale est de 2 003 microsecondes en moyenne.
Heuristique powersave
Essayons à présent avec le governor économique afin de voir la durée des boucles avec la fré-
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
quence minimale :
Dans ce cas, la durée moyenne des boucles est en moyenne de 3 006 microsecondes, soit 1,5 fois
la durée en mode performance. Ceci est cohérent avec les fréquences de fonctionnement de ce
processeur :
# cd /sys/devices/system/cpu/cpu0/cpufreq/
# cat scaling_available_frequencies
600000 900000
#
Nous disposons donc facilement de deux modes de fonctionnement pour notre processeur : un
mode économique pour les périodes où le système est au repos ou exécute des tâches de fond
peu intenses, et un mode performant où le processeur fonctionne au maximum de ses possibili-
tés. Dans certains cas, il peut être suffisant de programmer la fréquence de fonctionnement au
boot et de la laisser inchangée pour la suite.
Toutefois, dans de nombreuses applications temps réel embarquées, il est important de bénéfi-
cier à la fois d’une consommation électrique aussi faible que possible et d’une puissance CPU
importante pendant les pointes de traitement. Vérifions donc le comportement du governor qui
effectue les basculements automatiquement.
Heuristique ondemand
Le test avec l’heuristique ondemand nous montre une augmentation dynamique de la fréquence
afin de répondre à la demande soutenue de temps CPU :
Nous voyons bien que les premières occurrences de la boucle se déroulent à la fréquence basse
du système, ce qui conduit à des durées de 3 000 microsecondes de moyenne, puis la fréquence
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
$ cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_available_frequencies
2400000 2300000 2200000 2100000 2000000 1900000 1800000 1700000 1600000 1500000
1400000 1300000 1200000
Il existe alors une autre heuristique, conservative, qui retarde les transitions entre fréquences
pour obtenir des changements plus rares.
Il faut être conscient que ces manipulations sont très sensibles à l’activité générale du système et
à la charge du CPU concerné. Si l’on réitère cette manipulation à plusieurs reprises, les résultats
peuvent beaucoup varier, notamment le délai avant que le thread décide de beaucoup augmenter
la fréquence processeur en réponse à la demande de temps CPU. En général, nous trouvons une
latence de plusieurs millisecondes entre le début de la phase active et le changement de fré-
quence. C’est habituellement trop pour une application temps réel.
Dans le cas d’un système temps réel embarqué, où nous voulons concilier l’économie de batterie
avec une fréquence réduite pour les phases de repos et la puissance processeur pour répondre
aux charges de calcul importantes, nous devrons adapter nous-même la fréquence en basculant
d’un governor powersave à un governor performance à la demande.
Conclusion
Le noyau Linux est un projet en développement actif. Ses fonctionnalités temps réel, bien
qu’elles intéressent un public assez réduit, ne font pas exception et leur amélioration est per-
manente. Lorsqu’on désire ajuster les performances temps réel de Linux, plusieurs solutions
existent, à commencer par l’application du patch PREEMPT_RT. Dans ce cas, il est important
d’utiliser des outils de mesure pour valider les améliorations obtenues. D’autres alternatives
peuvent également s’offrir au concepteur de systèmes temps réel, comme Xenomai ou RTAI,
que nous étudierons dans les prochains chapitres.
Points clés
• Avec le noyau Linux standard, le traitement de certaines interruptions est parfois long, au
détriment des tâches utilisateur, même temps réel.
• L’évolution actuelle des gestionnaires d’interruptions vers les threaded interrupt améliorera
le comportement de Linux, mais le patch PREEMPT_RT permet de bénéficier de fonction-
nalités avancées.
• Il est important de disposer de moyens permettant de mesurer les performances d’un sys-
tème temps réel pendant sa mise au point. L’outil cyclictest et ceux qui l’accompagnent
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Exercices
Exercice 1 (**)
Appliquez le dernier patch PREEMPT_RT sur le noyau correspondant, compilez-le pour votre
machine en suivant les recommandations de l’annexe A, et démarrez votre système sur ce nou-
veau kernel.
Exercice 2 (**)
Reprenez le programme exemple-timer-create-02 du chapitre 4 et faites-le fonctionner sur un
système PREEMPT_RT. Observez-vous des différences notables dans les performances ?
Exercice 3 (**)
Écrivez un programme qui fasse une boucle active (limitée dans le temps) et faites-le tourner sur
un noyau Linux standard avec un ordonnancement temps réel et une priorité élevée. Votre sys-
tème répond-il au ping depuis une autre machine ? Qu’en est-il sur un système PREEMPT_RT ?
Exercice 4 (***)
Repérez l’interruption correspondant à votre interface réseau. Sur un système PREEMPT_RT,
identifiez le kernel thread qui gère cette interruption. En faisant varier la priorité de ce thread,
arrangez-vous pour que votre système réponde à nouveau au ping.
Exercice 5 (***)
Écrivez un programme qui boucle pendant 5 secondes et affiche ensuite le nombre d’itérations
qu’il a réalisé. Testez-le sur un processeur avec un governor performance, powersave et ondemand,
en observant les variations de puissance CPU.
9
Extensions temps réel de Linux
Jusqu’à présent, nous avons étudié le noyau Linux standard (celui que l’on nomme Vanilla Ker-
nel) distribué par Linus Torvalds, ainsi que le patch PREEMPT_RT de Thomas Gleixner qui
améliore le comportement temps réel natif de Linux. Il existe également une seconde approche
pour optimiser les performances et se rapprocher des systèmes temps réel stricts, s’appuyant
sur un nanokernel plus prioritaire que Linux, comme le proposent les projets RTLinux, RTAI
et Xenomai. C’est ce dernier que nous développerons plus en détail dans les chapitres à venir.
Principes
Si nous reprenons le principe de fonctionnement général d’un système Linux, nous pouvons
observer son comportement sur la figure 9-1.
L’interactivité du système est organisée autour de l’arrivée d’interruptions depuis des périphé-
riques externes (clavier, souris, disque, ports de communication, cartes d’acquisition, etc.) et des
composants internes du processeur (MMU, timers matériels, etc.). Le traitement des interrup-
tions est réalisé dans des handlers qui réveillent les tâches de l’espace utilisateur et les threads
du kernel. L’appel de l’ordonnanceur – qui s’effectue à des moments variables suivant la confi-
guration de la préemptibilité du noyau – entraîne la commutation entre les tâches en fonction de
leurs priorités temps réel et temps partagé.
Figure 9-1
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Ordonnancement dans
le noyau Linux standard
L’idée d’améliorer le comportement de Linux pour supporter les contraintes du temps réel strict
est apparue dans le courant des années 1990, principalement à travers le projet RTLinux de
Victor Yodaiken et Michael Barabanov. Le concept est représenté sur la figure 9-2.
Figure 9-2
Ordonnancement avec
RTLinux
RTLinux
Une couche logicielle supplémentaire vient s’intercaler entre le contrôleur d’interruption et les
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
gestionnaires du noyau Linux. En pratique, le handler invoqué par le processeur lorsque sur-
vient une interruption ne dépend plus du noyau Linux original mais d’un ensemble de routines
ajoutées dans le noyau par un patch. Ce handler activera un petit ordonnanceur simple et déter-
ministe capable de commuter entre des tâches temps réel se trouvant dans l’espace mémoire du
kernel Linux (les RT-Threads).
Lorsque les traitements temps réel sont terminés, l’ordonnanceur RTLinux rend le contrôle au
noyau Linux original, en invoquant éventuellement le handler concerné par l’interruption reçue.
Le projet RTLinux, développé initialement à l’université du Nouveau-Mexique, est très rapidement devenu
un produit commercial, supporté par la société FSMLabs, fondée par Victor Yodaiken et Michael Baraba-
nov, et rachetée en 2007 par Wind River Systems.
Plutôt bien accueilli initialement dans la communauté Linux, ce projet s’est trouvé largement critiqué lorsque
Victor Yodaiken a déposé un brevet, validé en 1999 sous le numéro 59957450, pour réserver l’usage d’un
« système d’exploitation temps réel exécutant des tâches temps réel et exécutant un système d’exploitation
généraliste comme tâche de fond ». Ceci pour contrer la concurrence de projets commerciaux ou libres,
comme RTAI dans sa version initiale.
RTAI et Adeos
Face à l’orientation de plus en plus commerciale de RTLinux dans les années 1990, une équipe
de l’université polytechnique de Milan, menée par Paolo Mantegazza, a mis au point le projet
RTAI (Real Time Application Interface) utilisant le même principe que RTLinux (figure 9-3).
Figure 9-3
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Gratuit et sous licence libre (LGPL), RTAI se développa rapidement avec une interface de
programmation cohérente et facile d’accès (elle était assez proche des fonctions de la norme
Pthreads).
Lorsque le brevet de FSMLabs fut dévoilé, l’avenir de RTAI sembla soudainement incertain :
les restrictions d’usage du modèle employé par RTAI pesaient sur les possibilités d’évolution et
d’utilisation de ce système pour des projets industriels.
En 2001, Karim Yaghmour publia un article décrivant un mécanisme de gestion des interrup-
tions, nommé Adeos (Adaptative Domain Environment for Operating Systems) [YAGHMOUR
2001]. L’idée sous-jacente, très proche de ce que l’on nomme aujourd’hui « virtualisation »,
consiste à disposer d’une couche logicielle basse qui capture les interruptions matérielles et les
envoie dans un pipeline (figure 9-4). Plusieurs systèmes d’exploitation fonctionnent en paral-
lèle sur cette machine (en se répartissant correctement les ressources matérielles). Chacun est
installé dans un domaine spécifique et reçoit les interruptions en fonction de sa position sur le
pipeline.
On peut alors imaginer faire fonctionner – dans un environnement servi en premier lieu – un
petit système d’exploitation déterministe, et dans un second environnement – servi par la suite –,
un système d’exploitation comme Linux, sans contrainte temporelle particulière.
Bien entendu, ce concept que l’on observe sur la figure 9-5 fournira les mêmes fonctionnalités que RTLi-
nux. Toutefois, la présentation d’Adeos ne parle ni d’ordonnanceur temps réel, ni de système d’exploitation
généraliste fonctionnant en tâche de fond. Ainsi, il n’entre pas dans les limites du brevet de FSMLabs.
Figure 9-4
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Concept du mécanisme
Adeos
Figure 9-5
Adeos et le temps réel
L’implémentation d’Adeos fut réalisée par Philippe Gerum – talentueux développeur français –,
puis il fut intégré comme couche basse de RTAI. Adeos n’existe pas en tant que système indé-
pendant, mais vient se greffer dans le noyau Linux classique pour profiter de son initialisation,
de la gestion du matériel, et intercepte les IRQ avant que les handlers de Linux ne les voient.
Le résultat est donc très proche de celui que l’on obtient avec l’implémentation de la figure 9-2.
Les interruptions capturées sont transmises à un premier domaine (au sens Adeos) qui contient
l’ordonnanceur de RTAI. Celui-ci active les threads temps réel qui s’exécutent dans l’espace
mémoire du kernel, avec les privilèges de ce dernier (pour l’accès aux port d’entrées-sorties, par
exemple).
Lorsque l’ordonnanceur de RTAI a fini d’activer les tâches temps réel, il rend le contrôle à Adeos
qui enverra les interruptions au domaine suivant du pipeline : le noyau Linux classique dont le
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
scheduler ordonnancera les tâches temps réel souple et temps partagé. Le noyau Linux n’a plus
la possibilité de couper les interruptions matérielles comme il le faisait auparavant (notamment
dans les verrouillages de spinlocks) ; en remplacement, il verrouillera l’équivalent d’un mutex
demandant temporairement à Adeos de ne plus lui transmettre d’interruptions.
Les premières versions de RTAI ne pouvaient faire fonctionner que des tâches se trouvant
dans l’espace kernel de Linux. Pour cela, le code du thread temps réel était écrit sous forme de
module, puis chargé dans le noyau (avec la commande insmod). Ceci permettait d’être sûr que le
thread serait toujours chargé en mémoire lorsqu’il fallait l’activer (le code du noyau et ses don-
nées sont toujours présents en mémoire physique) et qu’il disposerait des privilèges nécessaires
pour réaliser des opérations d’entrés-sorties vers les périphériques.
L’évolution suivante consista à faire fonctionner des tâches temps réel non seulement dans l’es-
pace kernel, mais également à ordonnancer des processus de l’espace utilisateur. Pour cela, un
module spécifique de RTAI, nommé LXRT, supervise des processus de l’espace utilisateur.
Ces derniers peuvent demander explicitement, avec un appel système spécifique nommé make_
hard_realtime(), à être ordonnancés par LXRT. À partir de ce moment, Adeos n’envoie plus
d’interruptions au domaine Linux, jusqu’à ce que le processus invoque make_soft_realtime().
Figure 9-6
Processus temps réel RTAI
Xenomai
Ce principe est également celui du projet Xenomai, créé par Philippe Gerum. Intégré initiale-
ment dans RTAI, sous le nom RTAI/Fusion, Xenomai est devenu indépendant en 2005 lorsque
des divergences de vue sont apparues avec l’équipe originale de RTAI.
Dans le premier domaine d’Adeos, est installé un ordonnanceur simple et déterministe, nommé
Nucleus (qui n’a rien à voir avec le système Nucleus-RTOS de Mentor Graphics). Dans le second
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
domaine, se trouve le noyau Linux classique. Dans un processus de l’espace utilisateur, on peut
démarrer un thread reconnu par Nucleus, qui s’exécutera avec une priorité temps réel supérieure
à celles des tâches du noyau Linux. Tant que le thread réclamera du temps CPU, le kernel Linux
sera suspendu et ne recevra aucune interruption. Ce n’est qu’une fois tous les threads de Nucleus
endormis que Linux verra arriver les interruptions survenues entre temps.
Le problème principal avec ce type de construction est le risque de voir le thread temps réel
invoquer des appels système Linux et rendre le contrôle au noyau. Ce dernier serait alors suscep-
tible de le préempter pour commuter vers une autre tâche, nous entraînant dans un cas évident
d’inversion de priorité. En outre, les interruptions ne parvenant plus au kernel Linux, ce dernier
n’est plus à même d’effectuer les commutations (ni de gérer correctement la mémoire virtuelle
ou les entrées-sorties vers les disques, par exemple) et un blocage définitif du système est pro-
bable très rapidement. Les premières versions de RTAI/LXRT interdisaient totalement ces
appels système dans la portion temps réel strict.
Avec Xenomai, une autre approche a été adoptée autorisant les processus à faire des appels
système, durant lesquels ils seront temporairement ordonnancés par Linux. Pour cela, Adeos fait
circuler dans son pipeline des événements autres que les interruptions, comme les invocations
d’appels système ou les déclenchements du scheduler Linux. Les threads de Xenomai peuvent
s’exécuter selon deux modes :
• le mode primaire est celui où le thread est directement sous le contrôle de Nucleus ;
• le mode secondaire voit le thread ordonnancé par le noyau Linux.
Les migrations d’un mode à l’autre sont automatiquement prises en charge par Adeos.
Le thread Xenomai débute son existence par un appel rt_task_create() suivi d’un rt_task_
start() invoqués dans un processus Linux. L’exécution commence alors en mode primaire, et le
thread restera dans ce mode tant qu’il sera actif dans l’espace utilisateur (à consommer du temps
CPU). Il se trouvera en concurrence avec les autres threads Xenomai, et pourra être préempté
par les handlers des interruptions gérées par celui-ci. Pendant tout ce temps, le noyau Linux
reste suspendu.
Supposons à présent que notre thread effectue un appel système vers le noyau Linux (par
exemple, un write() vers un périphérique). Le déclenchement de l’appel système se traduit par
un événement Adeos qui circule dans le pipeline. Xenomai le reçoit et fait migrer le processus
en mode secondaire. Le noyau Linux est alors réactivé pour répondre à l’appel système, avec le
risque que celui-ci préempte la tâche pour en activer une autre plus prioritaire
Dans les versions précédentes, les interruptions restaient toujours en attente (dans un domaine intermé-
diaire du pipeline qui sert de « bouclier ») et n’étaient envoyées à Linux qu’une fois le processus endormi
dans l’appel système. Ceci n’est plus vrai de nos jours.
Inversement, lorsque le kernel reçoit une interruption qui entraîne le réveil du processus et
son retour dans l’espace utilisateur, Xenomai le fait à nouveau migrer, cette fois vers le mode
primaire.
L’implémentation d’Adeos et Nucleus est réalisée avec un soin particulier pour garantir les
temps de latence les plus faibles entre l’arrivée d’une interruption et sa prise en charge par le
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
processus concerné.
Figure 9-7
Processus avec Xenomai
Interface de programmation
Une innovation intéressante fut la mise à disposition de skins (peaux) différentes pour le sys-
tème. Les concepteurs ont défini une interface de programmation (API) pour Xenomai, en
tenant compte de toutes les fonctionnalités nécessaires pour un système temps réel :
• gestion des tâches : création, terminaison, attente, mise en pause, modification de paramé-
trage, etc. ;
• synchronisation : sémaphores, mutex, événements, variables, conditions, etc. ;
• communications entre tâches : files de messages, tubes, mémoire partagée, etc. ;
• gestion du temps : activation périodique, mesure de l’heure, etc. ;
• traitement des interruptions.
Une fois cette interface – dite API native – développée, ils ont créé des surcouches permettant
de simuler l’API d’autres systèmes temps réel en s’appuyant sur elle.
Ainsi, il devient facile de porter sous Xenomai du code développé pour d’autres environne-
ments. Les principales skins sont les suivantes :
• VxWorks : système temps réel, propriété de Wind River Systems, l’un des acteurs majeurs
dans le domaine industriel ;
• VRTX (Versatile Realtime Executive) : appartient à Mentor Graphics ;
• uITRON « micro-ITRON » : version des spécifications temps réel ITRON pour les systèmes
embarqués ;
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
• PSOS : développé initialement pas SCG (Software Components Group), ce système concur-
rent de VxWorks a été acheté par Wind Rivers en 1999, qui a abandonné son développement ;
• Posix PSE51 : couverture minimale de la norme Posix, qui spécifie les fonctionnalités temps
réel multithreads dans un seul processus et sans interactions avec le système de fichiers.
Installation de Xenomai
# cd /usr/src/
# wget http://xenomai.org/downloads/xenomai/stable/xenomai-2.6.4.tar.bz2
http://xenomai.org/downloads/xenomai/stable/xenomai-2.6.4.tar.bz2
[...]
100%[===============================>] 21 193 853 646K/s ds 32s
# tar xjf xenomai-2.6.4.tar.bz2
Dans le répertoire ksrc/arch/ de Xenomai, se trouvent des sous-répertoires pour les différentes
architectures supportées :
# ls xenomai-2.6.4/ksrc/arch/
arm blackfin generic Makefile nios2 powerpc sh x86
Nous allons ici installer Xenomai sur une architecture arm pour rester cohérent avec les tests
réalisés dans les chapitres précédents. Le principe est toutefois identique pour n’importe quelle
autre architecture.
Observons le contenu du répertoire ksrc/arch/arm/patches pour identifier les noyaux Linux qui
sont supportés par cette version de Xenomai.
# ls xenomai-2.6.4/ksrc/arch/arm/patches/
beaglebone ipipe-core-3.8.13-arm-4.patch README
ipipe-core-3.10.32-arm-5.patch mxc zynq
ipipe-core-3.14.17-arm-4.patch raspberry
Il y a un support pour trois noyaux : 3.8.13, 3.10.32 et 3.14.17. En outre, des patches supplémen-
taires devront être ajoutés pour les architectures Beaglebone, Zinq ou Raspberry Pi. Voyons le
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
$ ls arm/patches/raspberry/
ipipe-core-3.8.13-raspberry-post-2.patch
ipipe-core-3.8.13-raspberry-pre-2.patch
# cd /usr/src/
# git clone https://github.com/raspberrypi/linux linux-xenomai
# cd linux-xenomai
# git checkout rpi-3.8.y
Créons une branche de travail pour isoler les modifications qui seront apportées par les patches
à venir :
Les sources du noyau ont été mises à jour ; nous devons également modifier les fichiers de confi-
guration. Pour ce faire, Xenomai nous fournit un script auquel on indique les options suivantes :
• --arch= : l’architecture cible ;
• --linux= : l’emplacement des sources de Linux à modifier.
Dans le menu de configuration de Linux, nous pouvons observer une nouvelle entrée, Interrupt
Pipeline, dans le menu Processor Type and Features (ou Kernel features suivant les architec-
tures). Il s’agit de l’intégration d’Adeos, la couche bas niveau de gestion des interruptions.
Par ailleurs, un nouveau menu global de configuration est apparu, nommé Real-Time Sub-Sys-
tem. Il s’agit de la configuration de Xenomai en tant que module du domaine prioritaire d’Adeos.
Figure 9-8
Menu de configuration
de Xenomai
Certaines options de compilation du noyau standard sont incompatibles avec un bon fonctionnement de
Xenomai. Il s’agit notamment de l’APM (Advanced Poxer Management), du CPU Frequency Scaling et de
la gestion d’énergie par ACPI. Ces options devront être désactivées ; elles se trouvent toutes dans le menu
global Power Management. Si l’une d’entre elles reste activée par erreur, un message nous en avertit
dans le sous-menu Real-Time Sub-System.
Les deux premières options doivent obligatoirement être activées ; on remarquera néanmoins
que le domaine Nucleus peut être compilé sous forme de module que l’on insère après le boot.
L’option Pervasive Real-Time Support in User-Space est nécessaire pour pouvoir gérer avec
Xenomai des processus de l’espace utilisateur de Linux. En son absence, seuls des threads du
kernel pourraient être placés en temps réel strict. L’option Priority Coupling Support est à pré-
sent déconseillée : elle permettait de conserver vis-à-vis de Xenomai la priorité des threads
migrant en mode secondaire. Cela n’est généralement pas utile.
Comme Xenomai sera toujours le domaine le plus prioritaire du pipeline d’interruptions, des
optimisations peuvent être effectuées, si l’option Optimize as Pipeline Head est activée.
Nucleus propose des ordonnancements Fifo et Round Robin classiques, mais il permet égale-
ment, si l’option Extra Scheduling Classes est activée, d’en utiliser de plus rares comme les
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
partitions de temps ou les modes sporadiques. Nous pouvons également régler finement des
paramètres du système comme les volumes mémoire réservés pour divers éléments (séma-
phores, piles en espace noyau, etc.), ou activer des options de débogage ou d’instrumentation.
Les sous-menus suivants permettent d’agir sur des paramètres spécifiques de Xenomai.
Sous-menu Timing
Deux paramètres principaux sont présents dans ce sous-menu : l’un choisit le mode de fonc-
tionnement des timers (périodiques ou apériodiques) et l’autre préconfigure la latence des
interruptions. Si les divers timers employés par les threads temps réel sont tous multiples (expri-
més en ticks) d’une période de base bien définie, le mode périodique est le plus adapté ; sinon,
un mode apériodique peut être choisi et les durées utilisées par les timers logiciels sont précisées
en nanosecondes. Quoi qu’il en soit, la gestion du timer matériel par Xenomai est réalisée par
un fonctionnement apériodique. La latence (le délai entre l’arrivée d’une interruption et le réveil
du thread utilisateur qui l’attend) peut également être préconfigurée dans ce sous-menu. Une
valeur de zéro (par défaut) laisse toutefois le système mesurer automatiquement la latence et la
rendre visible dans /proc/xenomai/latency.
Sous-menu Scalability
L’option principale de ce sous-menu est l’activation ou non d’un ordonnanceur en temps constant
O(1) scheduler pour Nucleus. Rappelons que ceci n’a rien à voir avec l’ordonnanceur O(1) de
Linux dont nous avons parlé au chapitre 3 qui était consacré aux tâches temps partagé et temps
réel souple. Il s’agit ici uniquement de l’ordonnancement des threads de Xenomai. Pour un
nombre réduit de tâches temps réel actives simultanément (cas le plus fréquent), le surcoût lié à
la gestion d’un ordonnanceur O(1) ne se justifie pas vraiment.
Le même type de raisonnement s’applique à la seconde option Timer Indexing Method qui pro-
pose une organisation des timers sous forme de liste chaînée (méthode linéaire, adaptée à un
faible nombre de timers), d’arbre binaire ou de table de hachage (au prix de contraintes sur les
périodes de déclenchement).
Sous-menu Machine
Dépendant de l’architecture sous-jacente, ce sous-menu regroupe des paramètres permettant
d’optimiser l’utilisation du matériel (coprocesseur mathématique, cache mémoire et TLB, etc.).
Sous-menu Interface
C’est ici que l’on peut choisir d’intégrer les skins qui nous intéressent pour porter des appli-
cations existantes sous Xenomai. Les interfaces applicatives sont implémentées dans l’espace
utilisateur sous forme de bibliothèques, que nous allons compiler par la suite, mais un support
doit être incorporé dans le noyau.
Les API proposées dans cette version sont les suivantes : Native (que nous utiliserons dans le
prochain chapitre), Posix, pSOS+, uITRON, VRTX et VxWorks.
Par ailleurs, pour pouvoir écrire dans le noyau des drivers bas niveau capables de communiquer
avec les tâches Xenomai quelle que soit leur skin, il existe une interface nommée RTDM (Real
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Sous-menu Drivers
Si l’interface RTDM a été activée plus haut, quelques drivers sont disponibles pour Xenomai
(par exemple, pour contrôleur série ou CAN). On trouve entre autres des drivers qui permettent
d’effectuer des instrumentations de latence, par exemple.
Une fois la configuration réalisée, on lance la compilation du noyau Linux comme d’habitude,
voir l’annexe A pour plus de détails.
$ make
[…]
Image arch/arm/boot/zImage is ready
$
Compilation de Xenomai
Les bibliothèques de Xenomai ainsi que les programmes de tests et de mesures doivent être
compilés séparément du noyau Linux. Pour cela, il faut se rendre dans le répertoire source de
Xenomai.
Les options à passer au script configure dépendent à nouveau de l’architecture. Un fichier README.
INSTALL est présent pour les décrire. Dans le cas d’une cross-compilation, il faudrait préciser le
type de cible.
# cd ../xenomai-2.6.4/
# ./configure
[...]
#
On peut activer certaines options en fonction de l’architecture, par exemple --enable-smp sur les
systèmes multicœurs.
Une fois la configuration obtenue, nous pouvons lancer la compilation :
# make
[...]
#
Enfin, nous installons les bibliothèques, les fichiers d’en-tête (pour la compilation des applica-
tions), les exécutables de test, etc., dans un sous-répertoire spécifique. Par défaut, l’installation
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
s’effectue dans /usr/xenomai et nécessite donc les droits root. Il est possible de renseigner la
variable d’environnement DESTDIR pour installer Xenomai dans un autre répertoire (notamment
lorsqu’il s’agit d’une installation pour une cible différente).
# make install
Les fichiers installés se trouvent répartis dans les sous-répertoires bin, include, lib, sbin et
share.
Première exploration
Après un reboot du système sur le noyau Linux modifié, nous ne voyons pas de différence fla-
grante de comportement. Deux sous-répertoires sont toutefois apparus dans /proc :
• /proc/ipipe (Interrupt Pipeline) contient des informations concernant Adeos et ses domaines ;
• /proc/xenomai regroupe des pseudofichiers qui présentent les paramètres du domaine Xeno-
mai et de Nucleus.
Examinons le contenu de /proc/ipipe :
# cd /proc/ipipe/
# ls
Linux Xenomai version
# cat version
4
# cat Linux
+--- Handled
|+-- Locked
||+- Virtual
[IRQ] ||| Handler
0: H.. __ipipe_do_IRQ
1: H.. __ipipe_do_IRQ
2: H.. __ipipe_do_IRQ
[...]
1023: H.. __ipipe_do_IRQ
1024: H.V __ipipe_flush_printk
1025: H.V __ipipe_do_work
1026: H.V rthal_apc_handler
1027: ..V
+--- Handled
|+-- Locked
||+- Virtual
[IRQ] ||| Handler
0: ...
1: ...
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
2: ...
3: H.. xnintr_clock_handler
4: ...
[...]
1023: ...
1024: ..V
1025: ..V
1026: ..V
1027: H.V xnpod_schedule_handler
#
Le domaine Linux est nommé Root Domain dans les sources de Xenomai, car il s’agit de l’hôte
accueillant Adeos. Il peut recevoir toutes les interruptions réelles ou virtuelles (internes au pro-
cesseur). Son identifiant (pour Adeos) est 0, et sa priorité 100.
Pour Xenomai, la priorité est maximale car il s’agit du premier domaine du pipeline.
Observons à présent les informations fournies par /proc/xenomai :
# ls /proc/xenomai/
acct faults interfaces latency rtdm schedclasses timebases timerstat
apc heap irq registry sched stat timer version
#
Le contenu de ce répertoire peut varier en fonction des options choisies dans le sous-menu
realtime sub-system de Linux. Les principaux fichiers en sont :
• acct : état brut des tâches temps réel, que l’on retrouve formaté de manière plus lisible dans
stat ;
• affinity : (absent sur le Raspberry Pi unicœur où Xenomai est compilé sans l’option --smp)
affinité de Xenomai par rapport aux processeurs du système.
• apc : informations sur les Asynchronous Procedure Call, c’est-à-dire les traitements par
Linux de la fin d’une interruption gérée par Xenomai ;
• faults : liste des exceptions déclenchées par Xenomai sur chaque processeur ;
• heap : statistiques sur l’utilisation de la mémoire par Xenomai et les composants employés par
l’API (sémaphores, pile, etc.) ;
• interfaces/ : dans ce répertoire, un pseudofichier est présent pour chaque skin de Xenomai,
indiquant un compteur d’usage ;
• irq : liste des interruptions gérées par Xenomai avec leur nombre d’occurrences sur chaque
CPU ;
• latency : latence mesurée en nanosecondes entre le déclenchement d’une interruption et le
début du traitement dans un handler de Xenomai ;
• rtdm/ : les fichiers contenus dans ce sous-répertoire fournissent des informations sur les dri-
vers Xenomai employant l’interface RTDM ;
• sched : liste des tâches temps réel avec leur état et les informations sur les timers qu’elles
emploient ;
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
• stat : états des tâches du système, assez proche de ce que l’on trouve dans sched, avec en
outre le nombre de Context Switches et de Mode Switches (commutation du mode primaire
en secondaire et inversement) ;
• timebases : bases de temps utilisées par chaque skin ;
• timer : informations sur le timer disponible pour Xenomai ;
• timerstat/ : statistiques sur les déclenchements des timers utilisés par les différentes skins ;
• version : numéro de version de Xenomai (2.6.4 dans notre cas).
Programmes de tests
Xenomai est livré avec quelques applications de tests, qui se trouvent dans /usr/xenomai/bin,
sauf si vous avez utilisé l’option --prefix du script configure.
# cd /usr/xenomai/bin/
# ls
arith insn_read wakeup-time
check-vdso insn_write wf_generate
clocktest irqloop wrap-link.sh
cmd_bits klatency xeno
cmd_read latency xeno-config
cmd_write mutex-torture-native xeno-regression-test
cond-torture-native mutex-torture-posix xeno-test
cond-torture-posix rtcanrecv xeno-test-run
cyclictest rtcansend xeno-test-run-wrapper
dohell rtdm
insn_bits switchtest
#
# /usr/xenomai/bin/latency
== Sampling period: 1000 us
== Test mode: periodic user-mode task
== All results in microseconds
warming up...
RTT| 00:00:01 (periodic user-mode task, 1000 us period, priority 99)
RTH|----lat min|----lat avg|----lat max|-overrun|---msw|---lat best|--lat worst
RTD| -6.000| -5.000| 13.000| 0| 0| -6.000| 13.000
RTD| -7.000| -5.000| 14.000| 0| 0| -7.000| 14.000
Ce programme a tourné pendant quarante-cinq minutes sur un Raspberry Pi avec une charge
moyenne (quelques opérations sur le système de fichiers et observations du comportement du
programme). Un timer matériel est déclenché toutes les 100 microsecondes et l’application
vérifie l’instant auquel le gestionnaire d’interruptions est invoqué. Nous voyons que sur notre
système, il y a un décalage, une latence, entre l’instant attendu et le véritable déclenchement.
La latence la plus faible obtenue est négative – le déclenchement a eu lieu plus tôt que prévu car
Xenomai anticipe les retards –, et la pire (c’est la valeur qui importe vraiment dans un contexte
temps réel strict) de 36 microsecondes.
Il s’agit d’une situation moyenne car la charge système est normale et un nombre raisonnable
d’interruptions se sont déclenchées. Nous aimerions mesurer ces paramètres dans des circons-
tances plus extrêmes. Si nous connaissons approximativement les conditions moyennes de
fonctionnement du système final (nombre de processus, débit réseau, fréquence des interrup-
tions, etc.), nous pouvons effectuer des tests de longue durée dans un environnement beaucoup
plus perturbé.
Xenomai est fourni avec un script nommé dohell qui place le système dans des conditions
infernales... En exécutant de nombreuses commandes en parallèle, il provoque une activité
importante des processus et le déclenchement de fréquentes interruptions (réseau, disque...). Il
est configurable grâce à différentes options :
Options Signification
-s serveur -p port Cible vers qui envoyer des paquets réseau avec nc (ou netcat). Par défaut,
le port est 9. Si -s n’est pas précisé, cet outil est ignoré.
-l repertoire Répertoire contenant les test LTP (Linux Test Packages) à exécuter.
duree Durée en secondes d’exécution de dohell.
Utilisé par l’option -b, le programme hackbench que nous avons évoqué dans le chapitre pré-
cédent peut être trouvé sur le site d’Ingo Molnár chez Redhat : http://people.redhat.com/mingo/
cfs-scheduler/tools/.
Quant aux tests de LTP, on peut les obtenir à l’adresse suivante : http://ltp.sourceforge.net/.
Outre les commandes précédentes, le script dohell exécute en boucle les actions suivantes en
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
parallèle :
• lire /proc/interrupts ;
• exécuter ps ;
• copier /dev/zero dans /dev/null ;
• descendre l’arborescence depuis la racine.
Voici un exemple d’exécution de dohell :
# while true; do
scanimage –format=pnm –resolution=1200dpi > fic.png
sleep 1; done
# ./latency
[...]
RTS| -7.000| -4.000| 51.000| 0| 0| 00:22:02/00:22:02
#
Cette fois, la latence maximale, de 51 microsecondes, est sensiblement plus longue, mais nous
sommes probablement proche du pire délai possible. On peut remarquer que pendant l’exécu-
tion de dohell, les latences moyennes mesurées pendant chaque intervalle sont également plus
élevées. Lorsque dohell s’interrompt, les valeurs minimales et moyennes diminuent à nouveau.
D’autres outils de tests sont fournis avec Xenomai, notamment une version compilée avec l’API
Posix du programme cyclictest que nous avons utilisé dans le chapitre précédent. En le laissant
tourner pendant une vingtaine de minutes (1 000 secondes) à mesurer les fluctuations d’un timer
à la milliseconde, on obtient :
meilleur que les résultats obtenus avec le patch PREEMPT_RT (63 microsecondes).
Conclusion
L’installation de Xenomai est une opération relativement simple. La phase de compilation du
noyau ne diffère pas d’une adaptation classique, et pour la préparation des bibliothèques de
l’espace utilisateur, les scripts de configuration déterminent les paramètres à employer en inter-
rogeant au maximum la chaîne de compilation.
Les outils de tests permettent d’obtenir rapidement un aperçu des possibilités et des limites du
système. Nous allons examiner dans le prochain chapitre l’API native proposée par Xenomai
afin de développer nos propres applications.
Points clés
• Les extensions temps réel pour Linux s’appuient généralement sur une couche logicielle qui
capture les interruptions et les renvoie au noyau Linux standard après avoir réalisé des opé-
rations plus prioritaires.
• Le pipeline d’interruptions proposé par Adeos permet de s’affranchir du brevet FSM Labs et
de faire fonctionner des systèmes temps réel strict sur Linux. Xenomai est l’un des projets les
plus aboutis et les plus dynamiques.
• L’installation de Xenomai passe par l’application d’un patch sur les sources du noyau, sa
recompilation et la génération des bibliothèques nécessaires de l’espace utilisateur.
• Les outils de tests de Xenomai permettent de mesurer la latence et la fluctuation des timers.
Les résultats sont souvent sensiblement meilleurs qu’avec PREEMPT_RT.
Exercices
Exercice 1 (*)
Téléchargez la dernière version de Xenomai et vérifiez pour quelles versions du noyau Linux
sont fournis les patches. Téléchargez une de ces versions et appliquez-lui le patch.
Exercice 2 (**)
Préparez la configuration du noyau modifié. Activez les options liées au temps réel. Compilez
Linux et démarrez votre système sur ce kernel. Vérifiez la présence des sous-répertoires ipipe
et xenomai de /proc.
Exercice 3 (**)
Compilez et installez les bibliothèques de Xenomai et les outils de tests. Utilisez le programme
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Exercice 4 (***)
Exécutez le programme cyclictest, tout en faisant fonctionner dohell en parallèle. Invoquez
ce dernier de manière à charger le système au maximum, tant en activité CPU qu’en interrup-
tions. Laissez le système fonctionner quelques heures. Comparez la pire latence obtenue avec les
résultats du chapitre précédent.
10
Programmer avec Xenomai
Nous avons précédemment installé Xenomai et exécuté quelques programmes de tests qui nous
permettent d’avoir un premier aperçu des performances de ce système. Nous allons à présent
commencer à créer nos propres applications en utilisant l’API de Xenomai.
Principes
Comme nous l’avons évoqué au chapitre précédent, le développement sous Xenomai, s’effec-
tue en utilisant des processus tout à fait classiques de l’espace utilisateur, au sein desquels on
démarre des threads temps réel qui sont ordonnancés par Xenomai, sauf lorsqu’ils exécutent des
appels système durant lesquels c’est l’ordonnanceur Linux qui reprend le contrôle, ce que l’on
nomme « mode secondaire ».
Les deux difficultés que peut rencontrer le développeur Xenomai sont :
• la connaissance de l’API temps réel : si Xenomai propose des skins permettant d’incorporer
facilement du code écrit pour d’autres systèmes d’exploitation, il est néanmoins conseillé
d’utiliser l’API « native » pour les développements spécifiques, car elle est en principe plus
efficace ;
• la recherche des commutations du mode primaire au mode secondaire qui se produisent lors
d’appels système, parfois dissimulés dans des fonctions de bibliothèque C, comme malloc(),
qui invoque de temps à autre les appels brk() ou mmap().
A priori, le point le plus compliqué est le second, car il entraîne un passage d’un ordonnance-
ment temps réel strict Xenomai (plus prioritaire que les traitements d’interruptions de Linux) à
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
un temps réel souple Linux. Les conséquences ne sont pas directement visibles, mais la prédicti-
bilité des temps de réponse est amoindrie. En outre, la détection de ces basculements est difficile
du fait que certaines fonctions de bibliothèques n’invoquent pas immédiatement les appels
système sous-jacents mais les diffèrent pour optimiser les temps de traitement en regroupant
plusieurs opérations. On peut penser à malloc(), mais aussi à fprintf(), par exemple. Heureu-
sement, il existe des outils de mise au point intégrés dans Xenomai qui nous permettront de
détecter ces basculements en mode secondaire.
La documentation de l’API native de Xenomai est disponible sur le site web de Xenomai (http://
www.xenomai.org), mais également dans le répertoire d’installation : /usr/xenomai/share/doc/
xenomai/html/api/.
Il s’agit d’une documentation générée à l’aide de l’outil Doxygen. Je ne présenterai ici que les
fonctions essentielles à l’écriture de tâches, en laissant le lecteur se reporter à ces sources pour
avoir plus de précisions sur les routines utilisées.
L’écriture d’application avec Xenomai nécessite l’inclusion de certains des fichiers d’en-tête
suivants.
Fichiers Rôles
<native/alarm.h> Programmation d’alarme pour activer un thread temps réel de manière différée
et/ou périodique.
<native/pipe.h> Communication par tubes nommés avec les processus de l’espace Linux.
Initialisation du processus
Au chapitre 6, nous avons évoqué l’importance de l’utilisation de mlockall() afin de verrouiller
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
en mémoire physique les pages utilisées par le processus, évitant ainsi tous les comportements
imprévisibles liés à la gestion de la mémoire virtuelle.
Avec Xenomai, cet appel est indispensable, car le déclenchement d’une faute de page (lors d’un
accès à une zone qui n’est pas encore disponible) entraînerait un retour en mode secondaire.
Ceci se produirait de manière totalement imprévisible puisque par défaut, c’est à l’instant de la
première utilisation d’une page mémoire qu’elle est attribuée par le noyau, et non lors de son
allocation initiale.
Tout programme Xenomai devra donc commencer – avant de lancer ses tâches temps réel – par
une invocation :
mlockall(MCL_CURRENT | MCL_FUTURE);
Si le programme temps réel doit afficher des messages sur sa sortie standard, sa sortie d’erreur
ou dans un fichier de traces, il peut utiliser l’une des fonctions de la bibliothèque RTDK (inté-
grée dans Xenomai) :
int rt_printf (const char * format...);
int rt_vprintf (const char * format,
va_list arguments);
À l’usage, ces fonctions sont très proches de leurs équivalents de la libC standard. Leur fonc-
tionnement interne est toutefois assez différent, car pour garantir un déroulement déterministe,
il est hors de question de s’appuyer directement sur les appels système de Linux comme write().
La bibliothèque RTDK de Jan Kiszka propose donc de mémoriser les messages dans des buffers
circulaires spécifiques à chaque thread. Un thread non temps réel est chargé d’effectuer l’affi-
chage des données périodiquement (100 ms).
Ceci nécessite de démarrer le thread d’affichage et d’initialiser les buffers de stockage, ce qui
s’effectue à l’aide de la fonction :
int rt_print_init (size_t taille, const char * nom) ;
qui s’assurera que, dès la première utilisation d’une des fonctions de la famille précédente,
rt_print_init() sera correctement appelée. On ajoute donc, juste après le mlockall() précédem-
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
ment évoqué :
rt_print_auto_init(1);
Création de tâche
La création d’une tâche peut être réalisée avec rt_task_create(), qui prépare toutes les struc-
tures de données nécessaires au fonctionnement d’un nouveau thread, suivi de rt_task_start(),
qui démarre véritablement le thread suspendu.
On peut également regrouper les deux opérations en une seule avec rt_task_spawn(), qui crée
une tâche et la laisse démarrer immédiatement.
int rt_task_create (RT_TASK * tache,
const char * nom,
int taille_pile,
int priorite,
int mode);
Paramètres Signification
tache Pointeur sur une structure qui sera initialisée durant l’appel à rt_task_create() pour
représenter la tâche temps réel.
taille_pile Taille de la pile. Si la valeur est nulle, une taille par défaut est affectée permettant de
stocker 1 024 entiers.
priorite La priorité va de 1 à 99 dans l’espace des tâches Xenomai de manière similaire aux
threads temps réel souple de Linux.
arg Argument que la fonction du thread temps réel reçoit en paramètre à son démarrage.
Le paramètre mode est une combinaison par OU binaire entre les éléments suivants.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Constantes Signification
T_CPU(numero) La tâche s’exécutera sur le CPU indiqué. Le numero doit être strictement inférieur à
RTHAL_NR_CPUS.
T_FPU La tâche temps réel utilisera les ressources de la FPU (Floating Point Unit). Il s’agit d’un
attribut fixé automatiquement pour les tâches créées dans l’espace utilisateur. Pour celles
créées dans le noyau, ceci permet d’indiquer à Xenomai s’il faut sauvegarder ou non les
registres de la FPU lors des commutations vers cette tâche.
T_JOINABLE Cet attribut indique qu’il est possible d’attendre la fin de l’exécution de la tâche. Nous
allons l’utiliser ci-après.
T_SUSP Le thread est créé dans un état suspendu. On l’activera ensuite avec rt_task_resume().
T_WARNSW Si le thread passe du mode primaire au mode secondaire, il recevra un signal SIGXCPU.
Par défaut, ceci tue le processus, mais on peut le capturer pour analyser le problème.
Nous le détaillerons ci-après.
Dans notre premier exemple, nous allons également utiliser les appels système :
int rt_task_join (RT_TASK * tache);
pour endormir la tâche courante pendant une durée comptée en nanosecondes (nous reviendrons
sur cet argument ultérieurement).
exemple-hello-01.c :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <rtdk.h>
#include <native/task.h>
int main(void)
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
{
int err;
RT_TASK task;
mlockall(MCL_CURRENT|MCL_FUTURE);
rt_print_auto_init(1);
Les fonctions de l’API Xenomai renvoient généralement zéro si tout s’est bien passé et une
valeur d’erreur en cas d’échec. Elles ne remplissent pas la variable globale errno comme peuvent
le faire d’autres fonctions de la bibliothèque C, aussi ne doit-on pas appeler directement per-
ror(), mais enregistrer le code d’erreur négatif et le transmettre à strerror() avant de l’afficher.
Compilation et exécution
La compilation d’un programme avec l’API native de Xenomai va faire appel à un Makefile dans
lequel on invoquera le script xeno-config (installé dans /usr/xenomai/bin/) pour obtenir les para-
mètres de compilation à transmettre à gcc. En voici un exemple minimal.
Makefile :
XENOCONFIG=/usr/xenomai/bin/xeno-config
CC= $(shell $(XENOCONFIG) --cc)
CFLAGS= $(shell $(XENOCONFIG) --skin=native --cflags)
LDFLAGS=$(shell $(XENOCONFIG) --skin=native --ldflags)
LDFLAGS+=-lnative
LDLIBS=-lnative -lxenomai
all:: exemple-hello-01
clean::
rm -f exemple-hello-01 *.o
S’il s’agit d’une cross-compilation (pour un processeur différent), le script xeno-config indiquera le nom
du compilateur utilisé lors de la génération des bibliothèques. Toutefois, il faudra s’assurer que la variable
d’environnement PATH contient bien le répertoire où se trouve ce dernier.
$ make
gcc -I/usr/xenomai/include -D_GNU_SOURCE -D_REENTRANT -Wall
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
On démarre le processus normalement. Pour qu’il puisse trouver les bibliothèques dont il a
besoin, il faut configurer la variable LD_LIBRARY_PATH avant de lancer l’application.
# export LD_LIBRARY_PATH=/usr/xenomai/lib/
# ./exemple-hello-01
Hello from Xenomai Realtime Space
Hello from Xenomai Realtime Space
Hello from Xenomai Realtime Space
(Contrôle-C)
#
# cat /proc/xenomai/sched
CPU PID CLASS PRI TIMEOUT TIMEBASE STAT NAME
0 0 idle -1 - master R ROOT/0
1 0 idle -1 - master R ROOT/1
2 0 idle -1 - master R ROOT/2
3 0 idle -1 - master R ROOT/3
0 13159 rt 99 686ms314us master D Hello_01
0 0 idle -1 - master R ROOT/0
1 0 idle -1 - master R ROOT/1
# cat /proc/xenomai/stat
CPU PID MSW CSW PF STAT %CPU NAME
0 0 0 29036091 0 00500080 99.9 ROOT/0
1 0 0 8 0 00500080 100.0 ROOT/1
2 0 0 7 0 00500080 100.0 ROOT/2
3 0 0 0 0 00500080 100.0 ROOT/3
0 13159 0 183 0 00300184 0.0 Hello_01
0 0 0 41560275 0 00000000 0.0 IRQ2316: [timer]
#
Nous observons la présence du noyau Linux (ROOT) sur quatre CPU et du handler de l’interrup-
tion timer pour Xenomai. Notre tâche a été activée 183 fois (colonne Context Switches, CSW)
mais n’a pas subi de commutation du mode primaire vers le secondaire (colonne Mode Switches,
MSW). Ces informations peuvent être vues de manière plus synthétique avec la commande rtps se
trouvant dans /usr/xenomai/sbin/ :
# /usr/xenomai/sbin/rtps
PID TIME THREAD CMD
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
0 023:32:54.105,594 ROOT/0 -
0 023:34:05.627,537 ROOT/1 -
0 023:34:05.316,192 ROOT/2 -
0 023:34:06.580,449 ROOT/3 -
13159 000:00:00.003,791 Hello_01 ./exemple-hello-01
0 000:00:22.984,643 IRQ2316:[timer] -
#
Processus unithread
Il est possible de basculer en temps réel un thread déjà existant dans le domaine Linux sans en
créer de nouveau. Pour cela, on invoque :
int rt_task_shadow (RT_TASK * tache,
const char * nom,
int priorite,
int mode);
#include <rtdk.h>
#include <native/task.h>
int main(void)
{
int err;
RT_TASK task;
mlockall(MCL_CURRENT|MCL_FUTURE);
rt_print_auto_init(1);
rt_task_sleep(1000000000LL); // 1 sec.
}
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
return 0;
}
# ./exemple-hello-02
Hello World 02
Hello World 02
Hello World 02
Hello World 02
Hello World 02
(Contrôle-C)
#
Cette expérience ne présente pas toujours les mêmes résultats, en fonction de la bibliothèque C et de la
plate-forme de tests utilisées.
La fonction fprintf() de la bibliothèque C stocke les messages dans un buffer de sortie. Ce n’est
que lors de la réception d’un retour chariot, lorsque le buffer est plein ou encore lors d’un appel
fflush(), que l’appel système write() de Linux est invoqué.
Le buffer par défaut faisant 4 096 octets, ce n’est qu’à la seizième écriture que la commutation
se fera. Vérifions le comportement :
exemple-hello-03.c :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <rtdk.h>
#include <native/task.h>
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
int main(void)
{
int err;
RT_TASK task;
[...]
À l’exécution, nous voyons les messages écrits avec rt_fprintf() sur la sortie d’erreur, mais pas
ceux sur la sortie standard, puisqu’ils sont conservés dans le buffer.
# ./exemple-hello-03
Ecriture de 256 octets : 1
Ecriture de 256 octets : 2
Ecriture de 256 octets : 3
[...]
Ecriture de 256 octets : 14
Ecriture de 256 octets : 15
Ecriture de 256 octets : 16
CPU time limit exceeded
#
Il est bien sûr possible d’intercepter le signal SIGXCPU avec la fonction sigaction() classique, et
d’installer un gestionnaire qui affichera des informations avant de terminer le processus. On
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
pourrait ainsi identifier les points de commutations vers le mode secondaire pour essayer de les
éliminer.
Réveils périodiques
Le principe est simple : nous pouvons rendre un thread périodique, après sa création, grâce à la
fonction :
int rt_task_set_periodic (RT_TASK * tache,
RTIME debut,
RTIME periode);
Le premier paramètre est l’identifiant de la tâche. Dans le cas où une tâche veut se rendre pério-
dique elle-même, elle peut employer NULL ou rt_task_self() qui lui renvoie toujours son propre
identifiant.
RT_TASK * rt_task_self (void);
Les deux autres paramètres s’expriment différemment suivant la valeur fournie pour l’option
Base Period du sous-menu Real-Time Sub-System>Interfaces>Native API lors de la compi-
lation du noyau. Ceci est vrai par ailleurs pour toutes les valeurs temporelles de l’API native de
Xenomai :
• si la valeur est zéro, les dates de déclenchement et les durées sont mesurées en nanosecondes ;
• si la valeur n’est pas nulle, elle correspond à la durée (en microsecondes) d’un tick, et toutes
les autres valeurs de temps sont exprimées en multiples de ce tick.
Dans la plupart des cas, on préfère laisser la période de base de Xenomai à zéro, et exprimer les
valeurs temporelles en nanosecondes. C’est ce que nous utiliserons dans la suite de ce chapitre.
Il est possible de modifier dynamiquement la configuration pour la base de temps de l’API native à l’aide de
la fonction rt_timer_set_mode(), mais en pratique, ceci est rarement utilisé.
Le paramètre debut indique l’heure de la première activation de la tâche. Si la valeur est TM_NOW,
l’activation est immédiate. On peut également utiliser la valeur rt_timer_read() qui nous renvoie
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
la valeur du timer système, à laquelle on ajoute la durée désirée avant le premier déclenchement.
RTIME rt_timer_read (void);
Le troisième paramètre est la période de réveil du thread. La valeur TM_INFINITE permet d’arrêter
l’activation régulière de la tâche.
Figure 10-1
Activations d’une tâche
périodique
Pour être réveillée, celle-ci doit préalablement être en attente sur l’appel rt_task_wait_period() :
int rt_task_wait_period (unsigned long * depassements);
Cette fonction bloque la tâche appelante (sauf si la période n’a pas encore été programmée)
jusqu’à la prochaine activation. Elle renvoie normalement zéro. En cas de problème (période non
programmée, signal reçu, etc.), rt_task_wait_period() renvoie un code d’erreur négatif.
Le comportement global de la tâche périodique ressemblera donc à :
while (rt_task_wait_period (NULL) == 0) {
// traitement
}
Si toutefois le traitement dure plus longtemps qu’une période, ou si une autre tâche plus prio-
ritaire préempte le thread appelant, un dépassement pourra se produire. Si le paramètre de
rt_task_wait_period() n’est pas NULL, le pointeur sera renseigné avec le nombre d’activations
manquées. Nous examinerons ce cas plus loin.
Voici un exemple simple d’une tâche affichant l’heure toutes les secondes :
exemple-periodique-01.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <native/task.h>
#include <native/timer.h>
#include <rtdk.h>
rt_task_set_periodic(rt_task_self(), TM_NOW,
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
1000000000); // 1. sec.
rt_printf("[%lld] Timer programmé...\n",
rt_timer_read());
while ((err=rt_task_wait_period(&depassements)) == 0){
rt_printf("[%lld]", rt_timer_read());
if (depassements != 0)
rt_printf(" Depassements: %lu", depassements);
rt_printf("\n");
}
fprintf(stderr, "rt_task_wait_period(): %s\n",
strerror(-err));
exit(EXIT_FAILURE);
}
int main()
{
RT_TASK task;
mlockall(MCL_CURRENT|MCL_FUTURE);
rt_print_auto_init(1);
rt_task_join(& task);
return 0;
}
# ./exemple-periodique-01
[3358442575000] Timer programmé...
[3359442585000]
[3360442583000]
[3361442581000]
[3362442583000]
[3363442584000]
[3364442584000]
[3365442581000]
[3366442583000]
[3367442583000]
[3368442583000]
(Contrôle-C)
#
Le chiffre en gras évoluant sur chaque ligne représente bien les milliards de nanosecondes.
Nous voyons que les microsecondes fluctuent à chaque période (ce qui est cohérent avec les
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
résultats déjà obtenus), mais que les dizaines de microsecondes restent presque stables (système
avec une charge moyenne).
Voyons à présent ce qui se passe si notre application – mal dimensionnée évidemment – doit
réaliser ponctuellement un traitement plus long que la période d’activation, ou si une autre tâche
la préempte pendant plus d’une période.
Pour éviter de geler totalement notre système, nous allons réaliser une boucle active plus longue
que la période lors d’une activation sur quatre. La période est également réduite pour avoir une
activation toutes les demi-secondes (le comportement est plus visible ainsi).
La fonction périodique est modifiée comme suit :
exemple-periodique-02.c :
[...]
rt_task_set_periodic(rt_task_self(),
TM_NOW, 500000000);
rt_printf("[%lld] Timer programmé...\n",
rt_timer_read());
i = 0;
while (1) {
rt_task_wait_period(& depassements);
rt_printf("[%lld]", rt_timer_read());
if (depassements != 0)
rt_printf(" Depassements: %lu", depassements);
rt_printf("\n");
i ++;
i = i % 4;
if (i == 0)
rt_timer_spin(800000000);
}
fprintf(stderr, "rt_task_wait_period(): %s\n",
strerror(-err));
exit(EXIT_FAILURE);
}
[...]
# ./exemple-periodique-02
[3432254806000] Timer programmé...
[3432754815000]
[3433254813000]
[3433754811000]
[3434254814000]
[3435054882000]
[3435254815000]
[3435754810000]
[3436254817000]
[3437054863000]
[3437254817000]
[3437754810000]
[3438254817000]
[3439054864000]
[3439254814000]
[3439754811000]
(Contrôle-C)
#
Les chiffres en gras représentent les dixièmes de secondes. Nous remarquons qu’une période sur
quatre dure 0,8 seconde tandis que les trois autres durent 0,5 seconde, ce que nous attendions.
Nous pouvons toutefois observer que rt_task_wait_period() n’indique pas de débordement
complet de période. La situation est résumée sur la figure 10-2 sur laquelle les zones grisées
représentent les cycles de travail actif du thread.
Figure 10-2
Dépassement de la période
d’activation
Les numéros situés sous l’axe des abscisses indiquent les valeurs de la variable i. L’activation
de la séquence i=1 commence immédiatement après celle i=0, sans attendre la période suivante.
Toutefois, cette activation se produit bien dans son créneau et aucun décalage n’est indiqué. Si
nous modifions comme suit notre programme pour allonger la période de travail (de boucle
active dans notre cas) à 1,2 seconde :
exemple-periodique-03.c :
[...]
i = i % 4;
if (i == 0)
rt_timer_spin(1200000000);
}
[...]
# ./exemple-periodique-03
[3583969345000] Timer programme...
[3584469354000]
[3584969345000]
[3585469350000]
[3585969345000]
[3587169406000] Depassements : 1
[3587469354000]
[3587969344000]
[3588469352000]
[3589669399000] Depassements : 1
[3589969353000]
[3590469349000]
[3590969347000]
[3592169390000] Depassements : 1
[3592469353000]
(Contrôle-C)
#
Figure 10-3
Dépassement de plus d’une
période
Nous pouvons effectuer une mesure de précision des tâches périodiques avec le programme sui-
vant, auquel on fournit une période en microsecondes et qui nous affiche sur sa sortie standard
les durées effectives entre activations.
exemple-periodique-04.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <native/task.h>
#include <native/timer.h>
#include <rtdk.h>
{
RTIME precedent = 0;
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
RTIME heure = 0;
RTIME periode;
RTIME duree;
periode = * (RTIME *) arg;
periode = periode * 1000; // en ns
rt_task_set_periodic(NULL, TM_NOW, periode);
while(1) {
precedent = heure;
rt_task_wait_period(NULL);
heure = rt_timer_read();
if (precedent == 0)
// Ignorer le premier déclenchement
continue;
duree = heure - precedent;
rt_printf("%llu\n", duree/1000);
}
}
if ((argc != 2)
|| (sscanf(argv[1], "%llu", & periode) != 1)) {
fprintf(stderr, "usage: %s periode_en_us\n", argv[0]);
exit(EXIT_FAILURE);
}
mlockall(MCL_CURRENT|MCL_FUTURE);
rt_print_auto_init(1);
rt_task_join(& task);
return 0;
}
Effectuons un premier test avec une période d’une milliseconde, suivie d’une mesure longue
durant une heure.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
# ./exemple-periodique-04 1000
1000
1000
999
1001
999
999
1000
999
1000
1000
999
1001
998
999
1000
999
999
1000
(Contrôle-C)
# ./exemple-periodique-04 1000 > data-1.txt
(Contrôle-C au bout d’une heure environ)
#
Le premier jeu de résultats peut être analysé avec les outils développés au chapitre 4 :
Le résultat est visible sur la figure 10-4 : les valeurs sont équivalentes à celles que nous avions
obtenues avec le temps réel de Linux (chapitre 6), mais ici, nous avons laissé le système fonc-
tionner pendant beaucoup plus longtemps, ce qui le rend sensible à des interruptions rares.
Figure 10-4
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Faisons à présent fonctionner le même programme avec de fortes perturbations engendrées par
le script dohell dont nous avons parlé dans le précédent chapitre. Ce dernier est invoqué réguliè-
rement afin de déclencher des pointes d’activité, tant au niveau de la charge du système que des
interruptions reçues. Voici les résultats obtenus :
La figure 10-5 montre peu de changements par rapport à la précédente : les valeurs étant beau-
coup plus nombreuses, la courbe semble plus effilée, mais il y a en réalité peu de différences.
Nous remarquons néanmoins une très bonne tenue de la valeur maximale puisqu’elle est com-
parable à celle obtenue avec le noyau PREEMPT_RT, alors que les expériences du chapitre 8 se
déroulaient sur des durées beaucoup plus courtes.
Figure 10-5
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Alarmes
Les alarmes de Xenomai ont un rôle un peu différent des timers précédents, car elles sont beau-
coup plus proches des systèmes de watchdogs (chiens de garde) ou de timeout (dépassement de
délai).
Un watchdog est un mécanisme courant en informatique industrielle : il s’agit d’un composant logiciel ou
matériel qui est prêt à réinitialiser le système s’il détecte que l’application principale ne répond plus.
Une alarme est programmée pour se déclencher à partir d’un instant donné et se répéter éven-
tuellement avec une période fixée. Une tâche réveillée par une alarme s’exécute avec une
priorité supérieure à toutes les tâches normales de Xenomai. Elle pourra donc prendre toutes les
mesures d’urgence impliquées par le déclenchement de l’alarme.
On crée l’alarme et on la détruit avec :
int rt_alarm_create (RT_ALARM * alarme, const char *nom);
void rt_alarm_delete (RT_ALARM * alarme);
Enfin, une tâche peut se mettre en attente sur l’alarme avec la fonction rt_alarm_wait(), qui nous
rappelle le rt_task_wait_period() étudié précédemment.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Dans l’exemple suivant, nous allons vérifier que plusieurs tâches peuvent être simultanément en
attente sur la même alarme. Nous allons également examiner leurs priorités d’exécution avant et
après déclenchement de l’alarme, grâce à la fonction :
int rt_task_inquire (RT_TASK *tache, RT_TASK_INFO *info);
Celle-ci remplit une structure contenant les champs suivants, susceptibles de nous intéresser
pour suivre l’activité d’une tâche.
status unsigned État de la tâche. Par exemple, T_BLOCKED (endormie), T_READY (prête),
T_DELAYED (différée), etc.
bprio int Priorité initiale.
cprio int Priorité courante (peut être différente de la priorité initiale dans les
situations d’héritage de priorité).
#include <native/alarm.h>
#include <native/task.h>
#include <native/timer.h>
#include <rtdk.h>
#define NB_TACHES 5
int main(void)
{
int err;
int i;
RT_TASK task[NB_TACHES];
char nom_tache[80];
mlockall(MCL_CURRENT|MCL_FUTURE);
rt_print_auto_init(1);
rt_task_join(& task[i]);
return 0;
}
Les cinq tâches sont créées avec des priorités croissantes pourtant, lorsque l’alarme se déclenche,
elles sont réveillées dans l’ordre de mise en attente et s’exécutent alors avec une priorité beau-
coup plus élevée (257) :
# ./exemple-alarme-01
[1] Priorite initiale 85
[2] Priorite initiale 86
[3] Priorite initiale 87
[4] Priorite initiale 88
[1] priorite : 257, heure : 3680733471000
[2] priorite : 257, heure : 3680733518000
[3] priorite : 257, heure : 3680733549000
[4] priorite : 257, heure : 3680733574000
[1] priorite : 257, heure : 3681733456000
[2] priorite : 257, heure : 3681733519000
[3] priorite : 257, heure : 3681733553000
[4] priorite : 257, heure : 3681733583000
[1] priorite : 257, heure : 3682733455000
[2] priorite : 257, heure : 3682733517000
[3] priorite : 257, heure : 3682733548000
[4] priorite : 257, heure : 3682733578000
[1] priorite : 257, heure : 3683733455000
[2] priorite : 257, heure : 3683733514000
[3] priorite : 257, heure : 3683733547000
[4] priorite : 257, heure : 3683733576000
[1] priorite : 257, heure : 3684733455000
[2] priorite : 257, heure : 3684733516000
[3] priorite : 257, heure : 3684733549000
[4] priorite : 257, heure : 3684733579000
(Contrôle-C)
#
Ce mécanisme est donc conçu pour créer des tâches de surveillance, qui pourront se déclen-
cher périodiquement (ou au bout d’une durée préprogrammée si l’alarme n’est pas inhibée entre
temps) et agir plus prioritairement que les autres tâches de l’application temps réel.
Watchdog
On notera qu’il existe dans Xenomai un chien de garde intégré qui se déclenche si une tâche
consomme du temps CPU de manière ininterrompue pendant une durée supérieure à un seuil
configurable au moment de la compilation du noyau. Ce watchdog interrompra la tâche avec le
signal SIGXCPU.
• Watchdog Support : activer la présence d’un chien de garde logiciel intégré dans Xenomai ;
• Watchdog Timeout : durée (en secondes) maximale d’exécution ininterrompue d’une tâche
avant déclenchement du signal SIGXPCU.
Pour vérifier si le watchdog est programmé dans le noyau, on peut rechercher les lignes sui-
vantes dans le fichier .config ou dans /proc/config.gz, si les options Kernel .config Support et
Enable Access to .config Through /proc/config.gz sont activées dans le menu General Setup
comme recommandé dans l’annexe A.
Ici, le délai est configuré à la valeur par défaut (4 secondes). Vérifions-le avec le programme
suivant qui réalise des boucles actives de durées croissantes :
exemple-watchdog-01.c :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <rtdk.h>
#include <native/task.h>
#include <native/timer.h>
int main(void)
{
int err;
RT_TASK task;
int nb_secondes;
int i;
mlockall(MCL_CURRENT|MCL_FUTURE);
rt_print_auto_init(1);
nb_secondes);
rt_task_sleep(500000000); // 0.5 s
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
# ./exemple-watchdog-01
Boucle active de 1 s.
Boucle active de 2 s.
Boucle active de 3 s.
Boucle active de 4 s.
CPU time limit exceeded
#
Sémaphores
Xenomai propose une implémentation des sémaphores classiques de Dijkstra, ainsi que quelques
extensions. Un sémaphore est un objet de synchronisation courant dans la programmation temps
réel. Il est associé à un compteur initialisé avec une valeur N positive. Chaque fois qu’une tâche
a besoin d’accéder à la ressource protégée par le sémaphore, elle appelle l’opération P(). Si le
compteur est strictement supérieur à zéro, il est décrémenté et l’opération se termine immédiate-
ment, sinon la tâche est endormie jusqu’à ce que le compteur redevienne positif (ou que le délai
maximal d’attente soit atteint). Lorsqu’une tâche a terminé son accès à la ressource concernée,
elle invoque l’opération V(), qui incrémente le compteur en réveillant éventuellement une tâche
en attente. Nous sommes ainsi sûrs qu’il n’y aura jamais plus de N tâches tenant le sémaphore
à un instant donné (si N est initialisé à 1, le comportement est approximativement celui d’un
mutex).
P() et V() viennent du hollandais Proberen (tester) et Verhogen (augmenter), langue maternelle de
Dijkstra, qui a décrit en premier cette structure de synchronisation.
Les fonctions essentielles pour manipuler les sémaphores de Xenomai sont les suivantes :
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Nous allons construire un petit exemple dans lequel six tâches temps réel vont tenter simulta-
nément d’accéder à un sémaphore dont le compteur est initialisé – avec le troisième argument
de rt_sem_create() – à la valeur 4. Chaque tâche qui obtient l’accès au sémaphore le conserve
durant deux secondes, avant de le restituer et de se remettre en attente, ceci à quatre reprises.
À chaque fois qu’un thread obtient ou restitue le sémaphore, il l’indique dans une table glo-
bale qu’une septième tâche vient consulter régulièrement pour afficher l’état des threads du
processus.
exemple-semaphore-01.c
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <rtdk.h>
#include <native/sem.h>
#include <native/task.h>
#include <native/timer.h>
#define NB_TACHES 6
static RT_SEM sem;
static int tient_semaphore[NB_TACHES];
while (1) {
for (i = 0; i < NB_TACHES; i ++)
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
if (tient_semaphore[i] == 1)
rt_printf("%d ", i);
rt_printf (" ");
for (i = 0; i < NB_TACHES; i ++)
if (tient_semaphore[i] == 0)
rt_printf("%d ", i);
rt_printf("\n");
rt_task_sleep(1000000000);
}
}
mlockall(MCL_CURRENT|MCL_FUTURE);
rt_print_auto_init(1);
produise au moment exact d’une écriture par l’un des autres threads et obtienne une valeur
incohérente. Dans une application réelle, il conviendrait d’utiliser un autre mécanisme de ver-
rouillage pour protéger l’accès au tableau, comme un mutex que nous verrons plus loin. Lors de
l’exécution, nous voyons bien que seules quatre tâches peuvent obtenir simultanément le séma-
phore, les deux autres restant en attente.
# ./exemple-semaphore-01
Avec semaphore Sans semaphore
0 1 2 3 4 5
0 1 2 3 4 5
0 1 4 5 2 3
0 1 4 5 2 3
2 3 4 5 0 1
2 3 4 5 0 1
0 1 2 3 4 5
0 1 2 3 4 5
0 1 4 5 2 3
0 1 4 5 2 3
2 3 4 5
2 3 4 5
#
Le dernier paramètre de rt_sem_create() peut prendre l’une des trois valeurs suivantes, ce qui
représente une extension par rapport aux sémaphores usuels.
Valeurs Signification
S_PRIO Lors d’une opération V(), le compteur est incrémenté et on réveille la tâche la plus prioritaire
endormie dans une opération P() pour qu’elle puisse tester et décrémenter le compteur.
S_FIFO Lors d’une opération V(), le compteur est incrémenté, puis on réveille la première tâche
endormie, quelle que soit sa priorité.
S_PULSE Lors d’une opération V(), on réveille la tâche la plus prioritaire. Si aucune tâche n’est en attente,
le compteur n’est pas augmenté. Ainsi, le sémaphore joue un rôle de point de blocage pour
chaque P() et de libération à chaque V(), ce qui rappelle le principe des variables conditions.
Dans ce mode, le compteur doit obligatoirement être initialisé avec une valeur nulle (et il la
conserve).
Mutex
Les mutex de Xenomai sont principalement utilisés à travers les quatre fonctions suivantes :
int rt_mutex_create (RT_MUTEX * mutex, const char * nom);
int rt_mutex_delete (RT_MUTEX * mutex);
int rt_mutex_acquire (RT_MUTEX * mutex, RTIME delai);
int rt_mutex_release (RT_MUTEX * mutex);
#include <native/task.h>
#include <native/mutex.h>
#include <native/timer.h>
#include <rtdk.h>
rt_mutex_release(& mutex);
}
int main(void)
{
int i;
int err;
RT_TASK task[2];
mlockall(MCL_CURRENT|MCL_FUTURE);
rt_print_auto_init(1);
Le thread 1 prend le mutex en premier, attend quelques instants et le libère afin que le thread 2
puisse le prendre. La durée de cette commutation est mesurée. Le mutex 2 relâche ensuite le
mutex et le cycle reprend pendant 1 000 commutations.
Les résultats sont affichés à la fin de l’exécution. Nous pouvons les étudier avec les outils déve-
loppés au chapitre 4.
Les résultats sont comparables à ceux de Linux. La moyenne est de 4 microsecondes au lieu de
3, et le maximum de 11 microsecondes au lieu de 12.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
#include <native/task.h>
#include <native/mutex.h>
#include <native/timer.h>
#include <rtdk.h>
}
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
mlockall(MCL_CURRENT|MCL_FUTURE);
rt_print_auto_init(1);
strerror(-err));
exit(EXIT_FAILURE);
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
}
rt_task_join(& task);
rt_mutex_delete(& mutex);
return 0;
}
Dans une situation d’inversion de priorité, T2 pourrait s’exécuter en entier avant que T1 ne
puisse redémarrer, libérer le mutex et laisser T3 se dérouler.
Nous voyons qu’en réalité, il n’en est rien. L’héritage de priorité propulse T1 temporairement à
la priorité 30 où elle n’est pas préemptée par T2.
# ./exemple-mutex-02
T1 demarre
T1 demande le mutex
T1 tient le mutex
T1 demarre T3
T3 demarre
T3 demande le mutex
T1 demarre T2
T1 lache le mutex
T3 tient le mutex
T3 travaille
T3 lache le mutex
T3 se termine
T1 attend T2 et T3
T2 demarre
T2 travaille
T2 se termine
T1 se termine
Conclusion
Nous avons vu quelques primitives de base proposées par Xenomai pour la gestion des tâches et
leur synchronisation. L’API complète est naturellement bien plus riche, et l’on trouvera de nom-
breux détails et exemples dans la documentation en ligne.
Nous ne nous sommes intéressés pour le moment qu’à la programmation Xenomai (et Linux)
en mode utilisateur. Il est pourtant parfois nécessaire de développer des modules de code qui
viendront se loger directement dans le kernel. Ceci est utile pour gérer et traiter efficacement
les interruptions, par exemple, mais également les entrées-sorties vers les périphériques. Nous
allons en étudier un aperçu dans le prochain chapitre.
Points clés
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
• Le développement sous Xenomai se fait de manière tout à fait classique. Les spécificités ne
tiennent qu’aux options de compilation et d’édition des liens.
• La création de tâches Xenomai et leur synchronisation se programme à l’aide de fonctions
relativement simples, proches de l’API Posix de gestion des threads.
• Les performances obtenues avec Xenomai sont en moyenne identiques à celles attendues
avec un noyau Linux incluant le patch PREEMPT_RT. Toutefois, les résultats correspondant
aux pires cas (latence maximale) sont meilleurs qu’avec le noyau PREEMPT_RT.
• Il existe une grande variabilité des performances en fonction du matériel sous-jacent et de sa
configuration (Setup BIOS, par exemple). Nous retrouverons quelques conseils de configura-
tion dans l’annexe A.
Exercices
Exercice 1 (*)
Écrivez un programme qui lance un thread Xenomai pour chaque CPU (ou cœur) dont vous
disposez. Affectez chaque thread à un CPU en utilisant le paramètre mode de rt_task_create().
Après un petit sommeil d’une seconde, chaque thread devra se mettre en boucle active pendant
une dizaine de secondes.
Vérifiez que l’interface utilisateur du système est totalement gelée pendant cette période. Véri-
fiez également que le noyau Linux est incapable de répondre à un ping depuis un autre poste.
Exercice 2 (**)
Créez une tâche périodique Xenomai qui enchaîne régulièrement des boucles actives avec rt_
timer_spin(). Relevez l’heure avant et après la boucle active. Affichez la durée effective de
chaque boucle. Quelle est la précision de ces temps d’attente ?
Exercice 3 (**)
Renouvelez l’expérience précédente en utilisant un sommeil rt_task_sleep() au lieu de la boucle
active et vérifiez sa précision. Comparez à la valeur mesurée précédemment.
Exercice 4 (***)
Écrivez un programme qui lance une tâche périodique sur chaque CPU disponible, les périodes
étant différentes. Mesurez la durée de chaque période. Quelles sont les précisions des déclen-
chements périodiques ?
11
Traitement des interruptions
Jusqu’à présent, le code que nous avons étudié s’exécutait dans l’espace utilisateur, et c’est ainsi
qu’il est conseillé de développer les tâches temps réel. L’isolation mémoire entre les processus
et le noyau garantit la robustesse et la sécurité du système, même en cas de bogue dans une
application. Toutefois, il peut s’avérer indispensable d’écrire des portions de programme qui
s’exécutent dans l’espace kernel, par exemple pour gérer efficacement les interruptions. Ce cha-
pitre constitue une brève introduction à l’écriture de code pour le noyau et ne peut se substituer à
une documentation plus approfondie. Nous y étudierons le fonctionnement d’un driver, ainsi que
la gestion des interruptions par Linux et par Xenomai.
module_init(exemple_squelette_init);
module_exit(exemple_squelette_exit);
MODULE_LICENSE("GPL");
• THIS_MODULE est un pointeur global qui donne accès à la structure représentant le module
concerné. Ainsi, nous pouvons afficher son nom dans les traces du noyau.
• Enfin, il est nécessaire d’indiquer avec la macro MODULE_LICENSE() la licence d’utilisation du
module. Si celle-ci n’est pas GPL, GPLv2 ou quelques autres variantes compatibles avec la GPL,
le driver est considéré comme PROPRIETARY. Il ne pourra pas être intégré statiquement dans le
code du noyau et sera obligatoirement chargé comme module. Au chargement, il « tachera »
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
le noyau (qui aura alors l’attribut tainted) jusqu’au reboot. Certaines fonctionnalités centrales
du kernel ne lui seront pas accessibles.
La compilation d’un module nécessite de disposer des fichiers d’en-tête du noyau cible ainsi
que de son fichier de configuration .config. Le Makefile qui permet de générer le module s’appuie
également sur la chaîne de construction du noyau cible. Compilons notre module :
$ make
make -C /lib/modules/3.0.0-15-generic/build SUBDIRS=/exemples/chapitre-11 modules
make[1]: entrant dans le répertoire " /usr/src/linux-headers-3.0.0-15-generic "
CC [M] /exemples/chapitre-11/exemple-squelette-module.o
Building modules, stage 2.
MODPOST 1 modules
CC /exemples/chapitre-11/exemple-squelette-module.mod.o
LD [M] /exemples/chapitre-11/exemple-squelette-module.ko
make[1]: quittant le répertoire " /usr/src/linux-headers-3.0.0-15-generic "
$
Pour pouvoir l’insérer dans notre noyau, il est évidemment nécessaire de disposer des droits
root. Les messages envoyés dans les traces du kernel sont visibles avec la commande dmesg.
# insmod exemple-squelette-module.ko
# dmesg | tail
[...]
exemple_squelette_module: chargement du module
# rmmod exemple_squelette_module
# dmesg | tail
[...]
exemple_squelette_module: chargement du module
exemple_squelette_module: retrait du module
#
#include <linux/module.h>
#include <linux/mutex.h>
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
#include <linux/sched.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <asm/uaccess.h>
#define NB_MINEURS 1
return erreur;
}
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
return 0;
}
La plupart des drivers courants s’enregistrent dans des classes prédéfinies. Ceci simplifie grandement
l’écriture du code, puisque toutes les opérations décrites précédemment sont prises en charge automa-
tiquement. Seule l’écriture des méthodes du driver (comme read() et write() que nous allons voir plus
loin) est indispensable.
unsigned nb_mineurs);
La fonction cdev_init() permet d’associer une structure opaque cdev représentant un périphé-
rique caractère générique, avec une structure file_operations qui contient les méthodes que nous
implémentons pour ce fichier spécial. Cette structure est initialisée statiquement plus haut dans
le fichier avec une syntaxe un peu particulière, grâce à laquelle nous ne fournissons que les
champs qui nous intéressent. Les autres méthodes sont initialisées avec des comportements
par défaut qui réussissent toujours – par exemple, pour des appels open() ou close() – ou qui
échouent en indiquant qu’elles ne sont pas implémentées (pour select(), par exemple).
void cdev_init (struct cdev * periph_caractere,
const struct file_operations * methodes);
int cdev_add (struct cdev * periph_caractere,
dev_t premier_ident, unsigned nb_mineurs);
void cdev_del (struct cdev * periph_caractere);
Les deux méthodes qui nous intéressent ici sont read() et write() ; elles sont décrites plus loin.
Enfin, notre fonction d’initialisation enregistre le périphérique caractère en lui attribuant les
numéros majeur et mineur que nous avons obtenus au préalable. À partir de ce moment, il
devient possible d’invoquer open() sur ce fichier spécial depuis l’espace utilisateur pour obtenir
un descripteur, et d’appeler read() ou write() sur ce dernier. Ces invocations appelleront les
routines de la seconde partie de notre module.
La fonction exemple_exit(), invoquée lors du retrait du module avec rmmod, effectue les opéra-
tions inverses des précédentes, dans l’ordre opposé à celui de l’initialisation.
exemple-read-write.c (2/2) :
if (lg > 0) {
if (copy_to_user(buffer, data_exemple, lg) != 0) {
mutex_unlock(& mtx_data_exemple);
return -EFAULT;
}
lg_data_exemple -= lg;
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
if (lg_data_exemple > 0)
memmove(data_exemple,
& data_exemple[lg], lg_data_exemple);
}
mutex_unlock(& mtx_data_exemple);
return lg;
}
if (lg > 0) {
if (copy_from_user(&data_exemple[lg_data_exemple],
buffer, lg) != 0) {
mutex_unlock(& mtx_data_exemple);
return -EFAULT;
}
lg_data_exemple += lg;
}
mutex_unlock(& mtx_data_exemple);
return lg;
}
module_init(exemple_init);
module_exit(exemple_exit);
MODULE_LICENSE("GPL");
Nous voyons une variable globale data_exemple représentant un buffer dans lequel nous vien-
drons écrire les données que l’espace utilisateur nous envoie avec l’appel système write(), et lire
les données à renvoyer lors d’un appel read(). Le buffer est accompagné par une variable lg_
data_exemple indiquant le nombre de caractères qui s’y trouvent, et d’un mutex mtx_data_exemple
pour le protéger. Ceci évite les situations de concurrence entre des appels système read() ou
write() se déroulant en parallèle sur deux CPU différents ou s’entremêlant sur le même CPU
avec un noyau compilé en mode préemptible.
Nous pouvons remarquer que les deux appels système verrouillent consciencieusement le mutex
avant d’accéder au contenu du buffer (ou même à la variable lg_data_exemple) et le libèrent
ensuite. La prise du mutex se fait à l’aide de la fonction mutex_lock_interruptible :
• si le mutex est initialement libre, l’appel revient immédiatement après l’avoir verrouillé et
renvoie zéro ;
Lors d’un sommeil non interruptible (provoqué par mutex_lock(), par exemple) un processus ne peut
pas être réveillé prématurément par un signal, même s’il s’agit de SIGKILL (de numéro 9). La réception du
signal se fera lorsque le sommeil prendra fin – sur libération du mutex.
Enfin, outre la gestion du buffer qui n’est pas très compliquée, nous pouvons observer dans les
routines précédentes, les invocations de copy_to_user() et copy_from_user() qui permettent de
copier un bloc de données entre l’espace mémoire du kernel et celui du processus appelant. Ces
deux fonctions prennent en premier argument le pointeur de destination, puis celui de source
et enfin le nombre d’octets à transférer. Elles renvoient le nombre d’octets qui n’ont pas pu être
copiés (à cause d’une erreur de gestion mémoire dans l’espace utilisateur). La consigne, si l’une de
ces fonctions renvoie une valeur non nulle, est de terminer l’appel système avec l’erreur -EFAULT.
long copy_to_user (void __user *dest,
const void *source,
unsigned long taille) ;
long copy_from_user (void *dest,
const void __user * source,
unsigned long n)
# insmod exemple-read-write.ko
# ls /sys/class/
ata_device bdi dma firmware i2c-adapter mem
ata_link block dmi gpio input misc
ata_port bluetooth drm graphics leds mmc_host
backlight bsg exemple hwmon mdio_bus net
[...]
# ls /sys/class/exemple/
exemple_read_write
# cat /sys/class/exemple/exemple_read_write/dev
251:0
# ls -l /dev/ex*
crw------- 1 root root 251, 0 /dev/exemple_read_write
AZERTYUIOP
# echo ABC > /dev/exemple_read_write
# echo DEF > /dev/exemple_read_write
# cat /dev/exemple_read_write
ABC
DEF
# rmmod exemple_read_write
#
#include <linux/interrupt.h>
#include <linux/version.h>
#include <linux/device.h>
#include <linux/module.h>
#include <linux/sched.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <asm/uaccess.h>
#define NB_MINEURS 1
spin_lock_init(& spl_data_exemple);
{
free_irq(numero_irq, THIS_MODULE->name);
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
cdev_del(& exemple_cdev);
device_destroy(exemple_class, exemple_dev);
class_destroy(exemple_class);
unregister_chrdev_region(exemple_dev, NB_MINEURS);
}
Nous pouvons observer quelques éléments nouveaux par rapport au driver précédent. Tout
d’abord, la variable globale numero_irq représente le numéro de l’interruption à gérer. Elle est
initialisée à 1, mais il sera possible de la modifier au chargement du module en indiquant sa
valeur sur la ligne de commandes de insmod. C’est le rôle de la macro module_param().
Il y a toujours un buffer data_exemple, toutefois il n’est plus protégé par un mutex. Le verrouil-
lage d’un mutex étant susceptible d’endormir le processus, il n’est pas possible de l’invoquer
depuis le contexte d’un gestionnaire d’interruptions (qui s’exécute intempestivement sans pro-
cessus concerné). Aussi, une structure de données spéciale, nommée spinlock, est conçue pour
assurer la synchronisation d’accès aux variables communes entre appels système et handlers
d’interruption. L’initialisation correcte du spinlock est réalisée par la fonction spin_lock_init()
dans exemple_init().
void spin_lock_init (spinlock_t * spl);
Ces fonctions peuvent, par exemple, permettre de se prémunir contre un accès simultané depuis
deux CPU distincts. Il faut également éviter qu’un appel système qui manipule une variable glo-
bale soit interrompu pendant une modification pour laisser exécuter un handler d’interruption
accédant à cette même variable. Pour cela, spin_lock_irqsave() et spin_unlock_irqrestore() vont
respectivement inhiber ou activer les interruptions sur le CPU courant.
void spin_lock_irqsave (spinlock_t * spl,
unsigned long masque);
void spin_unlock_irqsrestore (spinlock_t * spl,
unsigned long masque);
On utilisera donc les protections par spin_lock() et spin_unlock() dans les gestionnaires d’inter-
ruptions et celles par spin_lock_irqsave() et spin_unlock_irqrestore() dans les appels système.
Le paramètre masque passé en argument sert à stocker et restaurer l’état des interruptions activées et
celles inhibées.
de la pression sur une touche du clavier. Pour connaître son numéro, je vous conseille d’exécuter
la commande suivante dans un terminal :
$ watch -n 0.1 "cat /proc/interrupts"
Suivant la localisation de votre système, il faudra utiliser 0.1 ou 0,1 pour demander un affichage
tous les dixièmes de seconde. Vous verrez les compteurs d’interruption évoluer au gré de la
vie du système et pourrez aisément déterminer quelle interruption est associée aux touches du
clavier.
Le second argument de request_irq() est le pointeur sur la fonction de traitement de l’inter-
ruption, suivi d’un argument contenant des attributs, dont le seul qui nous intéresse ici est
IRQF_SHARED indiquant que la ligne d’interruption est partagée par plusieurs gestionnaires simul-
tanément. Le handler bas niveau du kernel invoquera successivement chacun d’entre eux, chacun
devant renvoyer la valeur IRQ_HANDLED si l’interruption le concernait effectivement (par exemple,
après avoir interrogé le matériel) ou IRQ_NONE dans le cas contraire. Enfin, on fournit le nom du
driver (visible dans /proc/interrupts) et un pointeur qui servira d’identifiant pour le handler,
que nous devrons transmettre lors de la désinstallation du gestionnaire avec free_irq().
int request_irq (unsigned int numero_irq,
irq_handler_t handler,
unsigned long attributs,
const char * nom, void * ident) ;
void free_irq (unsigned int numero_irq,
void * ident) ;
Nous remarquons également dans le code précédent la déclaration d’une waitqueue, une file
d’attente, qui servira à bloquer le processus dans l’appel système de lecture en attendant qu’une
interruption arrive. Voici la seconde partie du code de notre driver, comportant l’appel système
read() et le handler de l’interruption capturée.
while (lg_data_exemple == 0) {
spin_unlock_irqrestore(& spl_data_exemple, masque);
if (wait_event_interruptible(wq_data_exemple,
(lg_data_exemple != 0)) != 0) {
printk(KERN_INFO "Sortie sur signal\n");
return -ERESTARTSYS;
}
spin_lock_irqsave(& spl_data_exemple, masque);
}
lg_data_exemple --;
if (lg_data_exemple > 0)
memmove(data_exemple, & (data_exemple[1]),
lg_data_exemple * sizeof(long int));
if (copy_to_user(buffer, chaine,
strlen(chaine)+1) != 0)
return -EFAULT;
return strlen(chaine)+1;
}
On remarque bien durant l’exécution du programme que notre processus appelant est endormi
dans l’appel système read(), jusqu’à la pression sur une touche où deux interruptions se pro-
duisent (pression et relâchement). Le handler alimente alors le buffer et réveille la tâche en
attente.
Cet exemple est très simple mais il correspond quand même à un schéma classique pour un
driver effectuant de l’acquisition de données. La complexité que l’on rencontre dans un « vrai »
driver est due essentiellement au dialogue avec le matériel (détection et négociation des plages
d’entrées-sorties, programmation des transferts de données par DMA, acquittement et réactiva-
tion des interruptions, etc.).
On notera également que si le volume de données à transférer entre l’espace utilisateur et l’es-
pace noyau est important, on évite les appels système read() et write() qui impliquent une copie
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
des données. Dans cette situation, on préfère employer un mmap() qui partage directement des
pages de mémoire physique entre processus et kernel. Le périphérique peut lire ou écrire ainsi
dans la mémoire projetée (par un transfert DMA) et le processus en attente – souvent dans un
ioctl() – sera réveillé dès la fin de l’opération.
La routine de service handler_irq() doit renvoyer la valeur IRQ_WAKE_THREAD pour que la seconde
soit exécutée.
Nous allons modifier légèrement notre code pour intégrer ceci. Toutefois, plutôt que d’afficher
l’instant de déclenchement de l’interruption (variable jiffies) comme nous l’avons fait précé-
demment, nous allons mesurer la durée s’écoulant entre le déclenchement de l’interruption et
celui du thread. Pour obtenir des valeurs assez précises, nous allons lire l’heure avec la fonction
getnstimeofday() qui nous fournit des secondes (depuis la date 01/01/1970) et un complément
en nanosecondes. Il faudra alors stocker les heures dans la table data_exemple (dont le type sera
modifié) et renvoyer leur différence lors de la lecture.
Voici les modifications apportées au programme précédent :
exemple-read-threadirq.c :
[...]
#define TAILLE_DATA_EXEMPLE 4096
struct mesure {
struct timespec handler;
struct timespec thread;
};
static struct mesure data_exemple [TAILLE_DATA_EXEMPLE];
static int lg_data_exemple = 0;
[...]
erreur = request_threaded_irq(numero_irq,
exemple_handler, exemple_thread,
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
IRQF_SHARED, THIS_MODULE->name,
THIS_MODULE->name);
[...]
}
lg_data_exemple --;
if (lg_data_exemple > 0)
memmove(data_exemple, & (data_exemple[1]),
lg_data_exemple * sizeof(struct mesure));
spin_unlock_irqrestore(& spl_data_exemple, masque);
Nous pouvons d’ailleurs observer le thread du kernel affecté au traitement de notre routine, il
est visible avec ps aux :
# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 3320 1800 ? Ss Jan27 0:02 /sbin/init
root 2 0.0 0.0 0 0 ? S Jan27 0:00 [kthreadd]
root 3 0.0 0.0 0 0 ? S Jan27 0:28 [ksoftirqd/0]
[…]
root 4123 0.0 0.0 0 0 ? S 10:42 0:00 [irq/17-exemple_]
[...]
#
Analysons les résultats obtenus, sans oublier que les valeurs sont en nanosecondes :
Ces mesures ont été réalisées sur un noyau vanilla, sans extensions temps réel. Elles montrent
néanmoins que le traitement réalisé dans le thread est différé en moyenne de près de 9 micro-
secondes, avec un retard maximal de plus de 125 microsecondes. Ceci explique pourquoi cette
approche n’est pas encore systématiquement appliquée sur le noyau standard : les performances
moyennes sont légèrement dégradées.
En réitérant cette expérience sur un noyau avec patch PREEMPT_RT, les résultats sont un peu
meilleurs :
Le pire cas est passé de 125 microsecondes à 34 microsecondes et la moyenne est également
légèrement inférieure. Si nous recommençons en augmentant la priorité du thread à la valeur
Fifo 99, on obtient :
Cette fois, il s’agit de la valeur moyenne qui est sensiblement améliorée, le thread étant plus
facilement sélectionné dès la fin du handler d’interruption même si un autre thread du kernel est
actif.
Nous avons vu comment traiter les interruptions depuis l’espace noyau, et communiquer les
données qu’elles fournissent à l’espace utilisateur, ceci avec un noyau Linux standard ou modifié
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
par le patch PREEMPT_RT. Il est également possible de traiter les interruptions avec Xenomai.
#include <linux/interrupt.h>
#include <linux/version.h>
#include <linux/device.h>
#include <linux/module.h>
#include <linux/sched.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <asm/uaccess.h>
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
#include <rtdm/rtdm_driver.h>
rtdm_mutex_init(&mtx_data_exemple);
rtdm_dev_register(& exemple_rtdev);
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
return 0;
}
Dans cette première partie, nous pouvons observer quelques éléments nouveaux par rapport aux
drivers Linux. Tout d’abord, la structure rtdm_device intègre tout ce qui concerne le driver, ce qui
permet de l’enregistrer de manière plus simple qu’avec le noyau classique. Dans cette structure,
nous voyons un champ nommé ops qui regroupe les méthodes – les appels système – implémen-
tées par le driver. Chacune d’elles reçoit en argument un paramètre de type rtdm_dev_context qui
contient les informations d’exécution temps réel, et un paramètre de type rtdm_user_info_t qui
correspond à l’environnement du processus appelant.
Chaque méthode existe en version temps réel et en version non temps réel (avec le suffixe _nrt).
L’implémentation de certaines méthodes (open et close, par exemple) est obligatoire en version
non temps réel. Si l’implémentation temps réel n’est pas fournie, l’implémentation non temps
réel peut être invoquée à sa place.
Nous voyons qu’il existe des objets de synchronisation spécifiques (ici les rtdm_mutex, mais on
peut trouver des sémaphores, des verrous, des événements, etc.).
Voyons la seconde partie du code, avec les méthodes read et write, qui sont directement cal-
quées sur celles définies précédemment pour le driver Linux. Nous définissons également deux
méthodes open et close qui n’ont pas d’effet ici mais qui sont obligatoires pour RTDM.
exemple-rtdm-read-write (2/2) :
lg_data_exemple -= lg;
if (lg_data_exemple > 0)
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
if (lg > 0) {
if (rtdm_safe_copy_from_user(info,
&data_exemple[lg_data_exemple],
buffer, lg) != 0) {
rtdm_mutex_unlock(& mtx_data_exemple);
return -EFAULT;
}
lg_data_exemple += lg;
}
rtdm_mutex_unlock(& mtx_data_exemple);
return lg;
}
module_init(exemple_init);
module_exit(exemple_exit);
MODULE_LICENSE("GPL");
L’utilisation des rtdm_mutex est équivalente à celle des mutex de Linux. Nous remarquons égale-
ment la présence de fonctions de copie de données depuis ou vers l’espace utilisateur. Chargeons
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
notre module :
# insmod exemple-rtdm-read-write.ko
# ls /proc/xenomai/rtdm/
exemple_rtdm fildes named_devices open_fildes
protocol_devices rttest-switchtest0 rttest-timerbench0
# cat /proc/xenomai/rtdm/named_devices
Hash Name Driver /proc
24 rttest-timerbench0 xeno_timerbench rttest-timerbench0
55 rttest-switchtest0 xeno_switchtest rttest-switchtest0
74 rtdev_exemple exemple_rtdm exemple_rtdm
# cat /proc/xenomai/rtdm/exemple_rtdm/information
driver: exemple_rtdm
version: 1.0.0
peripheral: exemple_rtdm
provider: cpb
class: 6
sub-class: 1
flags: NAMED_DEVICE
lock count: 0
#
Notre driver est bien chargé. Le périphérique est nommé rtdev_exemple. Il faut comprendre
que contrairement à Linux, RTDM n’emploie pas de fichiers spéciaux pour identifier les péri-
phériques, ni de numéros majeur ou mineur. Il utilise simplement le nom que l’on fournit au
moment de l’enregistrement du driver. Il n’est donc pas possible d’accéder aux méthodes de
notre driver en employant cat ou echo en ligne de commandes. Nous devons écrire des petits
programmes employant l’API utilisateur de RTDM.
Voici un petit programme approximativement équivalent à cat.
exemple-user-rtdm-read.c :
#include <fcntl.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <rtdm/rtdm.h>
#define LG_BUFFER 80
if (argc < 2) {
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
fd = rt_dev_open(argv[1], O_RDONLY);
if (fd < 0) {
fprintf(stderr, "%s: %s\n", argv[1],
strerror(-fd));
exit(EXIT_FAILURE);
}
Nous voyons que les appels système rt_dev_open(), rt_dev_read() et rt_dev_close() sont très
semblables à ceux de Linux. Nous pouvons ajouter un utilitaire d’écriture en modifiant simple-
ment les lignes centrales du programme :
exemple-user-rtdm-write.c :
[...]
fd = rt_dev_open(argv[1], O_WRONLY);
if (fd < 0) {
fprintf(stderr, "%s: %s\n", argv[1],
strerror(-fd));
exit(EXIT_FAILURE);
}
rt_dev_close(fd);
[...]
# export LD_LIBRARY_PATH=/usr/xenomai/lib/
# ./exemple-user-rtdm-write rtdev_exemple "Hello RTDM"
# ./exemple-user-rtdm-read rtdev_exemple
Hello RTDM
#
possible de partager une ligne d’interruption entre deux drivers RTDM, mais il est fortement
déconseillé de la partager avec un driver du kernel Linux.
Dans notre prochain exemple, nous allons utiliser une ligne d’interruption spécifique, contrôlée
indépendamment des périphériques classiques du système. Pour cela, il va falloir abandonner
l’architecture PC type bureautique employée jusqu’ici et se rapprocher des systèmes embarqués.
Pendant longtemps, il était possible d’utiliser le port parallèle des PC pour effectuer des entrées/sorties
facilement (lecture/écriture sur les ports 888, 889 et 890, et gestion de l’interruption 7). De nos jours, la
plupart des PC n’ont plus de véritable port parallèle, certains disposent encore d’une émulation basée sur
un contrôleur USB, mais elle ne convient pas pour notre exemple.
J’ai choisi d’utiliser le Raspberry Pi modèle 1 B+ sur lequel j’ai installé Xenomai dans le cha-
pitre 9. L’un des intérêts de cette carte est la disponibilité sur un connecteur d’extension de
plusieurs broches d’entrées-sorties facilement accessibles et pilotables par des ports GPIO
(General Purpose Input/Output).
Les GPIO sont des ports d’entrées-sorties que l’on trouve sur les microcontrôleurs et certains microproces-
seurs. Chaque port peut être configuré à la demande en entrée ou en sortie au choix du programmeur (en
fonction, bien sûr, du matériel externe auquel le port est relié). Sous Linux, il est très facile de manipuler
depuis l’espace utilisateur les ports GPIO grâce au pseudosystème de fichiers /sys/class/gpio/.
Je vais donc envoyer une impulsion sur un port d’entrée afin qu’elle déclenche une interruption
gérée par RTDM. Dans ce handler d’interruption, nous basculerons l’état d’un port de sortie,
observable à l’oscilloscope (ou avec une simple Led pour la mise au point).
Nous allons utiliser quelques fonctions de l’API Linux pour contrôler les entrées-sorties GPIO :
int gpio_request (unsigned gpio, const char * nom);
void gpio_free (unsigned gpio);
int gpio_direction_input (unsigned gpio);
int gpio_direction_output (unsigned gpio, int valeur);
int gpio_get_value (unsigned gpio);
void gpio_set_value (unsigned gpio, int valeur);
int gpio_to_irq (unsigned int gpio)
La fonction gpio_request() réserve un numéro de GPIO pour le module, afin d’éviter les col-
lisions entre plusieurs drivers. gpio_direction_input() et gpio_direction_output() orientent le
port en entrée ou en sortie. La lecture de la valeur sur un port d’entrée se fait avec gpio_get_
value() et l’écriture sur un port de sortie avec gpio_set_value(). Enfin, gpio_to_irq() permet de
retrouver le numéro de l’interruption associée à un changement d’état sur un port d’entrée.
Les numéros des GPIO disponibles et de ceux utilisés par les périphériques externes sont docu-
mentés sur le site raspberrypi.org . Ici, j’ai choisi en entrée la broche 11 (GPIO 17) et en sortie
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
la broche 12 (GPIO 18). Le programme suivant installe un handler d’interruption pour RTDM.
Cette interruption sera déclenchée lorsqu’une transition montante de 0 à 3,3 V sera détectée sur
la broche d’entrée. Le handler invoqué va alors basculer l’état du port de sortie.
exemple-rtdm-irq.c :
#include <linux/interrupt.h>
#include <linux/version.h>
#include <linux/device.h>
#include <linux/module.h>
#include <linux/sched.h>
#include <linux/cdev.h>
#include <linux/gpio.h>
#include <linux/fs.h>
#include <asm/uaccess.h>
#include <rtdm/rtdm_driver.h>
#define EXEMPLE_GPIO_IN 17
#define EXEMPLE_GPIO_OUT 18
if ((err = gpio_request(EXEMPLE_GPIO_IN,
THIS_MODULE->name)) != 0) {
return err;
}
if ((err = gpio_direction_input(EXEMPLE_GPIO_IN)) !=0){
gpio_free(EXEMPLE_GPIO_IN);
return err;
}
if ((err = gpio_request(EXEMPLE_GPIO_OUT,
THIS_MODULE->name)) != 0) {
gpio_free(EXEMPLE_GPIO_IN);
return err;
}
if ((err = gpio_direction_output(EXEMPLE_GPIO_OUT,
1)) != 0) {
gpio_free(EXEMPLE_GPIO_OUT);
gpio_free(EXEMPLE_GPIO_IN);
return err;
}
num_irq, exemple_handler,
0,
"Exemple", NULL)) != 0) {
gpio_free(EXEMPLE_GPIO_OUT);
gpio_free(EXEMPLE_GPIO_IN);
return err;
}
irq_set_irq_type(num_irq, IRQF_TRIGGER_RISING);
rtdm_irq_enable(& irq_exemple);
return 0;
}
module_init(exemple_init);
module_exit(exemple_exit);
MODULE_LICENSE("GPL");
Nous voyons que les primitives de RTDM utilisées sont très simples :
int rtdm_irq_request (rtdm_irq_t * irq,
unsigned int numero,
int (* handler)(rtdm_irq_t *),
unsigned long flags,
const char * nom,
void * arg);
Le type opaque rtdm_irq_t du premier argument contient toutes les informations de gestion de
l’interruption. Il faut le transmettre aux fonctions comme :
int rtdm_irq_free (rtdm_irq_t * irq);
int rtdm_irq_enable (rtdm_irq_t * irq);
int rtdm_irq_disable (rtdm_irq_t * irq);
# insmod exemple-rtdm-irq.ko
# cat /proc/interrupts
CPU0
3: 20768 ARMCTRL BCM2708 Timer Tick
32: 1 ARMCTRL dwc_otg, dwc_otg_pcd, dwc_otg_hcd:usb1
65: 4 ARMCTRL ARM Mailbox IRQ
66: 0 ARMCTRL VCHIQ doorbell
75: 1 ARMCTRL
77: 9553 ARMCTRL bcm2708_sdhci (dma)
79: 0 ARMCTRL bcm2708_i2c.0, bcm2708_i2c.1
80: 0 ARMCTRL bcm2708_spi.0
83: 4835 ARMCTRL uart-pl011
84: 10565 ARMCTRL mmc0
FIQ: usb_fiq
Err: 0
#
L’interruption (numéro 187) n’est pas vue par le noyau Linux. En revanche, Xenomai peut la gérer :
# cat /proc/xenomai/irq
IRQ CPU0
3: 23269 [timer]
187: 304556 Exemple
1027: 0 [virtual]
#
Si nous connectons un oscillateur à 1 kHz sur l’entrée 11 (GPIO 17) et que nous examinons ce signal
et la broche de sortie 12 (GPIO 18) sur un oscilloscope, nous observons le tracé de la figure 11-1.
Figure 11-1
Capture oscilloscope
(large)
par des effets capacitifs, probablement dus aux câbles que j’emploie, aux limitations de mon
oscilloscope et à celles des sorties GPIO du Raspberry Pi.
Nous pouvons également zoomer sur la transition afin de mesurer le temps de latence entre le
déclenchement de l’interruption et l’exécution du handler. Sur la figure 11-2, nous voyons que
ce temps est d’environ 3,5 microsecondes. Il y a naturellement quelques fluctuations, mais leur
amplitude est assez limitée.
Figure 11-2
Capture oscilloscope
(serrée)
Dans le cas d’un système temps réel strict, il serait nécessaire de mesurer le plus long temps de
latence, sous des conditions élevées de charge système et d’activité en interruption. Ceci peut
se réaliser en mettant en œuvre une procédure de mesure semblable à celle que nous avons éta-
blie au chapitre 6. J’ai réalisé quelques expériences de ce type décrites sur mon blog, mais il en
existe d’autres disponibles sur Internet. Je mentionnerai l’article [BROWN 2010] qui compare
des tests de stabilité de timers sur plusieurs versions de Linux, PREEMPT_RT et Xenomai.
Conclusion
Dans ce chapitre, nous avons observé les rudiments de la gestion des interruptions dans le noyau,
que ce soit sous Linux standard (éventuellement modifié avec PREEMPT_RT) ou en utilisant
l’API RTDM pour Xenomai.
La mise au point d’un driver pour Linux n’est pas très compliquée, mais nécessite une bonne
rigueur d’écriture et une certaine connaissance de l’API interne du noyau. L’emploi de RTDM
permet d’utiliser une interface plus standardisée, plus uniforme et mieux documentée. Nous
n’avons fait qu’effleurer le contenu de RTDM, cette API offre des fonctionnalités de gestion des
timers, des tâches, des outils de synchronisation, etc., que l’on retrouvera dans la documentation
disponible à l’adresse suivante : http://www.xenomai.org.
Points clés
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
• La gestion des interruptions depuis l’espace utilisateur n’est pas possible sous Linux. Il est
nécessaire d’écrire du code dans le noyau.
• L’écriture d’un driver s’appuie sur une interface spécifique, interne au kernel – qui est sus-
ceptible d’évoluer entre deux versions majeures de Linux – dont la documentation formelle
est limitée.
• Il existe une API, nommée RTDM (Real Time Driver Model), aujourd’hui intégrée principa-
lement dans Xenomai qui permet l’écriture de drivers de façon plus standardisée que Linux,
en différenciant les modes d’invocation (depuis un contexte temps réel ou non).
• La possibilité de gérer les interruptions directement depuis les threads Xenomai dans l’es-
pace utilisateur va disparaître dans les futures versions.
Exercices
Exercice 1 (*)
Écrivez un petit module pour le noyau Linux (en vous inspirant de exemple-read-write.c) qui
implémente une file de messages. Les messages écrits dans la file avec write() seront mémori-
sés avant d’être extraits dans l’ordre d’arrivée par des read().
Exercice 2 (**)
Écrivez un handler d’interruption pour Linux que vous attachez à l’interruption souris et qui
compte le nombre d’interruptions survenues. La lecture du compteur se fera par l’intermédiaire
d’un appel système read() sur un driver minimal. L’écriture sur ce driver permettra de réinitia-
liser le compteur à zéro.
Exercice 3 (**)
En utilisant l’API de RTDM décrite sur http://www.xenomai.org, écrivez une tâche temps réel
périodique qui lit l’heure dès son activation et calcule la durée écoulée depuis le réveil précé-
dent. Elle effectuera des statistiques (durée minimale, maximale, moyenne) qu’elle transmettra
à un processus de l’espace utilisateur via un appel système rtdm_read().
Exercice 4 (***)
Si vous avez accès à une carte offrant des possibilités de sorties sur des ports (GPIO, par
exemple), écrivez un module RTDM qui bascule périodiquement l’état d’une broche de sortie.
En la connectant à un oscilloscope, vérifiez la précision du signal produit et sa bonne tenue sui-
vant la charge en processus et en interruptions.
Conclusion
État des lieux et perspectives
Nous avons, au gré des chapitres de ce livre, exploré différentes possibilités pour implémenter
sous Linux des applications soumises à des contraintes temporelles. Il est temps de récapituler
les résultats obtenus et de dresser un état des lieux.
Situation actuelle
Linux « vanilla »
Le noyau Linux standard, celui proposé directement par les distributions classiques, est sur-
nommé vanilla. On peut en télécharger les sources sur le site http://www.kernel.org et le compiler
en suivant les étapes et les conseils décrits dans l’annexe A.
Sans précision particulière, une tâche (dont nous avons étudié les caractéristiques et les états au
chapitre 1) est exécutée suivant un ordonnancement temps partagé. Le but de l’ordonnanceur
temps partagé (chapitre 3) est d’offrir une répartition du temps CPU la plus équitable possible
entre les différentes tâches. Aussi, il ne garantit aucun délai, aucune précision temporelle. Nous
avons remarqué (chapitre 4) des fluctuations sensibles dans les actions périodiques (plusieurs
millisecondes) et des durées de préemption importantes même pour les tâches à qui nous don-
nions une priorité temps partagé élevée (à l’aide de la commande nice).
Il est également possible de faire exécuter une tâche sous un ordonnancement temps réel souple
comme nous l’avons observé au chapitre 5. Attention, pensez dans ce cas à désactiver le « garde-
fou » (/proc/sys/kernel/sched_rt_runtime_us) qui conserve toujours un peu de ressources CPU
pour les processus temps partagé, au détriment des processus temps réel. L’ordonnancement
temps réel s’obtient à l’aide de primitives Posix standards (sched_setscheduler(), etc.) ou à l’aide
de la commande shell chrt.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Un thread temps réel est peu sensible à la charge logicielle du système, mais il est toutefois
soumis à l’activité du noyau lui-même, notamment aux traitements réalisés en réponse aux
interruptions et aux appels système (parfois longs) invoqués par d’autres tâches. Pour améliorer
ceci, nous avons observé que la préemptibilité du kernel (option de compilation décrite dans
l’annexe A) permet de commuter plus rapidement vers une tâche temps réel lorsqu’un événement
externe la réveille, même si un autre processus est en train d’exécuter un appel système.
Patch PREEMPT_RT
Le projet PREEMPT_RT a pour vocation d’améliorer les performances temps réel du noyau
standard. Il s’agit d’un patch que l’on applique avant compilation, comme nous l’avons vu au
chapitre 8. Les avancées de PREEMPT_RT touchent de très nombreux domaines du kernel, et
rendent les tâches temps réel beaucoup plus prévisibles en ce qui concerne les comportements
temporels.
Un avantage de cette solution est qu’il n’est pas nécessaire de modifier le code existant, ni même
de le recompiler ou d’installer de bibliothèque spécifique. Le seul fait de booter sur un noyau
Linux modifié par le patch PREEMPT_RT confère au système une meilleure qualité du temps
réel, suffisante pour de nombreux domaines d’application.
Xenomai
L’approche de Xenomai (et de quelques autres systèmes comme RT-Linux ou RTAI) repose
aujourd’hui sur deux noyaux : Adeos comme base temps réel strict, et Linux pour la partie
temps partagé et temps réel souple.
Les performances sont comparables et souvent meilleures que celles de PREEMPT_RT. Toute-
fois, l’inconvénient majeur est la nécessité d’utiliser une API spécifique (chapitre 10), même si
des skins permettent d’importer du code en provenance d’autres systèmes temps réel.
Mesures
Les résultats varient très sensiblement d’une architecture à l’autre, d’une gamme de processeur
à une autre, d’une carte mère à l’autre, et les valeurs mentionnées ci-après n’ont qu’un intérêt
relatif, en les comparant les unes avec les autres.
Outre la fréquence du processeur et la capacité mémoire, qui influent sur les performances
(rapidité d’exécution du code), de nombreux autres facteurs entrent en jeu, notamment le type de
bus (PCI, PCI express, CAN, etc.) et les contrôleurs d’interruptions.
Les résultats du tableau suivant ont été relevés sur un PC d’entrée de gamme, un poste de t ravail
bureautique. La première série de mesures a été réalisée avec une charge système (processus)
soutenue mais une charge en interruption moyenne. Nous y voyons les fluctuations relevées sur
une tâche périodique programmée à 1 kHz, les durées étant mesurées en microsecondes.
Naturellement, les processus en temps partagé sont fortement pénalisés par l’activité du système
et ne sauraient nous convenir.
En ce qui concerne les tâches temps réel, les résultats sont comparables entre Linux vanilla et
les extensions temps réel. On peut même voir une légère diminution de performances pour les
pires des cas lorsqu’on utilise PREEMPT_RT ou Xenomai. Ces différences ne sont toutefois pas
très représentatives.
Pour mesurer des performances temps réel, il est indispensable de maintenir le système sous
une haute pression en interruption (tableau ci-dessous). Les mesures ont duré plusieurs heures,
sous une charge élevée d’interruption (disque, réseau, USB, etc.).
Linux vanilla, et dans une moindre mesure PREEMPT_RT, peuvent être sujets à des fluctua-
tions suffisamment importantes pour « rater » des déclenchements du timer. Xenomai conserve
des performances constantes.
J’insiste sur le fait qu’il ne faut pas prendre ces valeurs à la lettre, mais qu’il faut absolument
réaliser ses propres expérimentations, sur un système conforme à la cible visée et dans un envi-
ronnement le plus proche possible de celui du fonctionnement final.
Perspectives
Le domaine du temps réel sous Linux est très actif. De nombreux acteurs, notamment industriels,
s’intéressent à cet environnement pour des applications de plus en plus variées (commandes de
processus, communication, vidéo, mesures et instrumentations, etc.).
Les améliorations à attendre du noyau standard sont des incorporations lentes des fonctionnali-
tés offertes par le projet PREEMPT_RT. Pourquoi lentes ? Tout simplement parce que la plupart
du temps, les progrès obtenus sur les performances dans « le pire des cas » se soldent par une
légère dégradation des performances moyennes. Or, la plupart des utilisateurs de Linux ne sont
– à juste titre – intéressés que par ces performances moyennes.
Après un petit creux d’activité, le projet PREEMPT_RT a repris un certain dynamisme depuis
la sortie du kernel 3.0 (au moment de la rédaction de ces lignes, le dernier patch disponible
s’applique au noyau 4.1). On peut en attendre des améliorations constantes, en espérant que les
retours d’information des utilisateurs (industriels, par exemple) augmenteront afin d’aider les
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
développeurs à progresser.
La prochaine version de Xenomai (3.0), actuellement en cours de finalisation et déjà disponible
en version de test, semble très prometteuse. L’idée principale est de pouvoir utiliser l’API de
Xenomai et ses skins (notamment RTDM pour écrire des drivers temps réel) tant sur un système
à double kernel (Linux + Adeos) que sur le noyau PREEMPT_RT seul.
Ce rapprochement des deux principales extensions temps réel de Linux est très intéressant pour
les utilisateurs désireux d’importer du code existant sur d’autres environnements, tout en béné-
ficiant de la facilité d’installation de PREEMPT_RT (disponible directement dans certaines
distributions).
Pour terminer, j’insisterai sur l’importance pour la validation d’un système, d’une configuration
soigneuse du noyau avant sa compilation, de l’exécution de tests prolongés dans des conditions
de charge maximale et d’une analyse rigoureuse des résultats obtenus. Ces opérations, labo-
rieuses mais indispensables, seront la garantie de la bonne tenue en charge de vos solutions
temps réel sous Linux.
A
Compilation d’un noyau
Nous avons parlé à plusieurs reprises de compiler un noyau Linux, voici quelques conseils et
exemples de configuration.
Une autre possibilité consiste à télécharger l’ensemble des sources de Linux depuis la version
2.6.11 et à extraire celle qui nous intéresse. On utilisera l’outil de contrôle de version Git.
Cette opération peut prendre un certain temps car il est nécessaire de télécharger environ un
1 Go. Il faut ensuite choisir la version désirée (par exemple, 4.2) en examinant celles qui sont
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
disponibles :
$ git tag -l
[...]
v4.2
v4.2-rc1
v4.2-rc2
v4.2-rc3
v4.2-rc4
v4.2-rc5
v4.2-rc6
v4.2-rc7
$ git checkout v4.2
[...]
Il est possible à ce moment d’appliquer un patch comme PREEMPT_RT (comme nous l’avons
vu au chapitre 8) ou celui de Xenomai (étudié au chapitre 9).
Le répertoire des sources contient un ensemble de sous-répertoires qui deviennent vite familiers
après quelques compilations.
$ ls
arch drivers Kbuild mm security
block firmware Kconfig net sound
COPYING fs kernel README tools
CREDITS include lib REPORTING-BUGS usr
crypto init MAINTAINERS samples virt
Documentation ipc Makefile scripts
$
Le Raspberry Pi, que nous avons utilisé à maintes reprises dans les chapitres précédents, n’est
pas supporté par le noyau mainline au moment de la rédaction de ces lignes. Il faut donc télé-
charger les sources d’un noyau légèrement modifié à partir d’un dépôt particulier :
Configuration de la compilation
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Principes
La première étape consiste à configurer la compilation, c’est-à-dire à indiquer toutes les fonc-
tionnalités à intégrer dans le noyau voulu. L’un des principaux avantages de Linux est d’être
capable de fonctionner sur un très grand nombre d’architectures et de machines différentes, et
d’être adapté à des besoins spécifiques très variés. Il existe environ 15 000 options de configu-
ration pour le noyau 4.2 !
Il est naturellement impossible de fournir toutes ces options sur une ligne de commandes d’un
script ./configure, comme on le fait souvent pour les projets GNU. La compilation du noyau
Linux s’appuie sur un fichier nommé .config qui va contenir toutes les options choisies.
Ce fichier (qui n’est visible qu’avec l’option -a de ls car son nom commence par un point) est
constitué de texte lisible, mais sa production et sa lecture sont automatisées au sein de Makefile.
Lorsqu’on vient de décompresser un noyau vierge, il n’y a aucun fichier .config, toutefois cer-
tains fichiers génériques existent. Il faut employer la commande make help pour obtenir la liste
des configurations disponibles.
$ ls -a .config
ls: impossible d’accéder à .config: Aucun fichier ou dossier de ce type
$ make help
Cleaning targets:
clean Remove most generated files but keep the config and
enough build support to build external modules
mrproper Remove all generated files+config+various backup files
[...]
i386_defconfig Build for i386
x86_64_defconfig Build for x86_64
[...]
$
Ici, les deux configurations par défaut sont pour des architectures x86 32 et 64 bits, car il s’agit
de la plate-forme sur laquelle la commande make a été exécutée.
Nous pouvons préciser une architecture différente avec l’option ARCH= de make. Nous considére-
rons ci-après le cas d’une compilation croisée pour une cible ARM.
Attention à bien utiliser l’option ARCH= sur toutes les commandes make que nous emploierons par la suite.
Il faut également être attentif à la casse : ARCH est en majuscules et l’architecture proprement dite en
minuscules.
Les architectures disponibles sont visibles dans le sous-répertoire arch/. Voici, par exemple,
quelques configurations par défaut pour plates-formes Arm (il y en a 113 sur cette version de
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Linux) :
Interfaces utilisateur
Ensuite, il faudra affiner les options de configuration. Pour cela, il existe quatre interfaces uti-
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
lisateur qui vont éditer ce fichier .config, en le lisant au démarrage et en le réécrivant en sortie :
• make ARCH=arm nconfig : configuration utilisant une interface minimale sur console texte basée
sur la bibliothèque nCurses ;
• make ARCH=arm menuconfig : assez proche de la configuration précédente, avec des menus en
mode semi-graphique ;
• make ARCH=arm gconfig : interface graphique s’appuyant sur GTK+ ;
• make ARCH=arm xconfig : interface utilisant la bibliothèque graphique Qt.
On peut choisir n’importe quelle méthode, elles offrent les mêmes possibilités. Les deux pre-
mières permettent une navigation au clavier plus rapide qu’à la souris lorsque l’on connaît
l’organisation des menus de configuration. Les deux autres offrent une vision plus globale de la
configuration par des menus déroulants et une ergonomie améliorée, notamment par l’affichage
permanent de l’aide concernant l’option sélectionnée.
Prenons l’exemple de menuconfig, choix le plus fréquent parmi les hackers de Linux.
Figure A-1
Make menuconfig
Chaque option peut être activée ou non (en appuyant sur la barre d’espace du clavier). Par
exemple, sur la figure A-1, l’option Auditing support est activée et Namespace support est
désactivée.
En outre, certaines options peuvent être compilées sous forme de modules comme c’est le cas ici
pour Kernel .config support. Le code sera alors compilé sous forme de fichier objet indépendant
du noyau, que l’on pourra insérer et retirer dynamiquement.
La configuration est une étape longue et un peu fastidieuse, d’autant qu’il est généralement
nécessaire de la répéter plusieurs fois, en ajustant les paramètres après test du système.
Options de compilation
Voici quelques options de compilation concernant le comportement temps réel. Certaines
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
options dépendent d’autres sélections et ne sont pas toujours visibles. En outre, leurs noms
peuvent varier légèrement en fonction de l’architecture choisie.
J’aurais donc tendance à désactiver cette option pour améliorer les performances temps réel,
tout en sachant qu’elle a un coût énergétique non négligeable nécessitant de l’activer dans le
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Autres menus
Les choix de configuration dans les autres menus dépendent du système pour lequel on réalise
la compilation (notamment du matériel présent pour le choix des drivers) et des applications
désirées (pour les protocoles réseau, par exemple).
Je conseille toutefois de désactiver les options des menus Kernel hacking et Virtualization car
elles impliquent souvent une surcharge du code compilé.
Compilation et installation
Compilation croisée
La compilation pour une cible différente se fait en utilisant une chaîne de compilation croisée,
c’est-à-dire un ensemble d’outils qui fonctionnent sur la machine de développement et four-
nissent du code prêt à s’exécuter sur la machine cible. En voici un aperçu :
$ ls /opt/arm-linux/usr/bin/
arm-linux-addr2line
arm-linux-ar
arm-linux-as
arm-linux-c++
[...]
arm-linux-gcc
arm-linux-gcc-4.3.6
arm-linux-gcov
arm-linux-gdb
arm-linux-gprof
arm-linux-ld
[...]
$
Sur ce poste, la chaîne de compilation se trouve dans /opt/arm-linux/usr/bin. Tous les utili-
taires GNU classiques sont préfixés par arm-linux- pour éviter les confusions.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Il est indispensable d’indiquer l’architecture cible. Par ailleurs, on précise dans la variable
CROSS_COMPILE un préfixe qui sera ajouté par make avant le nom de chaque utilitaire invoqué (par
exemple, gcc). Ce préfixe doit donc se terminer par un tiret (afin d’obtenir, par exemple, .../
arm-linux-gcc).
On pourra ajouter d’autres éléments sur la ligne de commandes make.
• L’option -j n permet de compiler en lançant n jobs en parallèle en permanence. Ceci réduit le
temps de compilation si l’on dispose de n CPU (ou n cœurs).
• Le type d’image désirée peut être indiqué en fin de ligne, par exemple, uImage si la plate-
forme cible utilise le bootloader U-Boot.
La durée de la compilation varie en fonction du processeur et du contenu (le nombre d’options
choisies lors de la configuration), de quelques dizaines de secondes à plus d’une heure !
En fin de compilation, on obtient une image dans le répertoire arch/<architecture>/boot qu’il
faudra transférer sur la plate-forme cible. De plus, si des modules ont été compilés, on les
regroupera dans un répertoire (par exemple, tmp_mod) dont on transférera le contenu à la racine
du système de fichiers de la cible :
Il faudra bien entendu configurer le bootloader pour qu’il sélectionne l’image produite.
Compilation native
Lors d’une compilation native, c’est-à-dire sur la plate-forme même où le noyau sera installé, on
se contente de lancer :
$ make
$ make modules_install
puis :
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
$ make install
Cette dernière commande copie le nouveau noyau dans /boot/, recherche les fichiers de confi-
guration de Grub et ajoute une entrée pour la nouvelle image (en laissant les précédentes
accessibles au cas où notre nouveau noyau refuserait de démarrer).
Toutefois, sur les distributions Debian et Ubuntu, on procède habituellement d’une manière
légèrement différente en générant tout d’abord un package contenant le nouveau noyau, qu’on
installe ensuite avec les outils d’administration système standards :
Blaess.indb 290
22/10/2015 14:15
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
B
Bibliographie
Livres
• [BLAESS 2011] Développement système sous Linux, 3e édition de Christophe Blaess,
éditions Eyrolles (2011).
• [BONNET 1999] Introduction aux systèmes temps réel de Christian Bonnet et Isabelle
Demeure, Hermes Science (1999).
• [BOVET 2006] Understanding the Linux Kernel, 3rd Edition de Daniel P. Bovet et Marco
Cesati, O’Reilly (2006).
• [CORBET 2005] Linux Device Drivers, 3rd Edition de Jonathan Corbet, Alessandro Rubini
et Greg Kroah-Hartman, O’Reilly (2005).
• [COTTET 2000] Ordonnancement temps réel, cours et exercices corrigés de Francis Cottet,
Joëlle Delacroix, Claude Kaiser et Zoubir Mammeri, Hermes Science (2000).
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
• [DORSEUIL 1991] Le temps réel en milieu industriel de Alain Dorseuil et Pascal Pillot,
Dunod (1991).
• [FICHEUX 2011] Linux embarqué de Pierre Ficheux et Eric Bénard, Eyrolles (2012).
• [HOLLABAUGH 2002] Embedded Linux, Hardware, Software, and Interfacing de Craig
Hollabaugh, Addison Wesley (2002).
• [KERRISK 2010] The Linux Programming Interface, A Linux and UNIX System Program-
ming Handbook de Michael Kerrisk, No Starch Press (2010).
• [LOVE 2004] Linux Kernel Development de Robert Love, Developer’s Library (2004).
• [TANENBAUM 1987] Operating Systems, Design and Implementation de Andrew S.
Tannenbaum et Albert S. Woodhull, Prentice Hall (1987).
• [TORVALDS 2001] Il était une fois Linux de Linus Torvalds et David Diamond, OEM (2001).
• [YAGHMOUR 2003] Building Embedded Linux Systems de Karim Yaghmour, O’Reilly
(2003).
Articles
• [BROWN 2010] How Fast Is Fast Enough? Choosing Between Xenomai and Linux for Real-
Time Applications. In Twelfth OSADL Real-Time Linux Workshop de Dr. Jeremy H. Brown
et Brad Martin, octobre 2010. http://www.osadl.org/fileadmin/dam/rtlws/12/Brown.pdf.
• [GERUM 2005] Life with Adeos de Philippe Gerum. http://www.xenomai.org/documentation/
branches/v2.3.x/pdf/Life-with-Adeos-rev-B.pdf
• [KISZKA 2005] The Real-Time Driver Model and First Application de Jan Kiszka. http://svn.
gna.org/svn/xenomai/tags/v2.4.0/doc/nodist/pdf/RTDM-and-Applications.pdf
• [REEVES 1997] What Really Happened on Mars? de Glenn E. Reeves. http://research.micro-
soft.com/en-us/um/people/mbj/Mars_Pathfinder/ Authoritative_Account.html
• [YAGHMOUR 2001] Adaptative Domain Environment for Operating Systems de Karim
Yaghmour. http://www.opersys.com/ftp/pub/Adeos/adeos.pdf
• [YODAIKEN 2004] Against Priority Inheritance de Victor Yodaiken. http://www.yodaiken.
com/papers/inherit.pdf
Sites web
http://christophe.blaess.fr : site de l’auteur, codes sources des programmes du livre, articles…
http://www.kernel.org : The Linux Kernel Archives. Le dépôt officiel des sources du noyau Linux
(et de PREEMPT_RT).
http://rt.wiki.kernel.org : Real-Time Linux Wiki. Le site de PREEMPT_RT.
http://www.xenomai.org : site officiel de Xenomai (documentation, téléchargements, etc.).
http://ltp.sourceforge.net : Linux Test Project. De très nombreux outils de tests, pas seulement dans
le domaine temps réel.
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Blaess.indb 294
22/10/2015 14:15
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
Index
Symboles BFS 51
Busybox 110
$$ (variable) 111
__exit 248 C
__init 248
/proc 28 cdev_add() 250, 252, 256
/proc/config.gz 135 cdev_del() 251, 252
/proc/cpuinfo 8, 9 cdev_init() 250, 252, 256
/proc/interrupts 28, 210, 258 CFS 46, 47
/proc/ipipe 206 chrt 110, 119, 141, 264
/proc/irq/ 29 class_create() 250, 251, 256
/proc/sys/kernel/sched_rt_period_us 100 class_destroy() 251
/proc/sys/kernel/sched_rt_runtime_us 100 clock_getres() 61
/proc/xenomai 206, 207 clock_gettime() 61
/proc/xenomai/irq 274 clockid_t 61, 66
_SC_NPROCESSORS_ONLN 10 CLOCK_MONOTONIC 61, 62
/sys/class/ 251, 254 CLOCK_MONOTONIC_RAW 61, 62
/sys/class/gpio/ 271 CLOCK_PROCESS_CPUTIME_ID 61
CLOCK_REALTIME 61, 62
/usr/xenomai 206, 208
clock_settime() 61
CLOCK_THREAD_CPUTIME_ID 61
A Commutation 1, 241
Adeos 196, 197, 199-201, 203, 206, 207 Complexité 45, 46, 47
Affinité 13, 14, 49, 207 Conservative 187
alarm() 164 Consommation 26, 69, 186
alloc_chrdev_region() 250, 251, 256 copy_from_user() 253, 254
APIC 24, 28 copy_to_user() 252, 254, 259
APM 203 core 31
Appel système 2, 18, 24, 34, 42, 46, 253, 265 CPU 31
Arm 60 CPU_CLR() 13
CPU_ISSET() 13
B CPU_SET() 13
cpu_set_t 13
Barabanov, Michael 194, 195 CPU_ZERO() 13
Barrière 149 Cyclictest 184, 210
gnuplot 76
DEFINE_MUTEX() 252 Governor 187, 188, 189, 190
device_create() 250, 251, 256 GPIO 271, 274
device_destroy() 251 gpio_direction_input() 271
dev_t 250 gpio_direction_output() 271
dmesg 249 gpio_free() 271
dohell 209, 210, 231 gpio_get_value() 271
Down 18 gpio_request() 271
Driver 91, 249 gpio_set_value() 271
gpio_to_irq() 271
E GPL 91, 248
errno 218
Ethernet 175 H
Exception 2, 24, 30, 31, 130, 131
Hackbench 186, 209
exec() 3
handler 27, 28, 255, 258, 272
execl() 3
High Resolution Timers Support 123
execle() 3
Histogramme 74
execlp() 3, 5
Horloge 61
execv() 3
HR-Timers 70
execve() 3, 130
Hwlatency 185
execvp() 3
Hyperthreading 8
exit() 3
F I
Igep 60
Fifo 95, 106, 172, 204
Igep v.2 60
Fluctuation 120
init 37
fork() 3, 131
insmod 91, 141, 247, 249
fprintf() 221
FPU 31 Interruption 23, 24, 26, 28, 30, 34, 46, 91, 93,
171, 174, 176, 178, 193, 195, 255, 257, 271
free_irq() 257, 258
Futex 158 Pipeline d’~ 196, 199
IRQ 24, 27, 28, 176, 197, 255
irqbalance 29
G
IRQF_SHARED 256, 258
Galbraith, Mike 48 IRQ_HANDLED 258, 259
GDB 33 IRQ_NONE 181, 258
Gerum, Philippe 197, 198 irqreturn_t 259
getnstimeofday() 261, 263 IRQ_WAKE_THREAD 181, 261, 262
getpriority() 53 ITIMER_PROF 65
gettimeofday() 48, 58, 62, 80 ITIMER_REAL 65
GkrellM 10, 100 ITIMER_VIRTUAL 65
J N
jiffies 69 Nanokernel 193
Ce document est la propriété exclusive de Pierre Ficheux (pierre.ficheux@gmail.com) - 21 juin 2016 à 18:45
M P
Makefile 218 patch 180
Mantegazza, Paolo 195 PCI 28
Mars Pathfinder 153 Performance 187, 189
MCL_CURRENT 131 Ping 172, 174, 181, 210
MCL_FUTURE 131 PIP 154
Mémoire 128, 130 Pi_stress 186
Migration 11 PLC 26
mlockall() 131, 215 Posix 1, 5, 14, 61, 103, 149, 201, 204
mmap() 92, 261 posix_spawn() 3
MMU 2, 31, 129-131 Powersave 187, 190
Mode Prédictibilité 87
Primaire 199, 213 Préemptibilité 132, 134, 180, 183
Secondaire 199, 213 Préemption 20, 43, 47, 79, 177
Module 247, 248 Preemption Model 134
module_exit() 248 PREEMPT_RT 144, 178, 179, 181, 182, 185
module_init() 248 printk() 248
MODULE_LICENSE() 248 Priorité
module_param() 255, 257 Héritage de ~ 154, 243
Molnár, Ingo 45, 46, 51, 178 Inversion de ~ 150, 178, 243
Multicœur 8, 28 Temps partagé 51, 53
Multiprocesseur 8, 28 Temps réel 94
munlockall() 131 Processeur 24, 25, 27, 49
Mutex 124, 150, 154, 156, 240, 253 Arm 60
mutex_lock() 254 Sommeil du ~ 27
mutex_lock_interruptible() 252-254 Processus 1, 17, 31, 94, 98, 127, 128
mutex_unlock() 253, 254 Groupe de ~ 47
pthread_attr_getaffinity_np() 15 RSDL 51
pthread_attr_getinheritsched() 102 RTAI 195, 196, 198
pthread_attr_getschedparam() 101 RT_ALARM 232
pthread_attr_getschedpolicy() 102 rt_alarm_create() 232, 234
pthread_attr_getscope() 103 rt_alarm_delete() 232
pthread_attr_init() 102 rt_alarm_start() 232
pthread_attr_setaffinity_np() 15 rt_alarm_stop() 232
pthread_attr_setinheritsched() 103 rt_alarm_wait() 233
pthread_attr_setschedparam() 102 rt_dev_close() 270
pthread_attr_setschedpolicy() 102, 107 rt_dev_open() 270
pthread_attr_setscope() 103 rt_dev_read() 270
pthread_attr_t 5, 15 rt_dev_write() 270
pthread_atttr_setschedpolicy() 102 RTDK 215
pthread_barrier_init() 150 RTDM 205, 265, 269, 271
pthread_barrier_wait() 150 rtdm_dev_register() 267
pthread_create() 5, 15, 102 rtdm_dev_unregister() 267
pthread_exit() 5 rtdm_irq_disable() 273
PTHREAD_EXPLICIT_SCHED 103 rtdm_irq_enable() 273
pthread_getaffinity_np() 14 rtdm_irq_free() 273
pthread_getschedparam() 101 rtdm_irq_request() 273
PTHREAD_INHERIT_SCHED 103 rtdm_irq_t 273
pthread_join() 6 rtdm_mutex 267, 269
pthread_mutexattr_init() 155 rtdm_mutex_destroy() 267
pthread_mutexattr_setprotocol() 155 rtdm_mutex_init() 267
pthread_mutex_lock() 152, 158, 162 rtdm_mutex_lock() 267
pthread_mutex_trylock() 158 rtdm_mutex_t 266
pthread_mutex_unlock() 158, 162 rtdm_mutex_unlock() 267
PTHREAD_PRIO_INHERIT 155 rtdm_safe_copy_from_user() 268
PTHREAD_SCOPE_PROCESS 103 rtdm_safe_copy_to_user() 267
PTHREAD_SCOPE_SYSTEM 103 rtdm_user_info_t 266, 267
pthread_setaffinity_np() 14 rt_fprintf() 215, 222
pthread_setschedparam() 101 RT_INTR 265
pthread_t 5 rt_intr_enable() 265
rt_intr_wait() 265
R RTLinux 178, 194, 196
RT_MUTEX 240
Raspberry Pi 136 rt_mutex_acquire() 240, 242
read() 253 rt_mutex_create() 240, 242
request_irq() 256, 258 rt_mutex_delete() 240
request_threaded_irq() 261, 262 rt_mutex_release() 240, 241
Ritchie, Dennis 41 rt_print_auto_init() 215
rmmod 141, 247, 249 rt_printf() 215
U Z
uClibC 34 Zombie 18