Construisons un shell Linux – Partie III – Le démarrage
Ceci est la partie III d’un tutoriel sur la façon de construire un shell Linux. Vous pouvez lire les deux premières parties de ce tutoriel à partir de ces liens: partie I, partie II.
REMARQUE: Vous pouvez télécharger le code source complet des parties II et III sur ce référentiel GitHub.
Dans la partie précédente de ce tutoriel, nous avons implémenté notre scanner lexical. Tournons maintenant nos yeux vers l’analyseur.
Pour récapituler, l’analyseur est la partie de notre interpréteur de ligne de commande qui appelle le scanner lexical pour récupérer des jetons, puis construit un Arbre de syntaxe abstraite, ou AST, de ces jetons. Cette AST est ce que nous transmettrons à l’exécuteur testamentaire pour être, eh bien, exécuté.
Notre analyseur contiendra une seule fonction, parse_simple_command()
. Dans les prochaines parties de ce tutoriel, nous ajouterons plus de fonctions pour permettre à notre shell d’analyser les boucles et les expressions conditionnelles.
Commençons donc à coder notre analyseur. Vous pouvez commencer par créer un fichier nommé parser.h
dans le répertoire source, auquel vous ajouterez le code suivant:
#ifndef PARSER_H
#define PARSER_H#include "scanner.h" /* struct token_s */
#include "source.h" /* struct source_s */struct node_s *parse_simple_command(struct token_s *tok);#endif
Rien d’extraordinaire, juste déclarer notre seule fonction d’analyseur.
Ensuite, créez parser.c
et ajoutez-y le code suivant:
#includestruct node_s *parse_simple_command(struct token_s *tok)
#include "shell.h"
#include "parser.h"
#include "scanner.h"
#include "node.h"
#include "source.h"
{
if(!tok)
{
return NULL;
}struct node_s *cmd = new_node(NODE_COMMAND);
if(!cmd)
{
free_token(tok);
return NULL;
}struct source_s *src = tok->src;
do
struct node_s *word = new_node(NODE_VAR);
{
if(tok->text[0] == 'n')
{
free_token(tok);
break;
}
if(!word)
{
free_node_tree(cmd);
free_token(tok);
return NULL;
}
set_node_val_str(word, tok->text);
add_child_node(cmd, word); free_token(tok); } while((tok = tokenize(src)) != &eof_token); return cmd;
}
Assez simple, non? Pour analyser une commande simple, il suffit d’appeler tokenize()
pour récupérer les jetons d’entrée, un par un, jusqu’à ce que nous obtenions un jeton de nouvelle ligne (que nous testons dans la ligne qui lit: if(tok->text[0] == 'n')
), ou nous arrivons à la fin de notre entrée (nous savons que cela s’est produit lorsque nous obtenons un eof_token
jeton. Voir l’expression conditionnelle de boucle près du bas de la liste précédente). Nous utilisons des jetons d’entrée pour créer un AST, qui est un structure arborescente qui contient des informations sur les composants d’une commande. Les détails devraient être suffisants pour permettre à l’exécuteur d’exécuter correctement la commande. Par exemple, la figure ci-dessous montre à quoi ressemble l’AST d’une commande simple.
Chaque nœud de l’AST de la commande doit contenir des informations sur le jeton d’entrée qu’il représente (comme le texte du jeton d’origine). Le nœud doit également contenir des pointeurs vers ses nœuds enfants (si le nœud est un nœud racine), ainsi que ses nœuds frères (si le nœud est un nœud enfant). Par conséquent, nous devrons définir une autre structure, struct node_s
, que nous utiliserons pour représenter les nœuds dans notre AST.
Allez-y et créez un nouveau fichier, node.h
et ajoutez-y le code suivant:
#ifndef NODE_H
#define NODE_Henum node_type_e
{
NODE_COMMAND, /* simple command */
NODE_VAR, /* variable name (or simply, a word) */
};enum val_type_e
{
VAL_SINT = 1, /* signed int */
VAL_UINT, /* unsigned int */
VAL_SLLONG, /* signed long long */
VAL_ULLONG, /* unsigned long long */
VAL_FLOAT, /* floating point */
VAL_LDOUBLE, /* long double */
VAL_CHR, /* char */
VAL_STR, /* str (char pointer) */
};union symval_u
{
long sint;
unsigned long uint;
long long sllong;
unsigned long long ullong;
double sfloat;
long double ldouble;
char chr;
char *str;
};struct node_s
{
enum node_type_e type; /* type of this node */
enum val_type_e val_type; /* type of this node's val field */
union symval_u val; /* value of this node */
int children; /* number of child nodes */
struct node_s *first_child; /* first child node */
struct node_s *next_sibling, *prev_sibling;
/*
* if this is a child node, keep
* pointers to prev/next siblings
*/
};struct node_s *new_node(enum node_type_e type);
void add_child_node(struct node_s *parent, struct node_s *child);
void free_node_tree(struct node_s *node);
void set_node_val_str(struct node_s *node, char *val);#endif
le node_type_e
l’énumération définit les types de nos nœuds AST. Pour l’instant, nous n’avons besoin que de deux types. Le premier type représente le nœud racine de l’AST d’une commande simple, tandis que le second type représente les nœuds enfants de la commande simple (qui contiennent le nom de la commande et arguments). Dans les prochaines parties de ce didacticiel, nous ajouterons d’autres types de nœuds à cette énumération.
le val_type_e
l’énumération représente les types de valeurs que nous pouvons stocker dans une structure de nœuds donnée. Pour les commandes simples, nous n’utiliserons que des chaînes (VAL_STR
type d’énumération). Plus loin dans cette série, nous utiliserons les autres types lorsque nous traiterons d’autres types de commandes complexes.
le symval_u
union représente la valeur que nous pouvons stocker dans une structure de nœuds donnée. Chaque nœud ne peut avoir qu’un seul type de valeur, comme une chaîne de caractères ou une valeur numérique. Nous accédons à la valeur du nœud en référençant le membre de l’union approprié (sint
pour les entiers longs signés, str
pour les cordes, etc.).
le struct node_s
la structure représente un nœud AST. Il contient des champs qui nous renseignent sur le type du nœud, le type de la valeur du nœud, ainsi que la valeur elle-même. S’il s’agit d’un nœud racine, le children
champ contient le nombre de nœuds enfants, et first_child
pointe vers le premier nœud enfant (sinon il sera NULL
). S’il s’agit d’un nœud enfant, nous pouvons traverser les nœuds AST en suivant la next_sibling
et prev_sibling
pointeurs.
Si nous voulons récupérer la valeur d’un nœud, nous devons vérifier la val_type
domaine et, selon ce que nous y trouvons, accéder au membre approprié de la val
champ. Pour les commandes simples, tous les nœuds auront les attributs suivants:
-
type
=>NODE_COMMAND
(nœud racine) ouNODE_VAR
(nom de la commande et liste des arguments) -
val_type
=>VAL_STR
-
val.str
=> pointeur sur la valeur de chaîne
Écrivons maintenant quelques fonctions pour nous aider à travailler avec les structures de nœuds.
Créez un fichier nommé node.c
et ajoutez le code suivant:
#includestruct node_s *new_node(enum node_type_e type)
#include
#include
#include
#include "shell.h"
#include "node.h"
#include "parser.h"
{
struct node_s *node = malloc(sizeof(struct node_s)); if(!node)
{
return NULL;
}memset(node, 0, sizeof(struct node_s));
node->type = type;return node;
void add_child_node(struct node_s *parent, struct node_s *child)
}
{
if(!parent || !child)
{
return;
} if(!parent->first_child)
{
parent->first_child = child;
}
else
{
struct node_s *sibling = parent->first_child;while(sibling->next_sibling)
{
sibling = sibling->next_sibling;
}sibling->next_sibling = child;
void set_node_val_str(struct node_s *node, char *val)
child->prev_sibling = sibling;
}
parent->children++;
}
{
node->val_type = VAL_STR; if(!val)
{
node->val.str = NULL;
}
else
{
char *val2 = malloc(strlen(val)+1);if(!val2)
void free_node_tree(struct node_s *node)
{
node->val.str = NULL;
}
else
{
strcpy(val2, val);
node->val.str = val2;
}
}
}
{
if(!node)
{
return;
} struct node_s *child = node->first_child;while(child)
{
struct node_s *next = child->next_sibling;
free_node_tree(child);
child = next;
}if(node->val_type == VAL_STR)
{
if(node->val.str)
{
free(node->val.str);
}
}
free(node);
}
le new_node()
la fonction crée un nouveau nœud et définit son type
champ.
le add_child_node()
La fonction étend l’AST d’une simple commande en ajoutant un nouveau nœud enfant et en incrémentant le nœud racine children
champ. Si le nœud racine n’a pas d’enfants, le nouvel enfant est affecté au first_child
champ du nœud racine. Sinon, l’enfant est ajouté à la fin de la liste des enfants.
le set_node_val_str()
La fonction définit la valeur d’un nœud sur la chaîne donnée. Il copie la chaîne dans un espace mémoire nouvellement alloué, puis définit val_type
et val.str
champs en conséquence. À l’avenir, nous définirons des fonctions similaires pour nous permettre de définir des valeurs de nœud pour différents types de données, tels que des entiers et des virgules flottantes.
le free_node_tree()
libère la mémoire utilisée par une structure de noeud. Si le nœud a des enfants, la fonction est appelée récursivement pour libérer chacun d’eux.
C’est tout pour l’analyseur. Écrivons maintenant notre exécuteur de commande.
Semblable à notre analyseur, l’exécuteur ne contiendra qu’une seule fonction, do_simple_command()
. Dans les prochaines parties de ce tutoriel, nous ajouterons plus de fonctions pour nous permettre d’exécuter toutes sortes de commandes, telles que des boucles et des expressions conditionnelles.
Créez un fichier nommé executor.h
et ajoutez le code suivant:
#ifndef BACKEND_H
#define BACKEND_H#include "node.h"char *search_path(char *file);
int do_exec_cmd(int argc, char **argv);
int do_simple_command(struct node_s *node);#endif
Juste quelques prototypes de fonctions. Maintenant, créez executor.c
et définir les fonctions suivantes:
#includechar *search_path(char *file)
#include
#include
#include
#include
#include
#include
#include "shell.h"
#include "node.h"
#include "executor.h"
{
char *PATH = getenv("PATH");
char *p = PATH;
char *p2;while(p && *p)
while(*p2 && *p2 != ':')
{
p2 = p;
{
p2++;
}int plen = p2-p;
if(!plen)
{
plen = 1;
}int alen = strlen(file);
char path[plen+1+alen+1];strncpy(path, p, p2-p);
path[p2-p] = '';if(p2[-1] != '/')
strcat(path, file);
{
strcat(path, "/");
}struct stat st;
p = malloc(strlen(path)+1);
if(stat(path, &st) == 0)
{
if(!S_ISREG(st.st_mode))
{
errno = ENOENT;
p = p2;
if(*p2 == ':')
{
p++;
}
continue;
}
if(!p)
{
return NULL;
}strcpy(p, path);
errno = ENOENT;
return p;
}
else /* file not found */
{
p = p2;
if(*p2 == ':')
{
p++;
}
}
}
return NULL;
}
int do_exec_cmd(int argc, char **argv)
{
if(strchr(argv[0], '/'))
{
execv(argv[0], argv);
}
else
{
char *path = search_path(argv[0]);
if(!path)
{
return 0;
}
execv(path, argv);
free(path);
}
return 0;
}
static inline void free_argv(int argc, char **argv)
{
if(!argc)
{
return;
} while(argc--)
{
free(argv[argc]);
}
}
int do_simple_command(struct node_s *node)
{
if(!node)
{
return 0;
} struct node_s *child = node->first_child;
if(!child)
{
return 0;
}int argc = 0;
long max_args = 255;
char *argv[max_args+1];/* keep 1 for the terminating NULL arg */
char *str;while(child)
{
str = child->val.str;
argv[argc] = malloc(strlen(str)+1);if(!argv[argc])
{
free_argv(argc, argv);
return 0;
}strcpy(argv[argc], str);
pid_t child_pid = 0;
if(++argc >= max_args)
{
break;
}
child = child->next_sibling;
}
argv[argc] = NULL;
if((child_pid = fork()) == 0)
{
do_exec_cmd(argc, argv);
fprintf(stderr, "error: failed to execute command: %sn",
strerror(errno));
if(errno == ENOEXEC)
{
exit(126);
}
else if(errno == ENOENT)
{
exit(127);
}
else
{
exit(EXIT_FAILURE);
}
}
else if(child_pid < 0)
{
fprintf(stderr, "error: failed to fork command: %sn",
strerror(errno));
return 0;
} int status = 0;
waitpid(child_pid, &status, 0);
free_argv(argc, argv);return 1;
}
le search_path()
prend le nom d’une commande, puis recherche les répertoires répertoriés dans le $PATH
pour essayer de trouver le fichier exécutable de la commande. le $PATH
La variable contient une liste de répertoires séparés par des virgules, tels que /bin:/usr/bin
. Pour chaque répertoire, nous créons un chemin d’accès en ajoutant le nom de la commande au nom du répertoire, puis nous appelons stat()
pour voir si un fichier existe avec le nom de chemin donné (pour simplifier, nous ne vérifions pas si le fichier est réellement exécutable, ou si nous avons suffisamment d’autorisations pour l’exécuter). Si le fichier existe, nous supposons qu’il contient la commande que nous voulons exécuter et nous renvoyons le chemin d’accès complet de cette commande. Si nous ne trouvons pas le fichier dans le premier $PATH
répertoire, nous recherchons le deuxième, le troisième et le reste du $PATH
répertoires, jusqu’à ce que nous trouvions notre fichier exécutable. Si nous échouons à trouver la commande en recherchant tous les répertoires du $PATH
, nous retournons NULL
(cela signifie généralement que l’utilisateur a tapé un nom de commande non valide).
le do_exec_cmd()
La fonction exécute une commande en appelant execv()
pour remplacer l’image de processus actuelle par le nouvel exécutable de commande. Si le nom de la commande contient des barres obliques, nous le traitons comme un chemin d’accès et nous appelons directement execv()
. Sinon, nous essayons de localiser la commande en appelant search_path()
, qui devrait renvoyer le chemin d’accès complet que nous transmettrons à execv()
.
le free_argv()
libère la mémoire utilisée pour stocker la liste des arguments de la dernière commande exécutée.
le do_simple_command()
La fonction est la fonction principale de notre exécuteur. Il prend l’AST de la commande et le convertit en une liste d’arguments (N’oubliez pas que l’argument zéro, ou argv[0]
, contient le nom de la commande que nous voulons exécuter).
La fonction lance alors un nouveau processus enfant. Dans le processus enfant, nous exécutons la commande en appelant do_exec_cmd()
. Si la commande est exécutée avec succès, cet appel ne devrait pas retourner. S’il revient, cela signifie qu’une erreur s’est produite (par exemple, la commande n’a pas été trouvée, le fichier n’était pas exécutable, mémoire insuffisante, …). Dans ce cas, nous imprimons un message d’erreur approprié et quittons avec un état de sortie différent de zéro.
Dans le processus parent, nous appelons waitpid()
d’attendre la fin de l’exécution de notre processus enfant. Nous libérons ensuite la mémoire utilisée pour stocker la liste des arguments et la renvoyer.
Maintenant, afin d’incorporer le nouveau code dans notre shell existant, vous devez d’abord mettre à jour le main()
fonction en supprimant la ligne qui lit printf("%sn", cmd);
et remplacez-le par les lignes suivantes:
struct source_s src;
src.buffer = cmd;
src.bufsize = strlen(cmd);
src.curpos = INIT_SRC_POS;
parse_and_execute(&src);
Avant de fermer le main.c
fichier, allez au début du fichier et ajoutez les lignes suivantes après la dernière #include
directif:
#include "source.h"
#include "parser.h"
#include "backend.h"
Allez ensuite à la fin du fichier (après le read_cmd()
définition de la fonction) et ajoutez la fonction suivante:
int parse_and_execute(struct source_s *src)
{
skip_white_spaces(src); struct token_s *tok = tokenize(src); if(tok == &eof_token)
{
return 0;
} while(tok && tok != &eof_token)
{
struct node_s *cmd = parse_simple_command(tok); if(!cmd)
{
break;
} do_simple_command(cmd);
free_node_tree(cmd);
tok = tokenize(src);
} return 1;
}
Cette fonction prend la Eval-Print une partie de notre Lecture-Eval-Print-Loop (REPL) Loin de main()
une fonction. Il commence par ignorer les premiers caractères des espaces, puis analyse et exécute des commandes simples, une commande à la fois, jusqu’à ce que l’entrée soit consommée, avant de retourner le contrôle à la boucle REPL dans le main()
une fonction.
Enfin, n’oubliez pas d’ajouter le prototype d’inclusion et de fonction suivant à votre shell.h
dossier, juste avant la fermeture #endif
directif:
#include "source.h"
int parse_and_execute(struct source_s *src);
Et ça devrait être ça! Maintenant, compilons notre shell.
Compilons notre shell. Ouvrez votre émulateur de terminal préféré, accédez à votre répertoire source et assurez-vous que vous y avez 13 fichiers:
Compilez maintenant le shell à l’aide de la commande suivante:
gcc -o shell main.c source.c scanner.c parser.c node.c executor.c prompt.c
Si tout va bien, gcc
ne devrait rien produire, et il devrait y avoir un fichier exécutable nommé shell
dans le répertoire courant:
Appelez maintenant le shell en exécutant ./shell
et essayez de saisir quelques commandes, telles que ls
, ls -l
, et echo Hello World!
:
Comme vous pouvez le voir, notre shell peut désormais analyser et exécuter des commandes simples, quel que soit le nombre d’arguments que nous passons dans la ligne de commande. Yay!
Cependant, si vous essayez d’exécuter une commande telle que echo $PATH
, vous constaterez que le résultat n’est pas celui que vous attendiez! En effet, notre shell ne sait pas comment accéder à son environnement, comment stocker des variables de shell et comment effectuer une expansion de mots. C’est ce que nous allons corriger dans la prochaine partie de ce tutoriel.
Pour pouvoir développer des mots, nous devons d’abord accéder à l’environnement du shell et trouver un moyen dynamique de stocker les variables du shell. Nous le ferons dans la partie suivante.
Restez à l’écoute!