Un peu de théorie

Dans un système d’exploitation conforme à la norme POSIX, un processus est une abstraction fondamentale qui représente une instance en cours d’exécution d’un programme. Cette abstraction permet au noyau de gérer simultanément plusieurs programmes, en leur donnant l’illusion d’exécuter sur du matériel dédié. Le noyau maintient un ensemble d’informations pour chaque processus afin d’en assurer la gestion correcte. Parmi les informations essentielles, on trouve le PID (Process Identifier), qui est un entier unique attribué à chaque processus et qui le distingue de tous les autres processus actuellement actifs dans le système. Chaque processus dispose également de son propre espace d’adressage virtuel, qui comprend le code du programme (le segment texte), les données globales et statiques, la pile d’exécution et le tas pour les allocations dynamiques. Cette isolation de l’espace d’adressage garantit que les processus ne peuvent pas interférer directement avec la mémoire les uns des autres, sauf si des mécanismes explicites comme les pipes ou la mémoire partagée sont mis en place. En outre, chaque processus possède une table des descripteurs de fichiers qui lui permet d’accéder aux ressources d’entrée-sortie. Cette table établit un lien entre des petits entiers (appelés descripteurs de fichiers) et des ressources du système telles que des fichiers, des sockets ou des pipes.

Structure interne d’un processus

Un processus UNIX possède une organisation mémoire bien définie qui comporte plusieurs segments distincts.

  • Le segment de code, également appelé segment texte, contient les instructions du programme compilées en langage machine. Ce segment est généralement marqué comme étant en lecture seule pour éviter toute modification accidentelle du code en cours d’exécution. Un point important est que si plusieurs instances du même programme s’exécutent, le noyau peut faire en sorte qu’elles partagent le même segment de code en mémoire physique, ce qui représente une économie significative de ressources.
  • Le segment de données contient toutes les variables globales et statiques qui ont été explicitement initialisées dans le code source.
  • Viennent ensuite le segment BSS (Block Started by Symbol), qui contient les variables globales et statiques non initialisées ou initialisées à zéro. Le noyau initialise automatiquement ce segment à zéro lors du chargement du processus, sans avoir besoin de stocker ces données dans le fichier exécutable, ce qui rend les fichiers binaires plus compacts.

Le tas (heap) est une zone de mémoire dynamique gérée par des fonctions comme malloc() et free(). Cette région croît vers les adresses mémoire hautes à mesure que le processus alloue de la mémoire. La pile (stack) est utilisée pour stocker les variables locales, les paramètres de fonctions, les adresses de retour et d’autres informations nécessaires à l’exécution des fonctions. Contrairement au tas qui croît vers le haut, la pile croît vers les adresses basses. Une caractéristique importante du tas et de la pile est qu’elles croissent l’une vers l’autre, et le noyau s’assure qu’elles ne se chevauchent jamais. Enfin, le processus maintient un contexte d’exécution qui comprend l’état actuel des registres du processeur, le compteur de programme (qui indique l’instruction actuellement exécutée), et d’autres informations similaires nécessaires à la gestion du processeur.

Processus, PID et fork

Sous UNIX, un processus est donc une instance en cours d’exécution d’un programme. Le noyau lui associe notamment :

  • un PID (Process ID) unique ;
  • un espace d’adressage virtuel (code, données, pile, tas) ;
  • une table des descripteurs de fichiers (0 = stdin, 1 = stdout, 2 = stderr, etc.).

La création d’un nouveau processus se fait par l’appel système fork() :

#include <sys/types.h>
#include <unistd.h>

pid_t fork(void);

La création d’un nouveau processus en UNIX se fait principalement par le biais de l’appel système fork(). Cet appel système est particulièrement élégant et efficace dans sa conception. Lorsqu’un processus appelle fork(), le noyau crée une copie du processus appelant. Après l’appel à fork(), deux processus distincts existent en mémoire : le processus père (celui qui a appelé fork()) et le processus fils (la copie nouvellement créée). Un point crucial est que les deux processus reprennent leur exécution exactement au même endroit, c’est-à-dire à la ligne immédiatement après l’appel à fork(). La seule différence est que fork() retourne des valeurs différentes dans chaque processus, ce qui permet au code d’identifier dans quel contexte il s’exécute.

Précisément, fork() retourne une valeur de type pid_t qui peut prendre trois formes distinctes. Dans le processus père, fork() retourne un entier strictement positif, qui est le PID du processus fils nouvellement créé. Cette valeur permet au père de communiquer avec son fils si nécessaire. Dans le processus fils, fork() retourne zéro, ce qui permet au fils de déterminer qu’il est la nouvelle copie. Enfin, si la création du processus échoue, fork() retourne -1 dans le père, ce qui signifie généralement que le système a manqué de ressources (par exemple, si le nombre maximal de processus autorisés a été atteint) ou que l’utilisateur ne dispose pas des permissions nécessaires. Le code source doit toujours tester la valeur de retour de fork() et gérer convenablement le cas d’erreur.

Donc fork() duplique le processus courant : on obtient un père et un fils. Ils reprennent l’exécution juste après l’appel à fork, mais fork retourne une valeur différente dans chaque processus :

  • > 0 : dans le père, c’est le PID du fils ;
  • == 0 : dans le fils ;
  • == -1 : erreur (pas de fils créé).

Copy-On-Write : ce qui se passe dans la mémoire

Lors de fork(), la mémoire du père n’est pas copiée intégralement. Le noyau marque les pages comme partagées en lecture seule. À la première écriture dans une page, une copie physique est faite (« Copy-On-Write »).

Conséquences :

  • Juste après fork, père et fils voient les mêmes valeurs, aux mêmes adresses virtuelles.
  • Dès qu’un des deux modifie une donnée, la page est dupliquée pour ce processus.
  • Les modifications dans le père ne se reflètent pas dans le fils (et inversement), sauf si l’on utilise des mécanismes explicites comme mmap en mode partagé.
flowchart TD A["Père : espace virtuel"] --> B["fork()"] B --> C["Tables de pages dupliquées<br/>(pages partagées RO)"] C --> D["Fils : même vue mémoire<br/>jusqu'à la première écriture"] D --> E["Écriture dans une page"] E --> F["Page fault (COW)"] F --> G["Copie de la page\n→ 2 pages physiques séparées"]

Plutôt que de copier intégralement la totalité de l’espace d’adressage du père vers le fils (ce qui serait extrêmement coûteux en termes de temps et de mémoire), le noyau marque les pages de mémoire partagées comme étant en lecture seule dans les tables des pages des deux processus. Les deux processus pointent donc initialement vers les mêmes pages physiques de mémoire. Tant que ni le père ni le fils n’écrit dans ces pages, elles demeurent partagées. Cependant, dès qu’un des deux processus tente d’écrire dans une page partagée, une exception appelée page fault est levée. Le noyau intercepte cette exception, alloue une nouvelle page physique, copie le contenu de la page originale dans cette nouvelle page, met à jour la table des pages du processus qui tentait d’écrire, et autorise l’écriture à se poursuivre. Après cette opération, les deux processus possèdent des copies distinctes de cette page, et les modifications apportées par l’un n’affectent plus l’autre.

Terminaison, zombies et wait

Lorsqu’un processus se termine, que ce soit via un appel explicite à exit() ou par un return de la fonction main, le noyau du système d’exploitation ne supprime pas immédiatement toutes les traces de ce processus. Au lieu de cela, le noyau conserve une entrée réduite dans la table globale des processus qui contient le PID du processus décédé, son code de retour, et diverses informations statistiques concernant son exécution telle que le temps processeur consommé.

Le processus se trouve alors dans un état particulier appelé état zombie ou processus défunt. Dans cet état, le processus n’occupe plus de ressources mémoire significatives (puisque son espace d’adressage a été libéré), mais son entrée dans la table des processus persiste. Cette entrée ne sera supprimée que lorsque le processus parent du processus décédé récupérera le statut de ce dernier au moyen d’un appel système adéquat.

Cette conception peut sembler contre-intuitive à première vue, mais elle répond à une nécessité importante. En conservant temporairement les informations sur le processus décédé, le noyau s’assure que le processus parent peut toujours accéder au code de retour du fils, ce qui est crucial pour vérifier si le fils s’est exécuté correctement ou non. Si le noyau supprimait immédiatement cette information, le père ne pourrait jamais savoir comment s’est déroulée l’exécution du fils. Pour remédier à cet état zombie et récupérer les informations associées au processus décédé, le processus parent doit appeler les fonctions wait() ou waitpid(). Ces deux appels systèmes permettent au père de suspendre son exécution jusqu’à ce que l’un de ses fils se termine. Lorsqu’un fils se termine, l’appel système retourne avec le PID du fils terminé et remplit une variable d’état qui peut être examinée pour déterminer comment le processus s’est terminé.

En simple wait() et waitpid() permettent au père :

  • de bloquer jusqu’à la terminaison d’un fils ;
  • de récupérer son code de retour et son mode de terminaison ;
  • de nettoyer l’entrée du zombie.
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *wstatus);
pid_t waitpid(pid_t pid, int *wstatus, int options);

Les macros WIFEXITED, WEXITSTATUS, WIFSIGNALED, etc., servent à interpréter wstatus.

sequenceDiagram participant Pere as Père participant Fils participant Noyau Pere->>Noyau: fork() Noyau-->>Pere: Retour PID fils Noyau-->>Fils: Copie du contexte Fils->>Noyau: exit(code) Noyau->>Fils: Passe en état Z (zombie) Pere->>Noyau: wait(&status) Noyau-->>Pere: PID + status Noyau->>Fils: Nettoyage de l'entrée

Signaux et sigaction

Les signaux constituent un mécanisme léger et asynchrone de communication entre processus ou entre le noyau et un processus. Un signal peut être envisagé comme une notification envoyée à un processus, typiquement pour l’informer qu’un événement s’est produit. Contrairement aux autres mécanismes de communication inter-processus qui nécessitent que le processus destinataire soit activement en attente, les signaux fonctionnent de manière asynchrone : un processus peut recevoir un signal à n’importe quel moment, même s’il n’y était pas préparé. Pour cette raison, les signaux sont fréquemment utilisés pour implémenter des interruptions logicielles et des gestions d’exceptions.

Un signal c’est une sorte de notification asynchrone envoyée à un processus (ou à un groupe). Exemples :

  • SIGINT : Ctrl+C ;
  • SIGTERM : demande d’arrêt propre ;
  • SIGFPE : exception arithmétique (division par zéro, overflow) ;
  • SIGSEGV : accès mémoire invalide ;
  • SIGUSR1, SIGUSR2 : signaux utilisateur.

Chaque signal a :

  • une action par défaut (terminer, ignorer, stopper, core dump…) ;
  • un gestionnaire possible, une fonction C appelée à la réception.

Lorsqu’un signal est envoyé à un processus, ce dernier effectue par défaut l’action associée à ce signal. Pour certains signaux comme SIGTERM, l’action par défaut est de terminer le processus. Pour d’autres comme SIGCHLD, l’action par défaut est simplement d’ignorer le signal. Cependant, une application peut installer un gestionnaire de signal (signal handler), qui est une fonction programmée par l’utilisateur que le noyau exécutera à la place de l’action par défaut lorsque le signal est reçu. Le gestionnaire reçoit le numéro du signal en tant que paramètre et peut alors effectuer les actions appropriées. L’installation d’un gestionnaire de signal se fait traditionnellement via la fonction signal(), mais l’interface moderne et préférée est sigaction(), qui offre un meilleur contrôle et un comportement plus prévisible.

Exemple :

#include <signal.h>

struct sigaction {
    void     (*sa_handler)(int);
    sigset_t sa_mask;
    int      sa_flags;
};

int sigaction(int signum, const struct sigaction *act,
              struct sigaction *oldact);

La structure sigaction contient plusieurs champs qui contrôlent le comportement du gestionnaire.

  • Le champ sa_handler est un pointeur vers la fonction qui sera exécutée lors de la réception du signal.
  • Le champ sa_mask est un ensemble de signaux qui seront bloqués (c’est-à-dire temporairement masqués) pendant l’exécution du gestionnaire lui-même. Cette fonctionnalité est importante pour éviter les situations où un signal identique est reçu alors que le gestionnaire original s’exécute encore, ce qui pourrait conduire à des conditions de course dangereuses.
  • Enfin, le champ sa_flags contient des drapeaux qui modifient le comportement du gestionnaire.
  • Le drapeau SA_RESTART est particulièrement utile : il indique au noyau que si le gestionnaire est exécuté pendant que l’application attendait le résultat d’un appel système (comme read() ou write()), l’appel système doit être automatiquement redémarré après l’exécution du gestionnaire, plutôt que de retourner une erreur EINTR.

Un point fondamental à retenir est que très peu de fonctions de la bibliothèque C sont sûres pour les signaux (async-signal-safe en anglais). Ces fonctions peuvent être appelées sans danger depuis un gestionnaire de signal. La fonction write() fait partie des fonctions sûres, car elle est implémentée directement comme un appel système. Cependant, des fonctions comme printf() ne sont pas sûres, car elles utilisent des buffers internes qui peuvent être dans un état incohérent au moment où le signal a été reçu. Pour l’affichage de messages de diagnostic depuis un gestionnaire de signal, il est donc nécessaire d’utiliser write() plutôt que printf(). La liste complète des fonctions sûres pour les signaux peut être consultée dans la page de manuel man 7 signal-safety.

Descripteurs de fichiers, pipe, dup2

Un pipe est un mécanisme de communication unidirectionnel entre deux processus. Contrairement à un fichier ordinaire, un pipe n’existe que pendant qu’au moins un processus l’utilise, et ses données résident en mémoire plutôt que sur le disque dur. Un pipe se crée au moyen de l’appel système pipe(), qui retourne deux descripteurs de fichiers : le premier pour lire depuis le pipe et le second pour écrire dans le pipe. Un point important est qu’un pipe n’a qu’une seule direction : les données écrites dans le descripteur d’écriture ne peuvent être lues que depuis le descripteur de lecture correspondant. De plus, les données lues d’un pipe sont consommées et disparaissent, contrairement à un fichier où des lectures répétées retournent le même contenu.

Un pipe se crée par :

#include <unistd.h>
int pipe(int pipefd[2]);
  • pipefd[0] : lecture ;
  • pipefd[1] : écriture.

Pour rediriger stdin/stdout vers un pipe, on utilise dup2 :

int dup2(int oldfd, int newfd);
  • Lorsqu’un processus tente de lire depuis un pipe vide, l’appel système read() se bloque jusqu’à ce qu’une donnée soit disponible.
  • Si tous les descripteurs d’écriture du pipe ont été fermés, read() retourne immédiatement avec une valeur indiquant la fin du fichier (EOF).
  • Inversement, si un processus tente d’écrire dans un pipe dont tous les descripteurs de lecture ont été fermés, le noyau envoie le signal SIGPIPE au processus, ce qui termine normalement le processus si ce signal n’est pas géré.

Pour fonctionner correctement, il est crucial que chaque processus ferme les descripteurs qu’il n’utilise pas. Par exemple, si le père et le fils créent tous deux un pipe, et que le père ne prévoit d’écrire que dans le pipe, alors le père doit fermer le descripteur de lecture. Inversement, le fils qui prévoit de lire doit fermer le descripteur d’écriture.

L’appel système dup2() permet de rediriger un descripteur de fichier vers un autre. Spécifiquement, dup2(oldfd, newfd) ferme d’abord newfd s’il était ouvert, puis fait en sorte que newfd pointe vers la même ressource que oldfd. Après cette opération, tant oldfd que newfd accèdent à la même ressource et partagent la position actuelle de lecture/écriture (offset). Cette fonctionnalité est extrêmement utile pour implémenter les pipelines de commandes que l’on trouve dans les interpréteurs de commandes (shells). Pour rediriger la sortie standard d’un processus vers un pipe, on appelle dup2(pipe_fd, STDOUT_FILENO). Dorénavant, tout ce que le processus écrit sur sa sortie standard (via printf() ou write(STDOUT_FILENO, ...)) est en fait écrit dans le pipe.

mmap : mémoire partagée

Pour les applications qui nécessitent une communication plus complexe entre processus, le mécanisme de mémoire partagée offre une solution efficace. L’appel système mmap() permet de projeter un fichier ou une région de mémoire anonyme dans l’espace d’adressage d’un processus. Cette projection signifie que le contenu du fichier (ou de la région de mémoire) devient directement accessible en tant que bytes en mémoire du processus. Toute modification apportée à la région projetée est automatiquement synchronisée avec le fichier sous-jacent (si un fichier existe).

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
  • MAP_SHARED : modifications visibles par tous les processus projetant cette zone.
  • MAP_ANONYMOUS + MAP_SHARED : zone de mémoire partagée sans fichier, idéale pour l’IPC père/fils.

Après fork, le père et le fils voient la même zone projetée (même pages physiques), contrairement aux variables globales classiques.

flowchart LR subgraph Noyau Z["Zone mmap partagée\n(pages physiques)"] end A["Père"] -- pointeur --> Z B["Fils (après fork)"] -- pointeur --> Z A -->|"*shared = 1"| Z B -->|"printf(%d, *shared)"| Z

Exercice 1 : Création de processus

Le premier exercice met en pratique les concepts élémentaires de création et de gestion de processus. Le but principal est d’apprendre à distinguer le processus père du processus fils en examinant la valeur de retour de fork(), et d’acquérir une compréhension concrète de la manière dont un processus parent attend la terminaison de ses fils et récupère les informations relatives à leur exécution.

Fichier : exo1_fork_wait.c

  1. Inclure les bons en-têtes : <stdio.h>, <stdlib.h>, <unistd.h>, <sys/types.h>, <sys/wait.h>.
  2. Dans main :
  • Afficher le PID courant avec getpid().
  • Appeler fork() et tester la valeur de retour.
  1. Dans le fils :
  • Afficher un message du type : « Je suis le fils, PID = …, PPID = … ».
  • Terminer avec exit(42).
  1. Dans le père :
  • Afficher le PID du fils.
  • Appeler wait(&status).
  • Vérifier avec WIFEXITED(status) que le fils s’est terminé normalement.
  • Afficher le code de retour avec WEXITSTATUS(status).
  1. Gérer les erreurs (fork qui renvoie -1, wait qui échoue).

Questions

  • Combien de fois la ligne immédiatement après fork() est-elle exécutée ?
  • Que se passe-t-il si vous supprimez le wait() dans le père (observez ps ou top pour voir les zombies) ?

Exercice 2 : Signaux avec sigaction

Cet exercice explore le mécanisme des signaux et leur interception via des gestionnaires de signal. L’objectif est d’acquérir une compréhension pratique de la manière dont les signaux fonctionnent de façon asynchrone, comment installer un gestionnaire approprié, et comment coordonner deux processus via l’envoi de signaux.

Fichier : exo2_signaux_usr.c

  1. Écrire une fonction handler :
static void handler_usr1(int sig) {
     // Incrémenter un compteur global
     // et écrire un message minimal
}
  1. Dans main :
  • Installer ce handler pour SIGUSR1 avec sigaction :
    • sigemptyset(&sa.sa_mask);
    • sa.sa_flags = SA_RESTART;
  • Appeler fork().
  1. Dans le fils :
  • Boucler sur pause() (ou sigsuspend) ;
  • à chaque SIGUSR1, le handler est exécuté et incrémente un compteur ;
  • après 5 signaux reçus, afficher un message et terminer avec exit(0).
  1. Dans le père :
  • Dès que le fils est créé :
    • faire une boucle qui envoie un kill(pid_fils, SIGUSR1); toutes les 2 secondes ;
    • lorsque le fils se termine (détecté par wait), afficher un message et sortir.
  1. Documenter dans les commentaires :
  • la différence entre signal et sigaction ;
  • le rôle de sa_mask (signaux bloqués pendant l’exécution du handler) ;
  • l’effet de SA_RESTART.

Exercice 2# : SIGFPE

Cet exercice court démontre comment intercepter et gérer les exceptions arithmétiques qui sont normalement fatales pour un processus. Une division par zéro, qui en C est techniquement un comportement non défini au niveau du langage, génère un signal SIGFPE au niveau du système d’exploitation. Cet exercice montre comment capturer ce signal et implémenter un diagnostic plus gracieux que l’arrêt brutal du programme. Ce mechansime peut aller plus loin notament en resumant le programme a une autre instruction.

Fichier : exo2b_sigfpe.c

  1. Écrire un handler pour SIGFPE :
#include <signal.h>
#include <unistd.h>

static void fpe_handler(int sig) {
     const char msg[] = "Erreur : division par zero detectee, arret.\n";
     write(STDERR_FILENO, msg, sizeof(msg) - 1);
     _exit(1); // sortie immédiate, sans passer par stdio
}
  1. Dans main :
  • Installer ce handler avec sigaction(SIGFPE, &sa, NULL) ;
  • Initialiser deux entiers, par exemple int x = 1; int y = 0;.
  • Afficher un message « Avant division » ;
  • Provoquer volontairement int z = x / y;.
  • Observer que la ligne suivante n’est jamais atteinte.
  1. Tester :
  • Version sans sigaction (commenter l’installation) : vous devez voir une terminaison par défaut (souvent Floating point exception (core dumped)).
  • Version avec sigaction : le handler affiche son message, puis _exit(1).

Exercice 3 : Pipe

Cet exercice introduit le mécanisme des pipes pour la communication unidirectionnelle entre processus. L’objectif est de comprendre le modèle producteur-consommateur implémenté avec des pipes et d’acquérir de la pratique dans la gestion des descripteurs de fichiers.

Fichier : exo3_pipe.c

  1. Déclarer int fd[2]; et appeler pipe(fd) (tester les erreurs).
  2. Appeler fork().

  3. Dans le fils :

  • Fermer fd[1] (extrémité écriture) ;
  • Lire sur fd[0] jusqu’à EOF avec read ;
  • écrire ce qui est lu sur STDOUT_FILENO ;
  • fermer fd[0] puis exit(0).
  1. Dans le père :
  • Fermer fd[0] (extrémité lecture) ;
  • écrire une chaîne de caractères (par exemple plusieurs lignes) via write(fd[1], ...) ;
  • fermer fd[1] pour signaler EOF au fils ;
  • attendre le fils avec wait.
  1. Ajouter dans le code une section de commentaires expliquant :
  • pourquoi il est obligatoire de fermer l’extrémité d’écriture dans le père pour que le fils voie l’EOF ;
  • ce qui se passe si le père ne ferme jamais fd[1] (le read du fils reste bloqué).

Exercice 4 : Pipeline

Cet exercice montre la notion de pipes en les combinant avec la redirection de descripteurs de fichiers pour implémenter un vrai pipeline comme celui qu’on trouve dans les interpréteurs de commandes. L’objectif est de comprendre comment dup2() permet à plusieurs processus de communiquer à travers une chaîne de traitement.

Fichier : exo4_pipeline.c

  1. Créer un pipe int p[2]; pipe(p);.
  2. Créer un premier fils (producteur) avec fork() :
  • Dans le producteur :
    • rediriger sa sortie standard vers p[1] avec dup2(p[1], STDOUT_FILENO) ;
    • fermer p[0] et p[1] (après dup2) ;
    • générer les entiers de 0 à 9 sous forme texte, un par ligne, sur stdout ;
    • terminer avec exit(0).
  1. Créer un deuxième fils (filtre) avec fork() :
  • Dans le filtre :
    • rediriger son entrée standard depuis p[0] avec dup2(p[0], STDIN_FILENO) ;
    • fermer p[0] et p[1] ;
    • lire des entiers depuis stdin (scanf ou parsing manuel) ;
    • n’afficher que les entiers pairs sur stdout ;
    • exit(0).
  1. Dans le père :
  • fermer p[0] et p[1] (le père ne les utilise pas) ;
  • attendre les deux fils avec wait.
  1. Documenter :
  • la différence entre un pipe « brut » (exercice 3) et un pipeline redirigeant stdin/stdout (ici) ;
  • l’effet exact de dup2 : partager la description de fichier (même offset, mêmes flags).

Exercice 5 : Mémoire partagée

Cet exercice final explore un mécanisme de communication inter-processus : la mémoire partagée via mmap(). L’objectif est de comprendre que les variables globales ordinaires ne sont pas partagées entre père et fils après un fork() à cause du mécanisme Copy-On-Write, tandis que la mémoire projetée avec mmap() et le drapeau MAP_SHARED offre un vrai partage mémoire.

Fichier : exo5_mmap.c
1. Appeler mmap pour allouer une zone partagée :

#include <sys/mman.h>

int *compteur = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (compteur == MAP_FAILED) {
    perror("mmap");
    exit(EXIT_FAILURE);
}
*compteur = 0;
  1. Appeler fork().

  2. Dans le fils :

  • boucle de 10 itérations :
    • incrémenter (*compteur) ;
    • afficher « Fils: compteur = … » ;
    • sleep(1) ;
  • terminer avec exit(0).
  1. Dans le père :
  • pendant que le fils travaille :
    • toutes les 500 ms, afficher « Père: compteur = … » ;
    • utiliser wait pour attendre la fin du fils ;
  • après wait, afficher la valeur finale de *compteur ;
  • appeler munmap(compteur, sizeof(int));.

Voici comment écrire un exercice pour exécuter une commande Python dans un processus fils, récupérer sa sortie dans le père et l’exploiter, en utilisant les mécanismes de redirection de pipe et execvp. Ce schéma illustre la notion de redirection des flux standard entre parent et fils sous UNIX, et la récupération du résultat textuel issu de l’interpréteur Python dans le programme C. Ce type de construction est classique pour intégrer une logique de scripting externe ou réaliser des calculs via un langage embarqué tout en conservant la logique principale dans le programme hôte.

Exercice 6 : execvp et pipe

Dans cet exercice, vous allez explorer un modèle fréquent dans la programmation système : lancer un sous-processus via fork et execvp (ici un interpréteur Python), intercepter dynamiquement la sortie standard de ce dernier grâce à un pipe, puis exploiter ce résultat dans le processus père en C.

L’objectif est double :

  • montrer comment intercepter la sortie standard (stdout) du programme remplacé par execvp (ici Python)
  • récupérer et utiliser en C le résultat d’un calcul effectué dans Python

Le processus père crée un pipe, puis engendre un fils. Ce fils redirige sa sortie standard vers l’extrémité écriture du pipe, puis exécute Python via execvp avec une commande qui réalise un calcul (par exemple, print(6*7)). Le père lit la chaîne renvoyée par Python sur l’extrémité lecture du pipe, et l’affiche ou l’utilise comme entier C.

  1. Créez un pipe avec pipe(fd).
  2. Créez un processus fils avec fork.
  3. Dans le fils :
  • Redirigez la sortie standard (STDOUT_FILENO) vers l’écriture du pipe avec dup2.
  • Fermez les descripteurs inutilisés.
  • Remplacez le code du fils par un interpréteur Python exécutant par exemple print(6*7) grâce à execvp.
  1. Dans le père :
  • Fermez l’extrémité écriture du pipe.
  • Lisez la sortie de Python depuis le pipe (par exemple avec read ou fgets).
  • Attendez la terminaison du fils.
  • Affichez la chaîne retournée par Python et/ou la convertissez en entier (avec atoi ou strtol).
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int fd[2];
    if (pipe(fd) == -1) {
        perror("Erreur pipe");
        exit(1);
    }

    pid_t pid = fork();
    if (pid == -1) {
        perror("Erreur fork");
        exit(2);
    } else if (pid == 0) {
        char *prog = "python3";
        char *args[] = {prog, "-c", "print(6*7)", NULL};
        execvp(prog, args);
    } else {
    }
    return 0;
}

Dans cette architecture, la sortie générée par la fonction Python (ici la chaîne "42\n") transite par le pipe depuis le processus fils jusqu’au processus père, où elle peut être traitée en toute flexibilité. Vous pouvez adapter la commande Python pour produire d’autres résultats à récupérer ainsi dans le programme C.