Écrivez votre propre interpréteur de commandes

Gazette Linux n°111 — Février 2005

Hiran Ramankutty

Article paru dans le n°111 de la Gazette Linux de février 2005.

Traduction française par Joëlle Cornavin .

Relecture de la traduction française par Éric Madesclair .

Article publié sous Open Publication License. La Linux Gazette n'est ni produite, ni sponsorisée, ni avalisée par notre hébergeur principal, SSC, Inc.


Table des matières
1. Introduction
2. Les interpréteurs de commandes (ou shells)
3. Une note sur execve()
4. Quelques notions de base
5. Premiers pas
6. Exécution d'une commande
7. Code complet et imperfection
8. Conclusion

1. Introduction

Ce n'est pas un autre tutoriel sur les langages de programmation. Surprise ! Il y a quelques jours, j'ai essayé de donner des explications à un de mes amis sur l'implémentation de la commande ls, bien que je n'aie jamais pensé à aller au-delà du fait que ls ne fait que répertorier tous les fichiers et répertoires. Cependant, mon ami m'a justement incité à penser à tout qui se produit depuis le moment où nous saisissons la commande ls jusqu'à celui où nous voyons la sortie qu'elle produit. En conséquence, j'ai eu l'idée de placer le contenu dans un certain bloc de code qui fonctionnera de la même manière. En conclusion, j'ai fini par essayer d'écrire mon propre interpréteur, ce qui permet à mon programme de fonctionner d'une manière similaire au shell Linux.


2. Les interpréteurs de commandes (ou shells)

À l'amorçage du système, on peut voir l'écran de connexion. Nous ouvrons une session dans le système à l'aide de notre nom d'utilisateur, suivi de notre mot de passe. Le nom de connexion (ou login) fait l'objet d'une vérification dans le fichier de mots de passe du système (en principe /etc/passwd). Si le nom de connexion est trouvé, le mot de passe est vérifié. Le mot de passe chiffré de l'utilisateur est lisible dans le fichier /etc/shadow, immédiatement précédé du nom de l'utilisateur et d'un caractère :). Après vérification du mot de passe, nous sommes connectés au système.

Dès lors, nous pouvons voir l'interpréteur de commandes où nous saisissons habituellement les commandes à exécuter. L'interpréteur de commandes, comme le décrit Richard Stevens dans son ouvrage Advanced Programming in the Unix Environment, est un interpréteur en ligne de commande qui lit des entrées utilisateur et exécute des commandes.

C'était le point de départ pour moi. Un programme (notre shell) exécutant un autre programme (ce que l'utilisateur saisit à l'invite de commande). Je savais que execve et sa famille de fonctions pouvaient le faire, mais je n'avais jamais pensé à son utilisation pratique.


3. Une note sur execve()

En bref, execve et sa famille de fonctions vous aident à initier de nouveaux programmes. La famille se compose des fonctions suivantes :

int execve(const char *nom_de_fichier, char *const argv[], char *const envp[]); est le prototype tel qu'indiqué dans la page de man de execve. Le nom_de_fichier est le chemin complet de l'exécutable, argv et envp correspondent au tableau de chaînes contenant les variables d'argument et les variables d'environnement, respectivement.

En fait, l'appel système à proprement parler est sys_execve (pour la fonction execve) et les autres fonctions de cette famille sont juste des fonctions C d'encapsulation autour de execve. Maintenant, écrivons un petit programme utilisant execve. Reportez-vous au listing ci-dessous :

Listing 1.c

La compilation et l'exécution du a.out pour le programme précédent fournit la sortie de la commande /bin/ls. À présent, essayez celle-ci. Placez une instruction printf immédiatement après l'appel execve et exécutez le code.

Je n'entrerai pas dans les détails des encapsulations de execve. Il y a de bons ouvrages sur le sujet, dont celui que j'ai mentionné (de Richard Stevens), qui décrivent la famille execve en détail.


4. Quelques notions de base

Avant de commencer à écrire notre shell, jetons un coup d'½il à la séquence d'événements qui se produit, en partant du moment où l'utilisateur saisit quelque chose sur l'interpréteur jusqu'à celui où il voit la sortie de la commande qu'il a saisie. On n'imaginerait jamais qu'il se produise autant de traitement, même pour répertorier des fichiers.

Quand l'utilisateur appuie sur Entrée après avoir saisi /bin/ls, le programme qui exécute la commande (l'interpréteur de commandes ou shell) crée un nouveau processus en parallèle (fork. Ce processus invoque l'appel système execve pour exécuter bin/ls. Le chemin complet, /bin/ls est passé à titre de paramètre à execve ainsi que l'argument en ligne de commande (argv) et aux variables d'environnement (envp). Le gestionnaire d'appels système sys_execve vérifie que le fichier existe. Si c'est le cas, il vérifie s'il est dans un format de fichier exécutable. Pourquoi ? Si le fichier est dans un format de fichier exécutable, le contexte d'exécution du processus en cours est modifié. Pour finir, quand l'appel système sys_execve prend fin, bin/ls est exécutée et l'utilisateur voit la liste des répertoires !


5. Premiers pas

Assez de théorie ? Commençons par quelques fonctionnalités de base de l'interpréteur de commandes. Le listing ci-dessous essaie d'interpréter l'action de l'utilisateur qui appuie sur la touche Entrée à l'invite de commande.

Listing 2.c

C'est simple. Quelque chose comme l'incontournable programme hello world, qu'un programmeur écrit lorsqu'il apprend un nouveau langage de programmation. Chaque fois qu'il appuie sur la touche Entrée, l'interpréteur de commandes apparaît à nouveau. Lors de l'exécution de ce code, si l'utilisateur appuie sur Ctrl+D, le programme se termine. C'est la même chose que votre shell par défaut. Quand vous appuyez sur Ctrl+D, vous sortez du système.

Ajoutons une autre fonctionnalité pour interpréter une entrée Ctrl+C également. Vous y arriverez aisément en déclarant le gestionnaire de signal SIGINT. Que devrait faire le gestionnaire de signal ? Examinons le code du listing 3.

Listing 3.c

Exécutez le programme et appuyez sur Ctrl+C. Que se passe-t-il ? Vous voyez l'invite de commande à nouveau. Quelque chose que nous voyons quand nous appuyons sur Ctrl+C dans le shell que nous utilisons.

Maintenant, essayez ceci. Supprimez l'instruction fflush(stdout) et lancez le programme. Pour ceux qui ne peuvent pas prédire la sortie, l'astuce est que fflush force l'exécution de la fonction d'écriture sous-jacente pour la sortie standard.


6. Exécution d'une commande

Étendons les fonctionnalités de notre shell pour exécuter quelques commandes de base. En premier lieu, nous lirons les entrées utilisateur, puis nous vérifierons si une telle commande existe et nous l'exécuterons.

Je suis en train de lire les entrées utilisateur à l'aide de getchar(). Chaque caractère lu est placé dans un tableau temporaire, qui sera analysé ultérieurement pour mettre entre crochets la commande complète ainsi que ses options en ligne de commande. La lecture des caractères devrait se poursuivre jusqu'à ce que l'utilisateur appuie sur la touche Entrée. C'est ce que montre le listing 4.

Listing 4.c

Nous avons à présent la chaîne qui se compose des caractères que l'utilisateur a saisis à notre invite de commande. Il nous faut maintenant l'analyser pour séparer la commande de ses options. Pour être plus clair, supposons que l'utilisateur saisisse la commande suivante :



gcc -o hello hello.c

Nous avons alors les arguments en ligne de commande sous la forme suivante :


argv[0] = "gcc"
argv[1] = "-o"
argv[2] = "hello"
argv[3] = "hello.c"

Au lieu d'employer argv, nous créons notre propre structure de données (un tableau de chaînes) pour stocker les arguments en ligne de commande. Le listing ci-dessous définit la fonction fill_argv. Il considère la chaîne d'entrée utilisateur comme un paramètre et l'analyse pour remplir la structure de données my_argv. Nous faisons la distinction entre la commande et les options en ligne de commande avec des espaces intermédiaires ( ).

Listing 5.c

La chaîne d'entrée utilisateur est analysée caractère par caractère. Les caractères entre les blancs sont copiés dans la structure de données my_argv. J'ai limité le nombre d'arguments à 10, une décision arbitraire : nous pourrions en avoir davantage.

Pour finir, nous avons la chaîne d'entrée utilisateur entière de my_argv[0] à my_argv[9]. La commande sera my_argv[0] et les options de la commande (s'il y a lieu) iront de my_argv[1] à my_argv[k], où k<9. Ensuite ?

Après l'analyse, nous devons découvrir si la commande existe. Les appels à execve échoueront si la commande n'existe pas. Notez que la commande passée devra être le chemin complet. La variable d'environnement PATH stocke les différents chemins où les binaires pourraient être présents. Les chemins (un ou plusieurs) sont stockés dans PATH et séparés par un caractère :. La commande se doit de les rechercher.

La recherche peut être évitée par l'utilisation de execlp ou execvp que j'essaie de ne pas employer à dessein. execlp et execvp font cette recherche automatiquement.

Le listing ci-dessous définit une fonction qui vérifie l'existence de la commande.

Listing 6.c

La fonction attach_path dans le listing 6 sera appelée si son paramètre cmd n'a pas de caractère /. Quand la commande a un /, cela signifie que l'utilisateur spécifie un chemin pour la commande. Nous avons donc :


if(index(cmd, '/') == NULL) {
        attach_path(cmd);
        .....
}

La fonction attach_path utilise un tableau de chaînes qui est initialisé avec les chemins définis par la variable d'environnement PATH. Cette initialisation est indiquée dans le listing ci-dessous :

Listing 7.c

Le listing ci-dessus montre deux fonctions. La fonction get_path_string considère la variable d'environnement comme un paramètre et lit la valeur de l'élément PATH. Par exemple, nous avons :



PATH=/usr/kerberos/bin:/usr/local/bin:/bin:/usr/bin:/usr/X11R6/bin:/home/hiran/bin

La fonction utilise strstr depuis la bibliothèque standard pour placer le pointeur sur le début de la chaîne complète. La fonction insert_path_str_to_search l'emploie dans le listing 7 pour analyser différents chemins et les stocker dans une variable dont le rôle est de déterminer l'existence de chemins d'accès. Il y a d'autres méthodes, plus efficaces pour analyser, mais pour le moment, je m'en tiendrai à celle-ci.

Une fois que la fonction attach_path a déterminé l'existence de la commande, elle invoque execve pour exécuter la commande. Notez que attach_path copie le chemin d'accès complet avec la commande. Par exemple, si l'utilisateur saisit ls, attach_path la transforme en /bin/ls. Cette chaîne est ensuite passée tout en appelant execve en même temps que les arguments en ligne de commande (s'il y a lieu) et les variables d'environnement. C'est ce que montre le listing ci-dessous :

Listing 8.c

Ici, execve est appelée dans le processus fils, de sorte que le contexte du processus parent est maintenu.


7. Code complet et imperfection

Le listing ci-dessous est le code complet que j'ai (inefficacement) écrit.

Listing 9.c

Compilez et exécutez le code pour voir [MON_SHELL ]. Essayez de lancer quelques commandes de base : cela devrait fonctionner, prendre en charge la compilation et exécuter de petits programmes. Ne soyez pas surpris si cd ne répond pas. Cette commande, ainsi que plusieurs autres, sont intégrées au shell.

Vous pouvez faire en sorte que ce soit votre shell par défaut en éditant /etc/passwd ou via la commande chsh. La prochaine fois que vous vous connecterez, vous verrez [MON_SHELL ] au lieu du précédent par défaut.


8. Conclusion

L'idée force était de familiariser le lecteur avec ce que fait Linux quand il exécute une commande. Le code fourni ici ne gère pas toutes les fonctionnalités que bash, csh et ksh prennent en charge. La gestion des touches Tab, Page Haut/Bas, comme on le voit dans bash (mais non dans ksh) peut être implementée. D'autres fonctionnalités comme la prise en charge de la programmation shell, la modification des variables d'environnement pendant la durée d'exécution, etc. sont essentielles. Une étude attentive du code source de bash n'est pas une tâche aisée en raison des diverses complexités impliquées, mais elle vous aidera à développer un interpréteur de commandes très complet. Naturellement, la lecture du code source n'est pas la réponse complète. J'essaie également de trouver d'autres pistes, mais je manque de temps.

J'ai achevé ma formation de B. Tech in Computer Science & Engineering dans une petite ville appelée Trichur, dans le Kerala (Inde). Actuellement, je suis programmeur chez Naturesoft Pvt. Ltd®, à Chennai (Inde). Je consacre mes loisirs à lire et explorer des ouvrages sur Linux. Je m'intéresse également à la physique. Ma motivation dans la vie est d'aller de l'avant, plus loin, plus haut.