IzuBot

Criando Um Shell - Parte 1

Criando o meu próprio shell

21 de setembro de 2026

Índice

Eu vejo muito na internet programadores incríveis que do nada decidem criar a própria linguagem ou o próprio sistema e, sinceramente, eu admiro muito isso. A fim de me tornar um programador parecido com eles, eu resolvi criar também o meu próprio shell.

Toda pessoa que trabalha com informática já teve contato com o shell pelo menos uma vez. Algumas pessoas têm medo de abrir o terminal e digitar comandos, mas essa série de posts tem o objetivo de mostrar como ele funciona e, obviamente, criar um shell do zero.

Terminal vs. Shell

Antes de começarmos, é importante sabermos o que é um terminal e o que é o shell. Sim, eles são coisas diferentes! Pode não parecer, mas são conceitos distintos. É comum nunca ter pensado sobre isso, afinal de contas, no dia a dia você só precisa que seu computador faça o que você quer e isso já basta. Nem sempre estamos interessados em saber como as coisas realmente funcionam, mas se você está lendo isso, acredito que algo te instigou a descobrir mais sobre o assunto.

Terminal

Um terminal é a interface onde o usuário pode ativamente interagir com a máquina através de comandos; é ele quem disponibiliza a interação com o shell.

Antigamente, os terminais eram sistemas de máquinas simples em hardware ligadas a servidores de terminais (esses sim, potentes). Essas máquinas mais simples tinham uma tela e um teclado acoplados onde os profissionais poderiam executar comandos para serem processados no servidor central.

Essas máquinas simples na verdade se chamavam dumb terminals (ou terminais burros). “Burros” porque não tinham processador interno, não executavam cálculos nem aplicativos de forma independente. Elas precisavam de um host para processar tudo. Esses dumb terminals eram usados apenas para Entrada e Saída (I/O): capturavam o que você digitava, enviavam para o servidor processar, o servidor devolvia o resultado e o terminal mostrava na tela.

Hoje em dia, os terminais (ou melhor, emuladores de terminal) são aplicativos gráficos (como o GNOME Terminal, Alacritty e o Windows Terminal). Eles capturam o teclado, desenham as fontes na tela, interpretam cores ANSI e gerenciam janelas, criando um canal de comunicação virtual de entrada e saída (pseudo-terminal ou pty).

Shell

O shell é entendido como sendo uma camada que envolve o sistema operacional em uma casca ou concha (por isso o nome em inglês shell). Essa camada é responsável por disponibilizar uma interface entre o usuário e as funcionalidades do kernel. Por mais que essa ideia seja a mais conhecida e ensinada, o fato é que raramente um shell interage diretamente com o kernel (interagir diretamente com o kernel seria perigoso), sendo mais comum o kernel disponibilizar APIs (Application Programming Interfaces) para que os programas façam essas interações.

A função principal do shell é interpretar comandos. Ele recebe as instruções do usuário através de um dispositivo de entrada, avalia e processa essas instruções, repassa ao sistema operacional o que deve ser feito, retorna o resultado para o usuário e espera a próxima instrução.

Diagrama simplificado


    +------------+
    |  Usuário   |
    +------------+
        |
        | (Digita comandos)
        |
        v
    +------------+
    |  Terminal  |
    +------------+
        |
        | (Passa o texto digitado)
        |
        v 
    +------------+
    |   Shell    |
    +------------+
        |
        | (Chamadas de sistema / System Calls)
        |
        v 
    +----------------+
    |   OS (Kernel)  |
    +----------------+
        |
        v
    +---------------------------+
    |   Hardware do computador  |
    +---------------------------+

Syscalls / System Calls

Mais cedo eu disse que o shell não faz chamadas diretamente ao kernel sem intermediários e que usava uma API para essa comunicação. Pois bem, o shell utiliza as syscalls (chamadas de sistema) para interagir com o kernel.

Essa comunicação entre a aplicação e a máquina ocorre graças à GNU C Library (glibc), que traduz as funções do C para syscalls. Existem outras bibliotecas que fazem algo similar, mas essa é a padrão utilizada em sistemas GNU/Linux.

Algumas funções da API do sistema

São muitas, mas vou listar aqui as mais básicas que representam uma parcela dos comandos que uso todos os dias:

  1. Gerenciamento de Processos:
    • fork() / vfork(): Duplica o processo do shell atual para criar um processo filho.
    • execve(): Substitui o código do processo filho pelo código do programa que você quer executar (ex: ls, grep, cd).
    • wait() / waitpid(): Faz o shell esperar até que o comando executado termine para só então devolver o controle do prompt ao usuário.
    • exit(): Finaliza o processo atual.
  2. Entrada, Saida e Redirecionamento (I/O)
    • open(): Abre arquivos, essencial quando você usa redirecionamentos como > ou <.
    • read(): Lê a entrada que você digita no terminal.
    • write(): Exibe o resultado dos comandos ou erros na tela.
    • close(): Fecha os descritores de arquivo que não estão mais em uso.
    • dup2(): Copia descritores de arquivo. É a chamada chave para fazer os redirecionamentos (ex: mandar a saída de um comando para um arquivo de texto). Existem muitas outras. Caso esteja interessado, aqui está a documentação oficial da biblioteca: GNU C Library.

Criando um Shell

REPL

O conceito e a primeira implementação do REPL (Read-Eval-Print Loop ou Loop Leitura-Avaliação-Impressão em português) foram introduzidos por John McCarthy no início dos anos 1960 para a linguagem de programação Lisp.

McCarthy desenvolveu o Lisp no MIT para rodar em sistemas de tempo compartilhado (time-sharing), como o CTSS (Compatible Time-Sharing System). Essa inovação permitiu que programadores digitassem expressões diretamente em um terminal de computador e vissem o resultado imediato, substituindo o antigo e lento processo de cartões perfurados.

O termo “REPL” surgiu das funções primitivas que o próprio Lisp usava para criar esse laço interativo:

  • READ: lê o código digitado no terminal.
  • EVAL: avalia/executa a instrução.
  • PRINT: imprime o resultado na tela.
  • LOOP: reinicia o processo

É isso que vou replicar no meu shell.

Inicialização

É na função main() que vou iniciar meu shell, onde o loop tomará conta do ciclo de execução.


int main()
{
    cls_loop();

    return EXIT_SUCCESS;
}

cls_loop()

O loop é o que reiniciará o processo a cada devolução do sistema para o shell. Quero que ele faça o seguinte inicialmente:

  • Ler a linha
  • Devolve a linha impressa
  • Repetir

void cls_loop()
{
    char *line;
    int status = 1;

    do {
        printf("> ");
        line = cls_read_line();
        printf("O usuário digitou isso > %s\n", line);

    } while (status);
}

Se você reparar, meu loop ainda não está totalmente completo, afinal de contas eu não tenho nada para fazê-lo parar. No entanto, vou deixar assim por enquanto, pois essa estrutura básica já faz o que eu preciso (ler, devolver e repetir).

Escolhi o do-while pois posteriormente poderei testar a variável status, que verificará se deve repetir o loop ou não a cada iteração.

cls_read_line()

Esta função é a responsável por ler a entrada do usuário:


char* cls_read_line()
{
    int bufsize = CLS_BUFSIZE;
    char* buffer = malloc(bufsize * sizeof(char));
    int c;

    if (buffer == NULL) {
        fprintf(stderr, "Não foi possivel alocar memória.");
        exit(EXIT_FAILURE);
    }

    for (int i = 0; i < bufsize; i++) {
        c = getchar();

        if (c != EOF && c != '\n') {
            buffer[i] = c;
        } else {
            buffer[i] = '\0';
            break;
        }
    }

    return buffer;
}

Não sou a pessoa mais proficiente em C, mas fiz o meu máximo para deixar o código legível. Atualmente, o que estou fazendo aqui é:

  • Criar um buffer de caracteres com o tamanho de CLS_BUFSIZE (uma constante com valor 1024). Com o malloc, eu reservo um espaço na memória de 1024 bytes (1024 x sizeof(char)). Ou seja, no meu buffer de caracteres é possível conter no máximo 1023 caracteres digitados, já que o último precisa ser o terminador nulo \0 (o caractere que indica ao C onde a string termina).
  • Verifico se o buffer é nulo (NULL). Caso essa condição se comprove verdadeira, gravo a mensagem de erro no fluxo de saída de erros stderr e finalizo o programa chamando exit(EXIT_FAILURE).
  • Inicio um loop for que vai incrementar o índice até que ele seja menor que bufsize.
    • A cada volta do loop, a função getchar() lê um caractere digitado.
    • No if, verifico se o caractere atual é diferente de EOF (End of File) E diferente de \n (que representa a quebra de linha do Enter). Se for diferente, adiciono o caractere no buffer na posição i. Caso contrário, significa que chegamos ao fim da linha, então adiciono o terminador nulo (\0) e encerro o loop com o break.
    • Por fim, retorno o ponteiro do buffer contendo o texto lido.

A função cls_read_line() contém um bug que eu só fui perceber enquanto digitava isso, bom, se o usuário digitar 1024 caracteres sem presionar Enter, o for encerra normalmente ao atingir o fim da contagem sem passar pelo else. O resultado é que a string retornada não terá o terminador nulo \0 no final, gerando vazamento de memória e leitura de lixo nas etapas seguintes. Então na parte dois eu começo resolvendo isso.

Explicando int c;

Isso tem a ver com o funcionamento das bibliotecas do sistema. Declarar char c; funcionaria na maioria dos casos visíveis, porém a função getchar() retorna um int (4 bytes na arquitetura x86_64) e não um char (1 byte). O motivo é que ela precisa retornar qualquer caractere válido (de 0 a 255) mais a constante EOF (que geralmente vale -1). Se a variável fosse char, em algumas arquiteturas o valor -1 do EOF poderia ser confundido com um caractere legítimo da tabela ASCII.

Por hoje é isso! Caso queira ver o código fonte completo do projeto, acesse o repositório no GitHub: clshell. (Eu sei, não sou muito bom com nomes :(.