Des articles approfondis sur les technologies qui façonnent l'avenir.

Ce que la construction d'un shell vous apprend sur Unix

Construire un shell à partir de zéro révèle le fonctionnement réel d'Unix : processus, descripteurs de fichiers, tubes et signaux. Tour d'horizon des concepts clés.

Coupe d'un coquillage révélant une salle de contrôle de tuyaux, vannes et leviers acheminant la lumière.

Tous les développeurs utilisent un shell au quotidien, mais peu comprennent vraiment ce qu'il fait. Le shell ressemble à une application : vous tapez des commandes, il les exécute. En réalité, c'est une fine couche au-dessus d'un ensemble de primitives Unix fondamentales pour le fonctionnement des systèmes d'exploitation. Construire un shell à partir de zéro est l'une des meilleures façons de comprendre les processus, les descripteurs de fichiers, les tubes et les signaux, des concepts qui sous-tendent tout, des serveurs web aux conteneurs Docker.

Un shell basique est étonnamment simple. La boucle principale tient en quelques étapes : lire une ligne de saisie, l'analyser en une commande et ses arguments, créer un processus fils avec fork, exécuter la commande dans ce fils, puis attendre sa fin. Cela représente environ 50 lignes de C. La complexité vient des fonctionnalités qu'on tient pour acquises : tubes, redirections, processus en arrière-plan, gestion des signaux et contrôle de tâches.

La boucle Lire-Évaluer-Afficher (REPL)

Au fond, un shell est une REPL. Il lit l'entrée, l'évalue (c'est-à-dire l'exécute), affiche le résultat, puis recommence. La partie « affichage » est prise en charge par les commandes elles-mêmes : le shell fournit simplement l'environnement dans lequel elles tournent.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
// The simplest possible shell
int main(void) {
char line[1024];
while (1) {
printf("$ ");
if (!fgets(line, sizeof(line), stdin))
break;  // EOF (Ctrl+D)
// Remove trailing newline
line[strcspn(line, "\n")] = '\0';
// Fork a child process
pid_t pid = fork();
if (pid == 0) {
// Child: execute the command
execlp(line, line, NULL);  // This only handles single-word commands
perror("exec");
exit(1);
}
// Parent: wait for child to finish
waitpid(pid, NULL, 0);
}
return 0;
}

Ce shell de 25 lignes fonctionne réellement : il peut lancer des commandes comme ls, pwd et date. Il ne gère ni arguments, ni tubes, ni redirections, ni aucune des fonctionnalités auxquelles vous vous attendez. Mais il illustre le schéma fondamental : fork, exec, wait.

Fork et Exec : le modèle de processus d'Unix

La séparation fork/exec est la décision de conception la plus caractéristique d'Unix, et construire un shell permet de comprendre pourquoi elle existe.

fork() crée une copie exacte du processus courant. Le fils a la même mémoire, les mêmes fichiers ouverts et les mêmes variables d'environnement. exec() remplace le programme du fils par un nouveau. Ce sont deux opérations distinctes parce que l'intervalle entre les deux, c'est-à-dire après fork mais avant exec, est précisément l'endroit où le shell configure l'environnement du fils.

C'est l'idée clé. Quand vous tapez ls > output.txt, le shell fait un fork, puis, dans le fils et avant exec, ouvre output.txt, redirige la sortie standard vers ce fichier, et enfin exécute ls. Le programme ls ne sait rien de la redirection : il écrit sur la sortie standard comme d'habitude, et la manipulation des descripteurs effectuée entre fork et exec envoie cette sortie dans un fichier.

// How 'ls > output.txt' works
pid_t pid = fork();
if (pid == 0) {
// Child process — between fork and exec
// Open the output file
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
// Redirect stdout (fd 1) to the file
dup2(fd, STDOUT_FILENO);  // Now fd 1 points to output.txt
close(fd);                 // Close the original fd (no longer needed)
// exec ls — it writes to stdout, which now goes to output.txt
execlp("ls", "ls", NULL);
perror("exec");
exit(1);
}
waitpid(pid, NULL, 0);

Ce design est élégant parce qu'il se compose. Le fils peut préparer n'importe quel environnement avant exec : rediriger des fichiers, changer de répertoire, modifier les variables d'environnement, définir des limites de ressources, changer d'identifiant utilisateur. Le programme exécuté hérite de cet environnement sans avoir besoin d'en connaître le détail. Chaque commande reçoit donc un environnement préconfiguré, et c'est le shell qui s'en charge.

Les tubes : relier les processus

Les tubes sont le mécanisme de composition le plus puissant d'Unix, et les implémenter montre à quel point le mécanisme sous-jacent est simple.

L'appel système pipe() crée une paire de descripteurs de fichiers : un pour la lecture, un pour l'écriture. Ce qui est écrit à l'extrémité d'écriture ressort à l'extrémité de lecture. Pour implémenter ls | grep foo, le shell crée un tube, fait deux fork, relie la sortie standard de ls à l'extrémité d'écriture et l'entrée standard de grep à l'extrémité de lecture.

// How 'ls | grep foo' works
int pipefd[2];
pipe(pipefd);  // pipefd[0] = read end, pipefd[1] = write end
pid_t pid1 = fork();
if (pid1 == 0) {
// First child: ls
close(pipefd[0]);              // Don't need read end
dup2(pipefd[1], STDOUT_FILENO); // stdout → pipe write end
close(pipefd[1]);
execlp("ls", "ls", NULL);
exit(1);
}
pid_t pid2 = fork();
if (pid2 == 0) {
// Second child: grep
close(pipefd[1]);              // Don't need write end
dup2(pipefd[0], STDIN_FILENO);  // stdin → pipe read end
close(pipefd[0]);
execlp("grep", "grep", "foo", NULL);
exit(1);
}
// Parent: close both pipe ends and wait
close(pipefd[0]);
close(pipefd[1]);
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0);

Remarquez la fermeture soigneuse des extrémités de descripteurs inutilisées. C'est capital : si le parent ne ferme pas les deux extrémités du tube, grep ne recevra jamais d'EOF sur son entrée standard (puisque l'extrémité d'écriture reste ouverte dans le parent) et restera bloqué indéfiniment. Les fuites de descripteurs dans les chaînes de tubes figurent parmi les bugs les plus courants lorsqu'on construit un shell.

Les commandes intégrées

Certaines commandes ne peuvent pas être des programmes externes. cd en est l'exemple classique. Si le shell crée un fils et que celui-ci appelle chdir(), seul le répertoire de travail du fils change : celui du parent reste inchangé, et après la fin du fils, le shell se trouve toujours dans le même répertoire. Pour que cd fonctionne, le shell doit l'exécuter dans son propre processus, sans fork.

Parmi les autres commandes intégrées, on trouve export (qui modifie l'environnement du shell), exit (qui termine le processus du shell) et source (qui exécute un script dans le contexte du shell courant). Toutes modifient l'état propre du shell, ce qui ne peut se faire que dans le processus du shell lui-même.

Comprendre quelles commandes sont intégrées, et pourquoi, vous apprend quelque chose de fondamental sur l'isolation des processus. Un processus fils ne peut pas modifier son parent. C'est une fonctionnalité de sécurité, une garantie de fiabilité et parfois une contrainte gênante, mais elle est au cœur du fonctionnement des processus Unix.

Signaux et contrôle de tâches

Appuyez sur Ctrl+C dans votre terminal et la commande en cours s'arrête. Cela paraît simple, mais cela implique une interaction étonnamment complexe entre le terminal, le shell, les signaux et les groupes de processus.

Ctrl+C envoie SIGINT au groupe de processus de premier plan. C'est le pilote du terminal qui gère cela, et non le shell. Le rôle du shell est de placer chaque commande dans son propre groupe de processus, afin que SIGINT atteigne la commande et non le shell. Si le shell ne configure pas correctement les groupes de processus, Ctrl+C tue le shell au lieu de la commande en cours.

Le contrôle de tâches (processus en arrière-plan avec &, fg, bg, Ctrl+Z) ajoute une couche supplémentaire. Le shell doit suivre les processus appartenant à chaque tâche, gérer les groupes de premier et d'arrière-plan, et traiter des signaux comme SIGTSTP (Ctrl+Z, suspension) et SIGCHLD (fin d'un processus fils). L'implémenter correctement est la partie la plus difficile lorsqu'on construit un shell.

Ce que vous apprenez

Construire un shell vous fait découvrir des concepts qui reviennent constamment en développement logiciel, même si vous n'écrivez plus jamais de code système.

  • Les descripteurs de fichiers sont l'interface universelle. Fichiers, tubes, sockets, terminaux : tout est un descripteur de fichier. La redirection, le chaînage par tubes et la communication réseau reposent sur le même mécanisme sous-jacent. Comprendre cela rend plus clairs des sujets allant de la mise en réseau de Docker aux sockets de domaine Unix, en passant par les API système Linux.
  • L'isolation des processus est fondamentale. Un processus fils ne peut pas modifier son parent. Les variables d'environnement, le répertoire de travail et les fichiers ouverts sont des copies héritées, et non des références partagées. C'est pourquoi un cd dans un sous-shell n'affecte pas le parent, et pourquoi les conteneurs Docker héritent de l'environnement de l'hôte sans le partager.
  • La composition l'emporte sur les fonctionnalités. Unix n'a pas de commande « trouver les fichiers correspondant à un motif et les compter ». Il dispose de find, grep et wc, reliés par des tubes. Le mécanisme de tubes du shell permet de combiner de petits outils simples en flux de travail complexes. Cette philosophie de conception, de petits outils reliés par des interfaces standard, est l'ancêtre intellectuel des microservices, des sockets Unix et de la conception d'API.
  • La gestion des erreurs repose surtout sur les descripteurs de fichiers. Quand un tube se rompt, qu'un processus fils plante ou que l'entrée standard est épuisée, le mécanisme sous-jacent est toujours la fermeture de descripteurs, l'envoi de signaux ou la sortie de processus avec un code de statut. Une fois le modèle des descripteurs de fichiers assimilé, les schémas de gestion des erreurs dans les systèmes Unix deviennent intuitifs.

Par où commencer

Si vous voulez construire un shell, partez de la version de 25 lignes ci-dessus et ajoutez les fonctionnalités progressivement. D'abord, l'analyse des arguments (découper la saisie sur les espaces). Ensuite, les redirections d'entrée et de sortie (> et <). Puis les tubes, puis les commandes intégrées (cd, exit), et enfin les variables d'environnement. Chaque fonctionnalité enseigne un nouveau concept Unix, et chacune constitue un exercice autonome.

Utilisez C pour la correspondance la plus directe avec les appels système. Vous pouvez construire un shell en Python ou en Rust, mais l'implémentation en C rend explicites les appels système sous-jacents : vous voyez exactement ce que font fork(), exec(), dup2() et pipe(), puisque vous les appelez directement.

Vous n'avez pas besoin de construire un shell de production. Même un shell jouet, qui gère les commandes de base, les tubes et les redirections, vous apprend davantage sur le fonctionnement d'Unix que des années d'utilisation. Le shell est le programme le plus simple qui exploite les interfaces les plus importantes du système d'exploitation, et comprendre ces interfaces fait de vous un meilleur développeur, quel que soit le langage ou la plateforme avec lesquels vous travaillez.