Table of Contents
Construire un shell personnalisé en C est une excellente façon d'approfondir votre compréhension des systèmes d'exploitation, de la gestion des processus et de la programmation du système. Un shell est plus qu'un simple interprète de commande; il est l'interface principale entre l'utilisateur et le noyau, fournissant des fonctionnalités comme le contrôle de travail, la tuyauterie, le scripting et la gestion de l'environnement. Bien qu'un shell minimal puisse être mis en œuvre en quelques centaines de lignes, ajoutant des capacités avancées le transforme en un outil de production.
Comprendre les bases d'une coquille
Dans son noyau, un shell effectue une boucle simple : lire une ligne d'entrée, l'analyser en commandes et arguments, exécuter ces commandes, puis répéter. Cependant, un shell réel doit gérer de nombreux cas de bord et fournir une expérience utilisateur cohérente. La boucle de base – souvent appelée boucle -eval-print -repL – est le cœur de tout shell interactif. Dans une implémentation personnalisée, vous devez gérer les entrées de , gérer des caractères spéciaux (quoting, évasion) et différencier entre commandes intégrées et programmes externes.
Le shell fonctionne en deux modes principaux : interactive et non-interactive. En mode interactif, il imprime une invite (par exemple ) et attend l'entrée de l'utilisateur. Il doit supporter le contrôle de travail, les signaux et l'édition en ligne. En mode non-interactif (lors de la lecture d'un fichier script), le shell lit les commandes de façon séquentielle sans l'inviter.
Une compréhension historique des coquilles comme la coquille Bourne, Bash et Zsh fournit le contexte pour les caractéristiques que nous mettons en œuvre. Les coquilles modernes héritent de décennies de choix de conception – pipeline, groupes de processus, signaux de contrôle de travail – qui sont normalisés dans POSIX. En construisant une coquille à partir de zéro, vous obtenez une compréhension pratique de ces normes et des compromis qui les sous-tendent.
Composants de base d'une coquille personnalisée
Chaque coque, aussi simple soit-elle, doit fournir ces services fondamentaux :
- Parsage d'entrée:[ Tokenizing raw input into a command structure (manifestation, arguments, opérateurs).
- Exécution du mandat :[ Utiliser des appels système comme , et pour lancer des programmes externes.
- Contrôle de l'emploi : Gestion des processus de premier plan et de fond, des groupes de processus et de la manipulation des signaux.
- Piping et Redirection:[ Connecter la sortie d'un processus à l'entrée d'un autre, et rediriger les flux vers/depuis les fichiers.
- Support de scripts:[ Exécuter plusieurs commandes à partir d'un fichier, avec un flux de contrôle et une gestion d'erreurs optionnels.
- Commandes de construction:[ Mise en œuvre de commandes comme , , , , directement dans le processus shell.
- Gestion de l'environnement:[Élément et modification des variables environnementales.
Par exemple, le contrôle de travail repose sur des groupes de processus, qui affectent également le fonctionnement des tuyauteries. Construire une conception modulaire avec des interfaces claires entre l'analyse, l'exécution et la gestion de travail vous permettra d'économiser beaucoup de maux de tête plus tard.
Parsage d'entrée : de la ligne brute à la structure de commande
Analyse lexique
L'analyse commence par la division de la ligne d'entrée en jetons. Un jeton est une séquence de caractères qui forme une unité logique : une commande, un argument, un opérateur (, , , , ou une chaîne citée. Vous pouvez implémenter un jeton simple en itérant sur la ligne, en sautant l'espace blanc et en manipulant des guillemets ( et ) et des caractères d'évasion (). Par exemple, à l'intérieur de guillemets simples, tous les caractères sont littéraux; à l'intérieur de guillemets doubles, les variables (comme ) peuvent être élargies.
Une approche plus robuste utilise une machine d'état pour suivre si l'analyseur est à l'intérieur d'un guillemet, d'une séquence d'échappement ou d'un texte normal. Cela évite les bogues communs comme les espaces de manipulation mal placés à l'intérieur des arguments. Une fois les jetons extraits, ils sont assemblés dans une structure de commande.
Structure de commandement
Définir une structure pour représenter une commande simple :
struct simple_cmd {
char **args; // command name + arguments, NULL-terminated
int argc; // number of arguments
char **redirect_in; // input redirection file (if any)
char **redirect_out; // output redirection file (if any)
int append; // 1 if >>, 0 if >
int background; // 1 if & is present
};
Un pipeline est alors une liste de ces commandes simples, chacune avec ses propres redirections. L'analyseur doit également gérer et (opérateurs logiques) et des point-virgules pour l'exécution séquentielle. Pour un shell de production, vous devez construire un arbre de syntaxe abstraite (AST) représentant l'ensemble de la ligne de commande.
Exécution de commande : Fork, Exec et le chemin
Le modèle Fork-Exec
Pour exécuter un programme externe, le shell appelle d'abord pour créer un processus enfant. L'enfant appelle alors (ou une variante) pour remplacer son image par le programme désiré. Le parent doit attendre l'enfant (si c'est un travail de premier plan) ou l'ajouter à la liste de travail (si c'est un travail de fond).
Une nuance est que échoue si le programme ne peut pas être trouvé ou exécuté. Le shell doit signaler les erreurs gracieusement sans planter. De plus, le shell doit restaurer les gestionnaires de signal et les groupes de processus avant et après la fourche pour éviter d'interférer avec le contrôle de travail.
Commandes intégrées
Les built‐ins comme changent le propre état de la shell (par exemple, le répertoire courant) et ne peuvent donc pas être exécutés dans un processus enfant. Ils doivent fonctionner directement dans le processus shell. La façon la plus simple de gérer les built‐ins est de vérifier le nom de la commande après l'analyse et avant la forking. Si elle correspond à une built‐in connue, exécutez la fonction C correspondante et sautez le chemin fork-exec. Les built‐ins communs comprennent :
- – mettre fin à la coque
- – répertoire de modifications
- – imprimer le répertoire de travail
- – variable d'environnement définie
- – liste des emplois de base
- / – mettre le travail au premier plan/envoyer au second plan
- – arguments d'impression (avec expansion variable)
Variables d'environnement
La fonction utilise automatiquement l'environnement courant. Pour les commandes intégrées comme , vous pouvez modifier la variable ou l'utilisation [ globale. Pour supporter la substitution de variables dans les arguments de commande (p. ex., ) nécessite l'extension des jetons avant l'exécution. Ceci est fait en scannant chaque jeton pour et en cherchant la valeur dans l'environnement.
Résolution de trajectoire
Lorsque la commande n'est pas intégrée, le shell doit localiser l'exécutable en utilisant la variable d'environnement . La fonction le fait automatiquement si la commande ne contient pas de slash. Cependant, vous pouvez vouloir implémenter votre propre résolution de chemin pour des raisons de journalisation ou de sécurité. N'oubliez pas de gérer le cas où la commande est un chemin complet (en commençant par ou .
Piping et réorientation
Mise en œuvre d'un tuyau unique
Les tuyaux relient la sortie standard d'un processus à l'entrée standard d'un autre. L'appel système crée une paire de descripteurs de fichiers : pour la lecture, pour l'écriture. Pour mettre en œuvre un pipeline comme , vous fourchez deux enfants. Le premier enfant stdout est redirigé vers en utilisant , et le deuxième enfant stdin est redirigé de . Le parent doit fermer les deux extrémités du tuyau après avoir configuré les enfants, sinon le tuyau n'a pas fermé correctement lorsque le processus d'écriture se termine.
Attention à la gestion des descripteurs de fichiers : vous devez fermer la fin inutilisée de chaque enfant, et fermer tous les fds de pipe dans le parent après avoir forqué tous les enfants. Sinon, les processus peuvent attendre des entrées qui n'arrivent jamais.
Manipulation de tuyaux multiples
Pour les pipelines plus longs comme , vous avez besoin de plusieurs tuyaux. Une approche commune est de créer un tuyau pour chaque étape intermédiaire. Le processus enfant pour écrit au premier tuyau; lit de ce tuyau et écrit au suivant; et lit du dernier tuyau. Vous pouvez mettre en œuvre cette itérative: pour chaque commande après le premier, créer un nouveau tuyau, fourche un enfant, et mettre en place ses redirections en utilisant le tuyau précédent lire fin et le nouveau pipe ‹ écrire fin. Le parent doit garder une trace des descripteurs de fichiers ouverts pour les fermer après que tous les enfants sont créés.
I/S Rédirection
Les opérateurs de redirection (, , , ) modifient les descripteurs de fichiers standard avant l'exécution. L'implémentation est simple : avant d'appeler dans l'enfant, utilisez pour remplacer stdin ou stdout par le descripteur de fichiers approprié obtenu à partir de . Par exemple, ouvre ] pour écrire (créer ou tronquer) et duplique le descripteur de fichier à STDOUT FILENO. Pour le mode d'appendice (), utilisez le drapeau . La redirection d'entrée se lit à partir d'un fichier au lieu de stdin.
Vous devez également prendre en charge la redirection stderr () et la redirection combinée ([). Cela nécessite l'analyse du jeton pour identifier le numéro de descripteur du fichier et le nom du fichier cible.
Contrôle de l'emploi
Groupes de processus
Chaque pipeline (emplois) est placé dans son propre groupe de processus, avec l'ID du groupe égal à l'ID du processus du premier processus dans le travail. L'appel système ou définit le groupe de processus de chaque enfant immédiatement après la fourche. Le shell lui-même appartient à son propre groupe de processus (le groupe de processus de premier plan).
Le groupe de processus terminal=s est géré par . Lorsqu'un travail se déroule au premier plan, le shell doit donner le terminal à ce groupe de processus de travail=s. Après que le travail a été terminé (ou est suspendu), le shell récupère le terminal. Ceci est essentiel pour la bonne manipulation de Ctrl‐C () et Ctrl‐Z (), qui sont livrés au groupe de processus avant plan.
Signalisation
Le shell doit installer des gestionnaires de signal pour pour récolter les processus enfant terminés. Le gestionnaire doit appeler avec dans une boucle pour recueillir tous les enfants qui ont quitté. Cela évite les processus zombies et met à jour la liste de travail.
Pour les shells interactifs, (de Ctrl‐C) devrait faire cesser l'emploi actuel au premier plan, et non le shell lui-même. Vous pouvez définir comme étant ignoré dans le processus de shell et permettre qu'il soit livré au premier plan. De même, (Ctrl‐\) et (Ctrl‐Z) doivent être manipulés avec soin.
Premier plan et contexte des emplois
Lorsqu'un travail est lancé au premier plan (pas de trace ), le shell attend qu'il soit terminé en utilisant sans drapeaux spéciaux. En attendant, le shell peut être bloqué, mais il doit toujours gérer les signaux (p. ex., Ctrl‐C interrompre l'attente). Une technique courante consiste à utiliser une boucle qui vérifie l'achèvement et traite les signaux en parallèle.
Les tâches de fond (trailing ) sont lancées sans attendre. Le shell imprime le PID de travail et continue à l'invite suivante. La liste de tâches maintient les entrées avec PID, numéro de travail, chaîne de commande et état (exécution, arrêt, fait). Commandes intégrées comme , et manipulent cette liste. Par exemple, apporte le travail 1 au premier plan en envoyant et en attendant.
Structures de données de gestion de l'emploi
Mettre en place un tableau de travail (p. ex., un éventail de structures) pour suivre :
- ID de travail (petit entier attribué par shell)
- ID du groupe de processus
- Liste des PID de processus (un pour chaque commande en pipeline)
- État (course, arrêt, fin)
- Chaîne de commande (pour l'affichage)
Sur chaque , mettre à jour l'état de l'emploi touché. Lorsqu'un emploi de premier plan se termine, le supprimer de la liste. Pour les emplois de second plan, avisez l'utilisateur asynchronement (imprimer un message comme -[1]+ Commande terminée).
Scripting et traitement des lots
Exécution de scripts
Pour prendre en charge les fichiers script, ajoutez un drapeau en ligne de commande (p. ex. ) ou une commande intégrée . Le shell ouvre le fichier, lit les lignes et les traite comme s'ils étaient tapés de manière interactive, sauf qu'aucune invitation n'est imprimée et que le contrôle de travail peut être simplifié (les scripts sont généralement exécutés au premier plan).
La manipulation de Shebang () est facultative mais simple : si les deux premiers octets du fichier de script sont , le noyau interprétera le reste comme l'interprète. Pour que votre shell fonctionne comme un interpréteur de script, il doit ignorer la ligne de Shebang lors de la lecture d'un fichier (certains shells le traitent comme un commentaire).
Débit de contrôle
Le support complet du script nécessite l'analyse et l'exécution des instructions de flux de contrôle : , , , . Cela augmente considérablement la complexité de l'analyseur et de l'exécuteur. Une approche minimale consiste à fournir une exécution séquentielle et une chaîne de commande simple (en utilisant , , ). Pour un script avancé, vous pouvez mettre en œuvre un analyseur récursif-descent qui construit un arbre de commandes et de structures de contrôle.
Au minimum, la prise en charge de l'exécution conditionnelle basée sur les codes de sortie ( n'exécute cmd2 que si cmd1 réussit) ajoute une valeur énorme. Vous pouvez gérer cela pendant l'exécution : exécuter la première commande, vérifier son état de sortie et exécuter sous condition la suivante.
Gestion des erreurs dans les scripts
Les scripts nécessitent souvent une gestion robuste des erreurs. Implémentez l'option pour quitter le script si une commande échoue. Supportez également pour attraper les signaux et les erreurs. Ces fonctionnalités nécessitent une machine d'état global et un nettoyage soigneux (libération de mémoire, fermeture de fichiers).
Caractéristiques avancées
Historique des commandes
Un mécanisme d'historique permet aux utilisateurs de rappeler, de modifier et de ré-exécuter les commandes précédentes. Implémenter un tampon circulaire stockant les dernières commandes N (p. ex. 1000). Fournir des commandes intégrées et (ou implémenter les touches de navigation Bash : flèches vers le haut/vers le bas). Utiliser ou la bibliothèque pour supporter l'édition en ligne et le rappel d'historique en mode interactif.
Fin de l'onglet
Pour les fichiers, vous pouvez utiliser la bibliothèque pour lister les contenus du répertoire correspondant au préfixe courant. Pour les commandes, scannez les répertoires . Pour les variables, recherchez l'environnement. Cette fonctionnalité est un ajout populaire et vous enseignez sur la correspondance de la chaîne et de la traversée du répertoire.
Substitution et expansion variables
Au-delà de la simple , l'extension de l'accoudoir (), l'extension de tilde ([), la substitution de commande ( ou les backticks), et l'expansion arithmétique (). Chacun nécessite une phase d'analyse distincte. La substitution de commande, en particulier, est complexe parce qu'elle implique l'exécution d'une sous-coquille et la capture de sa sortie.
Autres
Les alias permettent aux utilisateurs de définir des commandes à main courte (par exemple ]. Stocker les alias dans une table de hachage ou une liste liée. Pendant la tokenisation, si le premier jeton correspond à un alias, le remplacer par son expansion.
Conclusion
Construire un shell personnalisé en C avec des fonctionnalités avancées est un projet complet qui touche à de nombreux concepts de système d'exploitation de base: gestion de processus, signaux, descripteurs de fichiers, et analyse. En implémentant le contrôle de travail, la tuyauterie, la redirection, le script, et des commodités supplémentaires comme l'historique et l'achèvement des onglets, vous créez un outil à la fois éducatif et pratique. Le code que vous écrivez approfondira votre compréhension de la façon dont Bash et Zsh fonctionnent sous le capot, et vous gagnerez en reconnaissance pour les décennies d'ingénierie qui rendent les shells modernes si polyvalents et fiables.
Commencez par une boucle minimale et ajoutez des fonctionnalités progressives. Testez chaque addition en profondeur, en particulier les cas de bord impliquant plusieurs tuyaux, processus de fond et interactions de signal. De nombreuses implémentations de shell open-source (comme GNU Bash[ et Zsh[) sont disponibles pour référence, tout comme les spécifications POSIX pour le comportement de shell.