Table of Contents
Het bouwen van een shell vanaf nul in C blijft een van de beste manieren om uw begrip van hoe besturingssystemen processen beheren, gebruikersinvoer verwerken en programma's uitvoeren. Terwijl moderne schelpen zoals Bash, Zsh en Fish ongelooflijk verfijnd zijn, komt hun kernfunctionaliteit neer op een eenvoudige lus: lees een commando, ontleed het, maak een nieuw proces, en wacht tot het klaar is. In deze uitgebreide gids, zult u lopen door elk van deze stappen in detail, van het opzetten van een robuuste input parser tot het verwerken van proces creatie met ]fork en exec[]. Aan het einde, zult u een werkende shell die u kunt uitbreiden met omleiding, pijpleidingen en taakcontrole. De code wordt bewust rechtlijnig gehouden om de onderliggende systeemconcepten te benadrukken.
Wat een Shell doet eigenlijk
Een shell is een commandoregelinterpreter. Het biedt een tekstuele interface waar gebruikers commando's typen, en de shell vertaalt die commando's naar acties uitgevoerd door het besturingssysteem. Wanneer u ls -la typt, moet de shell het s[] uitvoerend programma vinden, een nieuw proces aanmaken om het uit te voeren, de argumenten doorgeven -la, en dan wachten tot dat proces is voltooid voordat u wordt gevraagd voor het volgende commando. Dit schijnbaar eenvoudige gedrag omvat verschillende fundamentele systeemoproepen en zorgvuldig beheer van geheugen en processen.
Een minimale shell bouwen leert je over:
- Invoer van standaardinvoer lezen en token
- Het creëren en beheren van kindprocessen met vork
- De geheugenafbeelding van een proces vervangen door exec
- Synchroniseren van ouder en kind met wacht
- Gemeenschappelijke fouten op een sierlijke manier verwerken
Het begrijpen van deze bouwstenen geeft inzicht in hoe alle Unix-achtige systemen onder de kap werken, en het biedt een solide basis voor het leren over meer geavanceerde functies zoals signaalverwerking, taakbeheersing en inter-proces communicatie.
De Shell-lus instellen
Elke shell is gebouwd rond een hoofdlus die voor onbepaalde tijd herhaalt totdat de gebruiker verzoekt om te stoppen. Deze lus drukt een prompt af, leest een regel van invoer, ontleedt het, en handelt vervolgens op het geparsed commando. De eenvoudigste versie ziet er als volgt uit in pseudocode:
while (1) {
print_prompt();
read_input();
parse_input();
execute_command();
}
In C gebruik je meestal fgets om invoer te lezen omdat het regelgrenzen en bufferoverflows veiliger behandelt dan gets. De prompt kan zo eenvoudig zijn als een string als mysh> , maar echte shells bevatten vaak de huidige werkmap, gebruikersnaam en hostnaam. Voorlopig is een statische prompt voldoende om te starten.
Behandelen van Rand-gevallen in invoer
Gebruikersinvoer is zelden schoon. Een robuuste shell moet lege lijnen verwerken, leidend en achtervolgend witruimte, extreem lange commando's, en de eind-of-file-conditie (Ctrl+D). Als [fgets] terugkomt NULL[, moet je de lus verlaten en er sierlijk uit gaan. Als de invoer alleen uit witruimte bestaat, moet de shell gewoon opnieuw worden gestart zonder te proberen iets uit te voeren. Je moet ook het geval behandelen waarin de invoerlijn langer is dan je vaste buffer door het te kappen of de buffer dynamisch te wijzigen. Voor een basisimplementatie is een buffer van 1024 bytes meestal veilig, maar je moet altijd controleren of de volledige regel gelezen is door te controleren of de string eindigt met een nieuwline-karakter.
#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);
}
Opdrachten verwerken in argumenten
Zodra je een schone invoerstring hebt, moet je het in tokens splitsen. Het eerste token is de commandonaam (bijv. ls), en de overige tokens zijn de argumenten voor dat commando. De standaardbibliotheek C biedt strtok voor dit doel, maar je moet voorzichtig zijn omdat strtok de oorspronkelijke string wijzigt en niet draadveilig is. Voor een enkeldraads shell als deze werkt het prima.
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
Deze tokenizer splitst de invoer op spaties en tabbladen. Het doet niet] behandelen geciteerde strings, zodat een commando als echo "hello world"[] zou verkeerd breken in drie tokens in plaats van twee. Handling quotes is een waardevolle verbetering die een meer verfijnde parser vereist, maar voor de basis shell, deze eenvoudige aanpak is genoeg om de meeste commando's met één woord te draaien.
Na tokenization moet u controleren of de eerste token NULL is (leeg commando). Zo ja, ga dan gewoon door met de volgende iteratie van de lus zonder vorken.
Ingebouwde commando's
Niet alle commando's moeten een nieuw proces starten. Sommige commando's, zoals cd en exit[], moeten door het shellproces zelf worden uitgevoerd omdat ze de status van de shell beïnvloeden. Bijvoorbeeld, [cd wijzigt de huidige werkmap van de shell; als je een dochterproces voor ]cd forked], dan zou dat kind zijn eigen directory veranderen en dan afsluiten, waarbij de werkmap van de ouder ongewijzigd wordt verlaten.
Afsluiten
Het exit commando beëindigt de shell onmiddellijk. Het is de eenvoudigste ingebouwde optie om te implementeren: controleer of het eerste token gelijk is aan "uitschakelen" en breek uit de hoofdlus. Optioneel kun je een exit status argument accepteren en doorgeven aan de exit] systeemaanroep.
Map wijzigen (cd)
Het cd commando vereist dat je chdir[] aanroept. De doelmap is het tweede argument. Als er geen argument is gegeven, zou je in de standaardmap van de gebruiker kunnen staan (beschikbaar via ]HOME omgevingsvariabele). Controleer altijd de retourwaarde van chdir[ en druk een foutmelding af als de map niet bestaat of niet toegankelijk is.
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
}
Process Creation met Fork
Voor elk commando dat niet een ingebouwde is, moet je shell een dochterproces maken om het uit te voeren. De vork systeemaanroep creëert een nieuw proces door het aanroepen te dupliceren. Het nieuwe proces wordt het kind genoemd, en het origineel is de ouder. Na .fork blijven beide processen uitvoeren vanuit hetzelfde punt in de code. Het enige verschil is de terugkeerwaarde van vork: het geeft 0 terug aan het kind, en het kind PID aan de ouder.
pid_t pid = fork();
if (pid == -1) {
perror("fork");
continue;
}
if (pid == 0) {
// Child process
// ...
} else {
// Parent process
// ...
}
Waarom vork?
Je zou je kunnen afvragen waarom je een apart proces moet creëren. De reden is dat exec, dat een nieuw programma in het geheugen laadt, het huidige proces volledig vervangt. Als de shell exec direct wordt vervangen en nooit meer terugkeert naar nieuwe commando's. Door eerst te forken kan het kind exec bellen zonder de ouder shell te beïnvloeden.
Een programma uitvoeren met Exec
De exec]-familie van functies vervangt het huidige proces door een nieuw programma. Er zijn verschillende varianten: execl, execlp[execlp[execle, execv], execvp[]execvpe[]. Het belangrijkste verschil tussen beide is hoe het programma zich bevindt en hoe de argumenten worden doorgegeven. Voor uw shell, execvp is het meest geschikt omdat het de PATH[] omgeving variabel is voor de executable en accepteert het NULL-geterponeerde array van argumenten, die u precies past.
if (pid == 0) {
// Child process
execvp(args[0], args);
// If execvp returns, an error occurred
perror("exec");
exit(EXIT_FAILURE);
}
Let op de oproep naar exit na perror[. Indien [execvp[] niet lukt (bv. omdat het commando niet bestaat), moet het dochterproces worden beëindigd; anders zou het doorgaan met de gevolgde code, wat meestal de shelllus van de ouder is. Dat zou resulteren in twee shells die draaien en concurreren voor invoer.
Wachten op het kinderproces
Na het vorken wacht het ouderproces meestal op het kind om het opnieuw te laten eindigen. Dit gebeurt met wacht of waitpid. De functie waitpid geeft je meer controle omdat je kunt aangeven op welk kind je moet wachten (met behulp van de PID die door ]vork is teruggegeven) en eventueel opties om blokkering te voorkomen.
int status;
waitpid(pid, &status, 0);
De status variabele bevat informatie over hoe het kind is beëindigd. Je kunt macro's gebruiken zoals WIFEXITED, WEXITSTATUS, WIFSIGNALED, en WTERMSIG] om details te extraheren. Voor een basisshell is het voldoende om te weten dat het kind klaar is; het afdrukken van de exitcode is een leuke touch voor debugging.
Blokkeren vs. niet-blokkeren
De eenvoudige wachtpidaanroep blokkeert de ouder totdat het kind uitkomt. Dit is het juiste gedrag voor een voorgrondproces. Als je later ondersteuning voor achtergrondprocessen toevoegt (een opdracht uitvoeren met &), zou je de optie WNOHANG instellen om te voorkomen dat je ze blokkeert en zou je een lijst van kinder-PID's moeten beheren om ze later te oogsten.
Samenbrengen van de volledige Shell
Hier is een complete, minimale shell die alle tot nu toe besproken stukken integreert. Het verwerkt de exit en cd ingebouwden, ontleedt input met strtok, vorken voor externe opdrachten, en wacht tot het kind klaar is. Voor duidelijkheid, foutcontrole is opgenomen maar wordt beknopt gehouden.
#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;
}
Deze code is een complete, werkende shell. Kopieer het in een bestand genaamd myshell.c, compileer het met gcc -o myshell myshell.c, en voer het uit. Je zult een prompt zien waar je commando's kunt typen zoals ls, ]pwd, echo hello], en cd /tmp[. De exit[ command beëindigt de shell.
Veel voorkomende Pitfalls en Debugging Tips
Zelfs met deze kleine hoeveelheid code, kunnen verschillende dingen mis gaan. Hier zijn de meest voorkomende problemen en hoe ze te repareren:
Opdracht niet gevonden
Als u een commando typt dat niet bestaat (bijv. foobar), execvp geeft het kind terug en het kan een friendler bericht afdrukken. U kunt errno controleren na execvp maakt geen onderscheid tussen "file not found" en "permission denyd" (toestemming geweigerd).
Ontbrekende nieuwe regel of afgekorte invoer
Als je prompt verschijnt zonder te wachten op invoer, is de waarschijnlijke oorzaak resterende tekens in de invoerbuffer van een vorige oproep. Controleer altijd dat fgets de gehele regel heeft verbruikt (d.w.z. dat het laatste teken voor de nulterminator een nieuwe regel is). Als dat niet zo is, spoel dan de resterende invoer door zoals eerder is aangegeven.
Zombieprocessen
Als je vergeet om te bellen, worden kinderprocessen die eindigen zombies totdat je ze oogst. De shell in het voorbeeld roept waitpid, zodat zombies niet zouden moeten verschijnen. Echter, als je later achtergrondprocessen toevoegt en ze niet oogst, kan zombie-accumulatie een probleem worden. De oplossing is om een SIGCHLD te installeren die waitpid[ met [WNOG] op te ruimen om voltooide kinderen op te ruimen.
Uitbreiding van de Shell
Zodra je de basis shell werken, kunt u functies die het dichter bij een real-world shell brengen toevoegen. Elke functie leert u meer over het besturingssysteem.
Invoer/Uitvoer-redirectie
Dit gebeurt in het kinderproces tussen fork en exec.
Buizen
Het piken van de uitvoer van het ene commando in de invoer van een ander (cmd1
Taakbeheer
Om de achtergronduitvoering toe te voegen (& aan het einde van een opdracht) en de mogelijkheid om taken op de voorgrond te brengen, vereist het beheren van een taaktabel, het hanteren SIGTSTP[, SIGCONT, en SIGCHLD[, en het gebruik van tcsetpgrp[] om terminale eigendom te beheren.
Commandogeschiedenis
Het implementeren van een eenvoudig geschiedenismechanisme (op/omlaag pijlen om eerdere commando's terug te roepen) houdt in dat input wordt opgenomen met behulp van ruwe terminalmodus (via tcgetattr[] en tcsetattr[) of met behulp van de readline[] bibliotheek, die deze functionaliteit buiten het vak levert.
Verdere lezing en bronnen
Om uw begrip van de hier gepresenteerde concepten te verdiepen, zijn de volgende middelen van onschatbare waarde:
- GNU C Bibliotheekhandboek: Process Creation Voorbeelden
- De Open Group Base Specificaties: Shell Command Language .De formele specificatie voor POSIX shellgedrag; nuttig als je een standaard wilt aanpassen.
- Wikipedia: Unix shell
- Beej's Gids voor Interprocess Communicatie . .Bedek pijpen, FIFO's, berichtenwachtrijen en gedeeld geheugen met praktische voorbeelden.
Conclusie
Het bouwen van een basisshell in C is meer dan een academische oefening; het dwingt je om direct te gaan met de kern abstracties van het besturingssysteem. Je hebt gezien hoe je de gebruikersinvoer kunt lezen en ontleden, hoe fork een nieuw proces creëert, en hoe exec[] dat procesgeheugen vervangt door een gevraagd programma. Je hebt ook ingebouwde commando's toegevoegd die in de eigen context van de shell moeten draaien en fouten sierlijk moeten worden afgehandeld. De volledige shellcode die in dit artikel wordt gegeven is een solide uitgangspunt dat je kunt uitbreiden met omleiding, pijpleidingen, job control en geschiedenis. Elke uitbreiding zal je begrip van procesbeheer, bestandsdescriptoren en signalen verdiepen. Tegen de tijd dat je zelfs een paar van deze functies hebt toegevoegd, zul je een instrument hebben dat echt nuttig is en een veel sterker begrip van hoe moderne schelpen werken onder het oppervlak.