Blog ENI : Toute la veille numérique !
🐠 -25€ dès 75€ 
+ 7 jours d'accès à la Bibliothèque Numérique ENI. Cliquez ici
Accès illimité 24h/24 à tous nos livres & vidéos ! 
Découvrez la Bibliothèque Numérique ENI. Cliquez ici
  1. Livres et vidéos
  2. Programmation système
  3. Les processus
Extrait - Programmation système Maîtrisez les appels système Linux avec le langage C (Nouvelle édition)
Extraits du livre
Programmation système Maîtrisez les appels système Linux avec le langage C (Nouvelle édition) Revenir à la page d'achat du livre

Les processus

Processus et programme

En première approche, on peut considérer qu’un processus correspond à une instance d’exécution d’un programme.

1. Programme exécutable

Un programme exécutable est un fichier ordinaire (regular file), contenant des instructions en langage machine, une zone de données et éventuellement des informations permettant de le lier à des bibliothèques externes chargeables dynamiquement. Le tout est structuré selon un format de fichier spécifique, permettant au noyau de charger ces différents éléments pour l’exécution. Linux utilise le format de fichier ELF (Executable and Linking Format), commun à la plupart des systèmes de type Unix.

Avec les informations contenues dans le fichier, le noyau va pouvoir créer un processus en mémoire, en lui allouant diverses zones mémoire. Il charge la partie instructions exécutables dans une zone appelée « text segment », les données du fichier dans une partie appelée « data segment », initialise différentes structures de gestion du processus, puis prépare l’exécution de la première instruction du programme, son « point d’entrée ». Le programme ne commencera à s’exécuter que lorsque l’ordonnanceur...

Attributs d’un processus

Pour pouvoir gérer efficacement l’exécution d’un programme par un processus, le noyau maintient différents attributs associés à ce processus.

1. Identifiant du processus (PID)

Quand le noyau crée un processus, il lui attribue un numéro d’identification unique, sous forme d’un entier positif. Ce numéro, le PID (Process ID) identifie l’entrée du processus dans la table des processus gérée par le noyau. Il est garanti unique.

Au moment de la création du processus, le noyau lui attribue la première entrée disponible, en essayant de ne pas réutiliser un numéro récemment attribué. Quand le noyau atteint la limite maximale autorisée pour un processus (dépendant si le système est 32 ou 64 bits), il doit réutiliser des entrées libérées. Le noyau repart donc du bas de la table et cherche une entrée disponible.

Comme le PID d’un processus est unique, on peut exploiter cette caractéristique dans les programmes, par exemple pour générer un nom de fichier temporaire unique, en le suffixant avec le PID.

Parmi les PID, un a un rôle particulier, il s’agit du PID 1, qui est toujours attribué au programme init, lancé au démarrage du système et qui joue un rôle particulier vis-à-vis des autres processus.

Dans les versions récentes de distributions Linux, le rôle du processus init est assuré par le nouveau gestionnaire de démarrage, systemd. C’est lui qui a le PID 1.

2. Identifiant du parent du processus (PPID - Parent Process ID)

L’une des caractéristiques des systèmes de type Unix est qu’un processus est toujours créé à la demande d’un autre processus, son processus parent (parent process). Ce dernier, directement ou indirectement, provoque la création d’un processus enfant (child process) par l’appel système fork(), que nous étudierons dans la suite du chapitre.

En conséquence, les processus s’exécutant sur le système forment une arborescence, constituée de processus parents et de processus enfants : un processus n’a qu’un parent, un parent peut avoir plusieurs enfants.

À la racine...

Environnement d’un processus

Tout processus dispose d’une zone mémoire particulière appelée environnement. Elle est constituée d’un tableau, dont chaque entrée est constituée d’une chaîne de caractères de la forme "nom=valeur". Ces entrées correspondent aux variables d’environnement.

Un processus peut modifier le contenu de cette zone, en ajoutant, supprimant ou modifiant des entrées. Quand il crée un processus enfant, ce dernier hérite de cet environnement, qui est copié dans sa propre zone d’environnement. C’est ainsi qu’un processus peut transmettre des valeurs à son ou ses processus enfants.

Le shell utilise ce mécanisme pour gérer l’environnement d’un utilisateur, par différentes variables transmises aux commandes qu’il traite. Par exemple, la variable d’environnement PATH contient le répertoire courant du shell au moment de la création du processus, la variable TERM contient un identifiant de type de terminal.

1. Lire l’environnement : getenv()

Un programme C peut accéder au contenu de son environnement en récupérant le troisième argument passé à la fonction main(), un pointeur sur ce tableau de valeurs.

Si on veut seulement connaître le contenu d’une variable d’environnement dont on connaît le nom, on peut utiliser...

Créer un processus : fork()

Comme nous l’avons dit, un processus est toujours créé à partir d’un autre processus, exception faite du processus init (ou systemd) et de quelques processus exécutant des composants du noyau.

L’appel système permettant de demander la création d’un processus est fork(). Ses principales phases sont les suivantes :

  • Le processus exécute la fonction enveloppe fork(), qui n’attend aucun argument, et attend le retour de l’appel.

  • Le noyau duplique le processus pour créer un nouveau processus. Il partage la zone de code (text segment), puisque les deux processus exécutent le même code et que la zone mémoire est en lecture seule, et il duplique les autres zones (données, pile, tas, environnement).

Le noyau ne duplique pas vraiment les zones de données, il les partage en mode « copy on write » : il ne duplique effectivement une page mémoire d’une de ces zones qu’au moment où elle est accédée pour modification par l’un des deux processus. Cela permet d’accélérer considérablement la création d’un processus et d’optimiser l’occupation de la mémoire (seules les pages réellement différentes dans les deux processus sont distinctes en mémoire).

  • Le noyau modifie quelques attributs du processus enfant.

Le processus enfant est presque identique au processus parent, il n’en diffère que par les éléments suivants :

Son identifiant (PID), forcément différent puisque les identifiants de processus sont uniques.

Son identifiant de processus parent (PPID), qui a pour valeur l’identifiant du processus qui a fait l’appel fork().

Le code retour de l’appel système...

Gestion des attributs d’un processus

Un processus est associé à différents attributs, dont il peut consulter la valeur et, pour certains, la modifier.

1. Identifiants de processus : getpid(), getppid()

Un processus peut connaître son identifiant de processus et celui de son processus parent par les appels système getpid() et getppid().

Syntaxe

#include <unistd.h> 
pid_t getpid(void); 
pid_t getppid(void); 

Valeur retournée

Identifiant du processus ou du processus parent.

Ces appels n’échouent jamais.

Ces deux attributs ne sont pas modifiables.

2. Identifiants de groupe et de session : getpgid(), getsid()

Le processus peut déterminer l’identifiant de groupe de processus et l’identifiant de session d’un autre processus ou de lui-même, avec les appels système getpgid() et getsid().

Syntaxe

#include <unistd.h> 
pid_t getpgid(pid_t pid); 
pid_t getsid(pid_t pid); 

Argument

pid

PID d’un processus ou zéro pour le processus courant

Valeur retournée

-1

Erreur, code erreur positionné dans la variable errno

!= -1

Identifiant du groupe de processus ou de la session

Ces identifiants sont modifiables dans certaines conditions (voir la partie sur les groupes de processus et les sessions).

3. Identifiants d’utilisateurs : getuid()

Un processus peut savoir quels sont les comptes utilisateurs auxquels il est associé, avec les différents appels système de la famille getuid().

Syntaxe

#include <unistd.h> 
uid_t getuid(void); 
uid_t geteuid(void); 
int getresuid(uid_t *ruid, uid_t *euid, uid_t *suid); 

Valeur retournée

Les deux premiers appels système retournent l’identifiant utilisateur réel (getuid()) et effectif (geteuid()) du processus courant. Ils n’échouent jamais.

getresuid() écrit aux adresses fournies en argument : identifiant utilisateur réel, effectif et le setuid sauvegardé. Il retourne zéro ou -1 en cas d’erreur (et errno positionnée).

4. Identifiants de groupes d’utilisateurs : getgid()

Un processus peut savoir quels sont les groupes d’utilisateurs auxquels il est associé, avec les différents appels système de la famille getgid().

Syntaxe

#include <unistd.h> 
gid_t getgid(void); 
gid_t getegid(void); 
int getresgid(gid_t *rgid...

Terminaison d’un processus

Un processus se termine par l’appel système _exit(), directement ou indirectement. Cet appel système n’échoue jamais. Le noyau termine le processus, libère toutes les ressources qui lui étaient allouées, mais peut conserver, dans certains cas, son entrée dans la table des processus.

1. Appel système _exit()

L’appel système prend comme argument une valeur entière qui sera mise à la disposition du processus parent par le noyau.

Le processus peut utiliser l’appel système _exit() ou la fonction enveloppe exit(), qui effectue quelques opérations supplémentaires avant de demander l’exécution de l’appel système proprement dit.

Syntaxe

#include <unistd.h> 
#include <stdlib.h> 
void _exit(int status); 

Valeur retournée

L’appel n’échoue jamais et ne retourne donc rien, puisqu’il termine le processus. La valeur passée en argument pourra être transmise au processus parent par le noyau. Plus précisément, bien que l’argument soit de type entier, seul le dernier octet (celui de poids faible) sera transmis.

Bien que le programmeur soit libre de passer la valeur de son choix, une convention Unix veut qu’un processus qui se termine correctement retourne zéro à son processus parent. Les normes SUS proposent d’ailleurs...

Relations entre processus parent et enfant

Nous avons vu que, lorsqu’un processus se termine par l’appel système _exit(), le noyau transmet une valeur au processus parent. Ce code retour du processus est un entier sur 16 bits. Par défaut, tant que le processus parent ne s’est pas mis en état de recevoir cette information, le noyau maintient une entrée pour le processus enfant dans sa table des processus. Par contre, toutes les ressources allouées au processus enfant (zones mémoire, descripteurs de fichiers, verrous, etc.) sont libérées. On peut donc considérer que ce processus n’a plus aucune réalité, même s’il figure encore dans la table des processus du noyau. Cet état intermédiaire est appelé état zombie, le processus n’étant ni « vivant » (on ne peut pas le terminer par un appel système kill()) ni « mort » (son identifiant de processus est toujours attribué).

1. Appel système wait()

Pour éviter ce phénomène, le processus parent peut exécuter l’un des appels système de la famille wait(). Dans ce cas, quand le processus enfant se termine, le processus parent sort de l’appel système, reçoit comme code retour l’identifiant du processus enfant terminé, et, dans la zone mémoire qu’il a fournie en argument, la valeur passée à l’appel _exit() par le processus enfant.

Quand le processus parent sort de son appel wait(), le noyau libère l’entrée du processus enfant dans la table des processus, et le « zombie » disparaît.

Syntaxe

#include <sys/wait.h> 
pid_t wait(int *status); 
pid_t waitpid(pid_t pid, int *status, int options); 

Arguments

status

Adresse d’un entier recevant la valeur de terminaison du processus enfant

pid

Valeur entière positive ou négative indiquant le ou les processus à attendre

Options

Options d’attente

Valeur retournée

L’appel wait() retourne -1 s’il échoue et positionne le code erreur système dans errno. La cause d’échec la plus fréquente est qu’il n’y a pas ou plus de processus enfant à attendre, dans ce cas errno vaut ECHILD.

S’il réussit...

Processus zombie

Comme nous l’avons vu, un processus zombie est un processus qui a exécuté un appel système _exit(), mais dont le processus parent n’a pas encore fait d’appel système wait() pour effectivement recevoir l’information de terminaison de son processus enfant (et n’a pas spécifié qu’il ignorait le signal SIGCHLD). 

Le noyau a libéré les ressources affectées au processus enfant (mémoire, fichiers ouverts, verrous, etc.), mais il garde son entrée dans la table des processus, pour conserver les informations concernant sa consommation de ressources, ainsi que son code de terminaison.

Le processus n’ayant plus d’existence réelle, on ne peut demander sa terminaison par l’envoi d’un signal (commande kill ou appel système kill(), par exemple), y compris le signal de terminaison impérative (signal 9). En soi, ce n’est pas catastrophique, puisque le processus ne consomme pas de ressources. Cependant, le nombre d’entrées dans la table des processus est limité, ce qui peut poser un problème si le processus parent boucle et ne cesse de créer des processus enfants, sans jamais faire de wait(). Dans ce cas, il sera tôt ou tard confronté à diverses limites :

  • Le nombre maximum de processus par compte utilisateur non privilégié

  • La valeur maximale...

Chargement et exécution d’un programme externe

Très souvent, un processus enfant est créé dans le but de lui faire charger et exécuter un programme différent de celui du processus parent. C’est par exemple de cette façon que le shell gère les commandes externes, c’est-à-dire celles associées à un fichier exécutable.

L’appel système execve() permet de gérer ce mécanisme.

1. Appel système execve()

Syntaxe

#include <unistd.h> 
int execve(const char *pathname, char *const argv[], char *const 
envp[]); 

Arguments

pathname

Chemin d’accès du fichier exécutable à charger et à exécuter

argv

Adresse du tableau d’arguments pour le programme à exécuter

envp

Adresse du tableau d’environnement pour le programme à exécuter

Valeur retournée

-1

Erreur, code erreur positionné dans la variable errno

Cet appel système ne retourne jamais en cas de succès.

Description

Cet appel système a pour but de remplacer le programme qu’exécute le processus courant par un nouveau programme contenu dans le fichier exécutable dont on lui indique le chemin d’accès. S’il réussit, il termine le programme en cours et ne retourne rien. Les lignes de code se situant après l’appel système ne sont traitées qu’en cas d’échec de l’appel.

Le fichier chargé peut être un fichier binaire en format exécutable (ELF), ou un fichier texte à faire exécuter par un interpréteur. Dans ce dernier cas, le noyau chargera cet interpréteur dans le processus courant et lui passera en argument le chemin d’accès du fichier script indiqué.

Les autres arguments, argv et envp, correspondent à ceux reçus par la fonction main(). Ils doivent donc correspondre à des tableaux de chaînes de caractères ou à un pointeur NULL.

Le tableau d’arguments de ligne de commande devrait contenir le nom du programme comme premier élément.

Le tableau d’environnement devrait contenir des chaînes de caractères de la forme "Nom=Valeur".

Si le fichier spécifié a le setuid bit ou le setgid bit positionné, l’identifiant...