Construire un shell à partir de zéro en C reste l'une des meilleures façons d'approfondir votre compréhension de la façon dont les systèmes d'exploitation gèrent les processus, gèrent les entrées des utilisateurs et exécutent des programmes. Alors que les shells modernes comme Bash, Zsh et Fish sont incroyablement sophistiqués, leur fonctionnalité de base se résume à une simple boucle : lire une commande, l'analyser, créer un nouveau processus, et attendre qu'il soit terminé. Dans ce guide élargi, vous passerez en détail à travers chacune de ces étapes, de la mise en place d'un analyseur d'entrée robuste à la création de processus de manipulation avec fork et exec.

Ce qu'une coquille fait en fait

Au cœur de ce shell, il s'agit d'un interpréteur en ligne de commande. Il fournit une interface textuelle où les utilisateurs tapent des commandes, et le shell traduit ces commandes en actions effectuées par le système d'exploitation. Lorsque vous tapez ls -la, le shell doit trouver l'exécutable ls, créer un nouveau processus pour l'exécuter, passer les arguments -la, et attendre que ce processus se termine avant de vous inviter à la commande suivante.

Construire une coquille minimale vous apprend à savoir :

  • Lecture et tokenisation des données d'entrée standard
  • Création et gestion de processus pour enfants avec fork
  • Remplacer l'image mémoire d'un processus par exec
  • Synchronisation parent-enfant avec attente
  • Manipulation des erreurs courantes gracieusement

Comprendre ces éléments de construction vous donne un aperçu de la façon dont tous les systèmes similaires à Unix fonctionnent sous le capot, et il fournit une base solide pour apprendre sur des fonctionnalités plus avancées comme la manipulation des signaux, le contrôle de travail, et la communication inter-processus.

Configuration de la boucle Shell

Chaque shell est construit autour d'une boucle principale qui se répète indéfiniment jusqu'à ce que l'utilisateur demande à sortir. Cette boucle imprime une invite, lit une ligne d'entrée, l'analyse, puis agit sur la commande parsed. La version la plus simple ressemble à ceci dans le pseudocode:

while (1) {
 print_prompt();
 read_input();
 parse_input();
 execute_command();
}

En C, vous utilisez généralement fgets pour lire l'entrée car elle gère les limites de ligne et le tampon déborde plus en toute sécurité que gets. L'invite peut être aussi simple qu'une chaîne comme mysh> , mais les shells réels incluent souvent le répertoire de travail actuel, le nom d'utilisateur et le nom d'hôte.

Manipulation des cas de bord en entrée

L'entrée de l'utilisateur est rarement propre. Un shell robuste doit gérer les lignes vides, l'espace blanc menant et traînant, les commandes extrêmement longues, et l'état de fin de fichier (Ctrl+D). Si fgets retourne NULL[, vous devez sortir de la boucle et sortir gracieusement. Si l'entrée ne consiste que d'espace blanc, le shell devrait simplement ré-prompter sans essayer d'exécuter quoi que ce soit. Vous devez également gérer le cas où la ligne d'entrée est plus longue que votre tampon fixe en le tronquant ou en redimensionnant dynamiquement le tampon.

#define MAX_INPUT 1024

char input[MAX_INPUT];
if (fgets(input, MAX_INPUT, stdin) == NULL) {
 printf("\n");
 break; // EOF
}

// Remove trailing newline, if present
size_t len = strlen(input);
if (len > 0 && input[len-1] == '\n') {
 input[len-1] = '\0';
} else {
 // Input too long, flush remaining characters
 int ch;
 while ((ch = getchar()) != '\n' && ch != EOF);
}

Parsing Commands into Arguments

Une fois que vous avez une chaîne d'entrée propre, vous devez la diviser en jetons. La première est le nom de commande (par exemple, ls), et les autres jetons sont les arguments de cette commande. La bibliothèque standard C fournit strtok à cette fin, mais vous devez être prudent car strtok modifie la chaîne d'origine et n'est pas sans danger pour les fils.

char *args[MAX_ARGS];
int arg_count = 0;

args[arg_count] = strtok(input, " \t");
while (args[arg_count] != NULL && arg_count < MAX_ARGS - 1) {
 arg_count++;
 args[arg_count] = strtok(NULL, " \t");
}
args[arg_count] = NULL; // execvp expects a NULL-terminated array

Ce tokenizer divise l'entrée sur les espaces et les onglets. Il ne pas gère les chaînes citées, de sorte qu'une commande comme echo "hello world" se briserait incorrectement en trois jetons plutôt que deux. La manipulation des guillemets est une amélioration précieuse qui nécessite un analyseur plus sophistiqué, mais pour le shell de base, cette approche simple suffit pour exécuter la plupart des commandes à mots simples.

Après la tokenisation, vous devez vérifier si le premier token est NULL (commande vide). Si oui, continuez simplement à l'itération suivante de la boucle sans forking.

Commandes intégrées

Certaines commandes, comme cd et exit[, doivent être exécutées par le processus shell lui-même parce qu'elles affectent l'état du shell. Par exemple, cd modifie le répertoire de travail actuel du shell; si vous avez oublié un processus enfant pour cd, cet enfant changerait son propre répertoire et quitterait le répertoire de travail du parent, laissant inchangé.

Sortie

La commande exit met fin immédiatement au shell. C'est la plus simple intégrée à implémenter : il suffit de vérifier si la première jeton égale "exit" et s'en sortir de la boucle principale. En option, vous pouvez accepter un argument de statut de sortie et le passer à l'appel système exit.

Modifier le répertoire (cd)

La commande cd vous oblige à appeler chdir. Le répertoire cible est le deuxième argument. Si aucun argument n'est fourni, vous pouvez par défaut accéder au répertoire d'origine de l'utilisateur (disponible via la variable d'environnement HOME[. Vérifiez toujours la valeur de retour de chdir et imprimez un message d'erreur si le répertoire n'existe pas ou n'est pas accessible.

if (strcmp(args[0], "cd") == 0) {
 const char *path = args[1];
 if (path == NULL) {
 path = getenv("HOME");
 if (path == NULL) {
 fprintf(stderr, "cd: HOME not set\n");
 continue;
 }
 }
 if (chdir(path) != 0) {
 perror("cd");
 }
 continue; // skip fork/exec
}

Création de processus avec fourche

Pour toute commande qui n'est pas intégrée, votre shell doit créer un processus enfant pour l'exécuter. L'appel système fork crée un nouveau processus en dupliquer le processus d'appel. Le nouveau processus est appelé l'enfant, et l'original est le parent. Après fork, les deux processus continuent à exécuter à partir du même point du code. La seule différence est la valeur de retour de fork: il retourne 0 à l'enfant, et le PID de l'enfant au parent.

pid_t pid = fork();
if (pid == -1) {
 perror("fork");
 continue;
}

if (pid == 0) {
 // Child process
 // ...
} else {
 // Parent process
 // ...
}

Pourquoi la Fourche ?

Vous pourriez vous demander pourquoi vous devez créer un processus séparé du tout. La raison est que exec, qui charge un nouveau programme en mémoire, remplace entièrement le processus courant. Si le shell appelé exec directement, le programme shell serait remplacé et ne retournerait jamais accepter de nouvelles commandes. En forçant d'abord, l'enfant peut appeler exec sans affecter le shell parent.

Exécuter un programme avec Exec

La famille de fonctions exec[ remplace le processus actuel par un nouveau programme. Il existe plusieurs variantes: execl, execlp[execle[, execv[, execvp[, et execvpe[. La différence clé entre eux est la façon dont le programme est localisé et comment les arguments sont passés. Pour votre shell, execvp[ est la plus pratique parce qu'elle recherche la variable d'environnement PATH[ pour l'exécutable et accepte un tableau d'arguments NULL-terminé, qui correspond exactement à ce que vous avez construit pendant le paring.

if (pid == 0) {
 // Child process
 execvp(args[0], args);

 // If execvp returns, an error occurred
 perror("exec");
 exit(EXIT_FAILURE);
}

Notez l'appel à sortie[ après perror[. Si execvp[ échoue (par exemple, parce que la commande n'existe pas), le processus enfant doit se terminer; sinon, il continuerait à exécuter n'importe quel code suivi, qui est habituellement la boucle du shell du parent. Cela entraînerait deux shells qui s'exécutent et se disputent pour l'entrée.

En attendant le processus de l'enfant

Après le forking, le processus parent attend généralement que l'enfant finisse avant de recommencer à l'inviter. La fonction waitpid vous donne plus de contrôle car vous pouvez spécifier l'enfant à attendre (en utilisant le PID retourné par fork) et éventuellement définir des options pour éviter le blocage.

int status;
waitpid(pid, &status, 0);

La variable status[ contient des informations sur la façon dont l'enfant s'est terminé. Vous pouvez utiliser des macros comme WIFEXITED[, WEXITSTATUS[, WIFSIGNALED[ et WTERMSIG[ pour extraire des détails.

Blocage par rapport à non-blocage

L'appel waitpid simple bloque le parent jusqu'à ce que l'enfant sorte. C'est le comportement correct pour un processus de premier plan. Si vous ajoutez plus tard une prise en charge pour les processus de fond (exécutant une commande avec &), vous définiriez l'option WNOHANG pour éviter de bloquer, et vous devrez gérer une liste de PID enfants pour les récolter plus tard.

Mettre ensemble la coquille complète

Voici un shell complet et minimal qui intègre toutes les pièces discutées jusqu'ici. Il gère les exit et cd intégrés, analyse l'entrée en utilisant strtok, fourches pour commandes externes, et attend que l'enfant finisse. Pour plus de clarté, la vérification des erreurs est incluse mais est tenue concise.

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

#define MAX_INPUT 1024
#define MAX_ARGS 64

int main(void) {
 char input[MAX_INPUT];
 char *args[MAX_ARGS];
 int should_run = 1;

 while (should_run) {
 printf("mysh> ");
 fflush(stdout);

 if (fgets(input, MAX_INPUT, stdin) == NULL) {
 printf("\n");
 break;
 }

 // Remove trailing newline
 size_t len = strlen(input);
 if (len > 0 && input[len-1] == '\n') {
 input[len-1] = '\0';
 }

 // Tokenize
 int i = 0;
 args[i] = strtok(input, " \t");
 while (args[i] != NULL && i < MAX_ARGS - 1) {
 i++;
 args[i] = strtok(NULL, " \t");
 }
 args[i] = NULL;

 if (args[0] == NULL) {
 continue; // empty line
 }

 // Handle built-in commands
 if (strcmp(args[0], "exit") == 0) {
 should_run = 0;
 continue;
 }
 if (strcmp(args[0], "cd") == 0) {
 const char *path = args[1];
 if (path == NULL) {
 path = getenv("HOME");
 }
 if (chdir(path) != 0) {
 perror("cd");
 }
 continue;
 }

 // Fork and execute external command
 pid_t pid = fork();
 if (pid < 0) {
 perror("fork");
 continue;
 }

 if (pid == 0) {
 // Child
 execvp(args[0], args);
 perror("exec");
 exit(EXIT_FAILURE);
 } else {
 // Parent waits
 int status;
 waitpid(pid, &status, 0);
 }
 }

 return 0;
}

Ce code est un shell complet et fonctionnel. Copiez-le dans un fichier appelé myshell.c, compilez-le avec gcc -o myshell myshell.c, et lancez-le. Vous verrez une invite où vous pouvez saisir des commandes comme ls, pwd[, echo hello, et cd /tmp. La commande [exit] met fin au shell.

Pièges et conseils de débogage communs

Même avec ce petit nombre de code, plusieurs choses peuvent mal tourner. Voici les problèmes les plus fréquents et comment les résoudre:

Commande non trouvée

Si vous tapez une commande qui n'existe pas (par exemple, foobar), [execvp[ retourne et l'enfant imprime "exec: Pas de tel fichier ou répertoire" avant de quitter. Ceci est un comportement correct, mais vous pourriez vouloir imprimer un message amiler. Vous pouvez cocher errno après execvp ne permet pas de distinguer entre "fichier non trouvé" et "permission refusée".

Entrée de nouvelle ligne manquante ou tronquée

Si votre invite apparaît sans attendre d'entrée, la cause probable est les caractères restants dans le tampon d'entrée d'un appel précédent. Vérifiez toujours que fgets a consommé toute la ligne (c.-à-d. que le dernier caractère avant le terminateur nul est une nouvelle ligne).

Procédés de zombies

Si vous oubliez d'appeler wait[ (ou waitpid[), les processus d'enfant qui finissent par devenir des zombies jusqu'à ce que vous les récoltez. La coquille dans l'exemple appelle waitpid[, donc les zombies ne devraient pas apparaître. Cependant, si vous ajoutez plus tard des processus de fond et ne réussissez pas à les récolter, l'accumulation de zombies peut devenir un problème. La solution est d'installer un SIGCHLD gestionnaire qui appelle [ waitpid[ avec ] WNOHANG[ pour nettoyer les enfants terminés.

Élargissement de la coquille

Une fois que vous avez le shell de base, vous pouvez ajouter des fonctionnalités qui le rapprochent d'un shell réel. Chaque fonctionnalité vous apprend plus sur le système d'exploitation.

Rédirection des entrées/sorties

>, <, et >>[ vous oblige à analyser la ligne de commande de ces opérateurs, à ouvrir les fichiers appropriés en utilisant open, et à utiliser dup2 pour rediriger l'entrée ou la sortie standard avant d'appeler execvp. Cela se fait dans le processus pour enfants entre fork[ et exec[.

Tuyaux

Il est plus important de piéger la sortie d'une commande dans l'entrée d'une autre (cmd1=" cmd2). Vous devez créer une pipe avec pipe[, fourche deux processus pour enfants, rediriger la sortie d'un enfant vers l'extrémité d'écriture du tuyau et l'entrée de l'autre vers la fin de lecture, puis attendre que les deux soient terminés.

Contrôle de l'emploi

Ajouter l'exécution de fond (&[] à la fin d'une commande) et la capacité d'apporter des emplois au premier plan exige la gestion d'une table de travail, la gestion SIGTSTP[, SIGCONT[, et SIGCHLD, et l'utilisation tcsetpgrp[ pour gérer la propriété du terminal.

Historique des commandes

La mise en place d'un mécanisme d'historique simple (flèches ascendantes/dessins pour rappeler les commandes précédentes) implique la saisie d'entrées en mode terminal brut (via tcgetattr et tcsetattr) ou en utilisant la bibliothèque [readline[, qui fournit cette fonctionnalité hors de la boîte.

Lecture et ressources supplémentaires

Pour approfondir votre compréhension des concepts présentés ici, les ressources suivantes sont inestimables :

Conclusion

Construire un shell de base en C est plus qu'un exercice académique; il vous force à vous engager directement avec les abstractions du système d'exploitation. Vous avez vu comment lire et analyser l'entrée utilisateur, comment fork crée un nouveau processus, et comment exec[ remplace la mémoire de ce processus par un programme demandé. Vous avez également ajouté des commandes intégrées qui doivent fonctionner dans le contexte propre du shell et gérer les erreurs gracieusement. Le code shell complet fourni dans cet article est un point de départ solide que vous pouvez étendre avec la redirection, les pipelines, le contrôle de travail et l'historique. Chaque extension approfondira votre compréhension de la gestion du processus, des descripteurs de fichiers et des signaux.