Table of Contents
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 :
- GNU C Manuel de la bibliothèque : Exemples de création de processus[ – Documentation officielle sur fork, exec[ et attente.
- The Open Group Base Specifications: Shell Command Language – La spécification formelle pour le comportement du shell POSIX; utile si vous voulez correspondre à un standard.
- Wikipedia: Unix shell – Un large aperçu de l'historique, des variantes et des caractéristiques de la coquille.
- Le Guide de communication interprocessus de Beej – Couvre les tuyaux, les FIFO, les files d'attente de messages et la mémoire partagée avec des exemples pratiques.
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.