Fonctionnalités du noyau Linux (Episode 1) – Architecture et processus

Fonctionnalités du noyau Linux (Episode 1)

La tâche la plus difficile qu’on puisse concevoir en informatique est d’écrire un noyau de système d’exploitation. Seuls les développeurs les plus talentueux peuvent se lancer dans une telle tâche. Nous allons découvrir pourquoi en étudiant de plus près les fonctionnalités assurées par le noyau Linux, qui assure les fonctions d’un noyau Unix classique.

Le noyau Linux a été développé depuis 1991, initialement par un jeune informaticien finlandais nommé Linus Torvalds, puis au fil des années qui ont suivi avec la contribution de dizaines de milliers de développeurs dans le monde. Au fil des années, le noyau Linux est devenu ni plus ni moins que le plus grand projet collaboratif de l’histoire de l’humanité. La taille du code source du noyau Linux est réputée dépasser aujourd’hui plus de 40 millions de lignes de code : principalement du C, un peu d’assembleur, et une dynamique naissante pour y introduire du Rust.

Pour comprendre les fonctions qu’assure ce noyau, commençons par les aspects architecturaux de base.

La figure ci-dessus présente ce qui se passe sur une machine Linux allumée. La partie basse représente le matériel physique de la machine (clés USB, disques durs, cartes réseaux, clavier, souris, écran, etc.).

La partie milieu représente le noyau Linux. Seul ce noyau peut accéder au matériel, que ce soit en lecture ou en écriture.

La partie haute représente les applications qui tournent sur la machine (Firefox, etc.). Les applications ne peuvent en aucun cas accéder directement au matériel, elles ne peuvent que demander au noyau de le faire. Pour recueillir ces demandes, le noyau met à disposition des applications des appels systèmes, qui peuvent être appelés par les applications.

Parmi les applications, une a une importance particulière : le gestionnaire d’interface graphique. Historiquement les systèmes Unix, dont Linux, fonctionnaient avec un gestionnaire d’interface graphique appelé X-Window. X-Window avait des qualités : il était notamment nativement réseau, c’est-à-dire qu’une application lancée sur une machine A pouvait demander à apparaitre à l’écran d’une machine B. Pour cela il y avait une simple variable d’environnement à changer pour que le routage des fenêtres soit différent (on tapait par exemple export DISPLAY=machine_B :0 et l’application tournait sur la machine A mais ses fenêtres apparaissaient sur la machine B. C’était une époque très cool, on pouvait faire des plaisanteries envoyées sur les machines des copains). Mais X-Window avait aussi des défauts (parait-il), notamment de sécurité.

Sur les systèmes Linux modernes, Xfree86 (l’implémentation libre de X-Window) a donc été remplacé par Wayland, qui a les qualités et défauts inverses. Il n’a plus de problème de sécurité, il est probablement plus performant, mais il n’est plus réseau comme l’était X-Window.

Bref, quoi qu’il en soit, que l’on utilise X-Window ou Wayland, il y a toujours besoin de cette application un peu spéciale qu’est le gestionnaire d’interface graphique. Cette application gère les fenêtres graphiques et donc propose des primitives appelables par des environnements de bureau comme Gnome ou KDE, ou directement par vos applications graphiques si vous en programmez vous-même.

Boot du noyau Linux

Lorsqu’on allume un PC Linux, le BIOS/UEFI est le premier programme à être lancé. Ce programme lance éventuellement Grub, l’utilitaire de multi-boot (qui est assez récent lui aussi, cette fonction de multi-boot a longtemps été assurée par Lilo, avant que Grub n’existe). Si l’utilisateur choisit Linux dans Grub, alors le premier programme Linux qui est lancé est le noyau.

Le noyau est donc le premier programme chargé en mémoire RAM de la machine. Il s’initialise et se met dans une zone mémoire de haut privilège, la seule qui donne accès au matériel. Le noyau se verrouille dans cette zone, de sorte qu’aucun programme ne puisse venir ensuite lui contester ces hauts privilèges. Tout le reste de la mémoire RAM sera une zone de bas privilèges (pas d’accès au matériel).

Une fois le noyau chargé et verrouillé, il termine en passant la main au programme d’initialisation. Ce programme va lancer tous les autres programmes. Historiquement les noyaux Unix, dont Linux, lançaient le programme d’initialisation init system V. Ce programme respectait la philosophie KISS (Keep It Simple, Stupid), en faisait peu (lancer les autres programmes) mais le faisait bien.

Aujourd’hui le programme d’initialisation utilisé sur les systèmes Linux n’est majoritairement plus init system V. En effet, beaucoup de distributions Linux ont migré vers un autre programme d’initialisation : systemd. Systemd ne respecte pas le principe KISS, c’est une usine à gaz mais il est plébiscité car il permet non seulement d’initialiser mais aussi de fédérer les services tournant sur la machine. Ces fonctionnalités additionnelles sont appréciées des professionnels de l’IT, mais cette adoption éloigne de plus en plus Linux des systèmes Unix. A noter que certaines distributions Linux (Devuan qui est une Debian sans systemd, ou Slackware par exemple, qui est la plus ancienne distribution Linux en circulation) utilisent toujours init et non systemd. Et c’est triste à dire mais ces distributions sont, du coup, beaucoup plus véloces que les autres, notamment sur les vieux PC.

Gestion des processus

Jusqu’à présent nous avons parlé de programmes mais, vu du noyau, les programmes sont en réalité des processus.

Un processus est un programme chargé en mémoire.

Lorsqu’une machine Linux est allumée, il y a des dizaines, ou des centaines ou même parfois des milliers de processus qui sont lancés.

Pour donner aux utilisateurs l’illusion que tous ces processus tournent en même temps, le noyau leur donne la main à tour de rôle, selon une cadence si élevée qu’on a l’impression que tous les processus tournent en parallèle. Mais c’est faux : à un instant t donné, un seul processus est actif sur chaque micro-processeur de la machine (si la machine ne contient qu’un seul micro-processeur, seul un processus est actif à l’instant t).

Il arrive qu’on appelle un processus une tâche. C’est un autre vocabulaire pour dire la même chose. Linux est donc un noyau multi-tâches.

Les processus n’ont aucun pouvoir sur le fait de prendre ou de garder la main en terme d’exécution. C’est le noyau qui décide, seul, quand, pour combien de temps, et à quel processus il donne quel micro-processeur pour faire avancer l’exécution de ce processus. Pour réaliser cela, le noyau contient et utilise un scheduler. Le scheduler est donc un sous-ensemble très important du noyau qui orchestre le scheduling c’est-à-dire le fait de donner arbitrairement la main à chaque processus le nécessitant.

Comme ce ne sont pas les processus qui décident si ou même quand ils auront la main, mais le scheduler (et ce de manière préemptive, c’est-à-dire autoritaire), le noyau Linux est dit multi-tâches préemptif.

La préemption, c’est-à-dire le fait de retirer arbitrairement le micro-processeur à un processus donné, pour la lui redonner plus tard, quand son tour reviendra, est un acte complexe car il nécessite d’enregistrer l’état exact qui caractérise le processus à l’instant où on lui retire la ressource, et notamment toutes les valeurs numériques présentes dans tous les registres du micro-processeur au moment où l’on le lui retire. C’est ce que l’on appelle la sauvegarde du contexte.

Ainsi, lorsque le tour du processus revient à nouveau, le noyau effectue une restauration de contexte, c’est-à-dire notamment la remise de toutes les valeurs dans tous les registres du micro-processeur, avant de redonner la main au processus.

Vu d’un processus qui tourne sur la machine, la préemption n’existe pas. Le processus ne peut pas savoir qu’il a été arrêté et mis de côté n fois pendant un certain temps. Vu de lui, il exécute son programme de manière linéaire et continue. Seul le noyau a la vision extérieure juste.

Comme nous l’avons vu plus haut, init ou systemd lancent les autres processus. Le noyau attribuant un numéro unique à chaque processus (numéro appelé PID, pour Process Identifier), il attribue au processus d’initialisation le numéro 1. Systemd ou init ont donc le PID 1.

Chaque processus lancé ensuite a son propre PID unique. L’ensemble de ces PID forme un arbre, puisque chaque processus a non seulement un PID mais aussi un PPID, c’est-à-dire le PID du processus père qui l’a lancé.

La commande unix pstree permet de visualiser l’arbre des processus présents sur une machine Linux.

Ainsi un processus lancé par systemd aura un PID égal à 2 par exemple, et un PPID égal à 1 (puisque que c’est systemd qui l’a lancé). Tous les processus lancés directement par systemd ou init auront un PPID égal à 1, signalant ainsi que leur processus père est le processus de PID 1.

Pour visualiser le PPID des processus, on peut taper la commande ps -ef :

Les noyaux Unix, dont Linux, ont une mécanique très astucieuse pour gérer la création de processus, ce que nous avons appelé le lancement des processus jusqu’ici. En effet, toute la création de processus ne repose que sur 2 appels systèmes mis à disposition des applications par le noyau. Ces 2 appels systèmes sont fork et exec.

fork est un appel système qui demande au noyau de créer un processus clone du processus qui a déclenché cet appel système. Le processus clone créé sera identique au processus appelant en tout point, sauf qu’il sera le fils du processus appelant, ce qui signifie qu’il aura un PID qui lui est propre et unique, et un PPID qui sera le PID du processus appelant. Tout le reste est identique.

exec est un appel système qui demande au noyau d’écraser le code en mémoire du processus appelant par le code exécutable d’un fichier exécutable présent sur le disque dur.

Ces deux appels systèmes suffisent pour contrôler toute la création des processus. Ainsi lorsqu’on dit que systemd ou init lancent les autres processus au boot, en réalité ce qu’ils font c’est d’abord un appel système fork au noyau pour créer un fils identique (donc init ou systemd le fils), puis le fils exécute un appel système exec au noyau pour lui demander d’écraser son code en mémoire par celui d’un binaire présent sur le disque dur.

La gestion des processus par le noyau ne s’arrête pas là.

En effet, elle couvre aussi le suivi des fichiers ouverts par chaque processus.

Un processus peut avoir besoin d’accéder à un ou plusieurs fichiers présents sur le disque dur, soit en lecture, soit en écriture, soit les deux. Pour cela le noyau offre l’appel système open, qui permet cette ouverture de fichier, l’appel système read qui permet d’y lire, l’appel système write qui permet d’y écrire, et enfin l’appel système close qui permet de fermer le fichier quand on n’a plus besoin d’y accéder.

Ces appels système réalisent une protection basée sur des droits. Pour faire simple, chaque processus est tagué par le noyau comme appartenant à l’utilisateur qui l’a lancé (chaque utilisateur connu a donc lui aussi un numéro, l’UID pour User Identifier), et chaque fichier présent sur le disque a lui aussi un utilisateur propriétaire, et un groupe d’utilisateur propriétaire. L’appel système open n’acceptera d’aller ouvrir le fichier demandé que si l’UID du processus appelant correspond à des droits positionnés au niveau du fichier demandé.

La commande ls –l permet de visualiser les droits fichier dont nous parlons.

Dans l’exemple ci-dessus : rwx (lire, écrire, exécuter) pour l’utilisateur propriétaire, rwx pour le groupe propriétaire, rx (lecture, exécuter) seulement pour les autres utilisateurs.

Si l’appel système open fonctionne, dans le cas donc où l’on a bien les droits pour accéder au fichier demandé, alors le noyau ouvre une entrée dans la table des descripteurs de fichiers du processus appelant. Concrètement, si j’effectue un open(« /home/toto/fichier ») et que j’ai les droits d’accès sur ce fichier, cette commande va ramener par exemple la valeur 12, ce qui signifie que dorénavant la case 12 de la table des descripteurs de fichiers de mon processus pointe vers le fichier /home/toto/fichier. Si j’effectue un read(12, …) je lis dans ce fichier, si j’effectue un write(12, …) j’écris dans ce fichier, et si j’effectue un close(12); je ferme ce fichier.

Vu du noyau Linux, un processus est donc un objet complexe constitué de (si l’on récapitule) :

  • Un PID (pour identifier le processus d’un numéro unique)
  • Un PPID (pour identifier le processus père qui a lancé ce processus via un fork puis un exec)
  • Un contexte d’exécution (pour remettre les registres du micro-processeur dans le même état quand le scheduler rendra la main au processus)
  • Un UID (pour identifier l’utilisateur qui a lancé le processus, les processus système comme init, systemd, et leurs fils directs étant lancés par l’utilisateur root, qui a toujours comme UID la valeur 0)
  • Un GID (pour identifier le groupe d’utilisateur qui a lancé le processus, un utilisateur donné étant toujours dans des groupes, et a minima constitue un groupe à son nom à lui tout seul)
  • Une table des descripteurs de fichiers (pour pointer vers les fichiers ouverts par le processus)
  • Un corps du programme à exécuter (situé dans des pages virtuelles, nous y reviendrons dans un prochain épisode)

Mais vu du noyau Linux, un processus c’est aussi :

  • Des variables d’environnement propres au processus (héritées notamment des variables d’environnement du processus père). Vous pouvez visualiser les variables d’environnement d’un shell en tapant la commande env.
  • Un statut de scheduling (voir ci-dessous les états possibles des processus)
  • Des compteurs de toutes sortes traçant par exemple le temps cumulé de processeur qui a été alloué depuis le début de la création du processus
  • Des fonctions de traitement associées à la réception des signaux (nous y reviendrons dans un autre épisode)
  • Et beaucoup d’autres choses encore
Résultat de la commande env

Revenons sur les états possibles des processus. Sous Linux, comme sur tous les Unices (pluriel d’Unix), un processus donné peut être, vu du noyau, dans l’un des états suivants :

  • Etat R (Running) : le processus est en cours d’exécution sur un des micro-processeurs présents sur la machine ou prêt à s’exécuter et attend dans la file d’attente du processeur
  • Etat S (Interruptible Sleep) : le processus est en sommeil interruptible. Il attend un événement (par exemple une entrée utilisateur) et peut être réveillé par la réception d’un signal
  • Etat D (Uninterruptible Sleep) : le processus est en sommeil non interruptible. Il attend une opération d’entrée/sortie (comme par exemple une lecture sur le disque dur) et ne peut pas être interrompu, même par la réception d’un signal
  • Etat T (Stopped) : le processus est arrêté, généralement par la réception d’un signal particulier appelé SIGSTOP. Il peut être réactivé si l’on lui envoie le signal SIGCONT. Nous ferons ultérieur un épisode sur les signaux
  • Etat Z (Zombie) : le processus est zombie, c’est-à-dire qu’il est terminé mais le noyau le conserve dans sa table des processus pour que son processus parent puisse récupérer son code de retour. Nous ferons ultérieurement un épisode sur les processus zombie.

Bien évidemment, le scheduler se base sur l’état des processus pour déterminer ceux pour lesquels cela a du sens de leur donner la main.

Revenons enfin sur la table des descripteurs de fichiers dont dispose chaque processus, et qui se transmet par héritage aux processus fils par l’appel système fork. Il y a quelques points à préciser sur cette table.

Tout d’abord sa taille : elle n’a pas de taille fixe. Elle évolue au fil des besoins, si le processus ouvre beaucoup de fichiers. Un processus commence avec une table d’au moins 64 entrées. Lorsqu’elle doit grandir, sa nouvelle taille est arrondie à la puissance de 2 supérieure (de 64 on passe à 128, puis à 256 entrées, etc.). Chaque entrée occupe environ 50 octets en mémoire. Il y a une limite théorique autour d’un milliard d’entrées.

Ensuite sa structure : 3 entrées ont une importance particulière.

  • L’entrée 0 est appelée l’entrée standard (stdin). Une lecture par le programme du processus sur cette entrée lit généralement ce qui est tapé au clavier. Sauf si votre programme ou son processus père a changé cela via des appels système au noyau. C’est le cas notamment lorsque vous utiliser la fonctionnalité de pipe dans un shell. Si vous tapez dans un shell bash par exemple ls | wc (suite de commande qui permet de savoir combien il y a de fichiers dans un répertoire donné), le shell va faire les appels système qui vont bien au noyau pour re-router la sortie standard du processus fils 1 (ici ls) vers l’entrée standard du processus 2 (ici wc). Nous verrons toute cette mécanique dans les épisodes ultérieurs.
  • L’entrée 1 est appelée la sortie standard (stdout). Une écriture par le programme du processus sur cette entrée écrit généralement le texte à l’écran (sauf si on utilise un pipe)
  • L’entrée 2 est appelée l’erreur standard (stderr). Votre processus peut préférer écrire certains messages d’erreur sur l’erreur standard plutôt que sur la sortie standard.

Enfin, sous Unix, donc Linux, tout est fichier. Les entrées de la table des descripteurs de fichiers peuvent donc pointer vers des objets qui peuvent paraître étonnant au premier abord, par exemple :

  • Si une entrée de la table des descripteurs de fichiers pointe vers une socket réseau, ce que vous écrivez sur cette entrée par sur le réseau vers une autre machine (qui peut être à l’autre bout du monde)
  • Si une entrée de la table des descripteurs de fichiers pointe vers un périphérique, par exemple une imprimante, toute écriture par votre processus sur cette entrée écrit sur le périphérique
  • Si une entrée de la table des descripteurs de fichiers pointe vers un mécanisme de communication inter-processus (comme les pipes, que nous étudierons dans un prochain épisode), alors si votre processus lit ou écrit sur cette entrée les données sont échangées avec un autre processus de la même machine
  • Si une entrée de la table des descripteurs de fichiers pointe vers un fichier virtuel situé dans /proc ou /sys, alors la lecture sur cette entrée permet de lire des informations dynamiques du noyau

Nous arrivons au terme de ce premier épisode qui fait un tour d’horizon théorique de la gestion des processus sous Unix général et sous Linux en particulier. Dans un prochain épisode nous en verrons un tour d’horizon pratique, mais cela nécessitera d’introduire un minimum de compétences en développement en langage C au préalable.

Nous avons commencé par dire que la programmation d’un noyau Unix était la tâche la plus ardue qu’un programmeur informatique pouvait réaliser. Dans cet épisode nous n’avons que survolé ce que cela signifie vis-à-vis de la gestion des processus par le noyau, mais cela n’est qu’une toute petite partie de ce que fait un noyau complet.

Dans les épisodes suivants nous verrons le reste, à savoir :

  • La gestion de la mémoire (physique et vituelle) et du swap
  • La gestion des systèmes de fichiers
  • La gestion des périphériques et des modules
  • La gestion du réseau
  • Les appels systèmes
  • La gestion des threads
  • La gestion des signaux
  • La gestion des tubes (pipes)
  • La gestion des IPC (sémaphores, segments de mémoire partagées, queues de messages)

Stay tuned !

Pour aller plus loin :

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *