Créer un shell minimaliste en C pour Linux : guide pas à pas

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.

Sommaire

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 :

  1. Afficher une invite de commande.
  2. Lire l’entrée utilisateur.
  3. Analyser et interpréter la commande.
  4. Exécuter la commande en créant un processus enfant.
  5. 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 :

  1. Mettre à jour le gestionnaire de paquets :
   sudo apt update && sudo apt upgrade
  1. 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 et make est correct avec which 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 commandeCapturer et analyser les saisies utilisateur.
Exécution de commandeLancer des processus pour exécuter des programmes.
Gestion des erreursFournir des messages clairs en cas d’échec.
Boucle interactivePermettre 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

  1. Compilation
    Utilisez le fichier Makefile décrit précédemment ou compilez manuellement :
gcc -o myshell minimal_shell.c
  1. Exécution du shell
    Lancez le programme :
./myshell
  1. 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 ou help.

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 planExécution normale avec blocage du shell.
Processus arrière-planExécution non bloquante avec le caractère &.
Redirection de sortieEnvoi de la sortie standard vers un fichier avec >.
Combinaison des fonctionnalitésGestion 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éesGérer directement des commandes comme cd ou exit.
PipelinesPermettre l’exécution de commandes liées avec |.
HistoriqueMé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é.

Sommaire