A construção de uma shell do zero em C continua a ser uma das melhores maneiras de aprofundar a sua compreensão de como os sistemas operativos gerem processos, manipulam a entrada do utilizador e executam programas. Enquanto as shells modernas como Bash, Zsh e Fish são incrivelmente sofisticadas, a sua funcionalidade central vai até um loop simples: leia um comando, analise- o, crie um novo processo e espere que ele termine. Neste guia expandido, você irá percorrer cada um destes passos em detalhe, desde a criação de um analisador de entrada robusto até à criação de processos ]fork[[FLT: 1]] e [[FLT: 2]]exec[[[[FLT: 3]]. No final, você terá uma shell de trabalho que poderá estender com redirecionamento, pipelimentos e controlo de tarefas. O código é mantido deliberadamente sem distinção para realçar os conceitos subjacentes do sistema.

O que realmente uma concha faz

No seu coração, uma shell é um interpretador de linha de comando. Ela fornece uma interface textual onde os usuários digitam comandos, e a shell traduz esses comandos em ações executadas pelo sistema operacional. Quando você digita ls -la, a shell deve encontrar o ls[ executável, criar um novo processo para executá-lo, passar os argumentos -la[, e esperar que esse processo termine antes de pedir o próximo comando. Este comportamento aparentemente simples envolve várias chamadas fundamentais do sistema e gerenciamento cuidadoso da memória e processos.

Construir uma concha mínima ensina-lhe sobre:

  • Leitura e entrada de tokenizing da entrada padrão
  • Criar e gerir processos infantis com garfo
  • Substituindo a imagem de memória de um processo com exec
  • Sincronizar pai e filho com esperar
  • Lidar com erros comuns graciosamente

Compreender esses blocos de construção dá a você uma visão de como todos os sistemas Unix operam sob o capô, e fornece uma base sólida para aprender sobre recursos mais avançados, como manuseio de sinal, controle de trabalho e comunicação interprocesso.

Configurar o Concha

Cada shell é construído em torno de uma alça principal que se repete indefinidamente até que o usuário requeira para sair. Esta alça imprime uma linha de entrada, analisa- a e então age no comando analisado. A versão mais simples se parece com esta no pseudocódigo:

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

Em C, você normalmente usa fgets para ler a entrada porque ela lida com limites de linha e buffer transborda mais com segurança do que fica[. O prompt pode ser tão simples quanto uma string como mysh> , mas as shells reais incluem frequentemente a pasta de trabalho atual, nome de usuário e nome da máquina. Por enquanto, uma prompt estático é suficiente para ser iniciada.

Manusear casos de borda na entrada

A entrada do usuário raramente está limpa. Uma shell robusta deve lidar com linhas vazias, levando e seguindo o espaço em branco, comandos extremamente longos, e a condição de fim de arquivo (Ctrl+D). Se fgets[ retornar [ NULL[, você deve sair do loop e sair graciosamente. Se a entrada consiste apenas em espaço em branco, a shell deve simplesmente reprompt sem tentar executar nada. Você também deve lidar com o caso em que a linha de entrada é maior do que o seu buffer fixo, truncando- o ou redimensionando dinamicamente o buffer. Para uma implementação básica, um buffer de 1024 bytes é geralmente seguro, mas você deve sempre verificar se a linha completa foi lida verificando se a string termina com um caractere de nova linha.

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

Processando os Comandos em Argumentos

Assim que tiver uma string de entrada limpa, você precisa dividi- la em tokens. O primeiro token é o nome do comando (por exemplo, ]ls[[FLT: 1]]), e os tokens restantes são os argumentos para esse comando. A biblioteca padrão C fornece [[FLT: 2]] srtok[[[[FLT: 3]]] para este fim, mas você deve ter cuidado porque [[FLT: 4]] srttok[[[[FLT: 5]]] modifica a string original e não é seguro para threads. Para uma shell de um fio simples como esta, ela funciona bem.

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

Este tokenizer divide a entrada em espaços e tabulações. Ele faz not manusear strings citadas, então um comando como eco "olá mundo" iria incorretamente quebrar em três tokens ao invés de dois. Lidar aspas é um aprimoramento valioso que requer um analisador mais sofisticado, mas para o shell básico, esta abordagem simples é suficiente para executar a maioria dos comandos de uma palavra.

Após a tokenização, você deve verificar se o primeiro token é NULL (comando vazio). Se assim for, simplesmente continue para a próxima iteração do loop sem bifurcar.

Comandos incorporados

Nem todos os comandos devem gerar um novo processo. Alguns comandos, como ] cd e exit[, devem ser executados pelo próprio processo de shell porque afetam o estado do shell. Por exemplo, cd[] muda a pasta de trabalho atual do shell; se você forkou um processo filho para cd[, essa criança mudaria seu próprio diretório e então sairia, deixando o diretório de trabalho do pai inalterado.

Sair

O comando exit] termina a shell imediatamente. É o mais simples incorporado para implementar: basta verificar se o primeiro token é igual a "saída" e sair do loop principal. Opcionalmente, você pode aceitar um argumento de status de saída e passá-lo para a chamada de sistema exit[.

Mudar a Pasta (cd)

O comando cd[] requer que você chame chdir. O diretório alvo é o segundo argumento. Se nenhum argumento for fornecido, você pode ser o padrão do diretório do usuário (disponível através da variável de ambiente HOME[). Verifique sempre o valor de retorno de chdir[ e imprima uma mensagem de erro se o diretório não existir ou não estiver acessível.

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
}

Criação de Processos com Garfo

Para qualquer comando que não seja um built- in, a sua shell deverá criar um processo filho para executá- lo. A chamada de sistema [[FLT: 0]]] fork[[[FLT: 1]] cria um novo processo duplicando o processo de chamada. O novo processo é chamado de criança, e o original é o pai. Depois de [FLT: 2]] fork[[[[FLT: 3]], ambos os processos continuam a executar do mesmo ponto no código. A única diferença é o valor de retorno [[FLT: 4]] fork[[FLT: 5]]: devolve 0 à criança e o PID da criança ao pai.

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

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

Porquê o garfo?

Você pode perguntar por que você precisa criar um processo separado em tudo. A razão é que exec, que carrega um novo programa na memória, substitui o processo atual inteiramente. Se o shell chamado exec diretamente, o programa shell seria substituído e nunca mais retornaria para aceitar novos comandos. Ao forcar primeiro, o filho pode chamar exec[ sem afetar o shell pai.

Executar um programa com o Exec

A família de funções exec[, execlp[, execcl[, execv[, execvp[[, e execvpe[[]. A diferença chave entre elas é como o programa está localizado e como os argumentos são passados. Para a sua shell, execvp[[[execvp[[[]execpe[[. A diferença entre eles é a forma como o programa é localizado e como os argumentos são passados. Para a sua shell, ]execp[[[[[execp[[[[[[FLTT

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

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

Observe a chamada para exit] após perror. Se execvp[] falhar (por exemplo, porque o comando não existe), o processo filho deve terminar; caso contrário, ele continuaria executando qualquer código seguido, que é geralmente o loop de shell do pai. Isso resultaria em duas shells rodando e competindo para entrada.

Esperando o Processo Infantil

Após a bifurcação, o processo pai normalmente espera que a criança termine antes de ser novamente solicitada. Isto é feito com [[FLT: 0]] aguarde[[FLT: 1]] ou [[FLT: 2] aguarde[[[FLT: 3]]. A função [[FLT: 4] espera [[FLT: 5]] lhe dá mais controle porque você pode especificar qual criança esperar (usando o PID retornado por [[FLT: 6]]] garfo[[[FLT: 7]]]) e potencialmente definir opções para evitar bloqueio.

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

A variável ] está disponível em todas as versões do arquivo. Você pode usar macros como WIFEXITED, WEXITSTATUS[, WIFSIGNALED[[] e [WTERMSIG[[] para extrair detalhes. Para uma camada básica, basta saber que a criança terminou; imprimir o código de saída é um toque agradável para depuração.

Bloqueamento vs. Não Bloqueamento

A chamada simples [[FLT: 0]] waitpid[[[ FLT: 1]] acima bloqueia o pai até que o filho saia. Este é o comportamento correto para um processo de primeiro plano. Se você adicionar mais tarde suporte para processos de fundo (executando um comando com [[ FLT: 2]]]&[[ FLT: 3]], você definiria a opção [[ FLT: 4] WNOHANG[[[[ FLT: 5]]] para evitar bloqueios, e você precisaria gerenciar uma lista de PIDs infantis para colhe- los mais tarde.

Juntando a Concha Plena

Aqui está uma shell completa e mínima que integra todas as peças discutidas até agora. Ela lida com a entrada ]exit e cd[ built-ins, análise de entrada usando strtok[[, garfos para comandos externos, e espera que a criança termine. Para clareza, a verificação de erros está incluída, mas mantida concisa.

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

Este código é uma shell completa e funcional. Copie- o para um ficheiro chamado myshell.c[, compile- o com gcc - o myshell myshell.c, e execute- o. Você verá uma linha de comandos onde poderá escrever comandos como ls[, pwd[, echo hello, e cd /tmp[]. O comando exit] termina a shell.

Dicas de Depuração e Pilhas Comuns

Mesmo com esta pequena quantidade de código, várias coisas podem dar errado. Aqui estão os problemas mais frequentes e como corrigi-los:

Comando Não Encontrado

Se você digitar um comando que não existe (por exemplo, ]] foobar, execvp[ retorna e o filho imprime "exec: Nenhum arquivo ou diretório" antes de sair. Este é o comportamento correto, mas você pode querer imprimir uma mensagem amigável. Você pode verificar errno[ depois execvp[] não consegue distinguir entre "arquivo não encontrado" e "permissão negada".

Falta a Nova Linha ou Entrada Truncada

Se o seu prompt aparecer sem esperar pela entrada, a causa provável é os caracteres restantes no buffer de entrada de uma chamada anterior. Verifique sempre se [[FLT: 0]] fgets[[ FLT: 1]] consumiu a linha inteira (ou seja, que o último caractere antes do terminador nulo é uma nova linha). Se não for, anule a entrada restante como mostrado anteriormente.

Processos de Zombies

Se você se esquecer de chamar aguarde (ou waitpid[, processos infantis que terminam se tornam zumbis até que você os colhe. A concha no exemplo chama waitpid, então zumbis não devem aparecer. No entanto, se você adicionar mais tarde processos de fundo e não conseguir colhe- los, o acúmulo de zumbi pode se tornar um problema. A solução é instalar um SIGCHLD[] manipulador que chama waitpid com WOHANG[[] para limpar crianças completadas.

Estendendo a Shell

Uma vez que você tenha a shell básica funcionando, você pode adicionar recursos que a aproximam de uma shell do mundo real. Cada recurso ensina mais sobre o sistema operacional.

Redirecionamento de entrada/saída

Suportando >, <[, e >>] requer que você analise a linha de comando para estes operadores, abra os arquivos apropriados usando ]]aberto[, e use dup2[[] para redirecionar a entrada padrão ou saída antes de chamar execvp[[. Isto é feito no processo infantil entre ]fork[[ e exec.

Tubos

A entrada de um comando na entrada de outro (]cmd1 , cmd2[ ])) está mais envolvida. Você precisa criar um tubo com pipe[ , bifurcar dois processos filhos, redirecionar a saída de uma criança para o final de gravação do tubo e a entrada do outro para o final lido, e esperar que ambos terminem.

Controle de tarefas

A adição de execução de fundo (&] no final de um comando) e a capacidade de trazer trabalhos para o primeiro plano requerem a gestão de uma tabela de tarefas, o tratamento SIGTSTP, SIGCONT[[], e [SIGCHLD[[, e o uso [tcsetpgrp[[] para gerir a propriedade de terminal.

Histórico de Comandos

A implementação de um mecanismo de histórico simples (setas para cima/para baixo para recordar comandos anteriores) envolve a captura de entrada usando o modo terminal bruto (via tcgetattr[] e tcsetatttr) ou usando a biblioteca de leitura , que fornece esta funcionalidade fora da caixa.

Leitura e recursos adicionais

Para aprofundar a compreensão dos conceitos aqui apresentados, os seguintes recursos são inestimáveis:

Conclusão

Construir uma shell básica em C é mais do que um exercício acadêmico; ela obriga- o a interagir diretamente com as abstrações centrais do sistema operacional. Você viu como ler e analisar a entrada do usuário, como ] fork[ cria um novo processo, e como exec[ substitui essa memória por um programa solicitado. Você também adicionou comandos embutidos que devem ser executados no contexto próprio da shell e tratados com graciosamente. O código completo da shell fornecido neste artigo é um ponto de partida sólido que você pode estender com redirecionamento, oleodutos, controle de trabalho e histórico. Cada extensão irá aprofundar sua compreensão sobre o gerenciamento de processos, descritores de arquivos e sinais. Quando você adicionar até mesmo algumas dessas funcionalidades, você terá uma ferramenta que é realmente útil e uma compreensão muito mais forte de como os shell modernos funcionam abaixo da superfície.