Fundierte Artikel über Technologien, die das Kommende formen.

Was der Bau einer Shell über Unix lehrt

Eine Shell selbst zu bauen zeigt, wie Unix wirklich funktioniert: Prozesse, Dateideskriptoren, Pipes und Signale. Ein Überblick über die Kernkonzepte.

Aufgeschnittene Muschel, in der eine Kontrollraum-Anlage aus Rohren, Ventilen und Hebeln Licht leitet.

Jeder Entwickler nutzt täglich eine Shell. Nur wenige verstehen, was sie tatsächlich tut. Die Shell wirkt wie eine Anwendung – man tippt Befehle, sie führt sie aus –, ist aber im Grunde nur eine dünne Schicht über einer Reihe von Unix-Primitiven, die grundlegend dafür sind, wie Betriebssysteme funktionieren. Eine Shell von Grund auf zu bauen ist eine der besten Methoden, Prozesse, Dateideskriptoren, Pipes und Signale zu verstehen – Konzepte, auf denen alles von Webservern bis zu Docker-Containern aufbaut.

Eine einfache Shell ist erstaunlich unkompliziert. Die Kernschleife lautet: eine Eingabezeile lesen, in Befehl und Argumente zerlegen, einen Kindprozess mit fork erzeugen, den Befehl im Kind ausführen und auf dessen Ende warten. Das sind vielleicht 50 Zeilen C. Die Komplexität steckt in den Features, die wir als selbstverständlich betrachten: Pipes, Umleitungen, Hintergrundprozesse, Signalbehandlung und Jobkontrolle.

Die Read-Eval-Print-Schleife

Im Kern ist eine Shell eine REPL. Eingabe lesen, auswerten (ausführen), Ergebnisse ausgeben, wieder von vorn. Den Teil „Print“ übernehmen die Befehle selbst – die Shell stellt lediglich die Umgebung bereit, in der sie laufen.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
// The simplest possible shell
int main(void) {
char line[1024];
while (1) {
printf("$ ");
if (!fgets(line, sizeof(line), stdin))
break;  // EOF (Ctrl+D)
// Remove trailing newline
line[strcspn(line, "\n")] = '\0';
// Fork a child process
pid_t pid = fork();
if (pid == 0) {
// Child: execute the command
execlp(line, line, NULL);  // This only handles single-word commands
perror("exec");
exit(1);
}
// Parent: wait for child to finish
waitpid(pid, NULL, 0);
}
return 0;
}

Diese 25-zeilige Shell funktioniert tatsächlich – sie kann Befehle wie ls, pwd und date ausführen. Argumente, Pipes, Umleitungen oder andere Features, die man erwarten würde, unterstützt sie nicht. Sie zeigt aber das grundlegende Muster: fork, exec, wait.

Fork und Exec: das Unix-Prozessmodell

Die Aufteilung in fork/exec ist Unix' markantester Designentscheid, und beim Bau einer Shell wird schnell klar, warum es sie gibt.

fork() erstellt eine exakte Kopie des aktuellen Prozesses. Das Kind hat denselben Speicher, dieselben offenen Dateien und dieselben Umgebungsvariablen. exec() ersetzt das Programm des Kindes durch ein neues. Diese Operationen sind bewusst getrennt, denn die Lücke zwischen ihnen – nach fork, aber vor exec – ist der Ort, an dem die Shell die Umgebung des Kindes einrichtet.

Hier liegt der Schlüssel. Wenn man ls > output.txt eingibt, erzeugt die Shell per fork einen Kindprozess. Dieser öffnet im Kind (vor exec) output.txt, leitet stdout dorthin um und ruft dann exec für ls auf. Das Programm ls weiß nichts von der Umleitung – es schreibt wie immer nach stdout, und die Manipulation der Dateideskriptoren zwischen fork und exec lenkt diese Ausgabe in eine Datei.

// How 'ls > output.txt' works
pid_t pid = fork();
if (pid == 0) {
// Child process — between fork and exec
// Open the output file
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
// Redirect stdout (fd 1) to the file
dup2(fd, STDOUT_FILENO);  // Now fd 1 points to output.txt
close(fd);                 // Close the original fd (no longer needed)
// exec ls — it writes to stdout, which now goes to output.txt
execlp("ls", "ls", NULL);
perror("exec");
exit(1);
}
waitpid(pid, NULL, 0);

Dieses Design ist elegant, weil es sich kombinieren lässt. Das Kind kann vor exec jede beliebige Umgebung einrichten – Dateien umleiten, Verzeichnisse wechseln, Umgebungsvariablen ändern, Ressourcenlimits setzen, Benutzer-IDs wechseln –, und das ausgeführte Programm erbt diese Umgebung, ohne davon wissen zu müssen. Jeder Befehl bekommt eine vorkonfigurierte Umgebung, und die Shell ist diejenige, die sie konfiguriert.

Pipes: Prozesse verbinden

Pipes sind der mächtigste Kombinationsmechanismus in Unix, und ihre Implementierung zeigt, wie simpel der zugrunde liegende Mechanismus eigentlich ist.

Der Systemaufruf pipe() erzeugt ein Paar von Dateideskriptoren: einen zum Lesen und einen zum Schreiben. Was am Schreibende geschrieben wird, erscheint am Leseende. Um ls | grep foo umzusetzen, erzeugt die Shell eine Pipe, forkt zweimal und verbindet die stdout von ls mit dem Schreibende und die stdin von grep mit dem Leseende.

// How 'ls | grep foo' works
int pipefd[2];
pipe(pipefd);  // pipefd[0] = read end, pipefd[1] = write end
pid_t pid1 = fork();
if (pid1 == 0) {
// First child: ls
close(pipefd[0]);              // Don't need read end
dup2(pipefd[1], STDOUT_FILENO); // stdout → pipe write end
close(pipefd[1]);
execlp("ls", "ls", NULL);
exit(1);
}
pid_t pid2 = fork();
if (pid2 == 0) {
// Second child: grep
close(pipefd[1]);              // Don't need write end
dup2(pipefd[0], STDIN_FILENO);  // stdin → pipe read end
close(pipefd[0]);
execlp("grep", "grep", "foo", NULL);
exit(1);
}
// Parent: close both pipe ends and wait
close(pipefd[0]);
close(pipefd[1]);
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0);

Beachte das sorgfältige Schließen der nicht benötigten Enden der Dateideskriptoren. Das ist entscheidend: Schließt der Elternprozess nicht beide Pipe-Enden, bekommt grep nie EOF auf seiner stdin (weil das Schreibende im Elternprozess noch offen ist) und hängt für immer. Lecks bei Dateideskriptoren in Pipe-Ketten gehören zu den häufigsten Fehlern beim Bau einer Shell.

Eingebaute Befehle

Manche Befehle können keine externen Programme sein. cd ist das klassische Beispiel. Wenn die Shell einen Kindprozess forkt und dieser chdir() aufruft, ändert sich nur das Arbeitsverzeichnis des Kindes – das des Elternprozesses bleibt unberührt, und nach dem Ende des Kindes steht die Shell immer noch im selben Verzeichnis. Damit cd funktioniert, muss die Shell den Befehl im eigenen Prozess ausführen, ohne zu forken.

Weitere eingebaute Befehle sind export (ändert die Umgebung der Shell), exit (beendet den Shell-Prozess) und source (führt ein Skript im aktuellen Shell-Kontext aus). Sie alle verändern den eigenen Zustand der Shell, und das kann nur im eigenen Prozess der Shell geschehen.

Wer versteht, welche Befehle eingebaut sind und warum, lernt etwas Grundlegendes über die Prozessisolation: Ein Kindprozess kann seinen Elternprozess nicht verändern. Das ist ein Sicherheitsmerkmal, ein Zuverlässigkeitsmerkmal und manchmal eine Unannehmlichkeit – aber es ist zentral dafür, wie Unix-Prozesse funktionieren.

Signale und Jobkontrolle

Drückt man im Terminal Strg+C, stoppt der laufende Befehl. Das klingt simpel, steckt aber in einer erstaunlich komplexen Interaktion zwischen Terminal, Shell, Signalen und Prozessgruppen.

Strg+C sendet SIGINT an die Vordergrund-Prozessgruppe. Den Vorgang übernimmt der Terminaltreiber – nicht die Shell. Die Aufgabe der Shell ist es, jeden Befehl in eine eigene Prozessgruppe zu legen, damit SIGINT beim Befehl ankommt und nicht bei der Shell selbst. Richtet die Shell die Prozessgruppen nicht korrekt ein, beendet Strg+C die Shell statt des laufenden Befehls.

Die Jobkontrolle – Hintergrundprozesse (&), fg, bg, Strg+Z – bringt eine weitere Schicht hinzu. Die Shell muss verfolgen, welche Prozesse zu welchen Jobs gehören, Vordergrund- und Hintergrundgruppen verwalten und Signale wie SIGTSTP (Strg+Z, Anhalten) und SIGCHLD (Kindprozess beendet) behandeln. Sie korrekt umzusetzen ist der schwierigste Teil beim Bau einer Shell.

Was man dabei lernt

Der Bau einer Shell vermittelt Konzepte, die in der Softwareentwicklung ständig auftauchen – selbst wenn man nie wieder Systemcode schreibt.

  • Dateideskriptoren sind die universelle Schnittstelle. Dateien, Pipes, Sockets, Terminals – alles sind Dateideskriptoren. Umleitung, Pipes und Netzwerkkommunikation nutzen denselben zugrunde liegenden Mechanismus. Wer das versteht, erkennt auch Docker-Networking, Unix-Domain-Sockets und Linux-System-APIs klarer.
  • Prozessisolation ist fundamental. Ein Kindprozess kann seinen Elternprozess nicht verändern. Umgebungsvariablen, Arbeitsverzeichnis und offene Dateien werden als Kopien geerbt, nicht als gemeinsam genutzte Referenzen. Deshalb wirkt sich ein cd in einer Subshell nicht auf den Elternprozess aus, und Docker-Container erben die Umgebung des Hosts, teilen sie aber nicht.
  • Komposition schlägt Funktionsumfang. Unix hat keinen Befehl „finde Dateien mit einem Muster und zähle sie“. Es hat find, grep und wc, verbunden durch Pipes. Der Pipe-Mechanismus der Shell macht einfache Werkzeuge zu komplexen Workflows kombinierbar. Diese Designphilosophie – kleine Werkzeuge, verbunden über standardisierte Schnittstellen – ist der geistige Vorläufer von Microservices, Unix-Sockets und API-Design.
  • Fehlerbehandlung dreht sich im Kern um Dateideskriptoren. Bricht eine Pipe, stürzt ein Kindprozess ab oder ist stdin erschöpft – dahinter steckt immer, dass Dateideskriptoren geschlossen, Signale zugestellt oder Prozesse mit Statuscodes beendet werden. Sobald man das Dateideskriptor-Modell verstanden hat, werden die Fehlermuster in Unix-Systemen intuitiv.

Wo man anfangen sollte

Wer eine Shell bauen will, startet mit der 25-zeiligen Version von oben und ergänzt Features schrittweise. Zuerst: Argumentzerlegung (Eingabe an Leerzeichen trennen). Dann: Ein- und Ausgabeumleitung (> und <). Danach: Pipes. Dann: eingebaute Befehle (cd, exit). Schließlich: Umgebungsvariablen. Jedes Feature vermittelt ein neues Unix-Konzept, und jedes ist eine in sich abgeschlossene Übung.

Nimm C für die direkteste Abbildung auf die Systemaufrufe. Man kann eine Shell auch in Python oder Rust bauen, doch die C-Implementierung macht die zugrunde liegenden Systemaufrufe explizit – man sieht genau, was fork(), exec(), dup2() und pipe() tun, weil man sie direkt aufruft.

Man braucht keine produktionsreife Shell zu bauen. Schon eine Spielzeug-Shell, die Grundbefehle, Pipes und Umleitungen beherrscht, lehrt mehr darüber, wie Unix funktioniert, als jahrelange Nutzung einer fertigen. Die Shell ist das einfachste Programm, das die wichtigsten Betriebssystemschnittstellen nutzt – und wer diese versteht, wird unabhängig von Sprache oder Plattform ein besserer Entwickler.