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 contratosFlujo end-to-end de un comando
Cuando el usuario escribe algo, el recorrido es:
mainlee la línea congetline.- Si hay contenido, llama a
parse_line. - El parser devuelve una estructura
pipeline_t(o error de sintaxis). execute_pipelinerecorre los comandos:- Decide si es builtin en padre (caso simple), o
- Crea hijos y pipes (caso general).
- Se espera a todos los hijos con
waitpid. - Se toma el status del último comando.
- 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:
cdnecesita cambiar el directorio de la shell real.exitnecesita 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:
cat salida.txt | wc -cQué ocurre internamente, resumido:
- Parser crea dos
command_ten unpipeline_t. - Executor crea un pipe.
- Hijo 1 (
cat):- Abre
salida.txt dup2(fd_archivo, STDIN_FILENO)dup2(pipe_write, STDOUT_FILENO)execvp("cat", ... )
- Abre
- Hijo 2 (
wc -c):dup2(pipe_read, STDIN_FILENO)execvp("wc", ... )
- Padre cierra extremos de pipe que no usa y espera ambos hijos.
- 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:
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:
-
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. -
En el hijo se restaura
SIGINTpor defecto.signal(SIGINT, SIG_DFL)permite que el comando externo responda aCtrl+Ccomo en una shell real. El proceso principal de la shell puede tener otra política, pero el hijo debe comportarse “normal”. -
dup2conecta la tubería al comando correcto. Si existeprev_read, se duplica aSTDIN_FILENO(entrada del comando actual). Sineeds_pipees verdadero, se duplicapipefd[1]aSTDOUT_FILENO(salida hacia el siguiente comando). En términos prácticos: entrada desde el comando anterior y salida hacia el siguiente. -
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. -
Se aplican redirecciones y luego se ejecuta.
apply_redirections(...)puede sobrescribirstdin/stdoutsi hay<,>o>>. Después, si el comando es builtin se ejecuta en el hijo (caso pipeline); si no, se llama aexecvp. Siexecvpfalla, sale con127, que es el código típico de “comando no ejecutable/no encontrado”. -
El padre conserva control del pipeline. Guarda el
pid, cierra lo que ya no necesita y mueveprev_readal 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é
cdno 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
-
Sistemas es orden de operaciones. El mismo conjunto de llamadas puede funcionar o romperse según el orden.
-
Una API clara entre módulos vale oro. Separar parser/executor/builtins hizo que el código fuese defendible y mantenible.
-
Definir alcance temprano evita caos. Decidir explícitamente lo que NO se implementa mantiene foco y calidad.
-
Errores claros son parte del producto. Un programa de sistemas sin mensajes útiles es mucho más difícil de usar y depurar.
-
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.