1. Qué es una shell
Una shell es un programa que lee comandos del usuario, los interpreta, y los ejecuta. Es la interfaz entre el usuario y el sistema operativo. La shell más común en Linux es bash, pero hay muchas otras (zsh, fish, dash, etc.).
La shell hace tres cosas principales:
- Leer: leer una línea de comandos del usuario (o de un script).
- Parsear: separar el comando, los argumentos, las redirecciones, las tuberías, los operadores.
- Ejecutar: crear un proceso hijo (fork), prepararlo (redirecciones, etc.), y ejecutar el programa (exec). El padre espera al hijo (wait).
En este tema construiremos una mini shell que implementa las funcionalidades básicas: leer, parsear, ejecutar, esperar y lanzar en segundo plano.
Todo lo que hace la shell se implementa con fork + exec + wait. Es el ejemplo más completo del patrón de Unix. Construir una mini shell es el mejor ejercicio para consolidar todo lo aprendido.
2. Estructura de la mini shell
La mini shell tiene una estructura simple:
- Bucle principal: mostrar un prompt, leer una línea, procesarla, repetir.
- Parser: separar la línea en comando, argumentos y modificadores (&, >, <).
- Ejecutor: fork + preparar + exec + wait.
- Gestor de trabajos: manejar trabajos en segundo plano y recoger zombis.
Las funcionalidades que implementaremos son:
- Ejecutar comandos externos (ls, cat, grep, etc.).
- Pasar argumentos al comando.
- Lanzar en segundo plano con &.
- Comando interno: cd (cambiar de directorio).
- Comando interno: exit (terminar la shell).
- Evitar zombis con SIGCHLD + WNOHANG.
No implementaremos tuberías (|), redirección (>, <), ni variables de entorno. Esas son extensiones que se pueden agregar.
3. Leer órdenes
La shell lee una línea del usuario con fgets() o readline(). Usaremos fgets() por simplicidad.
char linea[256];
printf("mini-shell> ");
fflush(stdout);
if (fgets(linea, sizeof linea, stdin) == NULL) {
/* EOF (Ctrl+D) */
printf("\n");
break;
}
/* Eliminar el newline */
linea[strcspn(linea, "\\n")] = 0;
strcspn(linea, "\\n") devuelve la posición del primer newline en la línea. Lo reemplazamos con \\0 para terminar la cadena.
Si el usuario presiona Ctrl+D (EOF), fgets devuelve NULL y terminamos la shell.
4. Parsear la línea de comandos
El parser separa la línea en tokens (palabras) usando strtok(). También detecta si el último token es & (segundo plano).
char *args[64]; /* arreglo de argumentos */
int nargs = 0;
int segundo_plano = 0;
char *token = strtok(linea, " \\t");
while (token != NULL) {
if (strcmp(token, "&") == 0) {
segundo_plano = 1;
} else {
args[nargs++] = token;
}
token = strtok(NULL, " \\t");
}
args[nargs] = NULL; /* terminador */
El parser es simple: separa por espacios y tabuladores. Detecta & y lo marca como segundo plano. Los demás tokens se guardan en args.
Este parser no maneja comillas, escapes ni variables de entorno. Es una simplificación para el proyecto.
5. Ejecutar con fork y execvp
Una vez parseada la línea, la shell ejecuta el comando con fork + execvp:
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return;
}
if (pid == 0) {
/* Hijo: ejecutar el comando */
execvp(args[0], args);
/* Si llegamos aquí, exec falló */
perror("execvp");
_exit(127);
}
/* Padre: esperar o no, según segundo_plano */
if (!segundo_plano) {
waitpid(pid, NULL, 0);
} else {
printf("[PID %d] en segundo plano\n", (int) pid);
}
El hijo llama a execvp, que busca el comando en PATH. Si falla, termina con código 127. El padre espera al hijo si no es segundo plano.
Antes de fork, la shell verifica si el comando es un comando interno (cd, exit). Si lo es, lo ejecuta directamente sin fork.
6. Esperar a los hijos
Cuando el comando se ejecuta en primer plano, la shell espera a que termine con waitpid(). Esto bloquea la shell hasta que el comando termina.
Si el comando se ejecuta en segundo plano (con &), la shell no espera. El hijo se ejecuta en paralelo, y la shell vuelve a mostrar el prompt inmediatamente.
El problema con los trabajos en segundo plano es que cuando terminan, quedan como zombis si nadie los recoge. La solución es usar un manejador de SIGCHLD que recoge a todos los hijos terminados.
7. Trabajos en segundo plano
Los trabajos en segundo plano son procesos que se ejecutan sin bloquear la shell. La shell puede lanzar varios trabajos en segundo plano y seguir aceptando comandos.
La shell mantiene una lista de trabajos activos. Cuando un trabajo termina, se recoge y se elimina de la lista. La shell puede mostrar el estado de los trabajos con un comando jobs.
En nuestra mini shell, no implementaremos la lista de trabajos ni el comando jobs. Solo lanzaremos en segundo plano y recogeremos con SIGCHLD.
Una shell real (como bash) mantiene una tabla de trabajos, soporta fg/bg, maneja señales (Ctrl+C, Ctrl+Z), y gestiona tuberías y redirecciones. Nuestra mini shell es una simplificación que captura la esencia del patrón fork + exec.
8. Evitar zombis
La mini shell usa un manejador de SIGCHLD para recoger a los hijos terminados sin bloquearse:
void manejador_sigchld(int sig)
{
(void) sig;
/* Recoger a todos los hijos terminados */
while (waitpid(-1, NULL, WNOHANG) > 0);
}
El manejador se instala al inicio de la shell. Cada vez que un hijo termina, se envía SIGCHLD, y el manejador recoge a todos los hijos terminados.
Esto evita que los hijos en segundo plano queden como zombis. También recoge a los hijos en primer plano (aunque el padre ya los recogió con waitpid, el manejador no hace daño).
9. Redirección (opcional)
La redirección de entrada/salida se puede agregar a la mini shell usando dup2. Por ejemplo, para redirigir la salida a un archivo:
/* En el hijo, antes de exec */
if (archivo_salida != NULL) {
int fd = open(archivo_salida, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) {
dup2(fd, STDOUT_FILENO);
close(fd);
}
}
La redirección requiere parsear el operador > en la línea de comandos. Es una extensión que se puede agregar.
10. El programa completo
Aquí está la mini shell completa:
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
#include
#include
#include
/* Manejador de SIGCHLD: recoger a los hijos terminados */
void manejador_sigchld(int sig)
{
(void) sig;
while (waitpid(-1, NULL, WNOHANG) > 0);
}
/* Comandos internos */
static int cmd_cd(char **args)
{
if (args[1] == NULL) {
fprintf(stderr, "cd: falta argumento\n");
return 1;
}
if (chdir(args[1]) != 0) {
perror("cd");
return 1;
}
return 0;
}
static int cmd_exit(char **args)
{
(void) args;
printf("Saliendo de la mini shell...\n");
exit(0);
return 0; /* no se alcanza */
}
/* Ejecutar un comando externo */
static void ejecutar_externo(char **args, int segundo_plano)
{
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return;
}
if (pid == 0) {
/* Hijo: ejecutar el comando */
execvp(args[0], args);
/* Si llegamos aquí, exec falló */
fprintf(stderr, "mini-shell: %s: comando no encontrado\n", args[0]);
_exit(127);
}
/* Padre */
if (segundo_plano) {
printf("[PID %d] en segundo plano\n", (int) pid);
} else {
/* Esperar al hijo */
int status;
if (waitpid(pid, &status, 0) == -1) {
if (errno != ECHILD)
perror("waitpid");
} else {
if (WIFEXITED(status)) {
int code = WEXITSTATUS(status);
if (code != 0)
printf("mini-shell: comando terminó con código %d\n", code);
} else if (WIFSIGNALED(status)) {
printf("mini-shell: comando terminado por señal %d\n", WTERMSIG(status));
}
}
}
}
/* Parsear la línea de comandos */
static int parsear(char *linea, char **args)
{
int nargs = 0;
char *token = strtok(linea, " \\t");
while (token != NULL && nargs < 63) {
args[nargs++] = token;
token = strtok(NULL, " \\t");
}
args[nargs] = NULL;
return nargs;
}
int main(void)
{
/* Instalar manejador de SIGCHLD */
struct sigaction sa;
sa.sa_handler = manejador_sigchld;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL);
/* Ignorar SIGINT en la shell (Ctrl+C no termina la shell) */
signal(SIGINT, SIG_IGN);
char linea[256];
while (1) {
printf("mini-shell> ");
fflush(stdout);
if (fgets(linea, sizeof linea, stdin) == NULL) {
/* EOF (Ctrl+D) */
printf("\n");
break;
}
/* Eliminar newline */
linea[strcspn(linea, "\\n")] = 0;
/* Saltar líneas vacías */
if (strlen(linea) == 0)
continue;
/* Parsear */
char *args[64];
int nargs = parsear(linea, args);
if (nargs == 0)
continue;
/* Detectar segundo plano (&) */
int segundo_plano = 0;
if (nargs > 0 && strcmp(args[nargs - 1], "&") == 0) {
segundo_plano = 1;
args[--nargs] = NULL;
}
/* Comandos internos */
if (strcmp(args[0], "cd") == 0) {
cmd_cd(args);
continue;
}
if (strcmp(args[0], "exit") == 0) {
cmd_exit(args);
}
/* Ejecutar comando externo */
ejecutar_externo(args, segundo_plano);
}
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o mini_shell mini_shell.c
$ ./mini_shell
mini-shell> ls
mini_shell.c mini_shell proceso.c proceso
mini-shell> echo hola
hola
mini-shell> sleep 5 &
[PID 1100] en segundo plano
mini-shell> echo "trabajando mientras sleep corre"
trabajando mientras sleep corre
mini-shell> pwd
/home/ana/procesos
mini-shell> cd /tmp
mini-shell> pwd
/tmp
mini-shell> exit
Saliendo de la mini shell...
La mini shell demuestra el patrón completo: leer, parsear, fork, exec, wait, segundo plano, SIGCHLD, comandos internos. Es una simplificación de bash, pero captura la esencia.
Extensiones posibles:
- Tuberías: implementar
|con pipe() y dup2(). - Redirección: implementar
>,<,2>con dup2(). - Variables de entorno: implementar
exporty expandir$VAR. - Comando jobs: mostrar los trabajos en segundo plano.
- Historial: guardar los comandos anteriores (con readline).
- Globs: expandir
*y?.
11. Resumen
- Una shell lee, parsea y ejecuta comandos usando fork + exec + wait.
- Los comandos internos (cd, exit) se ejecutan sin fork.
- Los comandos externos se ejecutan con fork + execvp.
- Los trabajos en segundo plano se lanzan con & y no se esperan.
- SIGCHLD + WNOHANG evita que los hijos queden como zombis.
- La shell ignora SIGINT para no terminar con Ctrl+C.
- La mini shell es una simplificación de bash que captura la esencia del patrón de Unix.
12. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. Que hace una shell?
Lee comandos del usuario, los parsea, y los ejecuta usando fork + exec + wait.
2. Por qué cd es un comando interno?
Porque un proceso hijo no puede cambiar el directorio del padre. Si cd se ejecutara en un hijo, el cambio no afectaría a la shell.
3. Por qué exit es un comando interno?
Porque si se ejecutara en un hijo, solo terminaría el hijo, no la shell.
4. Como se lanzan trabajos en segundo plano?
Detectando el operador & al final de la línea. El padre no llama a waitpid, y el hijo se ejecuta en paralelo.
5. Como se evitan zombis en la mini shell?
Con un manejador de SIGCHLD que llama a waitpid(-1, NULL, WNOHANG) en un bucle.
6. Por qué la shell ignora SIGINT?
Para que Ctrl+C no termine la shell. Solo los procesos hijos (que no ignoran SIGINT) se terminan.
7. Que código de salida usa el hijo cuando exec falla?
127, el código estándar de Unix para "comando no encontrado".
8. Que función se usa para parsear la línea?
strtok, que separa la línea por espacios y tabuladores.
9. Como se detecta EOF en la shell?
Cuando fgets devuelve NULL (el usuario presiona Ctrl+D).
10. Que extensiones se pueden agregar?
Tuberías (|), redirección (>, <), variables de entorno, comando jobs, historial, globs.
Para practicar
- Compila y ejecuta mini_shell.c. Prueba varios comandos.
- Prueba lanzar un comando en segundo plano con & y verifica que no quedan zombis.
- Agrega redirección de salida (>) a la mini shell.
- Agrega tuberías (|) a la mini shell.
- Agrega el comando jobs para mostrar los trabajos en segundo plano.
- Usa strace para ver las syscalls que genera la mini shell.