Un programme est un fichier binaire exécutable stocké sur le disque. C’est le résultat de la compilation d’un code source : une suite d’instructions machine, de données initialisées, et de métadonnées (format ELF sous Linux, PE sous Windows). Un programme est statique et inerte : il ne fait rien tant qu’il n’est pas lancé.

Format ELF (Executable and Linkable Format) : Le format standard des exécutables sous Linux. Il contient plusieurs sections : .text (code), .data (données initialisées), .bss (données non initialisées), .rodata (constantes), ainsi que des en-têtes décrivant comment charger le programme en mémoire. Vous pouvez inspecter un ELF avec readelf -a ./programme ou objdump -d ./programme.

Un processus est une instance en cours d’exécution d’un programme. C’est une entité vivante, gérée par le système d’exploitation, qui possède :

  • Un espace d’adressage virtuel propre (mémoire isolée)
  • Un état d’exécution (registres CPU, compteur de programme)
  • Un identifiant unique : le PID (Process ID)
  • Des ressources allouées (fichiers ouverts, sockets, etc.)
  • Un contexte de sécurité (utilisateur, groupe, permissions)
flowchart TB subgraph PROG["PROGRAMME (fichier sur disque)"] direction TB P1["hello (ELF)<br/>Statique, inerte"] end PROG -->|"./hello (exécution)"| PROCESS subgraph PROCESS["PROCESSUS (en mémoire)"] direction TB P2["PID: 1234<br/>État: Running<br/>Mémoire: 0x00400000 - 0x7FFFFFFF<br/>Fichiers ouverts: stdin, stdout, stderr<br/>Utilisateur: Canard (UID 1000)"] end style PROG fill:#e8e8e8,stroke:#333 style PROCESS fill:#d4edda,stroke:#28a745

Un même programme peut donner lieu à plusieurs processus simultanés. Par exemple, lancer deux fois htop crée deux processus distincts avec des PID différents, chacun avec sa propre mémoire. De plus, un processus peut charger et exécuter un autre programme via la famille de fonctions exec.

Le noyau gère les processus via une structure de données appelée PCB (Process Control Block) ou task_struct sous Linux. Cette structure contient toutes les informations sur le processus : état, registres sauvegardés, informations mémoire, fichiers ouverts, statistiques, etc. Chaque processus a une entrée dans la table des processus du noyau.

Création de processus

L’appel système fork() est le mécanisme fondamental de création de processus sous Unix. Il duplique le processus appelant, créant un processus enfant quasi-identique au parent. Plutôt que d’avoir un appel système complexe pour créer un processus avec des paramètres, Unix sépare les responsabilités : fork() duplique, exec() remplace. Cette séparation permet de configurer l’enfant (redirections, environnement) avant d’exécuter le nouveau programme. C’est élégant, composable mais pas le plus performant. Linux propose aussi clone(), un appel système plus flexible permettant de contrôler finement ce qui est partagé (mémoire, fichiers, signaux).

Exemple:

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

int main() {
    printf("Avant fork: PID = %d\n", getpid());

    pid_t pid = fork();

    if (pid < 0) {
        // Erreur : fork a échoué
        perror("fork");
        return 1;
    } else if (pid == 0) {
        // Code exécuté par l'ENFANT
        printf("Enfant: mon PID = %d, mon parent = %d\n", getpid(), getppid());
    } else {
        // Code exécuté par le PARENT
        printf("Parent: mon PID = %d, PID enfant = %d\n", getpid(), pid);
    }

    printf("Ce message s'affiche deux fois (PID=%d)\n", getpid());
    return 0;
}
  • Comportement de fork() :
    • Retourne 0 dans le processus enfant
    • Retourne le PID de l’enfant dans le processus parent
    • Retourne -1 en cas d’erreur (plus de ressources, limite atteinte)
    • Après fork(), qui s’exécute en premier ? C’est indéterminé. L’ordonnanceur décide.
  • Hérité (copié)
    • Espace mémoire (via CoW)
    • Descripteurs de fichiers ouverts
    • Variables d’environnement
    • Répertoire de travail courant
    • Masque de signaux
    • UID, GID effectifs
  • Non hérité
    • PID (nouveau et unique)
    • PPID (parent PID = PID du parent)
    • Verrous de fichiers (non copiés)
    • Signaux en attente (remis à zéro)
    • Temps CPU (remis à zéro)

Organisation mémoire

Chaque processus “croit” disposer de toute la mémoire pour lui seul (de 0x00000000 à 0xFFFFFFFF sur 32 bits). En réalité, le noyau et la MMU (Memory Management Unit) traduisent ces adresses virtuelles en adresses physiques. Deux processus peuvent avoir la même adresse virtuelle pointant vers des emplacements physiques différents. C’est ce qui garantit l’isolation mémoire entre processus.

Cette espace d’adressage virtuel est organisé en segments distincts :

Adresses hautes (0xFFFFFFFF sur 32 bits)
┌──────────────────────────┐
│       Kernel Space       │  ← Réservé au noyau (inaccessible)
├──────────────────────────┤
│          Stack           │  ← Variables locales, adresses retour
│            ↓             │    Croît vers le bas
│          .....           │
│            ↑             │
│           Heap           │  ← malloc(), allocation dynamique
│                          │    Croît vers le haut
├──────────────────────────┤
│           BSS            │  ← Variables globales non initialisées
├──────────────────────────┤
│          Data            │  ← Variables globales initialisées
├──────────────────────────┤
│          Text            │  ← Code exécutable (read-only)
└──────────────────────────┘
Adresses basses (0x00000000)

La direction de croissance Heap/Stack dépend de l’architecture ! Sur x86/x86_64, la stack croît vers les adresses basses. Sur certaines architectures (PA-RISC, certains ARM), elle peut croître vers le haut. Si la pile grandit trop (récursion infinie, tableaux locaux gigantesques), elle peut “déborder” et entrer en collision avec le heap ou atteindre sa limite. Le système envoie alors un signal SIGSEGV. La taille par défaut de la stack est souvent de 8 Mo (ulimit -s). Attention la gestion de la stack est liée non pas au systeme mais au language de programmation, il existe donc des cas exotiques.

États d’un processus

Un processus passe par différents états durant son cycle de vie :

stateDiagram-v2 [*] --> New: Création New --> Ready: Admis Ready --> Running: Scheduler Running --> Ready: Préemption / quantum épuisé Running --> Blocked: I/O ou événement Blocked --> Ready: I/O terminé / événement reçu Running --> Terminated: exit() / signal fatal Terminated --> [*]: wait() par parent note right of New: Processus en cours de création note right of Ready: Prêt, attend le CPU note right of Running: En exécution sur un CPU note right of Blocked: Attend I/O, signal, verrou note right of Terminated: Zombie, attend wait()

L’ordonnanceur (scheduler) du noyau choisit quel processus Ready passe en Running selon sa politique (priorité, round-robin, etc.). Chaque processus reçoit une tranche de temps CPU (typiquement 1-40 ms). À expiration, l’ordonnanceur peut décider de passer à un autre processus. C’est la préemption qui permet le multitâche même avec un seul CPU, tout du moins sont illusion lorsqu’il n’y a qu’un seul CPU. Mais cela permet aussi de gagner du temps, par exemple executer un autre processus pendant que le premier est en attente sur une commande (ex sleep(2);).

Les trois descripteurs standards

Descripteur Constante Usage
0 STDIN_FILENO Entrée standard
1 STDOUT_FILENO Sortie standard
2 STDERR_FILENO Erreur standard

Copy-on-Write (CoW)

Bien que fork() duplique le processus, le noyau n’effectue pas une copie physique immédiate de toute la mémoire. Il utilise le mécanisme Copy-on-Write : les pages mémoire sont partagées en lecture seule entre parent et enfant. Une copie réelle n’a lieu que lorsqu’un des deux tente de modifier une page. Cela rend fork() bien plus efficace qu’une copie complète.

Sans CoW, un fork() d’un processus utilisant 1 Go de RAM copierait 1 Go. Avec CoW, seules les tables de pages sont dupliquées (quelques Ko). La copie effective n’a lieu qu’au moment de l’écriture, page par page (4 Ko typiquement). Quand un processus tente d’écrire sur une page CoW, la MMU (memory management unit) génère une page fault, c’est un signal. Le noyau intercepte, copie la page, met à jour les tables de pages, puis laisse l’écriture se faire. Transparent pour le processus ! Grace au mecanisme d’addresse virtuel.

flowchart TB subgraph BEFORE["Après fork - Copy-on-Write"] direction TB P1["Parent<br/>PID: 100"] E1["Enfant<br/>PID: 101"] subgraph MEM1["Pages mémoire (read-only)"] direction LR PG1["Page1"] PG2["Page2"] PG3["Page3"] end P1 -->|"pointeurs partagés"| MEM1 E1 -->|"pointeurs partagés"| MEM1 end style P1 fill:#d4edda,stroke:#28a745 style E1 fill:#cce5ff,stroke:#004085 style MEM1 fill:#f8f9fa,stroke:#6c757d
flowchart TB subgraph AFTER["Après modification par l'enfant"] direction TB P2["Parent<br/>PID: 100"] E2["Enfant<br/>PID: 101"] subgraph MEMP["Mémoire Parent"] direction LR PGA["Page1"] PGB["Page2"] end subgraph MEME["Mémoire Enfant"] direction LR PGC["Page1' (copie)"] PGD["Page2"] end P2 --> MEMP E2 --> MEME PGB <-.->|"partagé"| PGD end style P2 fill:#d4edda,stroke:#28a745 style E2 fill:#cce5ff,stroke:#004085 style PGC fill:#f8d7da,stroke:#721c24

Gestion des processus

Après avoir créé un processus avec fork(), il faut savoir le gérer : attendre sa terminaison, récupérer son code de retour, lui faire exécuter un autre programme, ou rediriger ses entrées/sorties. Cette section couvre les outils essentiels du cycle de vie d’un processus :

  • wait() / waitpid() : Attendre la fin d’un processus enfant et récupérer son statut
  • exec() : Remplacer le programme en cours par un autre
  • dup2() : Rediriger les entrées/sorties (stdin, stdout, stderr)

Ces trois mécanismes, combinés avec fork(), permettent d’implémenter par exemple un shell complet.

Attendre un processus

Quand un processus enfant se termine, il devient un zombie : il a fini son exécution mais son entrée dans la table des processus persiste pour que le parent puisse récupérer son statut de sortie. Dans la plupart des cas c’est un état transitoire très rapide. Le parent doit appeler wait() ou waitpid() pour récupérer ce statut et libérer les ressources. Si un parent crée des milliers d’enfants sans jamais faire wait(), la table des processus se remplit. Limite typique : 32768 PIDs (cat /proc/sys/kernel/pid_max).

La fonction wait() retourne les informations associées au premier enfant qui se termine, sans distinction. Tandis que waitpid() permet d’attendre un enfant en particulier. Depuis le début du cours de C, nous avons toujours vu return 0; à la fin de notre programme, via la fonction main qui est le point d’entrée. C’est une convention et il existe quelques macros à disposition pour savoir ce qui s’est passé.

Par exemple :

Macro Description
WIFEXITED(status) Vrai si terminé normalement (exit/return 0)
WEXITSTATUS(status) Code de retour (si WIFEXITED, exit/return != 0 )
WIFSIGNALED(status) Vrai si tué par un signal
WTERMSIG(status) Numéro du signal (si WIFSIGNALED)
WIFSTOPPED(status) Vrai si stoppé (SIGSTOP)
WSTOPSIG(status) Signal de stop (si WIFSTOPPED)

Utilisation :

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // Enfant : travaille puis termine avec code 42
        printf("Enfant: je travaille...\n");
        sleep(2);
        printf("Enfant: je termine\n");
        exit(42);  // Code de sortie
    } else {
        // Parent : attend l'enfant
        int status;
        pid_t terminated = wait(&status);

        if (WIFEXITED(status)) {
            printf("Enfant %d terminé avec code %d\n", terminated, WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("Enfant %d tué par signal %d\n", terminated, WTERMSIG(status));
        }
    }
    return 0;
}

waitpid() permet d’attendre un processus spécifique ou d’utiliser des options avancées :

#include <sys/wait.h>

// Attendre un enfant spécifique
pid_t result = waitpid(child_pid, &status, 0);

// Attendre n'importe quel enfant (équivalent à wait())
pid_t result = waitpid(-1, &status, 0);

// Attendre sans bloquer (WNOHANG)
pid_t result = waitpid(-1, &status, WNOHANG);
if (result == 0) {
    printf("Aucun enfant terminé pour l'instant\n");
} else if (result > 0) {
    printf("Enfant %d terminé\n", result);
}

Options de waitpid() :

  • WNOHANG : Ne pas bloquer si aucun enfant n’est terminé
  • WUNTRACED : Retourner aussi pour les enfants stoppés
  • WCONTINUED : Retourner si un enfant stoppé a repris (SIGCONT)

Processus zombie

Un zombie est un processus terminé dont le parent n’a pas encore appelé wait(). Il occupe une entrée dans la table des processus mais ne consomme plus de ressources CPU.

// Création d'un zombie
int main() {
    if (fork() == 0) {
        exit(0);  // L'enfant termine immédiatement
    }
    sleep(60);    // Le parent dort sans faire wait()
    // Pendant 60 secondes, l'enfant est zombie
    return 0;
}

Exécuter un autre programme

La famille exec remplace l’image du processus courant par un nouveau programme. Le PID reste le même, mais le code, les données et la pile sont remplacés. Le code après exec() n’est exécuté que si exec() échoue. C’est une transformation irréversible : l’ancien programme est complètement remplacé.

#include <stdio.h>
#include <unistd.h>

int main() {
    printf("Avant exec, PID = %d\n", getpid());

    // execl : liste d'arguments terminée par NULL
    execl("/bin/ls", "ls", "-la", "/tmp", NULL);

    // Si on arrive ici, c'est que exec a échoué
    perror("execl failed");
    return 1;
}

Variantes de exec

Fonction Arguments Chemin Environnement
execl Liste (varargs) Chemin absolu Hérité
execv Tableau argv[] Chemin absolu Hérité
execle Liste Chemin absolu Fourni
execve Tableau Chemin absolu Fourni
execlp Liste Recherche PATH Hérité
execvp Tableau Recherche PATH Hérité
  • l (list) : arguments passés en liste variadique
  • v (vector) : arguments passés dans un tableau
  • p (path) : recherche l’exécutable dans PATH
  • e (environment) : permet de spécifier l’environnement
// execv avec tableau
char *args[] = {"ls", "-la", "/tmp", NULL};
execv("/bin/ls", args);

// execvp avec recherche dans PATH
char *args[] = {"ls", "-la", NULL};
execvp("ls", args);

// execle avec environnement personnalisé
char *env[] = {"PATH=/usr/bin", "HOME=/tmp", NULL};
execle("/bin/ls", "ls", "-la", NULL, env);

Le pattern classique pour lancer un programme externe est de combiner fork() et exec() :

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // Enfant : exécute un autre programme
        execlp("ls", "ls", "-la", NULL);
        perror("exec failed");
        exit(1);
    } else if (pid > 0) {
        // Parent : attend la fin
        int status;
        waitpid(pid, &status, 0);
        printf("ls terminé avec code %d\n", WEXITSTATUS(status));
    }
    return 0;
}

Ce pattern est la base de fonctionnement des shells : le shell fork, l’enfant exec la commande demandée, le parent wait.

Redirection avec dup2

dup2() permet de dupliquer un descripteur de fichier vers un autre numéro. C’est le mécanisme fondamental pour les redirections d’entrées/sorties. Dit autrement le | que vous avez certainement vue en bahs.

Descripteurs de fichiers : Ce sont des entiers (0, 1, 2, 3…) servant d’index dans une table maintenue par le noyau pour chaque processus. Cette table pointe vers les structures décrivant les fichiers/pipes/sockets ouverts. Par convention, 0=stdin, 1=stdout, 2=stderr.

#include <unistd.h>

int dup2(int oldfd, int newfd);
// Ferme newfd s'il est ouvert, puis fait pointer newfd vers la même ressource que oldfd

Atomicité : dup2() est atomique. Il ferme newfd et fait la duplication en une seule opération système, évitant les race conditions.

Redirection de stdout vers un fichier

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    // Ouvrir un fichier pour écriture
    int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);

    // Remplacer stdout (fd 1) par notre fichier
    dup2(fd, STDOUT_FILENO);  // STDOUT_FILENO = 1
    close(fd);  // L'original n'est plus nécessaire

    // Maintenant printf écrit dans output.txt
    printf("Ce texte va dans le fichier !\n");

    return 0;
}

Redirection pour un processus enfant

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // Enfant : rediriger stdout vers un fichier
        int fd = open("ls_output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
        dup2(fd, STDOUT_FILENO);
        close(fd);

        // Exécuter ls - sa sortie ira dans le fichier
        execlp("ls", "ls", "-la", NULL);
        exit(1);
    } else {
        wait(NULL);
        printf("Résultat de ls écrit dans ls_output.txt\n");
    }
    return 0;
}

Inter-Process Communication

IPC (Inter-Process Communication) désigne l’ensemble des mécanismes permettant à des processus de communiquer entre eux. Comme les processus sont isolés (espaces mémoire séparés), ils ne peuvent pas partager directement des variables. Il faut donc passer par le noyau.

Mécanisme Description Cas d’usage
Pipe Canal unidirectionnel, simple Pipelines shell, parent-enfant
FIFO (pipe nommé) Pipe accessible via le filesystem Processus non apparentés
Socket Bidirectionnel, local ou réseau Client-serveur, p2p
Shared Memory Zone mémoire partagée Haute performance, gros volumes
Message Queue File de messages typés Communication asynchrone
Signal Notification simple Événements, interruptions

Dans le cadre de ce cours, nous nous concentrerons sur les pipes car ils illustrent bien les concepts fondamentaux (descripteurs, fork, dup2) et sont à la base du fonctionnement des shells. Nous verrns egalement les signaux qui sont le pendant logiciel de leurs homologue materiel, les interuptions.

Pipe anonyme

Un pipe est un canal de communication unidirectionnel entre processus. Il crée deux descripteurs : un pour écrire, un pour lire. Le noyau maintient un buffer interne (typiquement 64 Ko sous Linux) pour chaque pipe. Si le buffer est plein, l’écrivain est bloqué (write() attend). Si le buffer est vide, le lecteur est bloqué (read() attend). C’est une synchronisation implicite entre producteur et consommateur.

Les pipes anonymes ne fonctionnent qu’entre processus apparentés (parent-enfant) car ils exploitent la propriété du fork() qui copie les descripteurs ouverts. Pour communiquer entre processus quelconques, il faut utiliser des pipes nommés (FIFO) via mkfifo() qui crée un fichier spécial visible dans le système de fichiers, pouvant être ouvert par n’importe quel processus.

#include <unistd.h>

int pipefd[2];
pipe(pipefd);
// pipefd[0] : extrémité lecture
// pipefd[1] : extrémité écriture

Communication parent-enfant

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int pipefd[2];
    pipe(pipefd);

    pid_t pid = fork();

    if (pid == 0) {
        // Enfant : lit depuis le pipe
        close(pipefd[1]);  // Fermer l'extrémité écriture

        char buffer[100];
        int n = read(pipefd[0], buffer, sizeof(buffer) - 1);
        buffer[n] = '\0';
        printf("Enfant a reçu: %s\n", buffer);

        close(pipefd[0]);
        exit(0);
    } else {
        // Parent : écrit dans le pipe
        close(pipefd[0]);  // Fermer l'extrémité lecture

        const char *message = "Hello depuis le parent!";
        write(pipefd[1], message, strlen(message));

        close(pipefd[1]);
        wait(NULL);
    }
    return 0;
}

Pipeline

Implémenter ls | wc -l en C nécessite de connecter la sortie de ls à l’entrée de wc via un pipe. Ce mecanisme est fourni par l’interpréteur (bash, sh, zsh, etc.).
Le principe est le suivant :

  1. Créer un pipe (deux descripteurs : lecture et écriture)
  2. Fork deux processus enfants
  3. Le premier enfant redirige sa sortie standard vers le pipe, puis exécute ls
  4. Le second enfant redirige son entrée standard depuis le pipe, puis exécute wc
  5. Le parent ferme ses descripteurs de pipe et attend les deux enfants
flowchart LR subgraph PARENT["Parent (shell)"] direction TB P1["1. pipe()"] P2["2. fork() × 2"] P3["3. close(pipe)"] P4["4. wait()"] P1 --> P2 --> P3 --> P4 end subgraph CHILD1["Enfant 1"] direction TB C1["close(pipefd[0])"] C2["dup2(pipefd[1], STDOUT)"] C3["close(pipefd[1])"] C4["exec(ls)"] C1 --> C2 --> C3 --> C4 end subgraph CHILD2["Enfant 2"] direction TB D1["close(pipefd[1])"] D2["dup2(pipefd[0], STDIN)"] D3["close(pipefd[0])"] D4["exec(wc -l)"] D1 --> D2 --> D3 --> D4 end CHILD1 -->|"pipe buffer"| CHILD2 style PARENT fill:#fff3cd,stroke:#856404 style CHILD1 fill:#d4edda,stroke:#28a745 style CHILD2 fill:#cce5ff,stroke:#004085

Point crucial : Le parent doit fermer ses descripteurs de pipe, sinon wc attendra indéfiniment des données (le pipe n’est “fermé” que quand tous les descripteurs d’écriture sont fermés). Un read() retourne 0 (EOF) uniquement quand tous les descripteurs d’écriture du pipe sont fermés. Tant qu’un processus (même le parent qui n’écrit pas) garde pipefd[1] ouvert, le lecteur attend.

Signaux

Les signaux sont des interruptions logicielles permettant de notifier un processus d’un événement. Ils constituent une forme primitive de communication inter-processus. Un signal peut arriver à n’importe quel moment, interrompant le code en cours. C’est pourquoi les handlers de signaux doivent être async-signal-safe : ils ne peuvent appeler qu’un ensemble limité de fonctions (write, _exit, etc.). Pas de printf, pas de malloc dans un handler !

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler(int sig) {
    printf("Signal %d reçu !\n", sig);
}

int main() {
    // Installer un handler pour SIGINT (Ctrl+C)
    signal(SIGINT, handler);

    printf("PID: %d, appuyez Ctrl+C...\n", getpid());

    while (1) {
        pause();  // Attend un signal
    }
    return 0;
}

Envoyer un signal

#include <signal.h>

// Envoyer SIGTERM au processus 1234
kill(1234, SIGTERM);
// Envoyer un signal à soi-même
raise(SIGTERM);
  • kill() ne tue pas forcément ! Le nom est trompeur. kill() envoie un signal, quel qu’il soit. kill(pid, 0) n’envoie rien mais vérifie si le processus existe (utile pour tester si un PID est valide).=
  • sigaction() vs signal() : signal() a un comportement qui varie selon les systèmes. Préférez sigaction() pour un contrôle précis et portable. Il permet aussi de spécifier des flags (SA_RESTART pour redémarrer les appels système interrompus, SA_SIGINFO pour recevoir des infos supplémentaires). C’est ce que l’on va utiliser en TP.

Signaux courants:

Signal Numéro Description Action par défaut
SIGINT 2 Interruption (Ctrl+C) Terminer
SIGTERM 15 Demande de terminaison Terminer
SIGKILL 9 Terminaison forcée Terminer (non interceptable)
SIGSEGV 11 Violation mémoire Core dump
SIGCHLD 17 Enfant terminé Ignoré
SIGSTOP 19 Stopper le processus Stopper (non interceptable)
SIGCONT 18 Reprendre si stoppé Reprendre