Eine Shell von Grund auf neu in C zu bauen, bleibt eine der besten Möglichkeiten, um Ihr Verständnis dafür zu vertiefen, wie Betriebssysteme Prozesse verwalten, Benutzereingaben handhaben und Programme ausführen. Während moderne Shells wie Bash, Zsh und Fish unglaublich anspruchsvoll sind, läuft ihre Kernfunktionalität auf eine einfache Schleife hinaus: Lesen Sie einen Befehl, analysieren Sie ihn, erstellen Sie einen neuen Prozess und warten Sie, bis er fertig ist. In diesem erweiterten Handbuch gehen Sie durch jeden dieser Schritte im Detail, von der Einrichtung eines robusten Eingabeparsers bis hin zur Prozesserstellung mit fork und exec. Am Ende haben Sie eine Arbeitsschale, die Sie mit Umleitung, Pipelines und Jobsteuerung erweitern können. Der Code wird absichtlich einfach gehalten, um die zugrunde liegenden Systemkonzepte hervorzuheben.

Was eine Shell eigentlich macht

Im Kern ist eine Shell ein Befehlszeilen-Interpreter. Sie stellt eine Textschnittstelle bereit, an der Benutzer Befehle eingeben, und die Shell übersetzt diese Befehle in Aktionen, die vom Betriebssystem ausgeführt werden. Wenn Sie ls -la eingeben, muss die Shell die ls ausführbar finden, einen neuen Prozess erstellen, um sie auszuführen, die Argumente -la übergeben und dann warten, bis dieser Prozess abgeschlossen ist, bevor Sie zum nächsten Befehl aufgefordert werden. Dieses scheinbar einfache Verhalten beinhaltet mehrere grundlegende Systemaufrufe und sorgfältige Verwaltung von Speicher und Prozessen.

Der Bau einer Minimal-Shell lehrt Sie:

  • Lesen und Tokenisieren von Eingaben aus Standard-Eingaben
  • Erstellen und Verwalten von Kindprozessen mit fork
  • Ersetzen eines Prozessspeicherbildes durch exec
  • Eltern und Kind mit warten
  • Umgang mit häufigen Fehlern anmutig

Wenn Sie diese Bausteine verstehen, erhalten Sie einen Einblick in die Funktionsweise aller Unix-ähnlichen Systeme unter der Haube und bieten eine solide Grundlage, um sich über fortschrittlichere Funktionen wie Signalverarbeitung, Jobsteuerung und Kommunikation zwischen Prozessen zu informieren.

Einrichten der Shell Loop

Jede Shell ist um eine Hauptschleife herum aufgebaut, die sich auf unbestimmte Zeit wiederholt, bis der Benutzer den Ausgang anfordert. Diese Schleife druckt eine Eingabeaufforderung, liest eine Eingabezeile, analysiert sie und handelt dann auf den analysierten Befehl. Die einfachste Version sieht so aus, als ob sie Pseudocode wäre:

while (1) {
 print_prompt();
 read_input();
 parse_input();
 execute_command();
}

In C verwenden Sie normalerweise fgets, um Eingaben zu lesen, weil sie Zeilengrenzen und Pufferüberläufe sicherer handhaben als gets. Die Eingabeaufforderung kann so einfach sein wie eine Zeichenfolge wie mysh> , aber echte Shells enthalten oft das aktuelle Arbeitsverzeichnis, den Benutzernamen und den Hostnamen. Vorerst reicht eine statische Eingabeaufforderung aus, um zu beginnen.

Umgang mit Edge Cases in Input

Die Eingabe des Benutzers ist selten sauber. Eine robuste Shell muss leere Zeilen, führenden und hinteren Whitespace, extrem lange Befehle und den Dateiendezustand (Strg + D) verarbeiten. Wenn fgets zurückgibt NULL, sollten Sie aus der Schleife ausbrechen und anmutig aussteigen. Wenn die Eingabe nur aus Whitespace besteht, sollte die Shell einfach erneut vorgeben, ohne zu versuchen, etwas auszuführen. Sie sollten auch den Fall behandeln, in dem die Eingabezeile länger als Ihr fester Puffer ist, indem Sie sie entweder abschneiden oder die Größe des Puffers dynamisch ändern. Für eine grundlegende Implementierung ist ein Puffer von 1024 Bytes normalerweise sicher, aber Sie sollten immer überprüfen, ob die volle Zeile gelesen wurde, indem Sie überprüfen, ob die Zeichenfolge mit einem neuen Zeilenzeichen endet.

#define MAX_INPUT 1024

char input[MAX_INPUT];
if (fgets(input, MAX_INPUT, stdin) == NULL) {
 printf("\n");
 break; // EOF
}

// Remove trailing newline, if present
size_t len = strlen(input);
if (len > 0 && input[len-1] == '\n') {
 input[len-1] = '\0';
} else {
 // Input too long, flush remaining characters
 int ch;
 while ((ch = getchar()) != '\n' && ch != EOF);
}

Parsing Befehle in Argumente

Sobald Sie eine saubere Eingabezeichenfolge haben, müssen Sie sie in Token aufteilen. Das erste Token ist der Befehlsname (z. B. ls), und die restlichen Token sind die Argumente zu diesem Befehl. Die C-Standardbibliothek bietet strtok für diesen Zweck, aber Sie müssen vorsichtig sein, weil strtok den ursprünglichen String modifiziert und nicht threadsicher ist. Für eine Single-Threaded-Shell wie diese funktioniert es gut.

char *args[MAX_ARGS];
int arg_count = 0;

args[arg_count] = strtok(input, " \t");
while (args[arg_count] != NULL && arg_count < MAX_ARGS - 1) {
 arg_count++;
 args[arg_count] = strtok(NULL, " \t");
}
args[arg_count] = NULL; // execvp expects a NULL-terminated array

Dieser Tokenizer teilt die Eingabe auf Leerzeichen und Registerkarten. Er behandelt zitierte Strings nicht, so dass ein Befehl wie echo "hello world" fälschlicherweise in drei Token und nicht in zwei zerfallen würde. Die Handhabung von Anführungszeichen ist eine wertvolle Verbesserung, die einen ausgeklügelteren Parser erfordert, aber für die Basis-Shell reicht dieser einfache Ansatz aus, um die meisten Einzelwort-Befehle auszuführen.

Nach der Tokenisierung sollten Sie prüfen, ob das erste Token NULL (Leerbefehl) ist, wenn ja, fahren Sie einfach mit der nächsten Iteration der Schleife fort, ohne sich zu verzweigen.

Eingebaute Befehle

Nicht alle Befehle sollten einen neuen Prozess hervorbringen. Einige Befehle, wie cd und exit, müssen vom Shell-Prozess selbst ausgeführt werden, weil sie den Zustand der Shell beeinflussen. Zum Beispiel ändert cd das aktuelle Arbeitsverzeichnis der Shell; wenn Sie einen Child-Prozess für cd forkten, würde dieses Kind sein eigenes Verzeichnis ändern und dann aussteigen, so dass das Arbeitsverzeichnis der Eltern unverändert bleibt.

Ausfahrt

Der Befehl exit beendet die Shell sofort. Es ist am einfachsten zu implementieren: Überprüfen Sie einfach, ob das erste Token mit “Exit” gleichzusetzen ist und brechen Sie aus der Hauptschleife aus. Optional können Sie ein Exit-Status-Argument akzeptieren und es an den exit Systemaufruf übergeben.

Änderungsverzeichnis (cd)

Der Befehl cd erfordert, dass Sie chdir aufrufen. Das Zielverzeichnis ist das zweite Argument. Wenn kein Argument bereitgestellt wird, können Sie standardmäßig im Home-Verzeichnis des Benutzers (verfügbar über die HOME Umgebungsvariablen) sein. Überprüfen Sie immer den Rückgabewert von chdir und drucken Sie eine Fehlermeldung aus, wenn das Verzeichnis nicht existiert oder nicht zugänglich ist.

if (strcmp(args[0], "cd") == 0) {
 const char *path = args[1];
 if (path == NULL) {
 path = getenv("HOME");
 if (path == NULL) {
 fprintf(stderr, "cd: HOME not set\n");
 continue;
 }
 }
 if (chdir(path) != 0) {
 perror("cd");
 }
 continue; // skip fork/exec
}

Prozesserstellung mit Fork

Für jeden Befehl, der nicht eingebaut ist, muss Ihre Shell einen Kindprozess erstellen, um ihn auszuführen. Der Systemaufruf fork erzeugt einen neuen Prozess, indem er den aufrufenden Prozess dupliziert. Der neue Prozess heißt Kind und das Original ist das Elternteil. Nach fork werden beide Prozesse vom gleichen Punkt im Code aus weiter ausgeführt. Der einzige Unterschied ist der Rückgabewert von fork: Es gibt 0 an das Kind und die PID des Kindes an das Elternteil zurück.

pid_t pid = fork();
if (pid == -1) {
 perror("fork");
 continue;
}

if (pid == 0) {
 // Child process
 // ...
} else {
 // Parent process
 // ...
}

Warum Gabel?

Vielleicht fragen Sie sich, warum Sie überhaupt einen separaten Prozess erstellen müssen. Der Grund ist, dass exec, der ein neues Programm in den Speicher lädt, den aktuellen Prozess vollständig ersetzt. Wenn die Shell exec direkt heißt, würde das Shell-Programm ersetzt und würde niemals zurückkehren, um neue Befehle anzunehmen. Indem das Kind zuerst gegabelt wird, kann es exec aufrufen, ohne die übergeordnete Shell zu beeinflussen.

Ausführen eines Programms mit Exec

Die exec-Funktionsfamilie ersetzt den aktuellen Prozess durch ein neues Programm. Es gibt mehrere Varianten: execl, execlp, execleexecleexecvpexecvpe Der Hauptunterschied zwischen ihnen ist, wie das Programm lokalisiert wird und wie Argumente übergeben werden. Für Ihre Shell ist execvp die bequemste, weil sie die PATH-Umgebungsvariable für die ausführbare Datei durchsucht und eine NULL-terminierte Array von Argumenten akzeptiert, die genau dem entspricht, was Sie während des Parsings erstellt haben.

if (pid == 0) {
 // Child process
 execvp(args[0], args);

 // If execvp returns, an error occurred
 perror("exec");
 exit(EXIT_FAILURE);
}

Beachten Sie den Aufruf von exit nach perror Wenn execvp fehlschlägt (z.B. weil der Befehl nicht existiert), muss der Kindprozess beendet werden; andernfalls würde er den folgenden Code weiter ausführen, was normalerweise die Shell-Schleife des Elternteils ist.

Warten auf den Kindsprozess

Nach dem Abzweigen wartet der übergeordnete Prozess normalerweise darauf, dass das Kind beendet wird, bevor es erneut aufgefordert wird. Dies geschieht mit wait oder waitpid Die waitpid-Funktion gibt Ihnen mehr Kontrolle, da Sie angeben können, auf welches Kind Sie warten sollen (unter Verwendung der PID, die von fork zurückgegeben wird) und möglicherweise Optionen festlegen, um ein Blockieren zu vermeiden.

int status;
waitpid(pid, &status, 0);

Die Variable status enthält Informationen darüber, wie das Kind beendet wurde. Sie können Makros wie WIFEXITED, WEXITSTATUS, WIFSIGNALED und WTERMSIG verwenden, um Details zu extrahieren. Für eine Basis-Shell genügt es zu wissen, dass das Kind fertig ist; das Drucken des Exit-Codes ist eine nette Geste zum Debuggen.

Blockieren vs. Nichtblockieren

Der einfache waitpid-Aufruf oben blockiert den Elternteil, bis das Kind aussteigt. Dies ist das richtige Verhalten für einen Vordergrundprozess. Wenn Sie später Unterstützung für Hintergrundprozesse hinzufügen (einen Befehl mit & ausführen), würden Sie die WNOHANG-Option einstellen, um eine Blockierung zu vermeiden, und Sie müssten eine Liste von Kinder-PIDs verwalten, um sie später zu ernten.

Zusammenstellen der Full Shell

Hier ist eine komplette, minimale Shell, die alle bisher diskutierten Teile integriert. Sie übernimmt die eingebauten exit und cd, analysiert Eingaben mit strtok, forks für externe Befehle und wartet auf das Ende des Kindes.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>

#define MAX_INPUT 1024
#define MAX_ARGS 64

int main(void) {
 char input[MAX_INPUT];
 char *args[MAX_ARGS];
 int should_run = 1;

 while (should_run) {
 printf("mysh> ");
 fflush(stdout);

 if (fgets(input, MAX_INPUT, stdin) == NULL) {
 printf("\n");
 break;
 }

 // Remove trailing newline
 size_t len = strlen(input);
 if (len > 0 && input[len-1] == '\n') {
 input[len-1] = '\0';
 }

 // Tokenize
 int i = 0;
 args[i] = strtok(input, " \t");
 while (args[i] != NULL && i < MAX_ARGS - 1) {
 i++;
 args[i] = strtok(NULL, " \t");
 }
 args[i] = NULL;

 if (args[0] == NULL) {
 continue; // empty line
 }

 // Handle built-in commands
 if (strcmp(args[0], "exit") == 0) {
 should_run = 0;
 continue;
 }
 if (strcmp(args[0], "cd") == 0) {
 const char *path = args[1];
 if (path == NULL) {
 path = getenv("HOME");
 }
 if (chdir(path) != 0) {
 perror("cd");
 }
 continue;
 }

 // Fork and execute external command
 pid_t pid = fork();
 if (pid < 0) {
 perror("fork");
 continue;
 }

 if (pid == 0) {
 // Child
 execvp(args[0], args);
 perror("exec");
 exit(EXIT_FAILURE);
 } else {
 // Parent waits
 int status;
 waitpid(pid, &status, 0);
 }
 }

 return 0;
}

Dieser Code ist eine vollständige, funktionierende Shell. Kopieren Sie ihn in eine Datei namens myshell.c, kompilieren Sie ihn mit gcc -o myshell myshell.c und führen Sie ihn aus. Sie sehen eine Eingabeaufforderung, in der Sie Befehle wie ls, pwd, echo hello und cd /tmp eingeben können Der exit beendet die Shell.

Häufige Fallstricke und Debugging-Tipps

Selbst mit dieser kleinen Menge an Code können mehrere Dinge schief gehen.

Befehl nicht gefunden

Wenn Sie einen Befehl eingeben, der nicht existiert (z. B. foobar), execvp gibt zurück und das Kind druckt vor dem Beenden “exec: Keine solche Datei oder ein solches Verzeichnis”. Dies ist korrektes Verhalten, aber Sie möchten vielleicht eine Freundschaftsnachricht ausdrucken. Sie können errno nach execvp überprüfen, um nicht zwischen “Datei nicht gefunden” und “Erlaubnis verweigert” zu unterscheiden.

Fehlende Newline oder Truncated Input

Wenn Ihre Eingabeaufforderung erscheint, ohne auf Eingabe zu warten, ist die wahrscheinliche Ursache die übrig gebliebenen Zeichen im Eingabepuffer eines vorherigen Aufrufs. Überprüfen Sie immer, ob fgets die gesamte Zeile verbraucht haben (d.h. dass das letzte Zeichen vor dem Nullterminator eine neue Zeile ist).

Zombie-Prozesse

Wenn du vergisst, zu warten (oder zu warten), werden Kinderprozesse, die zu Zombies werden, bis du sie erntest. Die Shell im Beispiel ruft abwarten, also sollten Zombies nicht erscheinen. Wenn du jedoch später Hintergrundprozesse hinzufügst und sie nicht ernten kannst, kann die Zombie-Akkumulation zu einem Problem werden. Die Lösung besteht darin, einen SIGCHLD Handler zu installieren, der abwarten mit WNOHANG aufruft, um abgeschlossene Kinder zu bereinigen.

Erweiterung der Shell

Sobald die Basis-Shell funktioniert, können Sie Funktionen hinzufügen, die sie einer realen Shell näher bringen.

Input/Output-Umleitung

Um , und zu unterstützen, müssen Sie die Befehlszeile für diese Operatoren analysieren, die entsprechenden Dateien mit öffnen und dup2 verwenden, um die Standardeingabe oder -ausgabe umzuleiten, bevor Sie execvp aufrufen.

Rohrleitungen

Das Piping der Ausgabe eines Befehls in die Eingabe eines anderen (cmd1 | cmd2) ist komplizierter. Sie müssen eine Pipe mit pipe erstellen, zwei Kindprozesse abzweigen, die Ausgabe eines Kindes zum schreibenden Ende der Pipe und die Eingabe des anderen zum lesenden Ende umleiten und dann warten, bis beide abgeschlossen sind.

Job Control

Das Hinzufügen von Hintergrundausführung ( & am Ende eines Befehls und die Fähigkeit, Jobs in den Vordergrund zu rücken, erfordert die Verwaltung einer Jobtabelle, die Handhabung von SIGTSTP, SIGCONT und SIGCHLD und die Verwendung von tcsetpgrp, um das Terminaleigentum zu verwalten.

Befehlshistorie

Die Implementierung eines einfachen Verlaufsmechanismus (Pfeile nach oben/unten, um frühere Befehle abzurufen) beinhaltet die Erfassung von Eingaben im Rohterminalmodus (über tcgetattr und tcsetattr) oder unter Verwendung der readline-Bibliothek, die diese Funktionalität sofort bereitstellt.

Weiteres Lesen und Ressourcen

Um Ihr Verständnis der hier vorgestellten Konzepte zu vertiefen, sind die folgenden Ressourcen von unschätzbarem Wert:

Schlussfolgerung

Eine Basis-Shell in C zu bauen ist mehr als eine akademische Übung; sie zwingt dich, dich direkt mit den Kernabstraktionen des Betriebssystems auseinanderzusetzen. Du hast gesehen, wie man Benutzereingaben liest und analysiert, wie fork einen neuen Prozess erstellt und wie exec den Speicher dieses Prozesses durch ein angefordertes Programm ersetzt. Du hast auch eingebaute Befehle hinzugefügt, die im Shell-eigenen Kontext laufen müssen und Fehler anmutig gehandhabt haben. Der vollständige Shell-Code in diesem Artikel ist ein solider Ausgangspunkt, den du mit Umleitung, Pipelines, Jobsteuerung und Geschichte erweitern kannst. Jede Erweiterung wird dein Verständnis von Prozessmanagement, Dateideskriptoren und Signalen vertiefen. Wenn du sogar ein paar dieser Funktionen hinzugefügt hast, wirst du ein Werkzeug haben, das wirklich nützlich ist und ein viel stärkeres Verständnis dafür, wie moderne Shells unter der Oberfläche funktionieren.