Dans cet article, nous explorons la création d’un shell minimaliste en C pour Linux. Un shell est une interface utilisateur essentielle dans les systèmes Unix, permettant de lancer des commandes, d’exécuter des scripts et de gérer les processus.
L’objectif est de comprendre les bases du fonctionnement des shells en implémentant une version simplifiée capable d’exécuter des commandes, de gérer les erreurs et de manipuler les processus. Ce projet pratique renforce la compréhension des systèmes Unix, des concepts de processus, et des interactions entre un programme et le système d’exploitation.
En suivant ce guide, vous apprendrez à :
- Configurer un environnement de développement pour travailler en C sous Linux.
- Implémenter une boucle interactive pour recevoir et exécuter des commandes.
- Gérer des fonctionnalités telles que les redirections de flux ou la gestion des processus enfants.
Que vous soyez un débutant souhaitant approfondir vos connaissances ou un développeur expérimenté en quête de projet stimulant, cet article est une opportunité pour comprendre et maîtriser les concepts fondamentaux des systèmes Linux et du langage C.
Fonctionnement d’un shell sous Linux
Un shell est un programme qui agit comme une interface entre l’utilisateur et le noyau d’un système d’exploitation. Il permet d’exécuter des commandes, de manipuler des fichiers, et de gérer des processus. Comprendre son fonctionnement est crucial pour en implémenter une version minimaliste.
Les processus dans un shell
Chaque commande exécutée dans un shell est associée à un processus. Voici les étapes principales :
- Lecture de commande : le shell attend une entrée de l’utilisateur.
- Interprétation : il analyse la commande saisie pour déterminer l’action à effectuer.
- Création de processus : le shell crée un processus enfant (via
fork
) pour exécuter la commande. - Exécution : le processus enfant utilise des appels système comme
exec
pour lancer le programme.
Les flux d’entrée et de sortie
Un shell interagit avec trois flux standard :
- stdin (entrée standard) : flux pour les entrées de l’utilisateur.
- stdout (sortie standard) : flux pour afficher les résultats des commandes.
- stderr (erreur standard) : flux pour afficher les messages d’erreur.
Ces flux peuvent être redirigés pour lire ou écrire des fichiers ou communiquer entre processus.
Boucle principale d’un shell
Un shell fonctionne de manière répétitive en suivant une boucle principale :
- Afficher une invite de commande.
- Lire l’entrée utilisateur.
- Analyser et interpréter la commande.
- Exécuter la commande en créant un processus enfant.
- Recommencer.
Exemple simple
Voici un exemple simplifié de la boucle d’un shell :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
char command[256];
while (1) {
printf("myshell> ");
if (fgets(command, sizeof(command), stdin) == NULL) {
break;
}
// Supprimer le caractère de nouvelle ligne
command[strcspn(command, "\n")] = 0;
if (fork() == 0) {
execlp(command, command, NULL);
perror("Command failed");
exit(1);
} else {
wait(NULL);
}
}
return 0;
}
Ce code montre un shell basique qui exécute des commandes simples. Il met en œuvre la boucle interactive et la gestion des processus.
En comprenant ces concepts, vous êtes prêt à approfondir et à construire un shell fonctionnel avec des fonctionnalités plus avancées.
Mise en place de l’environnement de développement
Pour développer un shell minimaliste en C, un environnement de développement correctement configuré est essentiel. Cette section détaille les étapes pour installer les outils nécessaires, configurer l’environnement, et préparer le projet.
Installer les outils de développement
Sous Linux, le développement en C repose principalement sur des outils tels que gcc
(GNU Compiler Collection) et make
. Voici comment les installer :
- Mettre à jour le gestionnaire de paquets :
sudo apt update && sudo apt upgrade
- Installer les outils nécessaires :
sudo apt install build-essential
sudo apt install manpages-dev
Cela inclut gcc
, make
, et d’autres utilitaires essentiels pour la programmation en C.
Configurer un éditeur ou un IDE
Vous pouvez utiliser n’importe quel éditeur de texte, mais voici des options populaires :
- Vim ou Nano : éditeurs légers disponibles dans le terminal.
- VS Code : un IDE moderne avec prise en charge de l’autocomplétion et du débogage.
- CLion : un IDE avancé pour le développement C/C++.
Pour installer VS Code :
sudo snap install code --classic
Organisation du projet
Créez une structure claire pour votre projet :
mkdir minimal_shell
cd minimal_shell
mkdir src include build
- src/ : contient les fichiers source
.c
. - include/ : pour les fichiers d’en-tête
.h
. - build/ : pour les fichiers compilés.
Premier fichier de configuration Makefile
Utiliser un fichier Makefile facilite la compilation. Exemple simple :
CC = gcc
CFLAGS = -Wall -Wextra -std=c99
SRC = src/main.c
OBJ = $(SRC:.c=.o)
EXEC = build/shell
all: $(EXEC)
$(EXEC): $(OBJ)
$(CC) $(CFLAGS) -o $@ $(OBJ)
clean:
rm -f $(OBJ) $(EXEC)
Placez ce fichier à la racine du projet. Pour compiler :
make
Tester l’environnement
Ajoutez un fichier source minimal pour vérifier que tout fonctionne :
src/main.c
#include <stdio.h>
int main() {
printf("Environnement configuré avec succès !\n");
return 0;
}
Compilez et exécutez :
make
./build/shell
Résolution des problèmes courants
- Erreur de compilation avec gcc introuvable : Assurez-vous que
build-essential
est installé. - Manque de permissions pour exécuter le fichier : Ajoutez des permissions d’exécution avec
chmod +x
. - Commandes non reconnues : Vérifiez que le chemin vers
gcc
etmake
est correct avecwhich gcc
.
Avec cet environnement de développement, vous êtes prêt à commencer l’implémentation de votre shell.
Décomposition des fonctionnalités d’un shell
Pour construire un shell minimaliste en C, il est crucial d’identifier et de décomposer les fonctionnalités de base. Cela permet de structurer le projet et d’ajouter progressivement des fonctionnalités avancées. Voici les principales composantes d’un shell.
Lecture et interprétation des commandes
Un shell doit lire les commandes saisies par l’utilisateur et les analyser :
- Lecture de l’entrée utilisateur : Utilisez des fonctions comme
fgets
pour capturer les commandes. - Suppression des caractères inutiles : Nettoyez les espaces ou les caractères de nouvelle ligne.
- Analyse syntaxique : Identifiez les arguments et les options d’une commande avec des fonctions comme
strtok
.
Exemple de code
#include <stdio.h>
#include <string.h>
void read_command(char *buffer, size_t size) {
printf("myshell> ");
if (fgets(buffer, size, stdin)) {
buffer[strcspn(buffer, "\n")] = 0; // Supprimer le caractère de nouvelle ligne
}
}
Exécution des commandes
Le shell doit exécuter les commandes saisies en créant un processus enfant :
- Appel système
fork
: Crée un processus enfant. - Appel système
exec
: Remplace le processus enfant par le programme demandé. - Gestion des erreurs : Affiche des messages clairs si la commande échoue.
Exemple de code
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
void execute_command(char *command) {
if (fork() == 0) { // Processus enfant
execlp(command, command, NULL);
perror("Erreur d'exécution");
exit(EXIT_FAILURE);
} else { // Processus parent
wait(NULL); // Attendre la fin de l'exécution
}
}
Gestion des erreurs
Un shell doit fournir un retour clair en cas de commande invalide :
- Commandes introuvables : Afficher un message d’erreur explicite.
- Gestion des signaux : Par exemple, ignorer
Ctrl+C
pour empêcher la fermeture du shell.
Exemple de gestion des erreurs
#include <signal.h>
void handle_signals(int sig) {
printf("\nSignal %d ignoré. Tapez 'exit' pour quitter.\n", sig);
}
int main() {
signal(SIGINT, handle_signals); // Ignorer Ctrl+C
return 0;
}
Ajout d’une boucle interactive
Un shell fonctionne dans une boucle continue, ce qui permet de recevoir et d’exécuter des commandes tant que l’utilisateur ne quitte pas.
Exemple de boucle interactive
int main() {
char command[256];
while (1) {
read_command(command, sizeof(command));
if (strcmp(command, "exit") == 0) {
break; // Quitter la boucle si l'utilisateur tape 'exit'
}
execute_command(command);
}
return 0;
}
Résumé des fonctionnalités
Fonctionnalité | Description |
---|---|
Lecture de commande | Capturer et analyser les saisies utilisateur. |
Exécution de commande | Lancer des processus pour exécuter des programmes. |
Gestion des erreurs | Fournir des messages clairs en cas d’échec. |
Boucle interactive | Permettre une interaction continue. |
Avec ces bases en place, vous aurez un shell minimaliste fonctionnel, prêt à être enrichi avec des fonctionnalités avancées.
Implémentation d’un shell minimaliste
Nous allons coder un shell minimaliste en C capable d’exécuter des commandes simples. Cette section présente le code, explique chaque étape, et détaille les points clés de l’implémentation.
Code complet du shell minimaliste
Voici le code pour un shell de base :
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define BUFFER_SIZE 256
void read_command(char *buffer, size_t size) {
printf("myshell> ");
if (fgets(buffer, size, stdin)) {
buffer[strcspn(buffer, "\n")] = 0; // Supprime le caractère de nouvelle ligne
}
}
void execute_command(char *command) {
// Crée un processus enfant pour exécuter la commande
pid_t pid = fork();
if (pid == -1) {
perror("Erreur lors de la création du processus");
exit(EXIT_FAILURE);
} else if (pid == 0) { // Processus enfant
execlp(command, command, NULL); // Exécute la commande
perror("Erreur d'exécution");
exit(EXIT_FAILURE);
} else { // Processus parent
wait(NULL); // Attend la fin de l'exécution
}
}
int main() {
char command[BUFFER_SIZE];
while (1) {
read_command(command, sizeof(command));
// Vérifie si l'utilisateur souhaite quitter
if (strcmp(command, "exit") == 0) {
printf("Fermeture du shell...\n");
break;
}
// Si la commande est vide, continue la boucle
if (strlen(command) == 0) {
continue;
}
execute_command(command);
}
return 0;
}
Explications détaillées
1. Lecture des commandes utilisateur
La fonction read_command
utilise fgets
pour capturer l’entrée utilisateur. Elle nettoie les caractères superflus comme \n
.
2. Création et gestion des processus
fork
crée un processus enfant pour exécuter la commande.- Dans le processus enfant,
execlp
remplace le code en cours par celui de la commande saisie. - Le processus parent utilise
wait
pour attendre la fin de l’exécution.
3. Gestion des commandes spéciales
- Si l’utilisateur tape
exit
, le shell quitte la boucle principale. - Les commandes vides sont ignorées pour éviter des erreurs inutiles.
Exécution et tests
- Compilation
Utilisez le fichier Makefile décrit précédemment ou compilez manuellement :
gcc -o myshell minimal_shell.c
- Exécution du shell
Lancez le programme :
./myshell
- Exemples de commandes
- Tapez
ls
pour lister les fichiers. - Tapez
pwd
pour afficher le répertoire courant. - Tapez
exit
pour quitter.
Améliorations possibles
- Gestion des arguments : Ajouter la prise en charge des arguments pour les commandes (par exemple,
ls -l
). - Gestion des redirections : Implémenter les redirections comme
>
ou<
. - Commandes intégrées : Ajouter des commandes internes comme
cd
ouhelp
.
Ce shell minimaliste pose les bases pour développer un shell plus complet, avec des fonctionnalités avancées.
Gestion des processus et redirections
Un shell minimaliste gagne en fonctionnalité lorsqu’il peut gérer des processus de manière avancée et implémenter des redirections des flux d’entrée et de sortie. Cette section explore comment ajouter ces fonctionnalités.
Gestion des processus
Un shell peut exécuter plusieurs types de processus :
- Processus premier plan : Exécutés directement par le shell.
- Processus arrière-plan : Exécutés sans bloquer le shell.
Exécution en arrière-plan
Pour permettre l’exécution en arrière-plan, ajoutez une vérification pour le caractère &
en fin de commande.
Exemple de gestion des processus en arrière-plan
void execute_command(char *command) {
int background = 0;
size_t len = strlen(command);
// Vérifie si la commande doit être exécutée en arrière-plan
if (command[len - 1] == '&') {
background = 1;
command[len - 1] = '\0'; // Supprime le caractère '&'
}
pid_t pid = fork();
if (pid == -1) {
perror("Erreur lors de la création du processus");
exit(EXIT_FAILURE);
} else if (pid == 0) {
execlp(command, command, NULL);
perror("Erreur d'exécution");
exit(EXIT_FAILURE);
} else {
if (!background) {
wait(NULL); // Attend la fin pour un processus premier plan
} else {
printf("[Processus en arrière-plan lancé : PID %d]\n", pid);
}
}
}
Redirections des flux
Un shell peut rediriger les flux d’entrée (stdin
), de sortie (stdout
), ou d’erreur (stderr
) vers des fichiers ou d’autres processus.
Redirection de la sortie vers un fichier
Pour rediriger la sortie vers un fichier (>
), utilisez les appels système open
et dup2
.
Exemple de redirection de sortie
#include <fcntl.h>
void execute_command_with_redirection(char *command, char *filename) {
pid_t pid = fork();
if (pid == -1) {
perror("Erreur lors de la création du processus");
exit(EXIT_FAILURE);
} else if (pid == 0) { // Processus enfant
int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("Erreur d'ouverture du fichier");
exit(EXIT_FAILURE);
}
dup2(fd, STDOUT_FILENO); // Redirige stdout vers le fichier
close(fd);
execlp(command, command, NULL);
perror("Erreur d'exécution");
exit(EXIT_FAILURE);
} else {
wait(NULL); // Attend la fin de l'exécution
}
}
Exemple d’appel :
Pour rediriger la sortie de ls
vers un fichier :
execute_command_with_redirection("ls", "output.txt");
Combinaison de redirections et processus
Pour gérer des commandes comme ls > output.txt &
, combinez la gestion des processus en arrière-plan avec les redirections.
Exemple avancé
void execute_advanced_command(char *command, char *filename, int background) {
pid_t pid = fork();
if (pid == -1) {
perror("Erreur lors de la création du processus");
exit(EXIT_FAILURE);
} else if (pid == 0) { // Processus enfant
if (filename) {
int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("Erreur d'ouverture du fichier");
exit(EXIT_FAILURE);
}
dup2(fd, STDOUT_FILENO);
close(fd);
}
execlp(command, command, NULL);
perror("Erreur d'exécution");
exit(EXIT_FAILURE);
} else {
if (!background) {
wait(NULL);
} else {
printf("[Processus en arrière-plan lancé : PID %d]\n", pid);
}
}
}
Résumé des fonctionnalités
Fonctionnalité | Description |
---|---|
Processus premier plan | Exécution normale avec blocage du shell. |
Processus arrière-plan | Exécution non bloquante avec le caractère & . |
Redirection de sortie | Envoi de la sortie standard vers un fichier avec > . |
Combinaison des fonctionnalités | Gestion des processus avec redirections combinées. |
Ces ajouts rendent le shell minimaliste plus proche d’un shell réel, en améliorant sa flexibilité et son utilité.
Ajout de fonctionnalités avancées
Pour enrichir un shell minimaliste, il est possible d’ajouter des fonctionnalités avancées telles que la gestion des commandes intégrées, la prise en charge des pipelines, et un historique des commandes. Ces fonctionnalités augmentent considérablement l’utilité et l’interactivité du shell.
Commandes intégrées
Certaines commandes, comme cd
ou exit
, doivent être gérées directement par le shell sans créer de processus enfant.
Implémentation des commandes intégrées
Ajoutez une fonction pour gérer ces commandes :
#include <unistd.h>
int handle_builtin_commands(char *command) {
if (strcmp(command, "exit") == 0) {
printf("Fermeture du shell...\n");
exit(0);
} else if (strncmp(command, "cd ", 3) == 0) {
char *path = command + 3; // Récupère le chemin après "cd "
if (chdir(path) == -1) {
perror("Erreur lors du changement de répertoire");
}
return 1; // Commande intégrée exécutée
}
return 0; // Commande non intégrée
}
Intégrez cette gestion dans la boucle principale :
if (!handle_builtin_commands(command)) {
execute_command(command);
}
Gestion des pipelines
Un pipeline permet de relier plusieurs commandes en transférant la sortie standard d’une commande vers l’entrée standard d’une autre (ex. : ls | grep txt
).
Implémentation des pipelines
Utilisez pipe
pour créer un pipeline :
#include <fcntl.h>
void execute_pipeline(char *cmd1, char *cmd2) {
int pipefd[2];
pid_t pid1, pid2;
if (pipe(pipefd) == -1) {
perror("Erreur lors de la création du pipeline");
exit(EXIT_FAILURE);
}
if ((pid1 = fork()) == 0) { // Premier processus
close(pipefd[0]); // Ferme la lecture
dup2(pipefd[1], STDOUT_FILENO); // Redirige stdout vers le pipe
close(pipefd[1]);
execlp(cmd1, cmd1, NULL);
perror("Erreur d'exécution de la commande 1");
exit(EXIT_FAILURE);
}
if ((pid2 = fork()) == 0) { // Deuxième processus
close(pipefd[1]); // Ferme l'écriture
dup2(pipefd[0], STDIN_FILENO); // Redirige stdin depuis le pipe
close(pipefd[0]);
execlp(cmd2, cmd2, NULL);
perror("Erreur d'exécution de la commande 2");
exit(EXIT_FAILURE);
}
close(pipefd[0]);
close(pipefd[1]);
wait(NULL);
wait(NULL);
}
Exemple d’appel :
Pour exécuter ls | grep txt
:
execute_pipeline("ls", "grep");
Historique des commandes
Un historique permet de mémoriser et de réutiliser les commandes précédemment saisies.
Implémentation de l’historique
Utilisez un tableau dynamique pour stocker les commandes :
#include <stdlib.h>
#define HISTORY_SIZE 100
char *history[HISTORY_SIZE];
int history_count = 0;
void add_to_history(char *command) {
if (history_count < HISTORY_SIZE) {
history[history_count++] = strdup(command);
} else {
free(history[0]);
memmove(history, history + 1, (HISTORY_SIZE - 1) * sizeof(char *));
history[HISTORY_SIZE - 1] = strdup(command);
}
}
void print_history() {
for (int i = 0; i < history_count; i++) {
printf("%d: %s\n", i + 1, history[i]);
}
}
Ajoutez l’appel dans la boucle principale :
add_to_history(command);
if (strcmp(command, "history") == 0) {
print_history();
continue;
}
Résumé des fonctionnalités avancées
Fonctionnalité | Description |
---|---|
Commandes intégrées | Gérer directement des commandes comme cd ou exit . |
Pipelines | Permettre l’exécution de commandes liées avec | . |
Historique | Mémoriser et afficher les commandes précédentes. |
Ces ajouts font passer le shell minimaliste à un niveau supérieur, offrant une interactivité et une flexibilité accrues.
Conclusion
Dans cet article, nous avons détaillé le processus de création d’un shell minimaliste en C pour Linux. Ce projet a permis de découvrir et de mettre en œuvre des concepts fondamentaux des systèmes Unix, comme la gestion des processus, les redirections, et les flux standard.
Nous avons commencé par construire les fonctionnalités de base : lecture des commandes, exécution de processus, et gestion des erreurs. Ensuite, nous avons ajouté des fonctionnalités avancées, telles que les pipelines, les commandes intégrées (cd
, exit
), et l’historique des commandes, pour rendre le shell plus fonctionnel et interactif.
Ce projet constitue une excellente base pour explorer davantage le développement de shells plus complets. Vous pouvez envisager d’ajouter :
- La prise en charge des arguments complexes et des options.
- Des redirections supplémentaires (
2>
,>>
). - Une gestion avancée des signaux (comme la gestion des processus suspendus).
En maîtrisant ces concepts, vous êtes non seulement capable de créer un shell fonctionnel, mais aussi d’approfondir votre compréhension des interactions entre le matériel, le système d’exploitation, et les applications. Continuez à expérimenter et à améliorer votre shell pour en faire un outil puissant et personnalisé.