College Online
0%

Projeto Final: Mini-Shell

Módulo 8 · Aula 3 ~40 min de leitura Nível: Intermediário/Avançado

Vídeo da aula estará disponível em breve

Visão Geral do Projeto

Neste projeto final, vamos construir um mini-shell funcional em C que integra os principais conceitos do curso: processos (fork/exec/wait), I/O (redirecionamento, pipes), sinais e controle de jobs. Ao final, você terá um shell que executa comandos reais do sistema.

Funcionalidades do mini-shell
Nível 1 (básico):
  ✓ Prompt com diretório atual
  ✓ Executar comandos simples (ls, cat, grep, etc.)
  ✓ Comandos built-in: cd, exit, pwd
  ✓ Tratamento de argumentos

Nível 2 (intermediário):
  ✓ Redirecionamento de saída: comando > arquivo
  ✓ Redirecionamento de entrada: comando < arquivo
  ✓ Pipes: cmd1 | cmd2

Nível 3 (avançado):
  ✓ Tratamento de sinais (Ctrl+C, Ctrl+Z)
  ✓ Execução em background: comando &
  ✓ Job control básico: jobs, fg, bg

Conceitos do curso exercitados:
  fork/exec/wait → Módulo 2 (Processos)
  pipe/dup2      → Módulo 7 (E/S)
  signals        → Módulo 2 (Comunicação entre processos)
  waitpid        → Módulo 3 (Escalonamento/estados)

Passo 1: Loop Principal e Parsing

Todo shell tem o mesmo loop: ler comando, parsear, executar, repetir. Vamos começar com a estrutura básica.

C — minishell.c (passo 1: loop + parsing)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <signal.h>
#include <errno.h>

#define MAX_LINE   1024   // tamanho máximo da linha
#define MAX_ARGS   64     // máximo de argumentos
#define MAX_JOBS   32     // máximo de jobs em background

/* ── Estrutura de um job em background ── */
typedef struct {
    pid_t pid;
    int id;
    char cmd[MAX_LINE];
    int stopped;  // 1 se parado por Ctrl+Z
} Job;

Job jobs[MAX_JOBS];
int job_count = 0;

/* ── Parsear linha em array de tokens ── */
int parse_line(char* line, char** args) {
    int argc = 0;
    char* token = strtok(line, " \t\n");

    while (token != NULL && argc < MAX_ARGS - 1) {
        args[argc++] = token;
        token = strtok(NULL, " \t\n");
    }
    args[argc] = NULL;  // execvp exige NULL no final
    return argc;
}

/* ── Exibir prompt ── */
void show_prompt() {
    char cwd[MAX_LINE];
    if (getcwd(cwd, sizeof(cwd)) != NULL) {
        printf("\033[1;32mminishell\033[0m:\033[1;34m%s\033[0m$ ", cwd);
    } else {
        printf("minishell$ ");
    }
    fflush(stdout);
}

Passo 2: Executar Comandos com fork/exec/wait

O coração de qualquer shell Unix: o pai faz fork() para criar um filho, o filho faz exec() para substituir seu código pelo comando, e o pai faz wait() para esperar o filho terminar.

C — Execução de comandos simples
/* ── Comandos built-in ── */
int handle_builtin(char** args, int argc) {
    if (argc == 0) return 1;

    if (strcmp(args[0], "exit") == 0) {
        printf("Saindo do minishell.\n");
        exit(0);
    }

    if (strcmp(args[0], "cd") == 0) {
        const char* dir = (argc > 1) ? args[1] : getenv("HOME");
        if (chdir(dir) != 0)
            perror("cd");
        return 1;  // comando tratado
    }

    if (strcmp(args[0], "pwd") == 0) {
        char cwd[MAX_LINE];
        if (getcwd(cwd, sizeof(cwd)))
            printf("%s\n", cwd);
        return 1;
    }

    if (strcmp(args[0], "jobs") == 0) {
        for (int i = 0; i < job_count; i++) {
            if (jobs[i].pid > 0) {
                printf("[%d] %s  %s\n", jobs[i].id,
                       jobs[i].stopped ? "Stopped" : "Running",
                       jobs[i].cmd);
            }
        }
        return 1;
    }

    return 0;  // não é built-in
}

/* ── Executar comando externo ── */
void execute_command(char** args, int background) {
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        return;
    }

    if (pid == 0) {
        /* ── FILHO ── */
        // Restaurar handlers de sinais ao default
        signal(SIGINT, SIG_DFL);
        signal(SIGTSTP, SIG_DFL);

        // Novo process group para job control
        setpgid(0, 0);

        execvp(args[0], args);
        // Se execvp retorna, houve erro
        fprintf(stderr, "minishell: comando não encontrado: %s\n", args[0]);
        exit(127);
    }

    /* ── PAI ── */
    if (background) {
        // Registrar job em background
        if (job_count < MAX_JOBS) {
            jobs[job_count].pid = pid;
            jobs[job_count].id = job_count + 1;
            jobs[job_count].stopped = 0;
            snprintf(jobs[job_count].cmd, MAX_LINE, "%s", args[0]);
            printf("[%d] %d\n", jobs[job_count].id, pid);
            job_count++;
        }
    } else {
        // Foreground: esperar o filho terminar
        int status;
        waitpid(pid, &status, WUNTRACED);

        if (WIFSTOPPED(status)) {
            // Filho foi parado por Ctrl+Z
            if (job_count < MAX_JOBS) {
                jobs[job_count].pid = pid;
                jobs[job_count].id = job_count + 1;
                jobs[job_count].stopped = 1;
                snprintf(jobs[job_count].cmd, MAX_LINE, "%s", args[0]);
                printf("\n[%d] Stopped  %s\n", jobs[job_count].id, args[0]);
                job_count++;
            }
        }
    }
}
i
Por que execvp e não exec? A família exec tem 6 variantes. execvp é a mais útil para shells: o v significa que argumentos vêm em um vetor (array), e o p significa que busca no PATH (não precisa passar caminho completo como /bin/ls).

Passo 3: Redirecionamento de E/S

Redirecionamento usa dup2() para substituir stdin (fd 0) ou stdout (fd 1) por um arquivo. A system call dup2(old_fd, new_fd) faz new_fd apontar para o mesmo arquivo que old_fd.

C — Redirecionamento com dup2
/* ── Processar redirecionamentos ── */
int setup_redirections(char** args, int argc) {
    for (int i = 0; i < argc; i++) {
        if (strcmp(args[i], ">") == 0 && i + 1 < argc) {
            // Redirecionar stdout para arquivo (sobrescrever)
            int fd = open(args[i + 1], O_WRONLY | O_CREAT | O_TRUNC, 0644);
            if (fd < 0) { perror("open"); return -1; }
            dup2(fd, STDOUT_FILENO);  // stdout agora aponta para o arquivo
            close(fd);
            args[i] = NULL;  // remover ">" e filename dos args
            return 0;
        }

        if (strcmp(args[i], ">>") == 0 && i + 1 < argc) {
            // Redirecionar stdout (append)
            int fd = open(args[i + 1], O_WRONLY | O_CREAT | O_APPEND, 0644);
            if (fd < 0) { perror("open"); return -1; }
            dup2(fd, STDOUT_FILENO);
            close(fd);
            args[i] = NULL;
            return 0;
        }

        if (strcmp(args[i], "<") == 0 && i + 1 < argc) {
            // Redirecionar stdin de arquivo
            int fd = open(args[i + 1], O_RDONLY);
            if (fd < 0) { perror("open"); return -1; }
            dup2(fd, STDIN_FILENO);  // stdin agora lê do arquivo
            close(fd);
            args[i] = NULL;
            return 0;
        }
    }
    return 0;
}

/*
 Como dup2 funciona:

 Antes de dup2(fd, STDOUT_FILENO):
   fd 0 (stdin)  → terminal
   fd 1 (stdout) → terminal     ← saída normal
   fd 3          → arquivo.txt

 Depois de dup2(3, 1):
   fd 0 (stdin)  → terminal
   fd 1 (stdout) → arquivo.txt  ← agora stdout vai pro arquivo!
   fd 3          → arquivo.txt

 close(3) limpa o fd extra. fd 1 continua apontando pro arquivo.
 printf() escreve no arquivo sem saber que mudou!
*/

Passo 4: Pipes

Pipes conectam a saída de um comando à entrada de outro. A system call pipe() cria dois file descriptors conectados: o que se escreve em um, sai pelo outro.

C — Implementação de pipe (cmd1 | cmd2)
/* ── Executar pipeline de dois comandos ── */
void execute_pipe(char** cmd1, char** cmd2) {
    int pipefd[2];
    pipe(pipefd);  // pipefd[0]=leitura, pipefd[1]=escrita

    pid_t pid1 = fork();
    if (pid1 == 0) {
        /* ── Filho 1: cmd1 (escreve no pipe) ── */
        close(pipefd[0]);               // não precisa ler do pipe
        dup2(pipefd[1], STDOUT_FILENO); // stdout → pipe
        close(pipefd[1]);
        signal(SIGINT, SIG_DFL);
        execvp(cmd1[0], cmd1);
        perror("execvp");
        exit(1);
    }

    pid_t pid2 = fork();
    if (pid2 == 0) {
        /* ── Filho 2: cmd2 (lê do pipe) ── */
        close(pipefd[1]);              // não precisa escrever no pipe
        dup2(pipefd[0], STDIN_FILENO); // stdin ← pipe
        close(pipefd[0]);
        signal(SIGINT, SIG_DFL);
        execvp(cmd2[0], cmd2);
        perror("execvp");
        exit(1);
    }

    /* ── Pai: fechar pipe e esperar ambos ── */
    close(pipefd[0]);
    close(pipefd[1]);
    waitpid(pid1, NULL, 0);
    waitpid(pid2, NULL, 0);
}

/*
 Diagrama do pipe:

 ┌────────┐    pipe     ┌────────┐
 │  cmd1  │──(stdout)──→│  cmd2  │
 │  (ls)  │  pipefd[1]  │ (grep) │
 └────────┘  pipefd[0]  └────────┘
              ↑    ↓
         escrita  leitura

 Exemplo: ls -la | grep ".c"
   cmd1 = ["ls", "-la", NULL]
   cmd2 = ["grep", ".c", NULL]

 Regra crítica de pipes:
   FECHAR todos os fds do pipe que não são usados!
   Se o pai não fechar pipefd[0] e pipefd[1]:
   cmd2 nunca receberá EOF e ficará travado forever.
*/

Passo 5: Sinais

O shell precisa tratar sinais para que Ctrl+C e Ctrl+Z não matem o próprio shell, mas sim o comando sendo executado.

C — Tratamento de sinais
/* ── Handler para SIGCHLD (filho terminou em background) ── */
void sigchld_handler(int sig) {
    int status;
    pid_t pid;

    // Coletar todos os filhos terminados (sem bloquear)
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        // Encontrar e remover o job da lista
        for (int i = 0; i < job_count; i++) {
            if (jobs[i].pid == pid) {
                printf("\n[%d] Done  %s\n", jobs[i].id, jobs[i].cmd);
                jobs[i].pid = 0;  // marcar como terminado
                break;
            }
        }
    }
}

/* ── Configurar sinais no shell (chamado uma vez no main) ── */
void setup_signals() {
    // Shell IGNORA Ctrl+C e Ctrl+Z
    signal(SIGINT, SIG_IGN);   // Ctrl+C: não mata o shell
    signal(SIGTSTP, SIG_IGN);  // Ctrl+Z: não para o shell

    // Tratar SIGCHLD para coletar filhos background
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    sigaction(SIGCHLD, &sa, NULL);
}

/*
 Fluxo de sinais:

 Usuário digita Ctrl+C:
   Kernel envia SIGINT a todos os processos no foreground group
   Shell: ignora (SIG_IGN) → continua rodando
   Filho: handler default (SIG_DFL) → termina

 Usuário digita Ctrl+Z:
   Kernel envia SIGTSTP ao foreground group
   Shell: ignora
   Filho: para (SIGTSTP) → waitpid retorna com WIFSTOPPED
   Shell: registra como job stopped

 Filho termina em background:
   Kernel envia SIGCHLD ao pai (shell)
   sigchld_handler: coleta com waitpid, imprime "[1] Done"
*/

Passo 6: Função main Completa

C — main() — juntando tudo
int main() {
    char line[MAX_LINE];
    char* args[MAX_ARGS];

    setup_signals();

    while (1) {
        show_prompt();

        // Ler linha (fgets é seguro: limita tamanho)
        if (fgets(line, MAX_LINE, stdin) == NULL) {
            printf("\n");
            break;  // Ctrl+D (EOF)
        }

        // Linha vazia? Próximo prompt
        if (line[0] == '\n') continue;

        // Guardar cópia para pipe parsing
        char line_copy[MAX_LINE];
        strncpy(line_copy, line, MAX_LINE);

        // Verificar se tem pipe
        char* pipe_pos = strchr(line_copy, '|');
        if (pipe_pos) {
            *pipe_pos = '\0';
            char* cmd1_str = line_copy;
            char* cmd2_str = pipe_pos + 1;

            char* cmd1_args[MAX_ARGS];
            char* cmd2_args[MAX_ARGS];
            parse_line(cmd1_str, cmd1_args);
            parse_line(cmd2_str, cmd2_args);

            execute_pipe(cmd1_args, cmd2_args);
            continue;
        }

        // Parsear argumentos
        int argc = parse_line(line, args);
        if (argc == 0) continue;

        // Verificar background (&)
        int background = 0;
        if (strcmp(args[argc - 1], "&") == 0) {
            background = 1;
            args[argc - 1] = NULL;
            argc--;
        }

        // Tentar built-in primeiro
        if (handle_builtin(args, argc))
            continue;

        // Comando externo
        pid_t pid = fork();
        if (pid == 0) {
            /* Filho */
            signal(SIGINT, SIG_DFL);
            signal(SIGTSTP, SIG_DFL);
            setup_redirections(args, argc);
            execvp(args[0], args);
            fprintf(stderr, "minishell: %s: comando não encontrado\n", args[0]);
            exit(127);
        } else if (pid > 0) {
            /* Pai */
            if (background) {
                if (job_count < MAX_JOBS) {
                    jobs[job_count].pid = pid;
                    jobs[job_count].id = job_count + 1;
                    jobs[job_count].stopped = 0;
                    snprintf(jobs[job_count].cmd, MAX_LINE, "%s", args[0]);
                    printf("[%d] %d\n", jobs[job_count].id, pid);
                    job_count++;
                }
            } else {
                int status;
                waitpid(pid, &status, WUNTRACED);
                if (WIFSTOPPED(status) && job_count < MAX_JOBS) {
                    jobs[job_count].pid = pid;
                    jobs[job_count].id = job_count + 1;
                    jobs[job_count].stopped = 1;
                    snprintf(jobs[job_count].cmd, MAX_LINE, "%s", args[0]);
                    printf("\n[%d] Stopped  %s\n", jobs[job_count].id, args[0]);
                    job_count++;
                }
            }
        }
    }

    return 0;
}

Compilar e Testar

Shell — Compilação e testes
# Compilar
$ gcc -Wall -Wextra -o minishell minishell.c

# Executar
$ ./minishell

# Testes nível 1 (básico):
minishell:/home/marco$ ls -la
minishell:/home/marco$ pwd
minishell:/home/marco$ cd /tmp
minishell:/tmp$ echo "hello world"
minishell:/tmp$ cd
minishell:/home/marco$ exit

# Testes nível 2 (redirecionamento e pipe):
minishell:~$ ls -la > listagem.txt
minishell:~$ cat listagem.txt
minishell:~$ wc -l < listagem.txt
minishell:~$ ls -la >> listagem.txt
minishell:~$ ls -la | grep ".c"
minishell:~$ cat /etc/passwd | grep root

# Testes nível 3 (sinais e background):
minishell:~$ sleep 100 &
[1] 12345
minishell:~$ jobs
[1] Running  sleep
minishell:~$ sleep 50       # depois Ctrl+C → mata o sleep
^C
minishell:~$ sleep 50       # depois Ctrl+Z → para o sleep
^Z
[2] Stopped  sleep
minishell:~$ jobs
[1] Running  sleep
[2] Stopped  sleep

No harness.os

O mini-shell que construímos é uma versão simplificada do que o shell real faz quando você executa comandos do harness. O pipeline de operações é idêntico:

Diagrama — Shell no contexto do harness.os
Quando você executa um agente no terminal:

  $ claude "log a decision about using React"

  O shell real (bash/zsh) faz exatamente o que nosso mini-shell faz:
  1. fork()  → cria processo filho
  2. exec()  → substitui por binário do claude
  3. Claude inicia → chama MCP tools (log_decision)
  4. MCP tools → queries no Neon Postgres
  5. Claude termina → exit()
  6. Shell faz wait() → coleta status do filho
  7. Shell exibe prompt novamente

  Com pipe:
  $ cat rules.json | claude "review these rules"
                   ↑
                pipe() + dup2()
                stdout do cat → stdin do claude

  O mini-shell que construímos implementa as mesmas system calls
  que o bash usa para executar qualquer programa no seu sistema.

Exercícios de Extensão

  1. Pipes múltiplos: Estenda o mini-shell para suportar cmd1 | cmd2 | cmd3 (pipeline de N comandos). Dica: crie N-1 pipes e N filhos em um loop.
  2. Histórico: Implemente um comando history que mostra os últimos 20 comandos digitados. Armazene em um array circular. Bônus: salve em ~/.minishell_history ao sair.
  3. fg e bg: Implemente os comandos fg %N (trazer job N para foreground) e bg %N (continuar job parado em background). Use kill(pid, SIGCONT) para continuar e tcsetpgrp() para controle de terminal.
  4. Variáveis de ambiente: Implemente export VAR=valor e $VAR expansion. Use setenv() e substitua tokens que começam com $ pelo valor correspondente via getenv().

Resumo

Verifique seu entendimento

Por que o processo filho deve fechar as pontas do pipe que não utiliza?

  • Para economizar memória, já que file descriptors ocupam espaço
  • Para que o leitor do pipe receba EOF quando o escritor terminar; caso contrário, fica bloqueado esperando dados indefinidamente
  • Para evitar que o kernel envie SIGPIPE ao processo
  • Porque o sistema operacional limita cada processo a 3 file descriptors