enero de 2026
Proyectos

MiniSH

Una mini shell en C para entender a fondo procesos, pipes, redirecciones y señales.

C POSIX Make fork execvp pipe dup2

TL;DR

Después de cursar Fundamentos de Sistemas Operativos, quise dejar de “entender en papel” y pasar a “entender en código”. Este proyecto nace de esa necesidad: construir una mini shell en C para ver, en la práctica, cómo funcionan procesos, pipes, redirecciones, señales y memoria dinámica.

No intenté clonar Bash. Intenté algo más útil para aprender: un sistema pequeño, legible y con límites claros.


El origen: cuando la teoría se queda corta

Hay una escena muy concreta que me empujó a hacer esto.

En clase hablábamos de fork(), de tablas de procesos, de descriptores de archivo y de execve(). Todo tenía sentido conceptual. Podía responder preguntas de examen. Pero cuando me preguntaba “¿qué ocurre exactamente cuando escribo cat archivo | wc -l en una terminal?”, mi respuesta real era: “más o menos lo sé”.

Y ese “más o menos” me molestaba.

Así que me puse una meta: construir una shell mínima desde cero, en C, y obligarme a resolver de verdad lo que una shell resuelve cada vez que ejecuta un comando.

No para sacar una feature impresionante. No para vender nada. No para presumir de complejidad.

Solo por curiosidad técnica bien entendida.


La idea del proyecto en una frase

minish es una mini shell tipo POSIX para aprender, que ejecuta comandos externos, soporta pipelines/redirecciones y tiene builtins básicos.

Lo importante no es la lista de features. Lo importante es que cada feature está elegida para tocar un concepto de sistemas:

  • Comandos externos -> fork + execvp
  • Pipelines -> pipe + dup2 + cierre correcto de FDs
  • Redirecciones -> open + dup2
  • Estado del shell -> builtins ejecutados en padre cuando toca
  • Control interactivo -> manejo de SIGINT

Alcance real

Qué sí hace

  • Loop interactivo con prompt (minish$)
  • Lectura de línea con getline
  • Parseo de tokens y operadores: |, <, >, >>
  • Soporte básico de comillas simples y dobles
  • Validaciones de sintaxis comunes
  • Ejecución de comandos externos por PATH (execvp)
  • Pipelines de N comandos
  • Redirecciones de entrada/salida
  • Builtins: cd, pwd, echo, exit
  • Manejo básico de Ctrl+C

Qué no hace

  • No expansión de variables ($HOME, $?)
  • No operadores &&, ||, ;, &
  • No job control (jobs, fg, bg)
  • No parser completo estilo Bash (escapes/quoting avanzados)
  • No historial/autocompletado

Este punto me parece clave para defender el proyecto: tener límites explicitados es una decisión técnica madura, no una carencia accidental.


Arquitectura: separación por responsabilidades

La estructura está pensada para que cada módulo haga una cosa y la haga bien:

minish
├── src
│ ├── main.c # REPL + gestión SIGINT + orquestación
│ ├── parser.c # tokenización y construcción de pipeline_t
│ ├── executor.c # ejecucción de comandos: fork, exec, dup2, pipes
│ ├── builtins.c # cd, pwd, echo, exit
│ └── utils.c # xmalloc, xrealloc, xstrdup
└── include
└── minish.h # tipos y contratos

Flujo end-to-end de un comando

Cuando el usuario escribe algo, el recorrido es:

  1. main lee la línea con getline.
  2. Si hay contenido, llama a parse_line.
  3. El parser devuelve una estructura pipeline_t (o error de sintaxis).
  4. execute_pipeline recorre los comandos:
    • Decide si es builtin en padre (caso simple), o
    • Crea hijos y pipes (caso general).
  5. Se espera a todos los hijos con waitpid.
  6. Se toma el status del último comando.
  7. Se libera memoria (free_pipeline) y vuelve al prompt.

La clave de este flujo es que no hay magia: son pasos directos y trazables.


Decisiones técnicas importantes y por qué

1) Builtins de estado en el proceso padre

Decidí que, si hay un único comando y es builtin, se ejecute en el padre.

Motivo:

  • cd necesita cambiar el directorio de la shell real.
  • exit necesita modificar el estado global de la shell.

Si eso corre en un hijo, el efecto muere con el hijo. Es una de esas lecciones de SO que se entienden de verdad cuando lo implementas.

2) Parser simple y explícitamente limitado

No quise construir una gramática compleja en esta fase.

Preferí un parser lineal, predecible, con errores claros:

  • “Comillas sin cerrar”
  • “Pipe sin comando”
  • “Falta archivo para redirección”
  • “Redirección duplicada”

El objetivo era robustez y entendibilidad, no soporte total de sintaxis shell.

3) Memoria dinámica en estructuras de longitud variable

Una shell no sabe cuántas palabras, comandos ni pipes vendrán en una línea.

Por eso token_list_t, argv y pipeline->commands crecen con xrealloc. Y por eso existen wrappers (xmalloc, xrealloc, xstrdup) para no repetir chequeos de NULL en cada uso.

4) Manejo de señales pensado para modo interactivo

En la shell principal, SIGINT no debe matar el proceso completo. En cambio, en hijos conviene comportamiento por defecto para que comandos externos reaccionen normal a Ctrl+C.

Esa separación mejora mucho la experiencia interactiva y refleja cómo trabajan shells reales.


Qué costó más (y por qué)

Cierre de descriptores en pipelines

Este fue el clásico punto donde un detalle pequeño rompe todo:

  • Cerrar demasiado pronto -> comando sin entrada/salida
  • No cerrar -> bloqueos o fugas

Entender el orden correcto de dup2, close y herencia de FDs fue de lo más formativo del proyecto.

Redirecciones en builtins

Para builtins en proceso padre, hay que redirigir temporalmente y luego restaurar stdin/stdout del shell.

Si no restauras, el shell “queda torcido” para comandos siguientes.

Consistencia de códigos de salida

No basta con ejecutar: hay que devolver status coherente.

En pipelines, minish usa el status del último comando, que es lo esperable para usuario y scripting básico.


Desventajas y errores actuales de esta versión

Esta parte es importante para una defensa honesta: lo que hoy funciona bien y lo que todavía es frágil.

Desventaja Situación actual Consecuencia
Quoting/escaping limitado Soporta comillas simples y dobles básicas, pero no un modelo completo de escapes tipo Bash; tampoco interpreta secuencias como \n en echo salvo literal. Algunos comandos válidos en Bash aquí se comportan distinto o no se soportan.
Sin expansión de variables No reemplaza $HOME, $USER, $?, etc. Scripts y hábitos comunes de shell no funcionan tal cual.
Sin operadores de control de flujo No hay &&, ||, ; ni ejecución en background con &. No se puede encadenar lógica de comandos en una sola línea como en shells completas.
Sin job control No existen jobs, fg, bg. No hay control de procesos en segundo plano.
Builtins con opciones limitadas echo implementa una versión mínima (-n simple) y cd, pwd, exit no cubren todas las variantes de shells maduras. Compatibilidad parcial, orientada a aprendizaje.
Memoria en modo fail-fast Si falla malloc/realloc/strdup, el programa termina. Es valido en contexto educativo, pero no ofrece degradación elegante de producto.
Pruebas mayormente manuales La validación se apoya sobre todo en pruebas manuales de comandos. Faltan tests automatizados para detectar regresiones de forma sistemática.
UX y mensajes básicos Mensajes de error funcionales pero mejorables; sin historial ni autocompletado. Experiencia de usuario aún simple.
Portabilidad no auditada a fondo Depende de APIs POSIX (fork, execvp, dup2, waitpid, etc.). Funciona bien en Unix-like, pero no apunta a multiplataforma amplia (p. ej., Windows nativo sin capa POSIX).

Un ejemplo concreto de ejecución

Comando:

Terminal window
cat salida.txt | wc -c

Qué ocurre internamente, resumido:

  1. Parser crea dos command_t en un pipeline_t.
  2. Executor crea un pipe.
  3. Hijo 1 (cat):
    • Abre salida.txt
    • dup2(fd_archivo, STDIN_FILENO)
    • dup2(pipe_write, STDOUT_FILENO)
    • execvp("cat", ... )
  4. Hijo 2 (wc -c):
    • dup2(pipe_read, STDIN_FILENO)
    • execvp("wc", ... )
  5. Padre cierra extremos de pipe que no usa y espera ambos hijos.
  6. Devuelve status del último (wc).

Visto así, deja de ser magia y pasa a ser mecánica de sistema.


Mini vistazo al código (fragmento real)

Este bloque sale de src/executor.c (repositorio original), dentro de execute_pipeline:

src/executor.c
pid_t pid = fork();
if (pid < 0) {
perror("fork");
if (pipefd[0] != -1) {
close(pipefd[0]);
}
if (pipefd[1] != -1) {
close(pipefd[1]);
}
if (prev_read != -1) {
close(prev_read);
}
for (int j = 0; j < started; ++j) {
waitpid(pids[j], NULL, 0);
}
free(pids);
return 1;
}
if (pid == 0) {
signal(SIGINT, SIG_DFL);
if (prev_read != -1) {
if (dup2(prev_read, STDIN_FILENO) < 0) {
perror("dup2");
_exit(1);
}
}
if (needs_pipe) {
if (dup2(pipefd[1], STDOUT_FILENO) < 0) {
perror("dup2");
_exit(1);
}
}
if (pipefd[0] != -1) {
close(pipefd[0]);
}
if (pipefd[1] != -1) {
close(pipefd[1]);
}
if (prev_read != -1) {
close(prev_read);
}
if (apply_redirections(&pipeline->commands[i]) != 0) {
_exit(1);
}
if (is_builtin(pipeline->commands[i].argv[0])) {
int st = execute_builtin(shell, pipeline->commands[i].argv);
_exit(st);
}
execvp(pipeline->commands[i].argv[0], pipeline->commands[i].argv);
fprintf(stderr, "%s: %s\n", pipeline->commands[i].argv[0], strerror(errno));
_exit(127);
}
pids[i] = pid;
started++;
if (prev_read != -1) {
close(prev_read);
}
if (pipefd[1] != -1) {
close(pipefd[1]);
}
prev_read = pipefd[0];

Así se ve, en código real, la parte más delicada: fork, dup2, cierre de FDs y ejecución.

Qué está pasando aquí, paso a paso:

  1. fork() divide el flujo en padre e hijo. Si falla (pid < 0), el código limpia todo lo abierto hasta ese momento (extremos de pipe, prev_read), espera los hijos ya lanzados y devuelve error. Esta limpieza evita fugas y estados inconsistentes.

  2. En el hijo se restaura SIGINT por defecto. signal(SIGINT, SIG_DFL) permite que el comando externo responda a Ctrl+C como en una shell real. El proceso principal de la shell puede tener otra política, pero el hijo debe comportarse “normal”.

  3. dup2 conecta la tubería al comando correcto. Si existe prev_read, se duplica a STDIN_FILENO (entrada del comando actual). Si needs_pipe es verdadero, se duplica pipefd[1] a STDOUT_FILENO (salida hacia el siguiente comando). En términos prácticos: entrada desde el comando anterior y salida hacia el siguiente.

  4. Después de dup2, se cierran FDs originales. Esto es clave: tras duplicar, los descriptores antiguos ya no hacen falta. Si no se cierran, puedes provocar bloqueos (porque algún extremo de escritura sigue abierto) o consumir recursos innecesariamente.

  5. Se aplican redirecciones y luego se ejecuta. apply_redirections(...) puede sobrescribir stdin/stdout si hay <, > o >>. Después, si el comando es builtin se ejecuta en el hijo (caso pipeline); si no, se llama a execvp. Si execvp falla, sale con 127, que es el código típico de “comando no ejecutable/no encontrado”.

  6. El padre conserva control del pipeline. Guarda el pid, cierra lo que ya no necesita y mueve prev_read al extremo de lectura del pipe recién creado. Ese patrón permite encadenar N comandos sin mezclar descriptores.

En resumen: el fragmento no solo “lanza procesos”; implementa una coreografía precisa de descriptores y señales. Si alteras el orden de estos pasos, el pipeline puede romperse aunque las llamadas al sistema sean las mismas.


Por qué este proyecto me sirvió tanto

Porque me obligó a responder preguntas que en clase podía esquivar:

  • “¿Qué cambia exactamente después de fork?”
  • “¿Qué hereda un hijo y qué no?”
  • “¿Cuándo debo cerrar cada FD?”
  • “¿Por qué cd no puede ser un ejecutable externo cualquiera?”

Y sobre todo: me obligó a depurar comportamiento real, no solo a repetir conceptos.


Lecciones técnicas que me llevo

  1. Sistemas es orden de operaciones. El mismo conjunto de llamadas puede funcionar o romperse según el orden.

  2. Una API clara entre módulos vale oro. Separar parser/executor/builtins hizo que el código fuese defendible y mantenible.

  3. Definir alcance temprano evita caos. Decidir explícitamente lo que NO se implementa mantiene foco y calidad.

  4. Errores claros son parte del producto. Un programa de sistemas sin mensajes útiles es mucho más difícil de usar y depurar.

  5. La memoria dinámica no es opcional aquí. En una shell, el tamaño de casi todo es variable.


Cierre personal

La parte más valiosa de minish no es que ejecute ls o que haga pipes.

La parte valiosa es haber pasado de “lo entiendo teóricamente” a “sé exactamente qué está pasando, dónde puede romperse y por qué”.

Si este proyecto tiene una idea central, es esta: la curiosidad técnica bien enfocada convierte conceptos abstractos en intuición real.