[Kernel] Interruptions et tasklets

Publié par cpb
Juin 06 2017

[Kernel Interruptions et tasklets

Il existe plusieurs mécanismes proposés par le noyau Linux pour programmer un traitement à réaliser lorsqu’un périphérique externe déclenche une interruption pour nous notifier de l’occurrence d’un événement. Gestionnaire monolithique, tasklet, workqueue, threaded interrupt, chaque solution a des avantages et des inconvénients, qu’il est intéressant de connaître pour optimiser l’efficacité de nos traitements.

Gestion des interruptions

Récapitulons rapidement le principe des interruptions sous Linux. Il convient tout d’abord de préciser que du fait de la portabilité du noyau sur un grand nombre d’architectures, les mécanismes très bas-niveau sont totalement abstraits pour ce qui concerne la plupart du code kernel classique (dans un driver par exemple).

Lorsqu’un périphérique externe – disons un contrôleur d’entrées-sorties GPIO par exemple – désire notifier le processeur de l’occurrence d’une situation intéressante (par exemple le changement d’état d’une broche d’entrée), il envoie un signal sur une entrée du contrôleur d’interruption APIC (Advanced Programmable Interrupt Controler). De nos jours celui-ci est intégré directement dans le microprocesseur, mais dans les anciens PC par exemple, il existait sous forme de composant indépendant.

Le contrôleur d’interruption effectue une demande d’interruption IRQ (Interrupt Request) auprès du processeur principal. Ce dernier arrête le traitement en cours, sauvegarde son état (ses registres) dans la pile et interroge le contrôleur d’interruption pour connaître l’événement survenu. L’APIC lui indique la source du signal initial sous forme d’un numéro. Le processeur déroute alors son exécution sur une routine de traitement bas-niveau chargée de prendre en compte l’interruption et de réagir en conséquence.

Une fois cette routine de traitement terminée le processeur reprend le cours de ses opérations précédentes comme si de rien n’était.

fig-01 - Déclenchement d'une interruption

Fig-01 – Déclenchement d’une interruption

On peut voir les différentes interruptions gérées par le noyau Linux dans le pseudo-fichier /proc/interrupts. Par exemple sur un Raspberry Pi 3, on observe les données suivantes.

# cat /proc/interrupts 
           CPU0       CPU1       CPU2       CPU3       
 16:          0          0          0          0  bcm2836-timer   0 Edge      arch_timer
 17:        974        466        348       1505  bcm2836-timer   1 Edge      arch_timer
 23:         18          0          0          0  ARMCTRL-level   1 Edge      3f00b880.mailbox
 24:          2          0          0          0  ARMCTRL-level   2 Edge      VCHIQ doorbell
 39:          1          0          0          0  ARMCTRL-level  41 Edge    
 46:          0          0          0          0  ARMCTRL-level  48 Edge      bcm2708_fb dma
 48:        267          0          0          0  ARMCTRL-level  50 Edge      DMA IRQ
 50:          0          0          0          0  ARMCTRL-level  52 Edge      DMA IRQ
 62:       8865          0          0          0  ARMCTRL-level  64 Edge      dwc_otg, dwc_otg_pcd, dwc_otg_hcd:usb1
 79:          0          0          0          0  ARMCTRL-level  81 Edge      3f200000.gpio:bank0
 80:          0          0          0          0  ARMCTRL-level  82 Edge      3f200000.gpio:bank1
 86:          4          0          0          0  ARMCTRL-level  88 Edge      mmc0
 87:        116          0          0          0  ARMCTRL-level  89 Edge      uart-pl011
 92:        456          0          0          0  ARMCTRL-level  94 Edge      mmc1
FIQ:              usb_fiq
IPI0:          0          0          0          0  CPU wakeup interrupts
IPI1:          0          0          0          0  Timer broadcast interrupts
IPI2:        323        610        369        468  Rescheduling interrupts
IPI3:          2          5          4          4  Function call interrupts
IPI4:          2          2          0          0  Single function call interrupts
IPI5:          0          0          0          0  CPU stop interrupts
IPI6:          1          0          0          0  IRQ work interrupts
IPI7:          0          0          0          0  completion interrupts
Err:          0
#

La première colonne représente le numéro de l’interruption (sauf pour celles du bas de la liste, FIQ et IPIx qui représentent des interruptions internes au processeur). Les quatre colonnes suivantes indiquent le nombre d’occurrence de chaque interruption depuis le boot du système sur chacun des quatre cœurs de processeur. Les valeurs sont faibles ici, le Raspberry Pi 3 vient de démarrer. Les colonnes suivantes affichent le type de contrôleur, un numéro interne à celui-ci, et le driver concerné.

Les fonctions de traitement bas-niveau sont déjà écrites dans le noyau Linux et nous n’avons pas à y toucher. Elles ont pour rôle d’appeler des fonctions de plus haut-niveau que l’on nomme routines de service (Interrupt Service Routine ou ISR), et ce sont ces routines de service que nous pouvons écrire dans nos drivers. Une fonction bas-niveau a également pour rôle de désactiver dans l’APIC l’interruption qui l’a déclenchée avant d’appeler la routine de service et de réactiver l’interruption ensuite. Imaginons le déclenchement d’une hypothétique interruption 100 :

Fig-02 - Handlers bas-niveau et ISR

Fig-02 – Handlers bas-niveau et ISR

Lorsque, dans le reste de cet article je parlerai – avec un léger abus de langage – de gestionnaire (ou de handler) d’interruption, il s’agira toujours d’une routine de service invoquée par une fonction de plus bas-niveau. Autrement dit, les schémas à venir ne représenteront plus les handlers bas-niveau, mais uniquement les routines ISR.

Fig-03 - Handler d'interruption

Fig-03 – Handler d’interruption

Précisons dès à présent qu’une fonction bas-niveau peut appeler successivement plusieurs routines de service en réponse à la même interruption. Par exemple dans le fichier /proc/interrupts ci-dessus, on voit que l’interruption 62 est traitée conjointement par trois routines de service appartenant aux drivers dwc_otg, dwc_otg_pcd, et dwc_otg_hcd appelées successivement. C’est ce qu’on nomme une interruption partagée.

Fig-04 - Interruption partagée

Fig-04 – Interruption partagée

Il est possible, lors de l’écriture d’un driver d’agir de différentes façons sur le traitement des interruptions.
On peut désactiver (on dit généralement “masquer“) ou activer (“démasquer“) le traitement d’une interruption. Si on masque une interruption, et qu’elle se produit effectivement, l’IRQ restera en attente au niveau de l’APIC, jusqu’à ce que le processeur démasque l’interruption. C’est à ce moment seulement qu’il recevra la demande en attente.

Fig-05 - Masquage d'interruption

Fig-05 – Masquage d’interruption

Il faut bien comprendre qu’une seule instance de l’interruption sera délivrée au moment du déblocage, même si elle est survenue à plusieurs reprises pendant la période de masquage.

Fig-06 - Occurrences d'interruption masquée

Fig-06 – Occurrences d’interruption masquée

Sur certains processeurs, il existe des interruptions non-masquables servant à la notification d’événements très urgents.
En outre certaines interruptions peuvent être plus prioritaires que d’autres. Du fait de la portabilité de Linux sur de nombreuses architectures, cette notion n’est pas prise en compte au niveau de l’API proposée aux drivers.

Un peu de pratique

Nous allons commencer quelques expériences avec les interruptions. J’ai choisi comme plate-forme d’illustration pour cet article le Raspberry Pi 3 en raison de sa grande disponibilité. Toutefois les exemples sont adaptables sur d’autres cartes. Durant des sessions de formation je les ai testés sur de nombreux autres systèmes (BeagleBoneBlack, Pandaboard, IGEPv2, i.MX6 Sabrelite, toute la gamme des Raspberry Pi, etc.) en adaptant les numéros de GPIO.

Dans notre premier exemple nous allons installer un petit gestionnaire d’interruption très simple qui inscrira lors de son déclenchement un message dans les traces du noyau. L’intérêt de travailler avec un Raspberry Pi est de pouvoir facilement gérer communications avec l’extérieur grâce aux GPIO, et de déclencher facilement des interruptions avec un simple morceau de fil électrique.

J’ai fait le choix, pour éviter de mettre en œuvre un environnement de cross-compilation qui compliquerait le propos de cet article, de faire toutes les compilations de modules du kernel directement sur le Raspberry Pi. La compilation d’un module prend deux à trois secondes sur un Raspberry Pi 3, ce qui est très raisonnable, même lorsque le nombre d’exemples est assez élevé. L’inconvénient, est que la compilation d’un module nécessite la présence des fichiers d’en-tête, du fichier de configuration et des Makefile du noyau cible. Or, les distributions Raspbian ne fournissent pas cet ensemble. Il existe bien un package linux-headers dans cette distribution mais il ne correspond pas du tout au noyau fourni.

Nous devons donc commencer par recompiler un noyau, opération très simple mais qui prend environ deux heures de compilation sur le Raspberry Pi 3. En partant d’une distribution Raspbian fraîchement installée, voici la suite de commandes à exécuter.

$ sudo apt update
$ sudo apt install -y ncurses-dev  bc
$ git clone https://github.com/raspberrypi/linux --depth 1
$ cd linux/
$ make bcm2709_defconfig
$ make -j 8     # C'est cette étape qui dure environ deux heures...
$ sudo make scripts
$ sudo make modules_install
$ sudo mkdir /boot/old-dtbs
$ sudo mv /boot/*dtb /boot/old-dtbs/
$ sudo make INSTALL_DTBS_PATH=/boot dtbs_install
$ sudo cp /boot/kernel7.img /boot/old-kernel7.img
$ sudo cp arch/arm/boot/zImage /boot/kernel7.img 
$ sudo reboot

Après redémarrage, on vérifie avec :

$ uname -a

que l’on se trouve bien sur notre noyau tout neuf.

Attention, le répertoire qui a servi pour la compilation sera référencé pendant la compilation des modules ultérieurs, et ne doit donc pas être déplacé, ni effacé. À la rigueur, on peut y faire un peu de ménage pour gagner de la place :

$ cd /lib/modules/$(uname -r)/build                                                                                  
$ rm -rf Documentation
$ find . -name '*.[coS]' | xargs rm -f

Ceci efface les fichiers sources C et assembleur ainsi que les fichiers objets. Il est important de conserver les fichiers d’en-tête .h et les fichiers Makefile. On gagne ainsi environ 700 Mo.

Handler d’interruption monolithique

Le cas le plus simple – et le plus courant – est celui du handler monolithique. Lorsque l’interruption se produit, le handler invoqué fait tout le travail attendu puis se termine et le processeur reprend son activité initiale.

Un handler d’interruption s’écrit en respectant un prototype bien défini (il existe d’ailleurs un typedef irq_handler_t pour représenter ce prototype) :

irqreturn_t irq_handler(int irq_num, void *irq_id);

Lorsque le handler est appelé, il recevra automatiquement deux arguments : le numéro de l’interruption qui l’a déclenché (utile lorsque le même handler gère plusieurs interruptions différentes), et un identifiant représenté par un pointeur générique. Nous fournirons cet identifiant lors de l’installation du handler. En général il s’agit d’un pointeur sur une structure de données personnalisées, contenant des informations propres à notre instance de driver.

Ce pointeur joue également un second rôle, notamment dans le cas d’une interruption partagée entre plusieurs périphériques : on fournit le même pointeur lors du retrait du driver afin d’indiquer quel handler doit être désinstallé.

Le handler doit également renvoyer une valeur de type irqreturn_t. Il s’agit d’un type énuméré pouvant prendre les valeurs IRQ_NONE ou IRQ_HANDLED (ainsi que la valeur IRQ_WAKE_THREAD que nous verrons dans le prochain article). En principe le handler doit interroger le matériel qu’il gère afin de savoir si l’interruption lui était bien destinée. Dans l’affirmative il renverra IRQ_HANDLED. Sinon, (il est probable que l’interruption soit partagée entre plusieurs drivers) il renverra IRQ_NONE.

Pour installer et désinstaller le handler, on utilise les routines suivantes :

int  request_irq(unsigned int irq_num, irq_handler_t irq_handler, unsigned long flags, const char *name, void *irq_dev);
void free_irq(unsigned int irq_num, void *irq_dev);

Il existe divers flags pour l’installation d’un handler, on les trouve dans le fichier <linux/interrupt.h>. En voici quelques-uns utilisés régulièrement :

  • IRQF_TRIGGER_RISING : déclenchement sur le front montant d’un signal logique.
  • IRQF_TRIGGER_FALLING : déclenchement sur le front descendant d’un signal logique.
  • IRQF_TRIGGER_HIGH : déclenchement tant qu’un signal logique est au niveau haut.
  • IRQF_TRIGGER_LOW : déclenchement tant qu’un signal logique est au niveau bas.
  • IRQF_SHARED : le handler accepte le partage de l’interruption avec d’autres handlers.
  • IRQF_NO_THREAD : l’interruption ne peut pas être threadée (nous en reparlerons ultérieurement) même avec le patch PREEMPT_RT.

Implémentation

Nous allons programmer un premier module simple, qui installera un handler pour une entrée GPIO. Lorsqu’un front montant se présentera sur cette broche, le handler enverra un message dans les traces du noyau. Le numéro de l’interruption associé à une borne GPIO donnée est obtenu avec la fonction gpio_to_irq().

J’ai choisi arbitrairement la broche 16 (GPIO 23, comme on peut le voir sur ce schéma du connecteur P1 du Raspberry Pi).

Téléchargeons et compilons les exemples de cet article :

$ git clone https://github.com/cpb-/Article-2017-06-06
$ cd Article-2017-06-06
$ make

Voici le listing du premier exemple :

/// \file test-irq-01.c
///
/// \brief Exemples de l'article "[KERNEL] Interruptions et tasklets" (https://www.blaess.fr/christophe/2017/06/05)
///
/// \author Christophe Blaess 2017 (https://www.blaess.fr/christophe)
///
/// \license GPL.

        #include <linux/gpio.h>
        #include <linux/interrupt.h>
        #include <linux/module.h>


        #define IRQ_TEST_GPIO_IN  23


static irqreturn_t irq_test_handler(int irq, void * ident)
{
        printk(KERN_INFO "%s: %s()\n", THIS_MODULE->name, __FUNCTION__);
        return IRQ_HANDLED;
}


static int __init irq_test_init (void)
{
        int err;

        if ((err = gpio_request(IRQ_TEST_GPIO_IN,THIS_MODULE->name)) != 0)
                return err;

        if ((err = gpio_direction_input(IRQ_TEST_GPIO_IN)) != 0) {
                gpio_free(IRQ_TEST_GPIO_IN);
                return err;
        }

        if ((err = request_irq(gpio_to_irq(IRQ_TEST_GPIO_IN), irq_test_handler,
                               IRQF_SHARED | IRQF_TRIGGER_RISING,
                               THIS_MODULE->name, THIS_MODULE->name)) != 0) {
                gpio_free(IRQ_TEST_GPIO_IN);
                return err;
        }

        return 0;
}


static void __exit irq_test_exit (void)
{
        free_irq(gpio_to_irq(IRQ_TEST_GPIO_IN), THIS_MODULE->name);
        gpio_free(IRQ_TEST_GPIO_IN);
}


module_init(irq_test_init);
module_exit(irq_test_exit);


MODULE_DESCRIPTION("Simple monolithic interrupt handler");
MODULE_AUTHOR("Christophe Blaess <Christophe.Blaess@Logilin.fr>");
MODULE_LICENSE("GPL");

Chargeons le module, et vérifions que le handler est bien installé :

$ sudo insmod test-irq-01.ko
$ cat /proc/interrupts 
           CPU0       CPU1       CPU2       CPU3       
   [...]
189:          0          0          0          0  pinctrl-bcm2835  23 Edge      test_irq_01
   [...]

On peut alors faire un contact entre la broche 16 (notre GPIO) et la broche 1 (le +3.3V) avec un petit fil par exemple :

$ cat /proc/interrupts
           CPU0       CPU1       CPU2       CPU3       
   [...]
189:         54          0          0          0  pinctrl-bcm2835  23 Edge      test_irq_01
   [...]

Cinquante quatre interruptions ! Et oui, ce n’est pas surprenant, un petit contact sec entre deux fils produit de nombreux rebonds très brefs. Vérifions les traces de notre handler :

$ dmesg
   [...]
[  177.947441] test_irq_01: irq_test_handler()
[  177.947541] test_irq_01: irq_test_handler()
[  177.947615] test_irq_01: irq_test_handler()
[  177.947675] test_irq_01: irq_test_handler()
[  177.947686] test_irq_01: irq_test_handler()
[  177.947696] test_irq_01: irq_test_handler()
[  177.947706] test_irq_01: irq_test_handler()
[  177.947728] test_irq_01: irq_test_handler()
[  177.947738] test_irq_01: irq_test_handler()
[  177.947747] test_irq_01: irq_test_handler()
   [...]
[  178.107233] test_irq_01: irq_test_handler()
[  178.107290] test_irq_01: irq_test_handler()
[  178.107322] test_irq_01: irq_test_handler()
[  178.107357] test_irq_01: irq_test_handler()
[  178.107376] test_irq_01: irq_test_handler()
$ sudo rmmod test_irq_01

Grâce à l’horodatage du printk(), nous voyons que les petits rebonds sont séparés par quelques dizaines de microsecondes seulement.

Différer un traitement

Nous avons parfaitement réussi à installer un handler simple qui se déclenche à chaque occurrence de l’interruption et s’exécute immédiatement et entièrement. Il est important de se souvenir que pendant toute l’exécution d’un handler, l’interruption qui l’a déclenché est masquée (sur tous les cœurs dans le cas d’un processeur multicœur).

Supposons à présent, que nous ayons un travail un peu plus conséquent à réaliser dans le handler, et que nous souhaitions par ailleurs horodater précisément l’occurrence de l’interruption. On pourrait très bien imaginer un transfert de données à effectuer, une vérification de checksum, voire un déchiffrement d’information encodées.

Pour simuler ceci, j’ai simplement ajouté dans le handler une attente active d’une milliseconde avec udelay(), après le printk() qui nous sert d’horodatage :

/// \file test-irq-02.c

   [...]

        #include <linux/delay.h>


        #define IRQ_TEST_GPIO_IN  23


static irqreturn_t irq_test_handler(int irq, void * ident)
{
        printk(KERN_INFO "%s: %s()\n", THIS_MODULE->name, __FUNCTION__);
        udelay(1000);
        return IRQ_HANDLED;
}

   [...]

Après chargement du module je réitère la même expérience en faisant un bref contact entre l’entrée GPIO et le +3.3V. Voici les traces des messages du kernel :

$ cat /proc/interrupts
           CPU0       CPU1       CPU2       CPU3       
   [...]
189:         57          0          0          0  pinctrl-bcm2835  23 Edge      test_irq_01
   [...]
$ dmesg
   [...]
[ 1297.485853] test_irq_02: irq_test_handler()
[ 1297.486866] test_irq_02: irq_test_handler()
[ 1297.487876] test_irq_02: irq_test_handler()

Cette fois nous n’avons que trois interruptions prises en compte, toutes séparées d’une milliseconde. Pour simplifier notre propos, je n’en ai représentées que deux sur le schéma suivant, mais le raisonnement est tout aussi valable. Nous savons que des rebonds se produisent et qu’une cinquantaine d’IRQ seront envoyées par l’APIC, toutes les vingt microsecondes environ. Après avoir horodaté son déclenchement, par un printk(), le handler monolithique effectue un travail consommant du temps CPU, une boucle active dans udelay(), pendant une milliseconde.

Fig-07 - Rafales d'interruptions monolithiques

Fig-07 – Rafales d’interruptions monolithiques

Sur le schéma ci-dessus, j’ai numéroté entre parenthèses les occurrences des interruptions. Nous voyons que la première est correctement traitée, avec un retard (on parle généralement de latence) de quelques microsecondes parfaitement justifié. Lorsque la deuxième arrive, l’interruption 189 étant masquée dans l’APIC, elle reste en attente et sera délivrée plusieurs centaines de microsecondes plus tard, une fois que le premier handler sera terminé. Nous avons donc un problème d’horodatage pour cette deuxième occurrence. La situation est pire pour la troisième interruption et les suivantes, puisque le masquage ne conservant qu’une seule occurrence d’IRQ, elles seront tout simplement perdues.

Il est clair que si une interruption périodique se produit toutes les vingt microsecondes dont le handler dure une milliseconde, le système n’est pas viable. Ce qui m’intéresse, c’est le cas où nous avons quelques déclenchements occasionnellement très rapprochés, mais que cela se produit suffisamment rarement pour permettre au système de fonctionner normalement le reste du temps. Et avec un handler monolithique, le résultat n’est pas satisfaisant : seule la première occurrence est correctement traitée, la seconde est mal horodatée, et les suivantes sont perdues !

Top-half et bottom-half

Alf

Une autre approche est possible, qui consiste à distinguer les opérations devant être exécutées immédiatement au déclenchement de l’IRQ, de celles qui peuvent être réalisées une fois que l’interruption aura été démasquée dans l’APIC. Les premières sont regroupées dans la partie supérieure (top half) du traitement, et les secondes dans la partie inférieure (bottom half).

Il existe plusieurs supports pour implémenter top half et bottom half. Dans cet article nous observerons les tasklets, dans le suivant nous verrons les workqueues et les threaded interrupts.

Tasklets

Contrairement à ce que leur nom – particulièrement mal choisi – laisse entendre, les tasklets ne sont pas des petites tâches. Il s’agit simplement d’un mécanisme permettant à un handler d’interruption de programmer l’exécution d’une fonction après avoir démasqué l’IRQ qui l’a déclenché. La fonction qui s’exécute doit être de type :

void tasklet_function(unsigned long arg);

On déclare une tasklet avec :

DECLARE_TASKLET(tasklet_name, tasklet_function, tasklet_arg)

La tasklet ainsi déclarée est implémentée par une structure tasklet_struct. Puis on peut programmer son exécution avec :

void tasklet_schedule(struct tasklet_struct *tasklet_name);

Lorsque le handler invoque tasklet_schedule(), nous avons plusieurs garanties :

  • La fonction de la tasklet sera exécutée (par abus de langage, on dit “la tasklet sera exécutée”) ultérieurement, le plus tôt possible après la fin du handler, sans passer par l’ordonnanceur (contrairement à ce que schedule dans tasklet_schedule() laisse entendre).
  • Si la tasklet est déjà programmée, mais n’a pas encore débuté, une seule instance sera exécutée.
  • Si la tasklet est déjà en cours d’exécution, une seconde instance sera exécutée après la fin de la première, sur le même cœur de CPU que celle-ci.
  • Si la tasklet n’est pas programmée ni en cours d’exécution, elle sera exécutée sur le même cœur de CPU que le handler qui invoque tasklet_schedule().

 

Attention, il est important au retrait du module de s’assurer qu’il n’y a pas de tasklet en cours ou en attente d’exécution (sur un autre cœur). Pour cela il faut appeler :

void tasklet_kill(struct tasklet_struct * tasklet_name);

Voyons comment se déroule le traitement d’une occurrence unique de l’interruption :

Fig-08 - Une interruption, une tasklet

Fig-08 – Une interruption, une tasklet

J’ai abrégé sur le schéma ci-dessus T.H. pour Top Half (le code qui est exécuté directement dans le handler d’interruption), et B.H. pour Bottom Half, le code ultérieurement exécuté dans la tasklet. Pas de surprise, le traitement est identique à celui d’un handler monolithique, avec un temps d’exécution très légèrement plus long (non représenté sur ce schéma) dû au mécanisme de programmation et d’invocation de la tasklet.

Supposons maintenant que deux interruptions très rapprochées se déclenchent. La Bottom Half de la première est interrompue par la Top Half de la seconde, et la deuxième Bottom Half s’exécutera à la suite.

Fig-09 - Deux interruptions, deux tasklets

Fig-09 – Deux interruptions, deux tasklets

L’avantage par rapport au handler monolithique, c’est que la seconde interruption est correctement horodatée. Intéressons-nous maintenant au cas de trois interruptions rapprochées :

Fig-10 - Trois interruptions, deux tasklets

Fig-10 – Trois interruptions, deux tasklets

À nouveau les trois interruptions sont correctement horodatées. Mais cette fois, le troisième tasklet_schedule() n’a pas d’effet car la seconde tasklet n’a pas encore démarré. C’est ce que confirme l’expérience suivante :

/// \file test-irq-03.c

  [...]
        static void irq_test_tasklet_function(unsigned long);

        static DECLARE_TASKLET(irq_test_tasklet, irq_test_tasklet_function, 0);


static irqreturn_t irq_test_handler(int irq, void * ident)
{
        printk(KERN_INFO "%s: %s()\n", THIS_MODULE->name, __FUNCTION__);
        tasklet_schedule(&irq_test_tasklet);
        return IRQ_HANDLED;
}


static void irq_test_tasklet_function(unsigned long unused)
{
        printk(KERN_INFO "%s: %s()\n", THIS_MODULE->,name, __FUNCTION__);
        udelay(1000);   
}
  [...]

Lorsqu’on fait à nouveau un bref contact entre la broche 16 et la broche 1, on observe :

$ dmesg
  [...]
[130129.690714] test_irq_03: irq_test_handler()
[130129.690734] test_irq_03: irq_test_handler()
[130129.690744] test_irq_03: irq_test_handler()
[130129.690776] test_irq_03: irq_test_tasklet_function()
[130129.690826] test_irq_03: irq_test_handler()
[130129.690857] test_irq_03: irq_test_handler()
[130129.690868] test_irq_03: irq_test_handler()
[130129.690877] test_irq_03: irq_test_handler()
[130129.690895] test_irq_03: irq_test_handler()
[130129.691814] test_irq_03: irq_test_tasklet_function()
  [...]

Mais alors, la situation n’est pas meilleure qu’avec un driver monolithique ! Somme nous condamnés à perdre la troisième interruption et les suivantes jusqu’au déclenchement de la tasklet ? Non. La situation n’est pas tout à fait la même. Car dans la Top Half, nous avons brièvement le contrôle, et pouvons par exemple incrémenter un compteur d’interruptions. La Bottom Half bouclera autant de fois qu’il y a d’interruptions reçues, et décrémentera le compteur. Voici un exemple d’implémentation :

/// \file test-irq-04.c
  [...]
        #include <linux/atomic.h>
        #include <linux/delay.h>
        #include <linux/gpio.h>
        #include <linux/interrupt.h>
        #include <linux/module.h>

  [...]
        static atomic_t irq_test_counter = ATOMIC_INIT(0);


static irqreturn_t irq_test_handler(int irq, void * ident)
{
        printk(KERN_INFO "%s: %s()\n", THIS_MODULE->name, __FUNCTION__);
        atomic_inc(&irq_test_counter);
        tasklet_schedule(&irq_test_tasklet);
        return IRQ_HANDLED;
}


static void irq_test_tasklet_function(unsigned long unused)
{
        printk(KERN_INFO "%s: %s()\n", THIS_MODULE->name, __FUNCTION__);
        do {
                printk(KERN_INFO "%s:%s() loop\n", THIS_MODULE->name, __FUNCTION__);
                udelay(1000);
        } while (atomic_dec_return(&irq_test_counter) > 0);
}
  [...]

Heureuse surprise, dès mon premier test, j’ai eu trois interruptions rapprochées et le résultat est concluant :

  [...]
[171984.092518] test_irq_04: irq_test_handler()
[171984.092569] test_irq_04: irq_test_handler()
[171984.092581] test_irq_04: irq_test_handler()
[171984.092595] test_irq_04: irq_test_tasklet_function()
[171984.092606] test_irq_04:irq_test_tasklet_function() loop
[171984.093617] test_irq_04:irq_test_tasklet_function() loop
[171984.094627] test_irq_04:irq_test_tasklet_function() loop
  [...]

Tasklet et contexte d’appel-système

Nous avons vu que, programmée depuis un contexte d’interruption, une tasklet est exécutée immédiatement sans repasser par l’ordonnanceur. Ceci est facile à vérifier, en modifiant légèrement le code de la tasklet ainsi :

/// \file test-irq-05.c

  [..]
static irqreturn_t irq_test_handler(int irq, void * ident)
{
        tasklet_schedule(&irq_test_tasklet);
        return IRQ_HANDLED;
}


static void irq_test_tasklet_function(unsigned long unused)
{
        printk(KERN_INFO "%s: current pid=%d comm=%s\n", THIS_MODULE->name, current->pid, current->comm);
}

Cette tasklet affiche le PID et le nom du processus current (celui actuellement ordonnancé sur le cœur de CPU où elle s’exécute). Nous voyons bien qu’il n’y a pas de tâche spécifique. Suivant les itérations différents processus où threads kernel sont actifs :

[198874.401602] test_irq_05: current pid=0 comm=swapper/0
  [...]
[198874.402142] test_irq_05: current pid=463 comm=rs:main Q:Reg
  [...]
[198874.402669] test_irq_05: current pid=90 comm=jbd2/mmcblk0p2-
  [...]
[198874.415554] test_irq_05: current pid=0 comm=swapper/0
  [...]
[198874.421986] test_irq_05: current pid=3 comm=ksoftirqd/0
  [...]
[198874.422530] test_irq_05: current pid=462 comm=in:imklog
  [...]
[198874.423017] test_irq_05: current pid=463 comm=rs:main Q:Reg
  [...]
[198874.423882] test_irq_05: current pid=0 comm=swapper/0
  [...]

Rien ne nous empêche néanmoins de programmer une tasklet depuis un contexte d’appel-système. Voici par exemple un module qui implémente un mini-driver proposant un unique appel-système write() qui ne fait qu’appeler notre tasklet :

/// \file test-irq-06.c
  [...]
        #include <linux/delay.h>
        #include <linux/interrupt.h>
        #include <linux/miscdevice.h>
        #include <linux/module.h>
        #include <linux/sched.h>

        static void irq_test_tasklet_function(unsigned long);

        static DECLARE_TASKLET(irq_test_tasklet, irq_test_tasklet_function, 0);


static void irq_test_tasklet_function(unsigned long unused)
{
        printk(KERN_INFO "%s: current pid=%d comm=%s\n", THIS_MODULE->name, current->pid, current->comm);
}


static ssize_t irq_test_write(struct file *filp, const char *buffer, size_t length, loff_t *offset)
{
        tasklet_schedule(&irq_test_tasklet);
        return length;
}


static struct file_operations irq_test_fops = {
        .owner = THIS_MODULE,
        .write = irq_test_write,
};

static struct miscdevice irq_test_misc = {
        .minor = MISC_DYNAMIC_MINOR,
        .name  = THIS_MODULE->name,
        .fops  = &irq_test_fops,
};


static int __init irq_test_init (void)
{
        return misc_register(&irq_test_misc);
}


static void __exit irq_test_exit (void)
{
        misc_deregister(&irq_test_misc);
}
  [...]

Pour déclencher l’appel-système, il nous suffit de faire une écriture avec un echo redirigé depuis la ligne de commande du shell.

$ sudo insmod test-irq-06.ko
$ echo 1 > /dev/test_irq_06 
-bash: /dev/test_irq_06: Permission denied
pi@raspberrypi:~/article-2017-06-06$ sudo -s
root@raspberrypi:/home/pi/article-2017-06-06# echo 1 > /dev/test_irq_06
root@raspberrypi:/home/pi/article-2017-06-06# echo 1 > /dev/test_irq_06
root@raspberrypi:/home/pi/article-2017-06-06# echo 1 > /dev/test_irq_06
root@raspberrypi:/home/pi/article-2017-06-06# dmesg
  [...]
[202648.772668] test_irq_06: current pid=15 comm=ksoftirqd/1
[202666.062771] test_irq_06: current pid=3 comm=ksoftirqd/0
[202668.352760] test_irq_06: current pid=3 comm=ksoftirqd/0

Lors de la programmation d’une tasklet depuis un contexte d’appel système, c’est donc un thread du kernel qui l’exécutera. Ces threads, nommés ksoftirqd sont parfaitement visibles dans la liste des tâches du système. Notons par ailleurs que ce n’est pas toujours le même thread, le noyau dispose d’un ensemble de ksoftirqd, et en sélectionne dynamiquement un disponible.

# ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.4  22780  3816 ?        Ss   Jun02   0:07 /sbin/init splash
root         2  0.0  0.0      0     0 ?        S    Jun02   0:00 [kthreadd]
root         3  0.0  0.0      0     0 ?        S    Jun02   0:05 [ksoftirqd/0]
root         5  0.0  0.0      0     0 ?        S<   Jun02   0:00 [kworker/0:0H]
root         7  0.0  0.0      0     0 ?        S    Jun02   0:17 [rcu_preempt]
root         8  0.0  0.0      0     0 ?        S    Jun02   0:00 [rcu_sched]
root         9  0.0  0.0      0     0 ?        S    Jun02   0:00 [rcu_bh]
root        10  0.0  0.0      0     0 ?        S    Jun02   0:00 [migration/0]
root        11  0.0  0.0      0     0 ?        S<   Jun02   0:00 [lru-add-drain]
root        12  0.0  0.0      0     0 ?        S    Jun02   0:00 [cpuhp/0]
root        13  0.0  0.0      0     0 ?        S    Jun02   0:00 [cpuhp/1]
root        14  0.0  0.0      0     0 ?        S    Jun02   0:00 [migration/1]
root        15  0.0  0.0      0     0 ?        S    Jun02   0:00 [ksoftirqd/1]
root        17  0.0  0.0      0     0 ?        S<   Jun02   0:00 [kworker/1:0H]
root        18  0.0  0.0      0     0 ?        S    Jun02   0:00 [cpuhp/2]

Conclusion

Nous avons vu le principe des tasklets pour réaliser un traitement différé depuis un handler d'interruption. Il existe d'autres mécanismes que nous verrons dans le prochain article. L'intérêt de la tasklet est d'être exécutée immédiatement, plus prioritairement que toutes les tâches ordonnancées du système. Ceci est toutefois différent si elle est programmée depuis un contexte d'appel système, puisqu'alors c'est un thread du kernel qui l'exécute.

Nous reviendrons sur cette exécution portée par un thread dans le prochain article, car cela peut nous réserver des surprises dans un contexte d'application temps réel...

4 Réponses

  1. Arthur LAMBERT dit :

    Article très sympa.

    J’ai deja pu voir dans des drivers similaires l’utilisation des kthread + semaphore (wait_event/wake_up) a la place des tasket. Ca serai bien d’en parlé

    C’est quoi l’avantage/inconvénient ou la raison de cet usage plutot que les tasklets ?

    • cpb dit :

      C’est prévu pour le prochain article. L’intérêt du thread personnalisé, c’est qu’on maîtrise sa priorité (temps réel). Ça sera utile pour remplacer les tasklets sur un noyau patché PREEMPT_RT.

  2. Louis Morge-Rollet dit :

    Salut,
    J’adore tes articles d’une très grandes qualités.
    Cependant, je n’arrive pas à comprendre un concept que tu expliques dès le début:
    Lorsque tu parles de handler bas-niveau, quel est leurs rôles, sauvegarder le contexte (avant l’éxécution du handler haut-niveau) et ensuite recharger le contexte (à la fin du handler bs-niveau) ou autres (masquer/démasquer l’interruption).
    Je n’ai vu que les interruptions dans un contexte microcontrôleur sans OS (style STM32 – Nucleo), il y a t-il des étapes en plus du au système d’exploitation ?
    Merci de votre réponse.

  3. Thierry dit :

    Merci, génial.
    J’avais déjà tester les ITs high level, mais trop lent pour une précision de l’ordre de 10-5s
    Je test ca dés que j’ai un moment.

URL de trackback pour cette page