Costruire una shell da zero in C rimane uno dei modi migliori per approfondire la vostra comprensione di come i sistemi operativi gestiscono i processi, gestiscono l'ingresso dell'utente e eseguire i programmi. Mentre le moderne conchiglie come Bash, Zsh e Fish sono incredibilmente sofisticate, la loro funzionalità principale si riduce a un semplice loop: leggere un comando, parse esso, creare un nuovo processo, e aspettare che finisca.

Che cosa una Shell fa

Il suo cuore, un guscio è un interprete di linea di comando. Fornisce un'interfaccia testuale in cui gli utenti digitano i comandi, e la shell traduce quei comandi in azioni eseguite dal sistema operativo. Quando digiti ls -la]], la shell deve trovare il ]] eseguibile, crea un processo semplice per eseguirlo, passa l'argomento

Costruire un guscio minimo vi insegna:

  • Lettura e tokenizzazione input da input standard
  • Creazione e gestione dei processi dei bambini con fork
  • Sostituzione dell'immagine di memoria di un processo con exec
  • Sincronizzazione del genitore e del bambino con wait
  • Gestione di errori comuni con grazia

Comprendere questi blocchi di costruzione ti dà una panoramica su come tutti i sistemi Unix-like operano sotto il cofano, e fornisce una solida base per conoscere caratteristiche più avanzate come la gestione del segnale, il controllo del lavoro e la comunicazione inter-process.

Impostazione del Shell Loop

Ogni shell è costruita intorno ad un loop principale che si ripete indefinitamente fino a quando l'utente non richiede di uscire. Questo loop stampa un prompt, legge una linea di input, lo analizza e poi agisce sul comando parsed. La versione più semplice sembra questo in pseudocodice:

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

In C, si usa tipicamente fgets] per leggere l'ingresso perché gestisce i confini della linea e i flussi di buffer più sicuri di gets]. Il prompt può essere semplice come una stringa come mysh> , ma i nomi utente reali di comando di funzionamento spesso includono la directory corrente.

Gestione di bordelli in ingresso

Un robusto guscio deve gestire linee vuote, leader e trailing whitespace, comandi estremamente lunghi, e la condizione di fine del file (Ctrl+D). Se fgets restituisce ] NULL, si dovrebbe rompere il ciclo più lungo e eseguire con garbo.

#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);
}

Comandi di Parsing in Argomenti

Una volta che hai una stringa di input pulita, devi dividerla in token. Il primo token è il nome di comando (ad esempio ls]), e i token rimanenti sono gli argomenti a quel comando. La libreria standard C fornisce strtok] per questo scopo, ma devi essere attento perché [FLT-F

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

Questo tokenizer divide l'ingresso su spazi e schede. non] gestire stringhe citate, quindi un comando come echo "hello world" non sarebbe erroneamente rompere in tre token anziché due.

Dopo la tokenizzazione, si dovrebbe verificare se il primo token è NULL (comando vuoto). In caso affermativo, semplicemente continuare alla successiva iterazione del loop senza forking.

Comandi integrati

Alcuni comandi, come ]]cd] e ]exit, devono essere eseguiti dal processo di shell stesso perché influiscono sullo stato della shell. Ad esempio, ]cd modifica l'attuale directory di lavoro della shell directory;

Esecuzione

Il comando exit]] termina immediatamente la shell. È il più semplice built-in da implementare: basta controllare se il primo token equivale "uscita" e uscire dal loop principale.

Modifica della directory (cd)

Il comando cd]] richiede di chiamare chdir]. La directory di destinazione è il secondo argomento. Se non viene fornito alcun argomento, si potrebbe default alla home directory dell'utente (disponibile tramite la ] HOME ambiente variabile).

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
}

Creazione di processo con forchetta

Per qualsiasi comando che non sia un built-in, la shell deve creare un processo per l'esecuzione del bambino. Il fork] sistema chiamata crea un nuovo processo duplicando il processo di chiamata. Il nuovo processo è chiamato il bambino, e l'originale è il genitore. Dopo fork, entrambi i processi continuano a eseguire il codice solo in base

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

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

Perché Fork?

Si potrebbe domandarsi perché è necessario creare un processo separato a tutti. Il motivo è che exec], che carica un nuovo programma in memoria, sostituisce il processo corrente completamente. Se la shell chiamato exec] direttamente, il programma shell sarebbe sostituito e non tornare mai ad accettare nuovi comandi.

Eseguire un programma con Exec

[LT] [LT] [[[FLT]]]] [[LT]]] [[EL]]]] [[EL]]]]] [[[EL]]]]] [[EL]]] [[EL]]]] [[EL]]]]] [[EL]]]]] [[EL]]]] [[EL]]]]]] [[EL]]]]]]]

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

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

Notare la chiamata a exit] dopo perror]. Se execvp] fallisce (ad esempio, perché il comando non esiste), il processo del bambino deve terminare; altrimenti, continuerà a eseguire qualsiasi codice seguito, che è di solito il risultato dei genitori.

In attesa del processo del bambino

Dopo aver predetto, il processo genitore solitamente aspetta che il bambino finisca prima di chiedere di nuovo. Questo viene fatto con wait[]] o waitpid]. Il waitpid] funzione vi dà più controllo perché è possibile specificare quale bambino di aspettare (utilizzando

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

status]] contiene informazioni su come il bambino ha terminato. È possibile utilizzare macro come WIFEXITED[, WEXITSTATUS], WIFSIGNALED[FLT]

Blocco vs. Non bloccaggio

waitpid[]]] chiama sopra blocca il genitore fino all'uscita del bambino. Questo è il comportamento corretto per un processo di primo piano. Se in seguito aggiungi supporto per i processi di sfondo (che comandano con ] &]), si imposta l'opzione WNOH[FANG

Mettere insieme la Shell completa

Ecco una shell completa e minimale che integra tutti i pezzi discussi finora. Si gestisce il exit] e []cd built-in, parses input utilizzando strtok], forche per comandi esterni, e aspetta che il bambino finisca l'errore.

#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;
}

[LT] [FLT]] [[FLT]]] [FLT]]] [[FLT]]]]] [[FLT]]]]] [[FLT]]]] [[FLT]]]] [[FLT]]]]] [[FLT]]]] [[FLT]]]]] [FLT]] [[[[FLT]]]]]]]]]]]]]]]]] [[[[[[[[[FLT]]]]]]]]]]]]]]]]]]]]]]]]]]]]]][[[[[[[[[[[[[[[[[[[FLT]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]

Pitfalls comune e suggerimenti di debugging

Anche con questa piccola quantità di codice, diverse cose possono andare storte. Ecco i problemi più frequenti e come risolverli:

Comando Non Trovato

Se digiti un comando che non esiste (ad esempio, foobar[]]), execvp[ restituisce e le stampe dei bambini "exec: No such file or directory" prima dell'uscita. Questo è un comportamento corretto, ma potresti voler stampare un messaggio di amicizia.

Manca Newline o Truncated Input

Se il prompt appare senza aspettare l'ingresso, la causa probabilmente è i caratteri di avanzi nel buffer di input da una chiamata precedente. Controlla sempre che [fgets[] consumato l'intera linea (cioè, che l'ultimo personaggio prima del terminatore null è una nuova linea).

Processi zombie

Se si dimentica di chiamare ] (o waitpid]), i processi per bambini che finiscono diventano zombie fino a quando non si raccoglie.

Estendere la Shell

Una volta che hai il guscio di base di lavoro, puoi aggiungere funzionalità che lo avvicinano a un guscio reale. Ogni funzione ti insegna di più sul sistema operativo.

Input/Redirecting di uscita

[LT][LT]] <[FLT][[[f]]]][LT]] <]] e > > richiede di analizzare la riga di comando per questi operatori, aprire i file appropriati usando open

Tubi

Il processo di inserimento di un comando nell'ingresso di un altro ([[cmd1 | cmd2]]) è più coinvolto. È necessario creare un tubo con pipe[], forcella due processi bambino, reindirizzare l'uscita di un bambino all'estremità di scrittura del tubo e l'ingresso completo per entrambi alla fine di lettura, quindi attendere per.

Controllo del lavoro

, e la capacità di portare posti di lavoro in primo piano richiede la gestione di un tavolo di lavoro, , [FLT:] , e [FLT:[FLT]]] [FLT]] [FLT]]]]]]

Storia del comando

L'implementazione di un semplice meccanismo di storia (frecce di aggiornamento/down per richiamare i comandi precedenti) comporta l'acquisizione di input utilizzando modalità di terminale raw (tramite [tcgetattr] e ]]tcsetattr]]]) o l'utilizzo della libreria , che fornisce questa casella.

Ulteriori letture e risorse

Per approfondire la vostra comprensione dei concetti presentati qui, le seguenti risorse sono inestimabili:

Conclusioni

Costruire un guscio di base in C è più di un esercizio accademico; esso ti costringe a impegnarsi direttamente con le astrazioni del sistema operativo. Hai visto come leggere e analizzare l'ingresso dell'utente, come fork crea un nuovo processo, e come ] ]]] ]]]]]] sostituisce la memoria di processo di shell run con un processo più forte