Proyecto 1: construir una minishell desde cero con fork, exec, pipes y señales

Por: Artiko
sistemas-operativosadaelixirconcurrenciaminishellfork-execpipesredireccionessenalesproceso-en-background

Proyecto 1: construir una minishell desde cero con fork, exec, pipes y señales

En el capítulo 14 cerramos el bloque de concurrencia dentro de un mismo programa: tareas Ada, cita extendida, objetos protegidos, y la idea de que el lenguaje puede ofrecer primitivas de sincronización verificables en tiempo de compilación. Todo ese trabajo ocurría del lado de arriba de la frontera: hilos dentro de un proceso, coordinados por estructuras del lenguaje.

Este capítulo cruza la frontera hacia abajo. Vamos a escribir un programa cuyo único trabajo es crear otros procesos, conectarles la entrada y la salida, esperarlos, y reaccionar a las señales que el terminal les envía. Es decir, vamos a construir una shell. No una shell completa —eso son decenas de miles de líneas— sino una minishell que ejecuta comandos, encadena hasta tres de ellos con tuberías, redirige entrada, salida y error a archivos, lanza trabajos en segundo plano, implementa cd y pwd internamente, y no muere cuando el usuario presiona Ctrl+C.

El valor del ejercicio no está en el producto. Está en que cada línea de código toca una llamada al sistema que hasta ahora solo habíamos descrito: fork, execvp, waitpid, dup2, pipe, open, close, chdir, sigaction. Cuando la minishell funcione, la relación entre un proceso, su tabla de descriptores y su grupo de proceso dejará de ser un diagrama y pasará a ser algo que se puede depurar.

Qué es exactamente una shell

Una shell es un programa de usuario. No forma parte del kernel, no tiene privilegios especiales, y cualquiera puede escribir una y usarla como shell de login. Su especificación cabe en una frase: lee líneas de texto, las traduce a llamadas al sistema, y espera resultados.

El ciclo, el mismo desde 1971, es este:

flowchart TD
    A["Imprimir el prompt"] --> B["Leer una línea desde stdin"]
    B --> C{"¿Fin de archivo o 'exit'?"}
    C -->|"sí"| Z["Terminar la shell"]
    C -->|"no"| D["Tokenizar la línea"]
    D --> E["Parsear a estructura de pipeline"]
    E --> F{"¿Es un built-in en el padre?"}
    F -->|"sí"| G["Ejecutar en el propio proceso shell"]
    F -->|"no"| H["Crear los procesos hijos"]
    H --> I["Conectar descriptores: pipes y redirecciones"]
    I --> J["exec en cada hijo"]
    J --> K{"¿Segundo plano?"}
    K -->|"no"| L["Esperar a que terminen y guardar el estado"]
    K -->|"sí"| M["Registrar el trabajo y volver de inmediato"]
    G --> A
    L --> A
    M --> A

A este ciclo se le llama REPL: read, eval, print, loop. La parte interesante es que el “eval” de una shell no evalúa expresiones: crea procesos. Y ahí es donde aparecen todos los conceptos del curso.

El alcance que vamos a cubrir

La minishell del proyecto debe soportar:

FuncionalidadEjemploMecanismo del sistema operativo
Comando simplels -lafork + execvp + waitpid
Búsqueda en PATHwc encontrado en /usr/bin/wcexecvp recorre $PATH
Redirección de salidals > archivos.txtopen + dup2 sobre el descriptor 1
Redirección de errorgcc a.c 2> errores.txtopen + dup2 sobre el descriptor 2
Redirección de entradawc -l < archivos.txtopen + dup2 sobre el descriptor 0
Tubería de 2 o 3 etapasls | sort | wc -lpipe + dup2 + cierre de extremos
Segundo planols | sort &no esperar, setpgid, cosecha con SIGCHLD
pwd internopwdgetcwd en el proceso shell
cd internocd ..chdir en el proceso shell
Sobrevivir a Ctrl+Cinterrumpir sleep 100 sin matar la shellgrupos de proceso + tcsetpgrp

Supuestos que simplifican el parser y que conviene fijar desde el principio: una sola línea por comando, sin comillas, sin escapes, sin saltos de línea, y todos los operadores separados por espacios. Con eso el parser se resuelve con comparaciones de cadenas y no necesita autómatas ni expresiones regulares. Es una decisión de alcance deliberada: el proyecto trata de procesos, no de análisis léxico.

Arquitectura del programa

Antes de escribir una línea conviene decidir la forma de los datos. La estructura correcta hace que la ejecución sea casi trivial; la estructura equivocada obliga a re-parsear la línea dentro del ejecutor.

Una línea de comando produce un pipeline: una secuencia de comandos conectados por tuberías, con una marca global de segundo plano. Cada comando tiene su vector de argumentos y, opcionalmente, sus tres redirecciones.

flowchart LR
    A["Línea cruda:<br/>cat &lt; datos.txt | sort | wc -l &"] --> B["Tokenizador"]
    B --> C["Lista de tokens:<br/>cat, &lt;, datos.txt, |, sort, |, wc, -l, &"]
    C --> D["Parser"]
    D --> E["pipeline<br/>ncmds = 3<br/>background = 1"]
    E --> F["cmd 0: argv = cat<br/>in_file = datos.txt"]
    E --> G["cmd 1: argv = sort"]
    E --> H["cmd 2: argv = wc, -l"]
    F --> I["Ejecutor"]
    G --> I
    H --> I
    I --> J["3 procesos hijos<br/>conectados por 2 pipes"]

En C esto se traduce directamente:

#define MAX_ARGS 64
#define MAX_CMDS 3

typedef struct {
    char *argv[MAX_ARGS + 1];  /* terminado en NULL para execvp */
    int   argc;
    char *in_file;             /* NULL si no hay redirección "<"  */
    char *out_file;            /* NULL si no hay redirección ">"  */
    char *err_file;            /* NULL si no hay redirección "2>" */
    int   append;              /* 1 si el operador fue ">>"       */
} command_t;

typedef struct {
    command_t cmds[MAX_CMDS];
    int       ncmds;
    int       background;
} pipeline_t;

Nótese que argv reserva MAX_ARGS + 1 posiciones. La posición extra es para el NULL final que execvp exige: sin él, execvp sigue leyendo memoria más allá del arreglo y el comportamiento es indefinido. Es el primer error clásico del proyecto.

Los punteros in_file, out_file y err_file apuntan dentro del buffer de la línea leída, no a copias. Eso funciona porque el buffer vive hasta que la línea termina de ejecutarse. Si más adelante se quiere guardar un historial, hay que copiar.

El parser: de una línea a un pipeline

El tokenizador parte la línea por espacios, tabuladores y salto de línea. strtok_r sirve perfectamente: modifica el buffer insertando terminadores nulos y devuelve punteros a los fragmentos.

El parser recorre los tokens con un puntero al comando actual y aplica cinco reglas:

  1. Si el token es |, cierra el comando actual y abre el siguiente.
  2. Si es <, >, >> o 2>, consume el token siguiente como nombre de archivo.
  3. Si es &, marca el pipeline como de segundo plano.
  4. En cualquier otro caso, es un argumento del comando actual.
  5. Al final, valida que ningún comando haya quedado vacío.

El corazón del bucle son cuatro comparaciones de cadena. La versión completa, con validaciones y mensajes de error, aparece en el listado íntegro más adelante; este es su esqueleto:

char *tok = strtok_r(line, " \t\n", &save);
while (tok != NULL) {
    if (strcmp(tok, "|") == 0) {
        cur = &p->cmds[p->ncmds++];            /* abre el siguiente comando */
    }
    else if (strcmp(tok, "<") == 0 || strcmp(tok, ">") == 0 ||
             strcmp(tok, ">>") == 0 || strcmp(tok, "2>") == 0) {
        char *op = tok;
        char *target = strtok_r(NULL, " \t\n", &save);   /* consume el archivo */
        if (strcmp(op, "<") == 0)        cur->in_file  = target;
        else if (strcmp(op, "2>") == 0)  cur->err_file = target;
        else { cur->out_file = target; cur->append = (strcmp(op, ">>") == 0); }
    }
    else if (strcmp(tok, "&") == 0) {
        p->background = 1;
    }
    else {
        cur->argv[cur->argc++] = tok;          /* argumento normal */
    }
    tok = strtok_r(NULL, " \t\n", &save);
}
for (int i = 0; i < p->ncmds; i++)
    p->cmds[i].argv[p->cmds[i].argc] = NULL;   /* terminador para execvp */

Dos detalles que suelen pasarse por alto:

  • El NULL terminador se coloca en el bucle final, después de conocer argc. Si se coloca antes de terminar de parsear, el siguiente argumento lo sobreescribe.
  • El & se acepta en cualquier posición por simplicidad, pero un parser estricto debería exigir que sea el último token. Validar eso es una línea más: comprobar que strtok_r devuelve NULL inmediatamente después.

Una línea vacía o compuesta solo de espacios produce ncmds = 1 con argc = 0, que el bucle final rechaza. Conviene interceptar ese caso antes y simplemente volver al prompt sin mensaje de error.

fork y exec: el mecanismo central

Toda la ejecución de comandos externos descansa en un patrón de tres llamadas. Vale la pena verlo aislado antes de integrarlo.

sequenceDiagram
    participant S as Shell (padre)
    participant K as Kernel
    participant H as Hijo

    S->>K: fork()
    K->>K: duplica PCB, tabla de descriptores,<br/>espacio de direcciones (copy-on-write)
    K-->>S: retorna PID del hijo (> 0)
    K-->>H: retorna 0
    Note over S,H: desde aquí son dos procesos<br/>ejecutando el mismo código
    H->>K: dup2 / close (conectar descriptores)
    H->>K: execvp("ls", argv)
    K->>K: reemplaza la imagen de memoria<br/>conserva PID y descriptores abiertos
    K-->>H: comienza main de /usr/bin/ls
    S->>K: waitpid(pid, &status, 0)
    K-->>S: bloquea al padre
    H->>K: exit(0)
    K->>K: el hijo pasa a estado zombie
    K-->>S: despierta con status codificado
    S->>K: (el zombie se libera al cosecharlo)

Los puntos clave del diagrama:

  • fork devuelve dos veces, con valores distintos. Es la única llamada del sistema con esa propiedad y la fuente inagotable de confusión inicial.
  • execvp no regresa si tiene éxito. Si la línea siguiente a execvp se ejecuta, es porque exec falló. Ese es el lugar correcto para imprimir “comando no encontrado”.
  • Los descriptores abiertos sobreviven a exec (salvo que tengan el flag close-on-exec). Esa propiedad es precisamente lo que hace posible la redirección: el hijo prepara su tabla de descriptores y luego se transforma en otro programa que hereda esa tabla ya arreglada.
  • Un hijo que terminó y no ha sido esperado es un zombie: ocupa una entrada en la tabla de procesos únicamente para conservar su código de salida.

La familia exec

exec no es una llamada sino una familia de envoltorios sobre la misma llamada al sistema. Las letras del sufijo indican la forma de los argumentos.

FunciónArgumentosEntornoBusca en PATH
execllista variádica terminada en NULLhereda environno
execlplista variádica terminada en NULLhereda environ
execlelista variádica + arreglo de entornoel que se pasano
execvarreglo char *argv[]hereda environno
execvparreglo char *argv[]hereda environ
execvpearreglo char *argv[] + entornoel que se pasa

Para una shell, la elección natural es execvp: el parser ya produce un arreglo, y la búsqueda en PATH es exactamente el comportamiento esperado cuando el usuario escribe ls en lugar de /usr/bin/ls. Si argv[0] contiene una barra, execvp lo trata como ruta y no consulta PATH.

Un ejemplo mínimo y completo

/* fork_exec.c — ejecutar un comando y reportar cómo terminó
   compilar: gcc -Wall -Wextra -o fork_exec fork_exec.c */
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char *argv[])
{
    if (argc < 2) { fprintf(stderr, "uso: %s comando [args...]\n", argv[0]); return 1; }

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

    if (pid == 0) {
        execvp(argv[1], &argv[1]);
        /* solo se llega aquí si execvp falló */
        fprintf(stderr, "%s: %s\n", argv[1], strerror(errno));
        _exit(127);
    }

    int status = 0;
    if (waitpid(pid, &status, 0) < 0) { perror("waitpid"); return 1; }

    if (WIFEXITED(status))
        printf("terminó normalmente con código %d\n", WEXITSTATUS(status));
    else if (WIFSIGNALED(status))
        printf("terminado por la señal %d (%s)\n",
               WTERMSIG(status), strsignal(WTERMSIG(status)));

    return 0;
}
./fork_exec ls -la          # código 0
./fork_exec ls /noexiste    # código 2, ls informa el error
./fork_exec comandoinventado # 127, el mensaje lo imprime nuestro código
./fork_exec sleep 30        # en otra terminal: kill -TERM <pid>

El último caso muestra la diferencia entre terminar y ser terminado. El entero status que devuelve waitpid no es un código de salida: es una palabra empaquetada que hay que decodificar con macros.

MacroPregunta que respondeValor asociado
WIFEXITED(status)¿terminó llamando a exit o retornando de main?WEXITSTATUS(status) da los 8 bits del código
WIFSIGNALED(status)¿lo terminó una señal?WTERMSIG(status) da el número de señal
WCOREDUMP(status)¿generó volcado de memoria?solo válido si WIFSIGNALED
WIFSTOPPED(status)¿fue detenido, no terminado?WSTOPSIG(status) da la señal que lo detuvo
WIFCONTINUED(status)¿fue reanudado con SIGCONT?requiere WCONTINUED en las opciones

Imprimir status directamente es un error frecuente: para una salida con código 1 se ve el número 256, porque el código ocupa los bits 8 a 15.

Por qué _exit y no exit en el hijo

Cuando execvp falla, el hijo debe morir. Usar exit en ese punto ejecuta los manejadores registrados con atexit y vacía los búferes de stdio heredados del padre. Si el padre tenía texto pendiente en el búfer de stdout, ese texto se imprime dos veces: una por el padre y otra por el hijo. _exit termina el proceso sin tocar los búferes de la biblioteca estándar, que es lo correcto en un hijo que no llegó a ser otro programa.

Por convención heredada de las shells POSIX, el código 127 significa “comando no encontrado” y el 126 “encontrado pero no ejecutable”.

Redirecciones: reescribir la tabla de descriptores

Cada proceso tiene una tabla de descriptores de archivo: un arreglo indexado por entero donde cada entrada apunta a una descripción de archivo abierto. Los índices 0, 1 y 2 son entrada estándar, salida estándar y error estándar, y los programas los usan sin preguntarse a qué apuntan.

Redirigir consiste en cambiar a dónde apunta la entrada 1 antes de ejecutar el programa. El programa nunca se entera.

flowchart TB
    subgraph antes["Hijo recién creado por fork"]
        A0["fd 0"] --> T0["terminal /dev/pts/3"]
        A1["fd 1"] --> T0
        A2["fd 2"] --> T0
    end

    subgraph paso["Tras open('salida.txt') → fd 3"]
        B0["fd 0"] --> T1["terminal"]
        B1["fd 1"] --> T1
        B2["fd 2"] --> T1
        B3["fd 3"] --> F1["archivo salida.txt"]
    end

    subgraph despues["Tras dup2(3, 1) y close(3)"]
        C0["fd 0"] --> T2["terminal"]
        C1["fd 1"] --> F2["archivo salida.txt"]
        C2["fd 2"] --> T2
    end

    antes --> paso --> despues

dup2(origen, destino) hace tres cosas de forma atómica: si destino estaba abierto lo cierra, hace que destino apunte a la misma descripción que origen, y devuelve destino. Después del dup2 el descriptor origen sigue abierto apuntando al mismo archivo, por eso se cierra: mantenerlo abierto desperdicia una entrada y, en el caso de las tuberías, impide que el lector vea el fin de archivo.

Los flags de open

OperadorLlamada openComportamiento si el archivo existe
<open(f, O_RDONLY)lo abre; falla si no existe
>open(f, O_WRONLY | O_CREAT | O_TRUNC, 0644)lo vacía
>>open(f, O_WRONLY | O_CREAT | O_APPEND, 0644)escribe al final
2>igual que >, pero se duplica sobre el fd 2lo vacía

El tercer argumento 0644 solo se usa cuando aparece O_CREAT, y expresa los permisos del archivo nuevo antes de aplicar la máscara umask del proceso. Omitirlo con O_CREAT presente deja permisos basura, porque open es variádica y lee un entero indeterminado de la pila.

Implementación

El patrón para cada uno de los tres casos es idéntico: abrir, duplicar sobre el descriptor estándar correspondiente, cerrar el original.

/* redirección de salida; entrada y error son análogos */
int flags = O_WRONLY | O_CREAT | (c->append ? O_APPEND : O_TRUNC);
int fd = open(c->out_file, flags, 0644);
if (fd < 0) { perror(c->out_file); return -1; }
if (dup2(fd, STDOUT_FILENO) < 0) { perror("dup2"); return -1; }
close(fd);

La función apply_redirections del listado completo agrupa los tres casos. Se ejecuta siempre en el hijo, después de fork y antes de exec. Llamarla en el padre redirige la shell misma, que es un error espectacular: el prompt desaparece dentro del archivo.

El orden entre redirecciones y tuberías importa. Si un comando está en medio de un pipeline y además tiene > archivo, la redirección explícita debe ganar: por eso apply_redirections se llama después de conectar los extremos del pipe. El dup2 posterior simplemente sobreescribe el descriptor 1 que apuntaba al pipe.

Tuberías: conectar la salida de uno con la entrada del otro

pipe(int fds[2]) crea un búfer en el kernel con dos extremos: fds[0] para leer y fds[1] para escribir. Lo que se escribe en el extremo de escritura se lee en el extremo de lectura, en orden y sin límites de mensaje. El lector recibe fin de archivo cuando todos los descriptores del extremo de escritura están cerrados.

Esa última frase es la fuente de la mitad de los bloqueos del proyecto.

flowchart LR
    subgraph shell["Proceso shell"]
        SP["pipe() → fds[0], fds[1]<br/>luego cierra ambos"]
    end

    subgraph h1["Hijo 1: ls"]
        H1A["fd 1 → extremo de escritura"]
        H1B["cierra fds[0]"]
    end

    subgraph buf["Búfer del kernel<br/>capacidad típica 64 KiB"]
        BUF["datos en tránsito"]
    end

    subgraph h2["Hijo 2: wc -l"]
        H2A["fd 0 → extremo de lectura"]
        H2B["cierra fds[1]"]
    end

    SP -.->|"hereda por fork"| h1
    SP -.->|"hereda por fork"| h2
    H1A -->|"write"| BUF
    BUF -->|"read"| H2A

El protocolo de cierre es rígido y no admite atajos:

  1. El padre crea el pipe antes de bifurcar a los dos hijos que lo comparten.
  2. El hijo escritor duplica fds[1] sobre su descriptor 1 y cierra ambos originales.
  3. El hijo lector duplica fds[0] sobre su descriptor 0 y cierra ambos originales.
  4. El padre cierra los dos extremos en cuanto ya no le sirven.

Si el padre olvida cerrar fds[1], wc nunca ve el fin de archivo y la shell queda esperando a un hijo que espera datos que nunca llegarán. Es un interbloqueo real, causado por un descriptor olvidado.

Ejemplo aislado de dos etapas

/* pipe2.c — implementa "ls -la | wc -l" sin usar shell
   compilar: gcc -Wall -Wextra -o pipe2 pipe2.c */
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    int fds[2];
    if (pipe(fds) < 0) { perror("pipe"); return 1; }

    pid_t p1 = fork();
    if (p1 < 0) { perror("fork"); return 1; }
    if (p1 == 0) {
        close(fds[0]);                 /* no leo del pipe */
        dup2(fds[1], STDOUT_FILENO);   /* mi salida va al pipe */
        close(fds[1]);
        char *argv[] = { "ls", "-la", NULL };
        execvp(argv[0], argv);
        perror("ls"); _exit(127);
    }

    pid_t p2 = fork();
    if (p2 < 0) { perror("fork"); return 1; }
    if (p2 == 0) {
        close(fds[1]);                 /* no escribo en el pipe */
        dup2(fds[0], STDIN_FILENO);    /* mi entrada viene del pipe */
        close(fds[0]);
        char *argv[] = { "wc", "-l", NULL };
        execvp(argv[0], argv);
        perror("wc"); _exit(127);
    }

    close(fds[0]);                     /* imprescindible */
    close(fds[1]);                     /* imprescindible */

    int st;
    waitpid(p1, &st, 0);
    waitpid(p2, &st, 0);
    return 0;
}

Prueba diagnóstica: comentar las dos líneas close del padre y volver a ejecutar. El programa imprime la salida de ls y se cuelga para siempre. Ese experimento vale más que cualquier explicación.

Generalizar a N etapas

Con tres comandos hacen falta dos pipes, pero no es necesario mantenerlos todos vivos a la vez. El patrón estándar usa una sola variable prev_read que arrastra el extremo de lectura del pipe anterior:

int prev_read = -1;
for (int i = 0; i < ncmds; i++) {
    int fds[2] = { -1, -1 };
    if (i < ncmds - 1 && pipe(fds) < 0) { perror("pipe"); return -1; }

    if (fork() == 0) {
        if (prev_read != -1) { dup2(prev_read, STDIN_FILENO); close(prev_read); }
        if (fds[1] != -1) { close(fds[0]); dup2(fds[1], STDOUT_FILENO); close(fds[1]); }
        /* aquí van las redirecciones explícitas y el execvp */
    }
    /* padre: cierra lo que ya no usa y arrastra el extremo de lectura */
    if (prev_read != -1) close(prev_read);
    if (fds[1] != -1)   close(fds[1]);
    prev_read = fds[0];
}

Al terminar el bucle, prev_read vale -1 porque la última iteración no creó pipe. La invariante es que el padre nunca conserva más de un descriptor de tubería abierto entre iteraciones.

Procesos en segundo plano, grupos y zombies

Cuando la línea termina en &, la shell no espera. Eso plantea dos problemas.

Primero: los zombies. Un hijo que termina queda en la tabla de procesos hasta que alguien llama a wait. Si la shell lanza cien trabajos en segundo plano y nunca los cosecha, acumula cien entradas muertas.

stateDiagram-v2
    [*] --> Listo: fork
    Listo --> Ejecutando: el planificador lo elige
    Ejecutando --> Listo: fin de quantum
    Ejecutando --> Bloqueado: read, wait, sleep
    Bloqueado --> Listo: llega el dato o la señal
    Ejecutando --> Zombie: exit
    Zombie --> [*]: el padre llama waitpid<br/>y libera la entrada
    Zombie --> Zombie: mientras nadie coseche,<br/>ocupa un PID

La solución es un manejador de SIGCHLD que cosecha en bucle con WNOHANG. El bucle es obligatorio: las señales no se encolan, así que si tres hijos terminan casi a la vez puede entregarse una sola SIGCHLD.

static void sigchld_handler(int sig)
{
    (void) sig;
    int saved_errno = errno;        /* el manejador no debe alterar errno */
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;
    errno = saved_errno;
}

Dentro de un manejador de señal solo se pueden llamar funciones seguras frente a asincronía. waitpid lo es; printf no. Imprimir “trabajo terminado” desde el manejador puede corromper el estado interno de stdio si la señal llega mientras el hilo principal estaba imprimiendo el prompt. La forma correcta es levantar una bandera volatile sig_atomic_t y reportar desde el bucle principal.

Hay un conflicto sutil: si la shell cosecha desde el manejador, también puede cosechar por accidente a un hijo de primer plano antes de que waitpid explícito lo vea, y ese waitpid fallará con ECHILD. Dos maneras de resolverlo: bloquear SIGCHLD con sigprocmask mientras se espera al pipeline de primer plano, o llevar una lista de PIDs en segundo plano y que el manejador solo coseche esos. Para el proyecto, la primera es más corta y suficiente.

Segundo: los grupos de proceso. Todos los procesos de un pipeline deben pertenecer al mismo grupo, y ese grupo debe ser distinto del de la shell. setpgid(pid, pgid) lo consigue; pasar pgid = 0 significa “usa tu propio PID como identificador de grupo”, que es lo que hace el primer proceso del pipeline.

Existe una condición de carrera clásica: el padre no sabe si el hijo ya ejecutó su setpgid, y el hijo no sabe si el padre ya lo hizo. La solución canónica es que ambos lo llamen. La llamada es idempotente y el error EACCES que aparece si el hijo ya hizo exec se ignora sin consecuencias.

Señales y control del terminal

Una shell interactiva que muere con Ctrl+C es inútil. El comportamiento correcto es que Ctrl+C interrumpa el trabajo de primer plano y deje la shell viva en su prompt.

El mecanismo real no es que la shell “capture” la señal y la reenvíe. Es que el controlador del terminal envía SIGINT a todo el grupo de procesos de primer plano, y la shell se ha asegurado de no estar en ese grupo.

sequenceDiagram
    participant U as Usuario
    participant T as Driver del terminal
    participant SH as Shell (pgid 900)
    participant J as Trabajo en primer plano (pgid 950)

    SH->>T: setpgid del hijo a 950
    SH->>T: tcsetpgrp(fd, 950)
    Note over T: el grupo de primer plano<br/>del terminal ahora es 950
    U->>T: Ctrl+C
    T->>J: SIGINT a todo el pgid 950
    Note over SH: la shell ignora SIGINT<br/>y no está en el pgid 950
    J->>SH: termina por señal
    SH->>T: tcsetpgrp(fd, 900)
    Note over T: la shell recupera el terminal
    SH->>U: imprime el prompt otra vez

Las señales relevantes para el proyecto:

SeñalOrigenAcción por defectoQué debe hacer la shell
SIGINTCtrl+C en el terminalterminarignorarla; los hijos la reciben con acción por defecto
SIGQUITCtrl+\terminar con volcadoignorarla en la shell
SIGTSTPCtrl+Zdetenerignorarla en la shell
SIGTTOUescribir en el terminal sin ser primer planodetenerignorarla, o tcsetpgrp detiene a la propia shell
SIGTTINleer del terminal sin ser primer planodetenerignorarla mientras se toma el terminal
SIGCHLDun hijo cambió de estadoignorarmanejarla para cosechar zombies

Un detalle crítico: los hijos deben restaurar la acción por defecto de las señales que la shell ignoró. fork hereda las disposiciones, y exec restaura a la acción por defecto solo las que estaban en su acción por defecto o capturadas; las que estaban ignoradas siguen ignoradas después de exec. Si la shell ignora SIGINT y no lo restaura en el hijo, ningún comando podrá interrumpirse jamás con Ctrl+C.

Para instalar manejadores, sigaction es preferible a signal: su comportamiento está especificado sin ambigüedad y permite pedir SA_RESTART para que las llamadas lentas interrumpidas se reintenten en lugar de fallar con EINTR.

static void install_handler(int signo, void (*fn)(int), int flags)
{
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = fn;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = flags;
    if (sigaction(signo, &sa, NULL) < 0)
        perror("sigaction");
}

La minishell completa en C

Este es el programa entero. Compila sin advertencias con gcc -Wall -Wextra -std=c11 -o minishell minishell.c y funciona en cualquier Linux reciente.

/* minishell.c — intérprete de comandos mínimo: comandos externos con PATH,
   redirecciones < > >> 2>, tuberías de hasta 3 etapas, segundo plano con &,
   built-ins cd/pwd/exit y Ctrl+C que interrumpe el trabajo, no la shell.
   compilar: gcc -Wall -Wextra -std=c11 -o minishell minishell.c */

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <limits.h>
#include <sys/wait.h>

#define MAX_ARGS 64
#define MAX_CMDS 3

typedef struct {
    char *argv[MAX_ARGS + 1];
    int   argc;
    char *in_file, *out_file, *err_file;
    int   append;
} command_t;

typedef struct {
    command_t cmds[MAX_CMDS];
    int ncmds, background;
} pipeline_t;

static int   shell_terminal;
static pid_t shell_pgid;
static int   last_status = 0;
static volatile sig_atomic_t hijo_termino = 0;

/* ---------- señales ---------- */

static void sigchld_handler(int sig)
{
    (void) sig;
    int saved = errno;
    while (waitpid(-1, NULL, WNOHANG) > 0) hijo_termino = 1;
    errno = saved;
}

static void install_handler(int signo, void (*fn)(int), int flags)
{
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = fn;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = flags;
    if (sigaction(signo, &sa, NULL) < 0) perror("sigaction");
}

static void init_shell(void)
{
    shell_terminal = STDIN_FILENO;
    if (isatty(shell_terminal)) {
        /* esperar hasta ser el grupo de primer plano del terminal */
        while (tcgetpgrp(shell_terminal) != (shell_pgid = getpgrp()))
            kill(-shell_pgid, SIGTTIN);
        install_handler(SIGINT,  SIG_IGN, 0);
        install_handler(SIGQUIT, SIG_IGN, 0);
        install_handler(SIGTSTP, SIG_IGN, 0);
        install_handler(SIGTTIN, SIG_IGN, 0);
        install_handler(SIGTTOU, SIG_IGN, 0);
        shell_pgid = getpid();
        if (setpgid(shell_pgid, shell_pgid) < 0 && errno != EPERM) {
            perror("setpgid de la shell"); exit(1);
        }
        tcsetpgrp(shell_terminal, shell_pgid);
    }
    install_handler(SIGCHLD, sigchld_handler, SA_RESTART | SA_NOCLDSTOP);
}

/* ---------- parser ---------- */

static int parse_line(char *line, pipeline_t *p)
{
    memset(p, 0, sizeof *p);
    p->ncmds = 1;
    command_t *cur = &p->cmds[0];
    char *save = NULL, *tok = strtok_r(line, " \t\n", &save);
    if (tok == NULL) return 1;                    /* línea vacía: no es error */
    while (tok != NULL) {
        if (strcmp(tok, "|") == 0) {
            if (cur->argc == 0) {
                fprintf(stderr, "minishell: '|' sin comando a la izquierda\n"); return -1; }
            if (p->ncmds == MAX_CMDS) {
                fprintf(stderr, "minishell: máximo %d comandos\n", MAX_CMDS); return -1; }
            cur = &p->cmds[p->ncmds++];
        }
        else if (strcmp(tok, "<") == 0 || strcmp(tok, ">") == 0 ||
                 strcmp(tok, ">>") == 0 || strcmp(tok, "2>") == 0) {
            char *op = tok, *target = strtok_r(NULL, " \t\n", &save);
            if (target == NULL) {
                fprintf(stderr, "minishell: falta el archivo tras '%s'\n", op); return -1; }
            if (strcmp(op, "<") == 0)       cur->in_file  = target;
            else if (strcmp(op, "2>") == 0) cur->err_file = target;
            else { cur->out_file = target; cur->append = (strcmp(op, ">>") == 0); }
        }
        else if (strcmp(tok, "&") == 0) p->background = 1;
        else {
            if (cur->argc == MAX_ARGS) {
                fprintf(stderr, "minishell: demasiados argumentos\n"); return -1; }
            cur->argv[cur->argc++] = tok;
        }
        tok = strtok_r(NULL, " \t\n", &save);
    }
    for (int i = 0; i < p->ncmds; i++) {
        if (p->cmds[i].argc == 0) {
            fprintf(stderr, "minishell: comando vacío en la tubería\n"); return -1; }
        p->cmds[i].argv[p->cmds[i].argc] = NULL;   /* terminador para execvp */
    }
    return 0;
}

/* ---------- built-ins ---------- */

static int es_builtin(const char *n)
{
    return strcmp(n, "cd") == 0 || strcmp(n, "pwd") == 0 || strcmp(n, "exit") == 0;
}

static int run_builtin(command_t *c)
{
    if (strcmp(c->argv[0], "pwd") == 0) {
        char buf[PATH_MAX];
        if (getcwd(buf, sizeof buf) == NULL) { perror("pwd"); return 1; }
        puts(buf);
        return 0;
    }
    if (strcmp(c->argv[0], "cd") == 0) {
        const char *destino = c->argv[1];
        if (destino == NULL) {
            destino = getenv("HOME");
            if (destino == NULL) destino = "/";
        }
        if (chdir(destino) < 0) {
            fprintf(stderr, "cd: %s: %s\n", destino, strerror(errno));
            return 1;
        }
        return 0;
    }
    if (strcmp(c->argv[0], "exit") == 0)
        exit(c->argc > 1 ? atoi(c->argv[1]) : last_status);
    return 1;
}

/* ---------- ejecución ---------- */

static int abrir_y_duplicar(const char *nombre, int flags, int destino)
{
    int fd = open(nombre, flags, 0644);
    if (fd < 0) { perror(nombre); return -1; }
    if (dup2(fd, destino) < 0) { perror("dup2"); close(fd); return -1; }
    close(fd);
    return 0;
}

static int apply_redirections(const command_t *c)
{
    int esc = O_WRONLY | O_CREAT | (c->append ? O_APPEND : O_TRUNC);
    if (c->in_file  && abrir_y_duplicar(c->in_file,  O_RDONLY, STDIN_FILENO)  < 0) return -1;
    if (c->out_file && abrir_y_duplicar(c->out_file, esc,      STDOUT_FILENO) < 0) return -1;
    if (c->err_file && abrir_y_duplicar(c->err_file, esc,      STDERR_FILENO) < 0) return -1;
    return 0;
}

static int run_pipeline(pipeline_t *p)
{
    pid_t pids[MAX_CMDS], pgid = 0;
    int prev_read = -1;
    for (int i = 0; i < p->ncmds; i++) {
        int fds[2] = { -1, -1 };
        if (i < p->ncmds - 1 && pipe(fds) < 0) { perror("pipe"); return -1; }
        pid_t pid = fork();
        if (pid < 0) { perror("fork"); return -1; }
        if (pid == 0) {                              /* ---------- hijo ---------- */
            setpgid(0, pgid);                        /* pgid 0 => usa su propio PID */
            signal(SIGINT,  SIG_DFL);  signal(SIGQUIT, SIG_DFL);
            signal(SIGTSTP, SIG_DFL);  signal(SIGTTIN, SIG_DFL);
            signal(SIGTTOU, SIG_DFL);  signal(SIGCHLD, SIG_DFL);
            if (prev_read != -1) { dup2(prev_read, STDIN_FILENO); close(prev_read); }
            if (fds[1] != -1) { close(fds[0]); dup2(fds[1], STDOUT_FILENO); close(fds[1]); }
            if (apply_redirections(&p->cmds[i]) < 0) _exit(1);
            if (es_builtin(p->cmds[i].argv[0])) _exit(run_builtin(&p->cmds[i]));
            execvp(p->cmds[i].argv[0], p->cmds[i].argv);
            fprintf(stderr, "minishell: %s: %s\n", p->cmds[i].argv[0], strerror(errno));
            _exit(errno == ENOENT ? 127 : 126);
        }
        if (pgid == 0) pgid = pid;                   /* ---------- padre ---------- */
        setpgid(pid, pgid);
        pids[i] = pid;
        if (prev_read != -1) close(prev_read);
        if (fds[1] != -1)   close(fds[1]);
        prev_read = fds[0];
    }
    if (p->background) {
        printf("[segundo plano] grupo %d\n", (int) pgid);
        fflush(stdout);
        return 0;
    }
    if (isatty(shell_terminal)) tcsetpgrp(shell_terminal, pgid);
    sigset_t bloquear, anterior;        /* que SIGCHLD no coseche antes de tiempo */
    sigemptyset(&bloquear);
    sigaddset(&bloquear, SIGCHLD);
    sigprocmask(SIG_BLOCK, &bloquear, &anterior);
    int status = 0;
    for (int i = 0; i < p->ncmds; i++)
        if (waitpid(pids[i], &status, 0) < 0 && errno != ECHILD) perror("waitpid");
    sigprocmask(SIG_SETMASK, &anterior, NULL);
    if (isatty(shell_terminal)) tcsetpgrp(shell_terminal, shell_pgid);
    if (WIFEXITED(status)) last_status = WEXITSTATUS(status);
    else if (WIFSIGNALED(status)) {
        last_status = 128 + WTERMSIG(status);
        fprintf(stderr, "\n[terminado por %s]\n", strsignal(WTERMSIG(status)));
    }
    return 0;
}

/* built-in en el proceso shell, salvando y restaurando sus descriptores */
static void run_builtin_en_padre(command_t *c)
{
    int s_in  = dup(STDIN_FILENO);
    int s_out = dup(STDOUT_FILENO);
    int s_err = dup(STDERR_FILENO);
    last_status = (apply_redirections(c) == 0) ? run_builtin(c) : 1;
    dup2(s_in,  STDIN_FILENO);  close(s_in);
    dup2(s_out, STDOUT_FILENO); close(s_out);
    dup2(s_err, STDERR_FILENO); close(s_err);
}

/* ---------- bucle principal ---------- */

int main(void)
{
    char *linea = NULL;
    size_t cap = 0;
    pipeline_t p;
    init_shell();
    for (;;) {
        if (hijo_termino) {
            hijo_termino = 0;
            fprintf(stderr, "[un trabajo en segundo plano terminó]\n");
        }
        if (isatty(shell_terminal)) {
            char buf[PATH_MAX];
            printf("minishell:%s$ ", getcwd(buf, sizeof buf) ? buf : "?");
            fflush(stdout);
        }
        if (getline(&linea, &cap, stdin) < 0) {
            if (errno == EINTR) { clearerr(stdin); errno = 0; continue; }
            putchar('\n');
            break;                                   /* Ctrl+D */
        }
        if (parse_line(linea, &p) != 0) continue;    /* vacía o error de sintaxis */
        if (p.ncmds == 1 && !p.background && es_builtin(p.cmds[0].argv[0]))
            run_builtin_en_padre(&p.cmds[0]);
        else
            run_pipeline(&p);
    }
    free(linea);
    return last_status;
}

Por qué cd no puede ser un programa externo

Es la pregunta que mejor distingue a quien entendió el modelo de procesos. chdir cambia el directorio de trabajo del proceso que la llama. Si cd fuera un ejecutable externo, la shell haría fork, el hijo cambiaría su propio directorio, y luego moriría. El directorio de la shell quedaría intacto y el comando no tendría ningún efecto observable.

Lo mismo aplica a exit, a la asignación de variables, y a export. Todo lo que modifica el estado del intérprete debe ejecutarse en el intérprete. Por eso existen los built-ins: no son una optimización, son una necesidad estructural.

En el código anterior el built-in aparece dos veces: en el padre cuando es el único comando de primer plano, y en el hijo cuando forma parte de una tubería. La segunda vez es correcta y a la vez inútil para cd —cambia el directorio de un proceso que va a morir—, pero es lo que hacen las shells reales: en pwd | wc -c, el pwd debe correr en un hijo para poder escribir en la tubería.

Variante en Ada con GNAT.OS_Lib

Ada no ofrece fork en su biblioteca estándar, porque fork interactúa mal con el modelo de tareas del lenguaje: duplicar un proceso con varias tareas activas deja al hijo con un solo hilo y con posibles cerrojos tomados por tareas que ya no existen. Lo que GNAT sí ofrece es el paquete GNAT.OS_Lib, con una interfaz de más alto nivel: lanzar un programa, esperarlo, y manipular descriptores.

Las operaciones relevantes son Locate_Exec_On_Path para resolver el PATH, Spawn para ejecutar y esperar, Non_Blocking_Spawn para el segundo plano, Wait_Process para cosechar, y Dup / Dup2 / Close / Open_Read / Create_File para las redirecciones.

--  minishell.adb — versión Ada de la minishell
--  compilar: gnatmake minishell.adb
with Ada.Text_IO;          use Ada.Text_IO;
with Ada.Strings.Fixed;    use Ada.Strings.Fixed;
with Ada.Directories;
with Ada.Command_Line;
with GNAT.OS_Lib;          use GNAT.OS_Lib;
with GNAT.String_Split;    use GNAT.String_Split;

procedure Minishell is

   --  Tokens 2..N convertidos a la lista de argumentos que espera Spawn
   function Construir_Args (S : Slice_Set) return Argument_List_Access is
      Total : constant Natural := Natural (Slice_Count (S));
      Args  : constant Argument_List_Access :=
                new Argument_List (1 .. Natural'Max (Total - 1, 0));
   begin
      for I in 2 .. Total loop
         Args (I - 1) := new String'(Slice (S, Slice_Number (I)));
      end loop;
      return Args;
   end Construir_Args;

   --  Ejecuta un comando; si Salida /= "" redirige la salida estándar al archivo
   procedure Ejecutar (Programa : String; Args : Argument_List;
                       Salida   : String; Codigo : out Integer)
   is
      Ruta : String_Access := Locate_Exec_On_Path (Programa);
   begin
      if Ruta = null then
         Put_Line (Standard_Error, "minishell: " & Programa & ": no encontrado");
         Codigo := 127;
         return;
      end if;
      if Salida = "" then
         declare
            Exito : Boolean;
         begin
            Spawn (Ruta.all, Args, Exito);
            Codigo := (if Exito then 0 else 1);
         end;
      else
         declare
            FD : File_Descriptor := Create_File (Salida, Binary);
         begin
            if FD = Invalid_FD then
               Put_Line (Standard_Error, "minishell: no puedo crear " & Salida);
               Codigo := 1;
            else
               Spawn (Ruta.all, Args, FD, Codigo, Err_To_Out => False);
               Close (FD);
            end if;
         end;
      end if;
      Free (Ruta);
   end Ejecutar;

   Linea  : String (1 .. 4096);
   Largo  : Natural;
   Tokens : Slice_Set;
   Codigo : Integer := 0;

begin
   loop
      Put (Ada.Directories.Current_Directory & " ada$ ");
      exit when End_Of_File;
      Get_Line (Linea, Largo);
      declare
         Texto : constant String := Trim (Linea (1 .. Largo), Ada.Strings.Both);
      begin
         if Texto = "" then
            null;
         elsif Texto = "exit" then
            exit;
         elsif Texto = "pwd" then
            Put_Line (Ada.Directories.Current_Directory);
         elsif Texto = "cd" or else Head (Texto, 3) = "cd " then
            declare
               Destino : constant String :=
                 (if Texto = "cd" then "/"
                  else Trim (Texto (4 .. Texto'Last), Ada.Strings.Both));
            begin
               Ada.Directories.Set_Directory (Destino);
            exception
               when others =>
                  Put_Line (Standard_Error, "cd: " & Destino & ": no accesible");
            end;
         else
            Create (Tokens, Texto, " ", Multiple);
            declare
               Args : Argument_List_Access := Construir_Args (Tokens);
            begin
               Ejecutar (Slice (Tokens, 1), Args.all, "", Codigo);
               for I in Args'Range loop
                  Free (Args (I));
               end loop;
               Free (Args);
            end;
         end if;
      end;
   end loop;

   Ada.Command_Line.Set_Exit_Status
     (Ada.Command_Line.Exit_Status (Codigo));
end Minishell;

Esta versión cubre ejecución, búsqueda en PATH, redirección de salida y los built-ins. Lo que no cubre con la misma naturalidad es la tubería: GNAT.OS_Lib expone Spawn con un descriptor de salida, pero no una primitiva que conecte la salida de un proceso con la entrada de otro sin ayuda del sistema de archivos.

La ruta practicable en Ada es la que el enunciado del proyecto admite explícitamente: implementar la tubería con un archivo temporal. GNAT.OS_Lib.Create_Temp_File devuelve un descriptor y el nombre del archivo; se ejecuta la primera etapa con ese descriptor como salida, se cierra, y la segunda etapa se ejecuta con el mismo archivo como entrada usando Open_Read más Dup2 sobre Standin. Semánticamente no es idéntico a un pipe —no hay concurrencia entre etapas ni control de flujo por contrapresión— pero produce el mismo resultado observable para comandos que terminan.

La diferencia conceptual merece explicitarse: con un pipe real, yes | head -5 termina, porque cuando head cierra su extremo de lectura el kernel envía SIGPIPE a yes. Con archivo temporal, yes llena el disco. La contrapresión no es un detalle de implementación: es parte de la semántica de la tubería.

Variante en Elixir con puertos

El BEAM no expone fork, exec ni dup2. Su modelo de procesos es propio y no se corresponde con el del sistema operativo. Para hablar con programas externos existe el puerto: un proceso del sistema operativo lanzado por la máquina virtual, con el que se intercambian mensajes.

Esto tiene una consecuencia directa para el proyecto: en Elixir se puede construir un intérprete que ejecute comandos, redirija salida, maneje segundo plano y responda a señales, pero las tuberías reales y la redirección de entrada requieren archivos intermedios, porque un puerto no permite media clausura de la entrada estándar. Reconocer esa limitación es parte del aprendizaje: cada abstracción de concurrencia paga un precio en control sobre el sistema.

defmodule Minishell do
  @moduledoc "Comandos externos, redirección de salida, segundo plano con Task, \
built-ins cd/pwd/exit y SIGINT. Las tuberías usan archivos temporales."

  def main(_argv \\ []) do
    instalar_sigint()
    loop()
  end

  defp loop do
    IO.write("#{File.cwd!()} elixir$ ")

    case IO.gets("") do
      :eof -> IO.puts("")
      {:error, _razon} -> loop()
      linea ->
        linea |> String.trim() |> ejecutar()
        loop()
    end
  end

  defp ejecutar(""), do: :ok
  defp ejecutar("exit"), do: System.halt(0)
  defp ejecutar("pwd"), do: IO.puts(File.cwd!())

  defp ejecutar("cd" <> resto) do
    destino =
      case String.trim(resto) do
        "" -> System.user_home() || "/"
        d -> d
      end

    with {:error, razon} <- File.cd(destino),
         do: IO.puts(:stderr, "cd: #{destino}: #{:file.format_error(razon)}")
  end

  defp ejecutar(linea) do
    {linea, fondo?} =
      if String.ends_with?(linea, "&"),
        do: {linea |> String.trim_trailing("&") |> String.trim(), true},
        else: {linea, false}

    etapas = linea |> String.split("|") |> Enum.map(&String.trim/1)
    trabajo = fn -> correr_pipeline(etapas) end

    if fondo? do
      Task.start(trabajo)
      IO.puts("[segundo plano lanzado]")
    else
      trabajo.()
    end
  end

  # Encadena las etapas usando archivos temporales como tubería
  defp correr_pipeline(etapas) do
    ultima = List.last(etapas)

    Enum.reduce(etapas, nil, fn etapa, entrada ->
      {[programa | args], redirs} =
        separar(String.split(etapa, ~r/\s+/, trim: true), [], %{})

      case System.find_executable(programa) do
        nil ->
          IO.puts(:stderr, "minishell: #{programa}: no encontrado")
          nil

        exe ->
          args = args ++ List.wrap(entrada || redirs[:in])
          {salida, codigo} = System.cmd(exe, args, stderr_to_stdout: false)

          cond do
            redirs[:out] != nil ->
              File.write!(redirs[:out], salida)
              nil

            etapa == ultima ->
              IO.write(salida)
              if codigo != 0, do: IO.puts(:stderr, "[código #{codigo}]")
              nil

            true ->
              tmp = Path.join(System.tmp_dir!(), "mnsh-#{:erlang.unique_integer([:positive])}")
              File.write!(tmp, salida)
              tmp
          end
      end
    end)
  end

  # ["wc", "-l", "<", "datos"] -> {["wc", "-l"], %{in: "datos"}}
  defp separar([], args, redirs), do: {Enum.reverse(args), redirs}
  defp separar([">", f | resto], args, redirs),
    do: separar(resto, args, Map.put(redirs, :out, f))
  defp separar(["2>", f | resto], args, redirs),
    do: separar(resto, args, Map.put(redirs, :err, f))
  defp separar(["<", f | resto], args, redirs),
    do: separar(resto, args, Map.put(redirs, :in, f))
  defp separar([tok | resto], args, redirs),
    do: separar(resto, [tok | args], redirs)

  defp instalar_sigint do
    :os.set_signal(:sigint, :handle)
    :gen_event.add_handler(:erl_signal_server, Minishell.SignalHandler, [])
  end
end

defmodule Minishell.SignalHandler do
  @behaviour :gen_event

  @impl true
  def init(_args), do: {:ok, %{}}

  @impl true
  def handle_event(:sigint, estado) do
    IO.puts("\n[interrumpido: use 'exit' para salir]")
    {:ok, estado}
  end

  def handle_event(_otro, estado), do: {:ok, estado}
  @impl true
  def handle_call(_msg, estado), do: {:ok, :ok, estado}
end

Dos observaciones sobre este código:

  • :os.set_signal(:sigint, :handle) cambia la disposición de SIGINT en la máquina virtual, de “terminar” a “entregar como evento”. Los eventos llegan al gen_event llamado :erl_signal_server, al que se le añade un manejador propio. Es el mecanismo oficial de OTP para señales del sistema operativo.
  • System.cmd/3 bloquea hasta que el programa termina y devuelve {salida, código}. Al envolverlo en Task.start, el segundo plano se obtiene gratis: no hay zombies que cosechar porque la máquina virtual lo hace por nosotros.

Comparación de los tres enfoques

AspectoC con POSIXAda con GNAT.OS_LibElixir con puertos
Creación de procesosfork + execvp explícitosSpawn / Non_Blocking_SpawnSystem.cmd / Port.open
Control de descriptorestotal: dup2, close, openparcial: Dup2, Create_File, Open_Readninguno
Tuberías realessí, pipe del kernelvía archivo temporalvía archivo temporal
Contrapresión del pipesí, con SIGPIPEnono
Cosecha de zombiesmanual, waitpid + SIGCHLDWait_Processautomática por la VM
Grupos de proceso y terminalcompleto, setpgid + tcsetpgrpno expuestono expuesto
Señalessigaction con control finolimitado:os.set_signal a nivel de VM
Líneas para el proyecto completo~350~250 sin pipes reales~150 sin pipes reales
Qué enseñael modelo de procesos tal cual esinterfaz portable sobre ese modeloqué se pierde al abstraerlo

La conclusión no es que un lenguaje sea mejor. Es que el modelo de procesos de UNIX está expuesto en C y oculto en los otros dos, y que una shell es precisamente el programa que necesita ese modelo expuesto.

Plan de desarrollo incremental

Intentar escribir la minishell completa de una sola vez es la forma más segura de terminar con un programa que se cuelga sin explicación. El orden siguiente garantiza que cada paso sea verificable de forma aislada.

flowchart TD
    E1["1. Bucle REPL y tokenizador<br/>imprimir los tokens numerados"] --> E2["2. fork + execvp + waitpid<br/>un comando con argumentos"]
    E2 --> E3["3. Decodificar status<br/>WIFEXITED y WIFSIGNALED"]
    E3 --> E4["4. Built-ins pwd, exit y cd<br/>verificar que cd persiste"]
    E4 --> E5["5. Segundo plano con &amp;<br/>sin esperar al hijo"]
    E5 --> E6["6. SIGCHLD para cosechar zombies<br/>verificar con ps"]
    E6 --> E7["7. Redirecciones &gt;, 2&gt; y &lt;"]
    E7 --> E8["8. Tubería de dos y luego de tres etapas"]
    E8 --> E9["9. Grupos de proceso y tcsetpgrp<br/>Ctrl+C no mata la shell"]
    E9 --> E10["10. Combinaciones: tubería + redirección + fondo"]

Batería de pruebas

ls -la                      # listado normal, el prompt vuelve
comandoquenoexiste          # error, la shell sigue viva, estado 127
pwd ; cd .. ; pwd           # el cambio de directorio debe persistir
cd /noexiste                # error, la shell no muere
ls > lista.txt              # lista.txt contiene el listado
cat < lista.txt             # muestra el contenido
ls /noexiste 2> err.txt     # el error va al archivo, no a la pantalla
echo linea >> lista.txt     # anexa sin truncar
ls | wc -l                  # un número
ls | sort | wc -l           # el mismo número
cat < lista.txt | sort      # redirección y tubería combinadas
sleep 5 &                   # el prompt vuelve de inmediato
ls | sort | wc -l &         # tubería completa en segundo plano
ps                          # no debe haber procesos <defunct>
sleep 100                   # Ctrl+C interrumpe sleep, no la shell
                            # Ctrl+D en el prompt cierra la shell
                            # línea vacía: solo vuelve el prompt
ls |                        # error de sintaxis, sin colgarse
| ls                        # error de sintaxis
ls >                        # error: falta el archivo

Herramientas de diagnóstico

# Todas las llamadas al sistema, incluidas las de los hijos
strace -f -e trace=clone,execve,dup2,pipe2,openat,close,wait4 ./minishell
# Descriptores abiertos de un proceso colgado
ls -l /proc/<pid>/fd
# Fugas de memoria y de descriptores
valgrind --leak-check=full --track-fds=yes ./minishell

--track-fds=yes reporta cada descriptor que quedó abierto al terminar: el síntoma exacto de un pipe mal cerrado.

Errores comunes

SíntomaCausaSolución
La shell se duplica y aparecen dos promptsFalta _exit tras un execvp fallido: el hijo vuelve al bucle principalTerminar el hijo siempre con _exit después de exec
execvp falla con “Bad address” o comportamiento erráticoargv no termina en NULLEscribir argv[argc] = NULL tras parsear
ls | wc -l se cuelga para siempreEl padre no cerró el extremo de escritura del pipe; el lector nunca ve EOFCerrar ambos extremos en el padre y el extremo no usado en cada hijo
Acumulación de procesos <defunct> en psLos hijos de segundo plano nunca se cosechanManejador de SIGCHLD con waitpid(-1, NULL, WNOHANG) en bucle
Ctrl+C mata la shell enteraLa shell y los hijos comparten grupo de procesosetpgid en el hijo, SIG_IGN de SIGINT en la shell, tcsetpgrp al entregar el terminal
Ningún comando puede interrumpirse con Ctrl+CEl hijo heredó la disposición “ignorar” y exec la conservaRestaurar SIG_DFL en el hijo antes de exec
cd no cambia nadaSe implementó como comando externo y el chdir ocurrió en el hijoEjecutarlo como built-in en el proceso shell
waitpid devuelve -1 con ECHILD en primer planoEl manejador de SIGCHLD cosechó al hijo antesBloquear SIGCHLD con sigprocmask mientras se espera al pipeline
El código de salida impreso es 256 en vez de 1Se imprime status crudo en lugar de decodificarloUsar WIFEXITED y WEXITSTATUS
El archivo creado por > tiene permisos absurdosSe usó O_CREAT sin el tercer argumento de modoPasar 0644 como tercer argumento de open
La salida del comando aparece mezclada con el promptBúferes de stdio sin vaciar antes de forkfflush(stdout) antes de bifurcar, o escribir el prompt con write
La segunda ejecución de un comando falla con “Too many open files”Fuga de descriptores: los pipes de la línea anterior siguen abiertosRevisar con ls -l /proc/<pid>/fd y cerrar en todas las rutas, incluidos los caminos de error
getline devuelve -1 al presionar Ctrl+CUna señal interrumpió la lectura y devolvió EINTRInstalar los manejadores con SA_RESTART, o reintentar cuando errno == EINTR
Los tres comandos de la tubería escriben en el terminalEl dup2 se aplicó en el padre o después de execAplicar redirecciones y pipes en el hijo, entre fork y exec
strtok corrompe los argumentos de comandos anterioresSe guardaron punteros al buffer y el buffer se reutilizóCopiar con strdup lo que deba sobrevivir a la línea actual

Extensiones opcionales

Cuando la versión base pase todas las pruebas, estas extensiones profundizan sin cambiar la arquitectura:

  • Número ilimitado de etapas: subir MAX_CMDS y reservar el arreglo dinámicamente. El bucle de ejecución ya lo soporta.
  • Variable de estado $? e historial: exponer last_status en el parser y guardar las líneas en un arreglo circular con un built-in history. Enlazar con GNU Readline aporta además edición de línea y flechas.
  • Control de trabajos completo: tabla de trabajos, built-ins jobs, fg y bg, manejo de SIGTSTP para suspender con Ctrl+Z y SIGCONT para reanudar.
  • Comillas y escapes: reemplazar el tokenizador por un autómata de tres estados —normal, dentro de comilla simple, dentro de comilla doble—. Es el paso que convierte un tokenizador de juguete en uno usable.

Ejercicios propuestos

  1. Diagnóstico del pipe abierto. Toma pipe2.c, comenta las dos llamadas close del padre y ejecuta el programa bajo strace -f. Identifica en la traza la llamada exacta donde el proceso queda bloqueado y explica por escrito qué condición del kernel impide que read devuelva 0.

  2. Códigos de salida. Extiende la minishell para que un built-in llamado estado imprima last_status. Verifica que ls /noexiste deja 2, que comandoinventado deja 127, y que sleep 100 interrumpido con Ctrl+C deja 130. Justifica el 130 a partir de la convención 128 + número de señal.

  3. Tubería de N etapas. Modifica MAX_CMDS para aceptar hasta 16 comandos y comprueba con seq 1 1000 | sort -n | uniq | head -5 | wc -l que el resultado coincide con el de bash. Instrumenta el código para imprimir cuántos descriptores tiene abiertos el padre en cada iteración del bucle y confirma que nunca pasa de uno.

  4. Contrapresión. Ejecuta yes | head -5 en tu minishell y explica por qué termina. Después implementa la misma tubería con un archivo temporal, como haría la versión Ada, y documenta qué ocurre. Relaciona el resultado con SIGPIPE y con el tamaño del búfer del pipe.

  5. Carrera de setpgid. Elimina la llamada setpgid del hijo dejando solo la del padre y explica, con un caso de prueba que envíe una señal a su propio grupo apenas arranca, por qué llamarla en ambos lados elimina la carrera.

  6. Zombies observables. Desactiva el manejador de SIGCHLD, lanza veinte sleep 1 & seguidos y captura la salida de ps -eo pid,stat,comm | grep defunct. Reactiva el manejador y repite. Adjunta ambas salidas y explica qué recurso concreto liberaba waitpid.

  7. Redirección y tubería combinadas. Determina qué debe ocurrir con ls | sort > salida.txt y con ls > a.txt | sort. Compara el comportamiento de tu minishell con el de bash y corrige el orden de aplicación si difieren.

  8. Puerto en Elixir. Sustituye System.cmd por Port.open({:spawn_executable, exe}, [:binary, :exit_status, args: args]) con un bucle receive que acumule {port, {:data, datos}} hasta recibir {port, {:exit_status, codigo}}. Documenta qué se gana —salida incremental, código de salida explícito— y qué sigue sin poder hacerse. Después completa la versión Ada implementando cmd1 | cmd2 con GNAT.OS_Lib.Create_Temp_File, Open_Read y Dup2 sobre Standin, y mide seq 1 100000 | wc -l en ambas.

  9. Tokenizador con comillas. Reemplaza strtok_r por un autómata que reconozca comillas simples y dobles, de modo que echo "hola mundo" produzca un solo argumento. Dibuja el autómata como diagrama de estados antes de escribir el código.

Lo que viene

La minishell terminada es un objeto pequeño con una densidad conceptual alta: en trescientas líneas conviven creación de procesos, herencia de descriptores, comunicación entre procesos, planificación implícita, señales asíncronas y control de terminal. Cada uno de esos temas ocupó un capítulo entero del curso, y aquí aparecen operando juntos y de forma verificable. Si el programa funciona, el modelo de procesos de UNIX dejó de ser teoría.

Ese proyecto trabajó la concurrencia entre procesos independientes, coordinados por el kernel a través de tuberías y señales. El capítulo 16 aborda el problema complementario: la concurrencia dentro de un programa, donde los flujos comparten memoria y la corrección depende de las primitivas de sincronización que estudiamos entre los capítulos 7 y 14. Allí volveremos a los semáforos, los monitores, las tareas de Ada y los procesos de Elixir, pero ya no como ejercicios aislados sino como piezas de un sistema completo que hay que diseñar, implementar y demostrar libre de condiciones de carrera. El índice completo del curso está en /tecnologias/sistemas-operativos/00-indice/.