27 septembre 2023
Historique
demo-exec-dup.c
+ typos (merci
Damien Simler !)La réalisation d’un mini-shell (interprète de commandes) est un projet classique de programmation système en C.
Dans sa version la plus simpliste, un shell est une boucle qui
Les commandes sont des suites de mots : en général le chemin d’accès d’un programme (exécutable), suivi par des options, des arguments…
La réalisation d’un tel programme n’est pas très compliquée.
Là où ça se complique un peu, c’est si on veut que le shell permette d’exécuter des “pipelines” de commandes, c’est-à-dire de lancer plusieurs commandes en redirigeant la sortie de l’une vers l’autre, comme dans
ls -l | grep -v ^d | more
La difficulté est essentiellement d’utiliser correctement les tuyaux qui interviennent dans un “pipeline” de commandes.
Nous allons voir ça après quelques rappels.
Attention : pour simplifier la présentation, dans le
code ci-dessous on ne vérifie jamais que les appels systèmes ont réussi.
Par exemple fork
, pipe
etc. peuvent
théoriquement échouer, et retourner -1 dans ce cas. En vrai, il faudrait
vérifier.
execv
Le programme suivant
ls /tmp
au moyen de
execv
.// demo-exec.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
("# lancement " __FILE__ "\n");
printf
char *path = "/bin/ls";
char *arguments[] = { "ls", "/tmp", NULL};
(path, arguments);
execv
("échec lancement");
perror(EXIT_FAILURE);
exit}
La fonction execv
prend comme paramètres
NULL
.Il existe des variantes de cette fonction, notamment
execvp
qui recherche l’exécutable indiqué en premier
paramètre dans les répertoires qui figurent dans la variable
d’environnement PATH
. Lire la page de manuel.
execv()
Normalement le fichier indiqué (/bin/ls
) sera chargé
dans la mémoire du processus en remplacement du code de
demo-exec
, et sa fonction main
, qui a comme
prototype int main(int argc, char **argv)
sera appelée
avec
argv
(argument
values)argc
(argument count)Le code qui s’exécute ayant été remplacé, la fin de l’exécution de
/bin/ls
termine le processus : on ne revient pas de
execv
.
execv()
Si le fichier indiqué en premier paramètre est absent, pas
accessible, pas exécutable etc. l’appel à execv
retourne,
et les instructions qui suivent sont exécutées.
Après l’appel à execv
, on trouve donc le code qui gère
cet échec.
execv
en cas d’échec est
-1
, mais peu importe : ce n’est pas la peine de la tester :
si on est ressorti d’execv
c’est que le lancement du
programme n’a pas pu se faire.execv
retourne), et “l’exécution du programme lancé a
échoué” (le programme lancé s’est effectivement exécuté, et s’est
terminé par exit
en retournant un code non nul).dup2
La plupart des commandes
STDIN_FILENO
),STDOUT_FILENO
= 1),STDERR_FILENO
= 2).L’exemple ci-dessous1 utilise l’appel dup2
pour que la commande tr
(lancée avec les paramètres pour
convertir les minuscules en majuscules), s’exécute avec son entrée
standard redirigée vers un fichier :
// demo-exec-dup.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
("# lancement " __FILE__ "\n");
printf
int file_fd = open("demo-exec-dup.c", O_RDONLY);
(file_fd, STDIN_FILENO);
dup2(file_fd);
close
("/bin/tr", (char *[]){ "tr", "a-z", "A-Z", NULL} );
execv
("échec lancement");
perror(EXIT_FAILURE);
exit
}
Remarque : Dans
execv("/bin/tr", (char *[]){"tr", "a-z", "A-Z", NULL});
on
utilise un tableau anonyme en C.
execv
en une
seule ligne, sans botter en touche sur des constantes, pour rendre plus
visible la gestion des descripteurs (dup2
,
close
).Explications :
open
ouvre le fichier en
lecture,dup2(file_fd, STDIN_FILENO)
fait en sorte que
le descripteur 0
conduise au même fichier que le
3
(le descripteur 0
est fermé
préalablement).execv
: la commande tr
s’exécute donc avec son
entrée standard reliée au fichier de données.close
de file_fd
évite
une fuite de descripteur : on ne veut transmettre que
les descripteurs 0
, 1
et 2
.Ce problème de fuite de descripteur va compliquer la réalisation d’un pipeline.
Exécution :
Le programme affiche le code source en majuscules :
# lancement demo-exec-dup.c
// DEMO-EXEC-DUP.C
#INCLUDE <STDIO.H>
#INCLUDE <STDLIB.H>
...
EXIT(EXIT_FAILURE);
}
fork
+ waitpid
Comme l’exécution d’une commande par execv
termine le
processus en cours, on va avoir un problème si on veut faire exécuter
plusieurs commandes.
La solution est de faire exécuter chaque commande par un processus créé à cet effet.
// demo-fork.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdbool.h>
#include <assert.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <fcntl.h>
void execute_task()
{
int fd = open("demo-fork.c", O_RDONLY);
(fd, STDOUT_FILENO);
dup2(fd);
close
("/bin/tr", (char *[]){ "tr", "a-z", "A-Z", NULL} );
execv// en cas de problème pour lancer tr
("erreur lancement commande tr");
perror(EXIT_FAILURE);
exit}
void explain_status(int child_status)
{
("# La commande s'est terminée ");
printfif (WIFEXITED(child_status)) {
("par exit(%d)\n", WEXITSTATUS(child_status));
printf} else if (WIFSIGNALED(child_status)) {
("par la réception du signal %d\n", WTERMSIG(child_status));
printf} else {
("pour une raison x ou y.\n");
printf}
}
int main(int argc, char *argv[])
{
= fork();
pid_t child_pid if (child_pid == 0) {
();
execute_task("on ne revient jamais ici" && false);
assert}
int child_status;
(child_pid, &child_status, 0);
waitpid
(child_status);
explain_status(EXIT_SUCCESS);
exit}
L’exécution montre ceci :
// DEMO-FORK.C
#INCLUDE <STDIO.H>
#INCLUDE <STDLIB.H>
....
EXPLAIN_STATUS(CHILD_STATUS);
EXIT(EXIT_SUCCESS);
}
# La commande s'est terminée par exit(0)
Explications brèves (qui ne remplacent pas un cours)
fork()
démarre un nouveau processus (dit
“fils”) qui est une copie du processus en cours d’exécution (son
“père”).
fork()
retourne au père l’identifiant du processus
fils, et 0 à celui-ci.Le processus fils appelle donc la fonction
execute_task()
, tandis que le processus père qui a reçu
l’identifiant non nul de son fils ne rentre pas dans le corps du
if
.
La fonction execute_task()
- exécutée par le fils -
lance la commande /bin/tr
comme dans l’exemple précédent.
On ne revient jamais de cette fonction qui lance /bin/tr
par execv
, ou exit(EXIT_FAILURE)
en cas
d’échec.
En même temps (à peu près) le processus père
waitpid
qui le bloque en attendant la fin de
l’exécution du fils,waitpid
est l’adresse d’un
entier qui contiendra les informations sur la fin du processus fils.
Dans explain_child_status
, diverses macros permettent
exit()
ou en recevant
un signal qui a provoqué sa fin,pipe
Un “tuyau” est un pseudo-fichier géré par le système d’exploitation. C’est un tampon en mémoire dans lequel on peut écrire et lire. Il sert à la communication entre processus issus d’un même père. Les données sont lues dans l’ordre où elles ont été écrites dans le tuyau.
Un tuyau a une capacité limitée. Un processus qui veut écrire plus de données dans le tuyau qu’il ne peut en contenir sera bloqué en attendant qu’il y ait suffisamment de place.
Le tuyau est créé par un appel à la commande pipe
qui
retourne - dans un tableau donné en paramètre - deux descripteurs :
celui qui sert à lire dans le tuyau, et celui qui sert à y écrire.
Plusieurs processus peuvent lire et/ou écrire dans un même tuyau, pourvu qu’ils disposent d’un descripteur ouvert qui permet l’opération.
Important : En lecture, on atteint la “fin de fichier” quand il n’y a plus de données disponibles dans le tuyau, et que tous les descripteurs d’écriture ont été fermés.
Pour chaque processus, il faudra donc faire très attention à bien refermer tous les descripteurs (surtout d’écriture) dont on ne se sert pas. Sinon un processus en lecture pourra rester bloqué indéfiniment, en attente de données que personne ne lui enverra.
L’exemple qui suit est equivalent au lancement de la commande shell :
date | tr a-z A-Z
qui affiche la date mise en majuscules
SAM. 14 JANV. 2023 18:50:22 CET
Voici le source :
// demo-pipe.c
// demo-pipe.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <fcntl.h>
int main(int argc, char *argv[])
{
int pipe_fd[2];
(pipe_fd);
pipe
= fork();
pid_t date_pid if (date_pid == 0) {
// code exécuté par le premier processus fils (date)
(pipe_fd[1], STDOUT_FILENO);
dup2(pipe_fd[0]);
close(pipe_fd[1]);
close("/bin/date", (char *[]){"date", NULL});
execv("échec lancement de date");
perror(EXIT_FAILURE);
exit}
= fork();
pid_t tr_pid if (tr_pid == 0) {
// code exécuté par le second processus fils (tr)
(pipe_fd[0], STDIN_FILENO);
dup2(pipe_fd[0]);
close(pipe_fd[1]);
close("/bin/tr", (char *[]){"tr", "a-z", "A-Z", NULL});
execv("échec lancement de tr");
perror(EXIT_FAILURE);
exit}
(pipe_fd[0]);
close(pipe_fd[1]);
close(date_pid, NULL, 0);
waitpid(tr_pid, NULL, 0);
waitpid
(EXIT_SUCCESS);
exit}
Explications :
pipe(pipe_fd)
crée un tuyau et place
les descripteurs dans le tableau.date
se fasse dans le tuyau ;tr
prenne son entrée dans le tuyau ;Remarque : Comme le descripteur d’écriture
pipe_fd[1]
n’est utilisé que par le premier processus fils,
le processus père pourrait le refermer immédiatement après le lancement
du premier fils, ce qui dispenserait d’avoir à le faire dans aussi le
code du second. Mais on a privilégié ici la simplicité du code.
fork()
crée un processus “fils” qui est une copie de
celui qui l’appelle (= père). Il retourne 0 au fils, et le numéro du
fils au père. Les descripteurs ouverts sont partagés.exit()
termine le processus qui l’appelle.waitpid()
bloque un processus en attente de la fin d’un
autre dont on donne le numéro.pipe()
crée un tuyau, et retourne dans un tableau une
paire de descripteurs vers les extrémités qui servent à y lire et y
écrire.dup2()
duplique un descripteur, ce qui permet de
rediriger une entrée ou une sortie vers un fichier ou un pipe.close()
ferme un descripteur.execv()
remplace le processus courant par l’exécution
d’un programme en lui transmettant des arguments, et en partageant les
descripteurs ouverts. L’exit du programme terminera le processus. En cas
d’échec de lancement (fichier absent, non exécutable, etc) l’appelant
continue.Quand on aborde un problème un peu compliqué, il est conseillé de commencer par regarder un exemple concret, petit mais significatif. À partir de là, on pourra construire plus facilement une solution générale.
Considérons donc un exemple de pipeline qui met en oeuvre 4 programmes :
A | B | C | D
pourquoi 4 ?
L’idée de base sera de lancer un processus pour chaque commande, et d’utiliser trois tuyaux T1, T2, T3 pour les faire communiquer
lancer processus fils:
exécuter A, qui
- lit sur l'entrée standard
- écrit dans T1
lancer processus fils:
exécuter B, qui
- lit dans T1
- écrit dans T2
lancer processus fils:
exécuter C, qui
- lit dans T2
- écrit dans T3
lancer processus fils:
exécuter D, qui
- lit dans T4
- écrit sur la sortie standard
attendre la fin des processus fils.
Détaillons le lancement du premier :
créer tuyau T1 // 1
lancer processus fils:
fermer T1[1] // 2
exécuter A, qui
- lit sur l'entrée standard
- écrit dans T1[1]
fermer T1[1] et entrée standard
Le second processus doit lire dans T1 et écrire dans T2.
créer tuyau T2 // 1
lancer processus fils:
fermer T2[1] // 2
exécuter B, qui
- lit sur T1[0]
- écrit dans T2[1]
fermer T1[0] et T2[1] // 3
La situation est similaire pour le troisième
créer tuyau T3 // 1
lancer processus fils:
fermer T3[1] // 2
exécuter C, qui
- lit sur T2[0]
- écrit dans T3[1]
fermer T2[0] et T3[1] // 3
pour la dernière commande, on ne crée pas de tuyau puisqu’on écrit sur la sortie standard.
lancer processus fils:
exécuter D, qui
- lit sur T3[0]
- écrit sur la sortie standard
fermer T3[0]
Nous allons nous intéresser aux descripteurs plutot qu’aux tableaux,
en passant par des variables, on écrit la création d’un tuyau sous la
forme créer tuyau (sortie_tuyau, entrée_tuyau)
en indiquant
les deux variables qui contiennent les descripteurs d’écriture et de
lecture.
Une autre variable désigne le descripteur utilisé en lecture par le prochain processus.
Au départ, c’est l’entrée standard :
entrée = entrée_standard
créer tuyau (sortie_tuyau, entrée_tuyau)
lancer processus fils:
fermer entrée_tuyau
exécuter A, qui
- lit sur entrée
- écrit dans sortie_tuyau
fermer sortie_tuyau et entrée
Le pseudo-code pour les processus B et C n’est pas très différent :
entrée = entrée_tuyau
créer tuyau (sortie_tuyau, entrée_tuyau)
lancer processus fils:
fermer entrée_tuyau
exécuter B, qui
- lit sur entrée
- écrit dans sortie_tuyau
fermer sortie_tuyau et entrée
entrée = entrée_tuyau
créer tuyau (sortie_tuyau, entrée_tuyau)
lancer processus fils:
fermer entrée_tuyau
exécuter C, qui
- lit sur entrée
- écrit dans sortie_tuyau
fermer sortie_tuyau et entrée
Pour le dernier processus fils, on ne crée pas de tuyau :
entrée = entrée_tuyau
lancer processus fils:
fermer entrée_tuyau
exécuter D, qui
- lit sur entrée
- écrit dans sortie_standard
fermer entrée
Ici on réalise le pipeline
ls -l | grep ^- | cat | wc -l
qui affiche le nombre de fichiers présents dans le répertoire
courant. (Le cat
est inutile, il sert juste à avoir 4
processus !).
// demo-pipeline.c
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <assert.h>
int main()
{
("Exécution " __FILE__ "\n");
printf("Nombre de fichiers dans le répertoire =\n");
printf
int input_fd = STDIN_FILENO;
int pipe_fd[2];
(pipe_fd); // création pipe T1
pipe
= fork();
pid_t a_pid if (a_pid == 0) {
// le processus A écrit dans T1
(pipe_fd[1], STDOUT_FILENO);
dup2(pipe_fd[0]);
close(pipe_fd[1]);
close("/bin/ls", (char *[]) {
execv"ls", "-l", NULL
});
("lancement premier fils" && false);
assert}
(pipe_fd[1]); // fermeture desc. écriture de T1
close
= pipe_fd[0]; // sauvegarde descripteur lecture T1
input_fd (pipe_fd); // création pipe T2
pipe
= fork();
pid_t b_pid if (b_pid == 0) {
// le processus B lit dans T1, écrit dans T2
(input_fd, STDIN_FILENO);
dup2(pipe_fd[1], STDOUT_FILENO);
dup2(input_fd);
close(pipe_fd[0]);
close(pipe_fd[1]);
close("/bin/grep", (char *[]) {
execv"grep", "^-", NULL
});
("lancement second fils" && false);
assert}
(input_fd); // descripteur lecture T1
close(pipe_fd[1]); // descripteur écriture T2
close
= pipe_fd[0]; // sauvegarde descripteur lecture T2
input_fd (pipe_fd); // création pipe T3
pipe
= fork();
pid_t c_pid if (c_pid == 0) {
// le processus C lit dans T2, écrit dans T3
(input_fd, STDIN_FILENO);
dup2(pipe_fd[1], STDOUT_FILENO);
dup2(input_fd); // desc lecture T2
close(pipe_fd[0]); // desc lecture T3
close(pipe_fd[1]); // desc écriture T3
close("/bin/cat", (char *[]) {
execv"cat", NULL
});
("lancement troisième fils" && false);
assert}
(input_fd); // desc lecture T2
close(pipe_fd[1]); // desc écriture T3
close
= pipe_fd[0]; // sauvegarde descripteur lecture T3
input_fd
= fork();
pid_t d_pid if (d_pid == 0) {
// le processus C lit dans T3
(input_fd, STDIN_FILENO);
dup2(input_fd); // desc lecture T3
close("/bin/wc", (char *[]) {
execv"wc", "-l", NULL
});
("lancement quatrième fils" && false);
assert}
(input_fd);
close
(a_pid, NULL, 0);
waitpid(b_pid, NULL, 0);
waitpid(c_pid, NULL, 0);
waitpid(d_pid, NULL, 0);
waitpid
("# fin\n");
printfreturn EXIT_SUCCESS;
}
L’exemple ci-dessus nous permet de voir les actions à effectuer pour chacun des processus fils, avec les particularités du premier et du dernier.
Le processus père :
input_fd
.input_fd
Chaque processus fils :
input_fd
, et
ferme input_fd
Il est assez facile de vérifier que ces règles fonctionnent même si il n’y a qu’un seul processus fils (le premier est aussi le dernier) ou deux (pas de processus intermédiaires).
Pour avoir un traitement plus général, nous allons définir une fonction
void execute_pipeline(struct Pipeline *pipeline);
qui agit sur une structure qui représente un pipeline constitué d’étapes
struct Step {
char *pathname;
char **argv;
};
struct Pipeline {
int nb_steps;
struct Step *steps;
};
La fonction main
de l’exemple précédent se ramènerait
à
int main()
{
struct Pipeline pipeline = {
.nb_steps = 4,
.steps = (struct Step [])
{
{
.pathname = "/bin/ls",
.argv = (char *[]) {
"ls", "-l", NULL
}
}, {
.pathname = "/bin/grep",
.argv = (char *[])
{
"grep", "^-", NULL
}
}, {
.pathname = "/bin/cat",
.argv = (char *[])
{
"cat", NULL
}
}, {
.pathname = "/bin/wc",
.argv = (char *[])
{
"wc", "-l", NULL
}
},
}
};
("Exécution " __FILE__ "\n");
printf("Nombre de fichiers dans le répertoire =\n");
printf
(& pipeline);
execute_pipeline
("# fin\n");
printfreturn EXIT_SUCCESS;
}
La fonction execute_pipeline
boucle sur les éléments du
pipeline, en tenant compte des cas particuliers du premier et du dernier
:
void execute_pipeline(const struct Pipeline *pipeline)
{
const int first = 0,
= pipeline->nb_steps - 1;
last
int input_fd = STDIN_FILENO;
for (int i = first; i <= last; i++) {
int pipe_fd[2];
if (i != last) {
(pipe_fd);
pipe}
if (fork() == 0) {
// exécution d'un processus fils
if (i != first) {
(input_fd, STDIN_FILENO);
dup2(input_fd);
close}
if (i != last) {
(pipe_fd[1], STDOUT_FILENO);
dup2(pipe_fd[0]);
close(pipe_fd[1]);
close}
(pipeline->steps[i].pathname,
execv->steps[i].argv);
pipeline("lancement fils" && false);
assert}
if (i != first) {
(input_fd);
close}
if (i != last) {
(pipe_fd[1]);
close= pipe_fd[0];
input_fd }
}
for (int i = 0; i <= last; i++) {
(NULL);
wait}
}
A la fin, à la place de waitpid
, on emploie
wait
qui attend la fin d’un processus fils quelconque, sans
devoir préciser son identifiant.
On peut remarquer qu’un pipeline est composé
Ca peut donner l’idée d’une solution récursive, une fonction qui prend comme paramètres :
Schématiquement :
pour exécuter une liste de commandes:
si la liste contient une seule commande:
lancer un processus fils qui:
exécute la commande
attendre la fin de la commande
sinon:
créer un tuyau
lancer un processus fils qui:
redirige la sortie vers le tuyau
exécute la première commande
rediriger l'entrée vers le tuyau
exécuter le reste des commandes
attendre la fin de la première commande
Comme souvent, la version récursive est plus simple que la version itérative !
Faire fonctionner un pipeline de commandes n’est pas un problème très compliqué, mais dont la solution comporte pas mal de détails techniques à manipuler avec précision.
Pour ajouter des pipelines dans un interprète de commandes (shell), il est donc fortement recommandé d’étudier à part la manière de programmer leur exécution. Sinon, on va combiner les problèmes d’exécution du pipeline aux autres qui se posent déjà dans le projet (analyse syntaxique des commandes, par exemple), et ça ne fera pas gagner de temps.
Cette remarque vaut évidemment pour tous les projets : séparer autant que possible les problèmes pour les étudier, avant d’essayer de les intégrer dans le projet.
Un autre point, la méthode de travail : avant de se lancer dans la réalisation d’un mécanisme général (ici exécution d’un pipeline de N commandes quelconques), regarder en détail un ou plusieurs cas concrets (ici le pipeline de 4 commandes) pour bien voir les problèmes qui se posent. C’est comme ça qu’on acquiert les éléments de compréhension que l’on peut appliquer ensuite à la recherche d’une solution dans le cas général.
Enfin : l’utilisation de pseudo-code permet de fixer sur papier (ou dans un fichier) les grandes lignes du code qu’on envisage d’écrire. Et donc de se concentrer ensuite sur les divers détails, sans perdre la vue d’ensemble qu’on n’arrivera pas à garder en mémoire dès qu’il y en un certain nombre, ou qu’ils requièrent une forte attention (limite de la charge cognitive).
correction appel dup2()
27 sept 2023 (merci Damien Simler)↩︎