Table of Contents
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:
- Manual da biblioteca GNU C: Exemplos de criação de processos – Documentação oficial sobre fork, exec[, e esperar[.
- A base de dados do grupo aberto Especificações: Shell Command Language – A especificação formal para o comportamento da shell POSIX; útil se você quiser corresponder a um padrão.
- Wikipedia: Unix shell – Uma ampla visão geral da história, variantes e características da shell.
- Guia de Beej para Comunicação Interprocesso – Abrange tubos, FIFOs, filas de mensagens e memória compartilhada com exemplos práticos.
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.