Table of Contents
Construir una cáscara desde cero en C sigue siendo una de las mejores maneras de profundizar su comprensión de cómo los sistemas operativos gestionan los procesos, manejan la entrada de usuario y ejecutan programas. Mientras que las cáscaras modernas como Bash, Zsh y Fish son increíblemente sofisticadas, su funcionalidad central se reduce a un simple bucle: leer un comando, parse, crear un nuevo proceso, y esperar a que termine.
Lo que un Shell realmente hace
En su corazón, un shell es un intérprete de línea de comandos. Proporciona una interfaz textual donde los usuarios escriben comandos, y la cáscara traduce esos comandos en acciones realizadas por el sistema operativo. Cuando escriba ls -la, la cáscara debe encontrar los ls ejecutable, crear un nuevo proceso para ejecutarlo [LT]
Construyendo una cáscara mínima te enseña sobre:
- Entrada de lectura y tokenización de entrada estándar
- Creación y gestión de procesos infantiles con fork]
- Reemplazando la imagen de memoria de un proceso con exec]
- Sincronizar a los padres y a los hijos con esperar]
- Manejo de errores comunes con gracia
Comprender estos bloques de construcción le da una visión de cómo todos los sistemas similares a Unix operan bajo la capucha, y proporciona una base sólida para aprender sobre características más avanzadas como el manejo de señales, el control de empleo y la comunicación entre procesos.
Configuración del bucle de Shell
Cada cáscara se construye alrededor de un bucle principal que repite indefinidamente hasta que el usuario solicita salir. Este bucle imprime un impulso, lee una línea de entrada, la analiza, y luego actúa en el comando analizado. La versión más simple se ve así en pseudocódigo:
while (1) {
print_prompt();
read_input();
parse_input();
execute_command();
}
En C, normalmente usa fgets] para leer entrada porque maneja límites de línea y desbordamientos de amortiguación más seguro que gets. El impulso puede ser tan simple como una cadena como mysh limitadogt; , pero los verdaderos directorios de trabajo a menudo incluyen el nombre de host
Manejo de casos de borde en la entrada
El nuevo control de la línea de control de la línea blanca es muy fácil. Un robusto shell debe manejar líneas vacías, liderar y seguir el espacio blanco, comandos extremadamente largos, y la condición de extremo (Ctrl+D). Si fgets vuelve a verificar el buffer de la línea blanca, siempre debe romperse el bump y salir con facilidad.
#define MAX_INPUT 1024
char input[MAX_INPUT];
if (fgets(input, MAX_INPUT, stdin) == NULL) {
printf("\n");
break; // EOF
}
// Remove trailing newline, if present
size_t len = strlen(input);
if (len > 0 && input[len-1] == '\n') {
input[len-1] = '\0';
} else {
// Input too long, flush remaining characters
int ch;
while ((ch = getchar()) != '\n' && ch != EOF);
}
Parsing Commands into Arguments
Una vez que usted tiene una cadena de entrada limpia, usted necesita dividirlo en fichas. La primera señal es el nombre de comando (por ejemplo, ls), y los tokens restantes son los argumentos a ese comando. La biblioteca estándar C proporciona strtok ] para este propósito, pero usted debe ser cuidadoso [LT]
char *args[MAX_ARGS];
int arg_count = 0;
args[arg_count] = strtok(input, " \t");
while (args[arg_count] != NULL && arg_count < MAX_ARGS - 1) {
arg_count++;
args[arg_count] = strtok(NULL, " \t");
}
args[arg_count] = NULL; // execvp expects a NULL-terminated array
Este tokenizer divide la entrada en espacios y pestañas. Hace no] mango citado cadenas, por lo que un comando como echo "hola mundo"] rompería incorrectamente en tres fichas en lugar de dos. Manejar citas es un valor valioso que requiere un analizador más sofisticado, pero para la más básica de ejecutar
Después de la tokenización, debe comprobar si el primer token es NULL (comando vacío). Si es así, simplemente continúe con la próxima iteración del bucle sin forjar.
Comandos incorporados
No todos los comandos deben desperdiciar un nuevo proceso. Algunos comandos, como cd y exit, deben ser ejecutados por el propio proceso de shell porque afectan el estado de la cáscara. Por ejemplo, cd [[F parent:5]] cambia el directorio de trabajo actual de la cásula;
Exit
El comando exit] termina la cáscara inmediatamente. Es el más simple incorporado para implementar: simplemente comprobar si la primera señal es igual a "exit" y romper el bucle principal. Opcionalmente, puede aceptar un argumento de estado de salida y pasarlo a la llamada del sistema.
Change Directory (cd)
El comando cd] requiere que llames chdir. El directorio objetivo es el segundo argumento. Si no se proporciona ningún argumento, puede predeterminarse al directorio de inicio del usuario (disponible a través de la ]OME] variable entorno.
if (strcmp(args[0], "cd") == 0) {
const char *path = args[1];
if (path == NULL) {
path = getenv("HOME");
if (path == NULL) {
fprintf(stderr, "cd: HOME not set\n");
continue;
}
}
if (chdir(path) != 0) {
perror("cd");
}
continue; // skip fork/exec
}
Creación de procesos con tenedor
Para cualquier comando que no sea un incorporado, su cáscara debe crear un proceso infantil para ejecutarlo. La llamada del sistema crea un nuevo proceso duplicando el proceso de llamada. El nuevo proceso se llama el niño, y el original es el padre. Después parak ambos procesos continúan ejecutando desde el mismo punto en el que se ejecuta el niño.
pid_t pid = fork();
if (pid == -1) {
perror("fork");
continue;
}
if (pid == 0) {
// Child process
// ...
} else {
// Parent process
// ...
}
¿Por qué Fork?
Usted podría preguntarse por qué usted necesita crear un proceso separado en absoluto. La razón es que exec, que carga un nuevo programa en memoria, reemplaza el proceso actual por completo. Si el shell llamado exec directamente, el programa de shell sería reemplazado y nunca volver a aceptar nuevos comandos.
Ejecución de un programa con Exec
[LT:15] exec] familia de funciones reemplaza el proceso actual con un nuevo programa. Hay varias variantes: execl, [FLT[4)] [exc]
if (pid == 0) {
// Child process
execvp(args[0], args);
// If execvp returns, an error occurred
perror("exec");
exit(EXIT_FAILURE);
}
Note la llamada a exit] después ]]perror. Si execvp[ falla (por ejemplo, porque el comando no existe), el proceso del niño debe terminar; de lo contrario, seguiría ejecutando cualquier código seguido, que suele ser el resultado de la concha del padre.
Esperando el proceso del niño
Después de la forja, el proceso padre normalmente espera que el niño termine antes de incitar de nuevo. Esto se hace con wait] o waitpid. La función ] le da más control porque puede especificar qué niño puede esperar (utilizando las opciones [LTF] [para el PID devuelto]
int status;
waitpid(pid, &status, 0);
status] variable contiene información sobre cómo terminó el niño. Puede utilizar macros como WIFEXITED, WEXITSTATUS[F touch:5], WIFSIGNALED[FLT:LT] [FLT]
Bloqueo vs. No bloqueo
La simple waitpid] llama por encima de los bloques del padre hasta que el niño salga. Este es el comportamiento correcto para un proceso de primer plano. Si después añades apoyo para los procesos de fondo (corregir un comando con ]) , establecerías la WNOHANG opción.
Poniendo juntos la Shell completa
Aquí hay una cáscara completa y mínima que integra todas las piezas discutidas hasta ahora. Se maneja el exit y cd] incorporado, púas de entrada utilizando strtok, tensores de comandos externos, y espera que el niño termine la claridad.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#define MAX_INPUT 1024
#define MAX_ARGS 64
int main(void) {
char input[MAX_INPUT];
char *args[MAX_ARGS];
int should_run = 1;
while (should_run) {
printf("mysh> ");
fflush(stdout);
if (fgets(input, MAX_INPUT, stdin) == NULL) {
printf("\n");
break;
}
// Remove trailing newline
size_t len = strlen(input);
if (len > 0 && input[len-1] == '\n') {
input[len-1] = '\0';
}
// Tokenize
int i = 0;
args[i] = strtok(input, " \t");
while (args[i] != NULL && i < MAX_ARGS - 1) {
i++;
args[i] = strtok(NULL, " \t");
}
args[i] = NULL;
if (args[0] == NULL) {
continue; // empty line
}
// Handle built-in commands
if (strcmp(args[0], "exit") == 0) {
should_run = 0;
continue;
}
if (strcmp(args[0], "cd") == 0) {
const char *path = args[1];
if (path == NULL) {
path = getenv("HOME");
}
if (chdir(path) != 0) {
perror("cd");
}
continue;
}
// Fork and execute external command
pid_t pid = fork();
if (pid < 0) {
perror("fork");
continue;
}
if (pid == 0) {
// Child
execvp(args[0], args);
perror("exec");
exit(EXIT_FAILURE);
} else {
// Parent waits
int status;
waitpid(pid, &status, 0);
}
}
return 0;
}
[LT] [LT] [FLT] [L]] [L] [L] [L]] [L]] [L]] [Llevad] [FLT] [Llevadlo con ] [L] [L] [L] [L] [L] [L] [L]]
Pitfalls comunes y consejos de depuración
Incluso con esta pequeña cantidad de código, varias cosas pueden ir mal. Aquí están los problemas más frecuentes y cómo solucionarlos:
Comando no encontrado
Si escribe un comando que no existe (por ejemplo, ]fobar]), execvp devuelve y los niños imprime "exec: No tal archivo o directorio" antes de salir. Esto es correcto comportamiento, pero es posible que desee imprimir un mensaje de amigo. Usted puede negar [LT]
Falta de Newline o de entrada truncada
Si su mensaje aparece sin esperar entrada, la causa probable es que los caracteres de sobra en el amortiguador de entrada de una llamada anterior. Siempre comprueba que fgets consumió toda la línea (es decir, que el último personaje antes del final de la unidad nula es una nueva línea). Si no es, haga fluir la entrada restante como se muestra antes.
Procesos de Zombie
Si olvidas llamar espera [o esperad]), procesos infantiles que terminan convirtiéndose en zombis hasta que los cosechas. La cáscara en el ejemplo llama esperad, por lo que los zombis no deben aparecer.
Extender el Shell
Una vez que tenga el trabajo básico de la cáscara, puede añadir características que lo acercan a una cáscara del mundo real. Cada característica le enseña más acerca del sistema operativo.
Input/Output Redirection
[LT] [LT] ]] ]] ] ]]] [FLT: [FLT] [4]]] [4]]] [FLT] [4]]] [FLT] [4]]]
Pipas
Piping the output of one command into the input of another (]cmd1 tención cmd2) is more involved. Necesita crear una tubería con pipe, tenedor de dos procesos infantiles, redireccionar la salida de un niño al extremo de la tubería y la entrada del otro para completar ambos, luego esperar a final.
Control de empleo
]SIGTSTP [FLT] [FLT] ]] [FLT: ]]] [FLT: ] [FLT] [FLT] [FLT] [FLT]] [FLT]] [FLT] [FLT]] [FLT] [p]
Historia del Comando
La implementación de un mecanismo de historia simple (flechas arriba/abajo para recordar comandos anteriores) implica capturar entrada utilizando modo terminal bruto (vía tcgetattr y tcsetatr) o utilizando la ] línea de lectura].
Lectura y recursos adicionales
Para profundizar en su comprensión de los conceptos presentados aquí, los siguientes recursos son inestimables:
- GNU C Manual de biblioteca: Ejemplos de creación de procesos] – Documentación oficial sobre fork, exec, y espera[.
- La Base de Grupo Abierto Especificaciones: Lenguaje de Comando de Cuerda] – La especificación formal para el comportamiento de la cáscara POSIX; útil si deseas igualar un estándar.
- Wikipedia: Unix shell – Una amplia visión general de la historia, las variantes y las características de la concha.
- Guía de Beej para la comunicación entre procesos – Cubre tubos, FIFOs, colas de mensajes y memoria compartida con ejemplos prácticos.
Conclusión
Construir una shell básica en C es más que un ejercicio académico; te obliga a comprometerte directamente con las abstracciones del sistema operativo. Has visto cómo leer y analizar la entrada del usuario, cómo fork crea un nuevo proceso, y cómo ]]exec] reemplaza el código de memoria de ese proceso con un programa de comandos sólidos.