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 avecreadelf -a ./programmeouobjdump -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)
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 :
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.
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ésWCONTINUED: 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 fermenewfdet 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) viamkfifo()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 :
- Créer un pipe (deux descripteurs : lecture et écriture)
- Fork deux processus enfants
- Le premier enfant redirige sa sortie standard vers le pipe, puis exécute
ls - Le second enfant redirige son entrée standard depuis le pipe, puis exécute
wc - Le parent ferme ses descripteurs de pipe et attend les deux enfants
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érezsigaction()pour un contrôle précis et portable. Il permet aussi de spécifier des flags (SA_RESTARTpour redémarrer les appels système interrompus,SA_SIGINFOpour 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 |