Expérimentation sur la préemptibilité du noyau Linux

Publié par cpb
Nov 04 2011

Cet article est extrait de la version préparatoire de mon livre « Solutions temps-réel sous Linux » (parution envisagée au début 2012).

J’ai eu envie de mettre en évidence la différence de comportement entre un noyau préemptible (avec l’option CONFIG_PREEMPT activée durant sa compilation) et un noyau non-préemptible classique. Toutefois, cette mise en évidence n’est pas très simple, car elle concerne précisément des cas rares et difficiles à  reproduire.

Nous allons nous intéresser à une interruption déclenchée par le port de communication série RS-232. Nous allons envoyer un caractère sur une liaison série à destination d’un système Linux sur lequel fonctionnera un processus temps-réel qui renverra le même caractère dans l’autre sens sur la même liaison série.

Mise en œuvre

L’expérience met en œuvre une carte à processeur Arm (Pandaboard, à gauche sur la photo ci-dessous) fonctionnant avec un noyau Linux 3.0. Ce choix s’explique par le fait que la Pandaboard dispose d’un véritable port série RS-232 possédant sa propre interruption – et non d’une émulation basée sur un contrôleur USB comme la plupart des PC actuels. Nous essaierons de mesurer précisément le temps de réponse de notre processus temps-réel et voir s’il varie sous l’influence d’un processus de moindre priorité.

Pour effectuer cette mesure, j’utiliserai une carte de développement (STK500, à droite sur la photo) pour micro-contrôleurs Atmel, plus particulièrement ici un ATmega32 (inséré dans le support de programmation rouge sur la partie gauche de la carte). Ce dernier fonctionne sans système d’exploitation et nous permet un contrôle direct et sans perturbation de la liaison série. Le micro-contrôleur enverra le caractère et mesurera le temps écoulé jusqu’à la réception du caractère renvoyé.

 

Pandaboard et STK500

Voici le programme qui est compilé puis chargé dans le micro-contrôleur (à l’aide des utilitaires avr-gcc et avr-dude qui sont librement disponible sous Linux). Mentionnons pour le lecteur non habitué à la programmation de micro-contrôleur que les constantes UDR, UCSRA, UCSRB, UCSRC, UBRRH et UBRRL représentent des accès directs aux registres du micro-contrôleur.

exemple-mesure-atmega.c :
// Declarer la frequence du micro-controleur (1MHz)
#define F_CPU 1000000
// Inclusion d'entetes specifiques au micro-controleur
#include <avr/io.h>
#include <util/delay.h>

void envoyer_octet (unsigned char octet)
{
    // Attendre buffer emission libre
    while ((UCSRA & (1<<UDRE)) == 0)
        ;
    // Ecriture sur le port Usart Data Register
    UDR = octet;
}

int main(void)
{
    unsigned char uc;
    unsigned long int nb_boucles;

    // Parametrage du port de communication
    // 12 -> 9600 bits/sec
    UBRRH = (unsigned char) (0);
    UBRRL = (unsigned char) (12);
    // Clock x 2
    UCSRA = (1 << U2X);
    // TX et RX actives
    UCSRB = (1 << RXEN) | (1 << TXEN);
    // 8 bits, 1 stop, pas de parite
    UCSRC = (1 << URSEL) | (1 << UCSZ1) | (1 << UCSZ0);

    while (1) {
        nb_boucles = 0;

        envoyer_octet(0xFF);
        // Attendre 1 octet recu
        while ((UCSRA & (1 << RXC)) == 0)
            nb_boucles ++;
        // Lire l'octet
        uc = UDR;

        envoyer_octet((nb_boucles >> 24) & 0xFF);
        _delay_ms(2);
        envoyer_octet((nb_boucles >> 16) & 0xFF);
        _delay_ms(2);
        envoyer_octet((nb_boucles >> 8 ) & 0xFF);
        _delay_ms(2);
        envoyer_octet((nb_boucles >> 0 ) & 0xFF);
        _delay_ms(500);
    }
    return 0;
}

On peut voir que ce programme envoie un octet (0xFF) sur le port série et boucle – en incrémentant une variable – tant qu’il n’a pas reçu de réponse. La durée de la boucle importe peu. Chaque itération dure environ 10 micro-secondes, et on pourrait la calibrer précisément, mais ceci 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 effectuées. Le petit sommeil de 2 ms entre les octets sert à garantir la réception par le correspondant car il n’y a pas de contrôle de flux (CTS/RTS, DTS/DSR, etc.) sur la carte à micro-contrôleur employée ici.

Le programme qui fonctionne sur la Pandaboard 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>

volatile int quitter = 0;

void handler_sigint(int unused)
{
    quitter = 1;
}

int main(int argc, char * argv[])
{
    int fd;
    unsigned char  octet;
    int nb_cycles;
    struct termios parametres;
    struct termios original;

    if (argc != 2) {
        fprintf(stderr, "usage: %s port_serien",argv[0]);
        exit(EXIT_FAILURE);
    }

    signal(SIGINT, handler_sigint);

    if ((fd = open(argv[1], O_RDWR | | O_NONBLOCK))<0){
        perror(argv[1]);
        exit(EXIT_FAILURE);
    }

    tcgetattr(fd, & original);
    tcgetattr(fd, & parametres);
    cfmakeraw(& parametres);
    cfsetispeed(& parametres, B9600);
    cfsetospeed(& parametres, B9600);
    parametres.c_iflag |= IGNPAR;
    parametres.c_cflag ^= (CSIZE | PARENB | CSTOPB);
    parametres.c_cflag |= CS8 | CLOCAL;
    if (tcsetattr(fd, TCSANOW, & parametres) != 0) {
        perror("tcsetattr");
        exit(EXIT_FAILURE);
    }
    fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) ^ O_NONBLOCK);

    while (! quitter) {
        if (read(fd, & octet, 1) < 0) {
            perror(argv[1]);
            exit(EXIT_FAILURE);
        }
        if (write(fd, & octet, 1) < 0) {
            perror(argv[1]);
            exit(EXIT_FAILURE);
        }
        nb_cycles = 0;
        read(fd, & octet, 1);
        nb_cycles |= octet;
        nb_cycles <<= 8;
        read(fd, & octet, 1);
        nb_cycles |= octet;
        nb_cycles <<= 8;
        read(fd, & octet, 1);
        nb_cycles |= octet;
        nb_cycles <<= 8;
        read(fd, & octet, 1);
        nb_cycles |= octet;
        fprintf(stdout, "%dn", nb_cycles);
    }
    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 quatre octets représentant son temps de réponse et les affiche sur sa sortie standard. Voici un exemple d’exécution sur un noyau non-préemptible.

[Panda]# ./exemple-reponse-irq-serie /dev/ttyO2
290
287
289
287
[...]
287
287
286
286
(Contrôle-C)
Bye !
[Panda]#

Appel-système long

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>

static int __init module_delay_init (void)
{
    unsigned long fin = jiffies + HZ/2; // 500 ms
    while(time_before(jiffies, fin))
        ;
    return 0;
}

static void __exit module_delay_exit (void)
{}

module_init(module_delay_init);
module_exit(module_delay_exit);
MODULE_LICENSE("GPL");

Ce module effectue une boucle active de 500 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. Une remarque : j’ai également monté le thread kernel kworker qui est utilisé pour gérer une partie de l’interruption série à la priorité Fifo 50.

Résultats sur système non-préemptible

[NB: Pour étudier les résultats, après les avoir redirigés dans un fichier, j’utilise un ensemble de petits outils développés précédement dans le livre]

[Panda]# uname -a
Linux (Pandaboard) 3.0.0-cpb #4 SMP Mon Oct 31 19:52:43 CET 2011 armv7l GNU/Linux
[Panda]# taskset -p 1  $$
pid 547's current affinity mask: 3
pid 547's new affinity mask: 1
[Panda]#  chrt -f 40 . ./exemple-reponse-irq-serie /dev/ttyO2 > resultats-non-preempt.txt
(Contrôle-C après quelques minutes)
Bye!
[Panda]#

Pendant le déroulement du programme, mon script qui charge et décharge le module perturbateur toutes les cinq secondes s’exécute sur le même processeur, avec une priorité temps-réel 10.

L’analyse du résultat donne :

$ ../chapitre-04/calculer-statistiques < resultats-non-preempt.txt
Nb mesures = 1156
Minimum = 283
Maximum = 46181
Moyenne = 4737
Ecart-type = 13397
$ ../chapitre-04/calculer-histogramme 100 0 50000  < resultat-nonpreempt.txt > histo-nonpreempt.txt

[NB: L’histogramme calculé ici est ensuite injecté dans un petit script Gnuplot, présenté dans un chapitre précédent, pour produire les figures ci-dessous]

Les résultats statistiques paraissent assez catastrophiques : une variabilité entre 283 et 46181 itérations de boucles, et un écart-type largement plus grand que la moyenne des valeurs ! Graphiquement les résultats sont plus compréhensibles, comme nous le voyons sur la figure suivante.

Réponse aux interruptions sur noyau non-préemptible

Notre système fait un grand écart entre un temps de réponse faible (290 itérations par boucles, environ 3 millisecondes) et une réponse très retardée par l’appel-système perturbateur (45000 itérations par boucle, à peu près 500 ms).

Résultat sur système préemptible

Après avoir redémarré la carte Pandaboard sur un noyau Linux compilé avec l’option CONFIG_PREEMPT, nous obtenons les résultats suivants.

[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]# taskset -p 1 $$
pid 579's current affinity mask: 3
pid 579's new affinity mask: 1
[Panda]#  chrt -f 40 . ./exemple-reponse-irq-serie /dev/ttyO2 > resultats-preempt.txt
(Contrôle-C après quelques minutes)
Bye!
[Panda]#

Après avoir rappatrié le fichier sur le PC de développement, nous analysons les résultats.

$ ../chapitre-04/calculer-statistiques < resultats-preempt.txt
Nb mesures = 1307
Minimum = 284
Maximum = 309
Moyenne = 287
Ecart-type = 2
$

Voilà qui est beaucoup mieux ! Le résultat est visible sur la figure suivante avec la même échelle que précédemment.
Réponse aux interruptions sur noyau préemptible (1)

Un zoom au début de l’axe des abscisses est représenté sur la figure ci-dessous. Cette fois aucune préemption du processus de haute priorité, la réponse à l’interruption est toujours comprise entre 284 et 309 boucles de la carte à micro-contrôleur. La réponse n’est pas réellement plus rapide, mais elle est plus fiable et prévisible.
Réponse aux interruptions sur noyau préemptible (2)

Conclusion

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énements externes. Elle permet de garantir qu’un processus de haute priorité en attente d’un événement externe (matérialisé par cette interruption) sera réveillé avec rapidité et surtout fiabilité, même si une autre tâche – de priorité moindre – est en train d’exécuter un appel-système. Ceci évite le problème classique de l’inversion de priorité.

Une réponse

  1. weaH dit :

    Joli démonstration!
    Vivement le livre 🙂

URL de trackback pour cette page