1. Por que son dos llamadas
En unix, fork() y exec() son dos llamadas al sistema separadas. fork() crea un proceso nuevo (Duplicando al padre), y exec() reemplaza el programa del proceso actual. La pregunta natural es: ¿por que no hay una sola syscall "crear proceso y ejecutar programa"?
La respuesta es que la separación permite preparar al hijo entre fork y exec. Despues de fork, el hijo es una copia del padre, pero puede modificar su entorno antes de ejecutar el nuevo programa. Puede redirigir su entrada/salida, cambiar de directorio, cerrar descriptores, cambiar de usuario, etc. Todo esto se hace en el hijo, Sin afectar al padre.
Si hubiera una sola syscall, no habria oportunidad de preparar al hijo. Cada variacion (redirigir salida, cambiar de directorio, etc.) requeriria un parámetro adicional en la syscall, haciendo la API compleja y rigida. Con la separación, la preparacion se hace con las syscalls normales (dup2, chdir, setuid, close), y exec simplemente carga el programa.
Esta separación es una de las decisiones de diseno más influyentes de unix. Es la base de la flexibilidad de la shell, la redirección de E/S, las tuberias, y los demonios. Casí todo lo que hace un sistema unix se basa en fork + preparacion + exec.
La separación entre fork y exec es lo que hace a unix tan flexible. Cada paso (crear, preparar, ejecutar) se puede modificar independientemente. La shell puede redirigir la salida, el programa puede cambiar de usuario, el demonio puede cerrar descriptores, todo sin necesidad de parámetros adicionales en fork o exec.
2. El patron en detalle
El patron completo de fork + exec es:
- fork(): el padre crea un hijo. El hijo es una copia del padre.
- El hijo prepara su entorno: redirige entrada/salida, cierra descriptores, cambia de directorio, cambia de usuario, etc.
- exec(): el hijo reemplaza su programa por el nuevo. Si exec tiene exito, no retorna.
- El padre espera: el padre llama a wait() o waitpid() para recoger el resultado del hijo.
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
int main(void)
{
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* 1. Preparar al hijo */
/* (redirigir, cerrar, chdir, setuid, etc.) */
/* 2. Ejecutar el nuevo programa */
execlp("ls", "ls", "-l", "/tmp", NULL);
perror("exec");
_exit(127);
}
/* 3. Padre espera al hijo */
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status))
printf("Hijo terminó con código %d\n", WEXITSTATUS(status));
return 0;
}
La preparacion del hijo (paso 2) es dónde esta la flexibilidad. Puede ser tan simple como no hacer nada (ejecutar el programa tal cual) o tan compleja como redirigir multiples descriptores, cambiar de directorio, y cambiar de usuario.
3. Preparar al hijo antes de exec
La preparacion del hijo se hace con las syscalls normales, antes de llamar a exec. Las operaciónes más comunes son:
- Redirigir descriptores: usar dup2 para cambiar stdin, stdout, stderr.
- Cerrar descriptores: cerrar archivos o sockets que el hijo no necesita.
- Cambiar de directorio: usar chdir para cambiar el cwd del hijo.
- Cambiar de usuario: usar setuid/setgid para cambiar las credenciales del hijo.
- Cambiar la umask: usar umask para cambiar los permisos de archivos nuevos.
- Ignorar senales: usar signal para ignorar senales antes de exec.
Todas estas operaciónes se hacen en el hijo, Después de fork y antes de exec. El padre no se ve afectado, porque padre e hijo tienen copias independientes de estos atributos.
4. Redirección con dup2
dup2(oldfd, newfd) es la syscall que duplica un descriptor de archivo: hace que newfd apunte a la misma estructura del núcleo que oldfd. Si newfd ya estaba abierto, se cierra primero. Es la herramienta fundamental para redirigir entrada/salida.
#include
int dup2(int oldfd, int newfd);
dup2 es atominimo: Sí newfd ya esta abierto, se cierra y se reasigna en una sola operación. Esto es importante para evitar condiciones de carrera.
El patron de redirección es:
- Abrir el archivo de destino: obtener un descriptor (por ejemplo, fd para un archivo de salida).
- dup2(fd, STDOUT_FILENO): hacer que stdout (descriptor 1) apunte al archivo.
- Cerrar fd: ya no se necesita el descriptor original, porque stdout ya apunta al archivo.
- exec(): ejecutar el programa, Que hereda stdout redirigido.
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
int main(void)
{
/* Abrir archivo de salida */
int fd = open("/tmp/salida.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) { perror("open"); return 1; }
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* Hijo: redirigir stdout al archivo */
dup2(fd, STDOUT_FILENO); /* stdout -> archivo */
close(fd); /* ya no se necesita */
/* Ejecutar un comando que escribe en stdout */
execlp("echo", "echo", "Hola, esto va al archivo", NULL);
perror("exec");
_exit(127);
}
close(fd); /* el padre también cierra */
wait(NULL);
/* Mostrar el archivo */
system("cat /tmp/salida.txt");
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o redirigir_stdout redirigir_stdout.c
$ ./redirigir_stdout
Hola, esto va al archivo
El hijo redirige su stdout al archivo, y luego ejecuta echo. La salida de echo va al archivo, no a la terminal. El padre cierra su copia del descriptor y espera al hijo.
5. Redirigir entrada, salida y error
Se pueden redirigir los tres descriptores estandar (stdin, stdout, stderr) usando dup2. El patron es el mismo para los tres:
| Descriptor | Constante | Redirección tipica |
|---|---|---|
| 0 | STDIN_FILENO | Leer de un archivo en lugar del teclado. |
| 1 | STDOUT_FILENO | Escribir en un archivo en lugar de la terminal. |
| 2 | STDERR_FILENO | Escribir errores en un archivo o en /dev/null. |
Para redirigir stderr a stdout (Como hace la shell con 2>&1), se usa dup2(STDOUT_FILENO, STDERR_FILENO). Esto hace que stderr apunte al mismo lugar que stdout.
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
int main(void)
{
/* Abrir archivos para entrada, salida y error */
int fd_in = open("/tmp/entrada.txt", O_RDONLY);
int fd_out = open("/tmp/salida.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
int fd_err = open("/tmp/error.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd_in < 0 || fd_out < 0 || fd_err < 0) {
perror("open"); return 1;
}
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* Redirigir stdin, stdout, stderr */
dup2(fd_in, STDIN_FILENO);
dup2(fd_out, STDOUT_FILENO);
dup2(fd_err, STDERR_FILENO);
/* Cerrar los descriptores originales */
close(fd_in); close(fd_out); close(fd_err);
/* Ejecutar un comando */
execlp("cat", "cat", NULL); /* cat lee de stdin y escribe en stdout */
perror("exec");
_exit(127);
}
/* Padre: cerrar descriptores */
close(fd_in); close(fd_out); close(fd_err);
wait(NULL);
printf("Salida: "); system("cat /tmp/salida.txt");
printf("Error: "); system("cat /tmp/error.txt");
return 0;
}
Este programa redirige stdin, stdout y stderr del hijo a archivos diferentes. El comando cat lee de stdin (el archivo de entrada) y escribe en stdout (el archivo de salida). Los errores irian al archivo de error.
Cuando escribes comando > salida.txt 2> error.txt < entrada.txt, la shell hace exactamente esto: abre los archivos, hace fork, en el hijo usa dup2 para redirigir los descriptores, cierra los descriptores originales, y llama a exec. Todo el mecanismo de redirección de la shell se basa en dup2.
6. Cerrar descriptores antes de exec
Despues de redirigir con dup2, los descriptores originales ya no se necesitan. Es importante cerrarlos en el hijo antes de exec, para evitar que el nuevo programa herede descriptores que no deberia tener.
Además, hay descriptores que el hijo hereda del padre y que no necesita. Por ejemplo, Sí el padre tiene un socket de red abierto, el hijo lo hereda. Si el hijo no lo cierra, el socket permanece abierto incluso después de que el padre lo cierre, lo que puede causar problemás.
La estrategia recomendada es: en el hijo, cerrar todos los descriptores que no se necesitan (excepto stdin, stdout, stderr, y los que se redirigieron). Esto se puede hacer recorriendo /proc/self/fd o usando el flag O_CLOEXEC.
El flag O_CLOEXEC (close-on-exec) es la forma moderna de evitar la herencia de descriptores. Cuando un descriptor se abre con O_CLOEXEC (o se marca con fcntl(fd, F_SETFD, FD_CLOEXEC)), se cierra automaticamente cuándo se ejecuta exec. Esto es más seguro y más simple que cerrar manualmente.
#define _POSIX_C_SOURCE 200809L
#include
/* Abrir con O_CLOEXEC: se cierra automaticamente con exec */
int fd = open("/tmp/datos.txt", O_RDONLY | O_CLOEXEC);
/* O marcar después de abrir */
int fd2 = open("/tmp/otro.txt", O_RDONLY);
fcntl(fd2, F_SETFD, FD_CLOEXEC);
En programás modernos, Siempre se debe usar O_CLOEXEC al abrir descriptores. Esto evita que los descriptores se hereden por accidente en fork + exec. La biblioteca de C (glibc) usa O_CLOEXEC internamente en muchas funciónes (fopen, socket, etc.).
7. Cambiar de directorio antes de exec
El hijo puede cambiar de directorio con chdir() antes de exec. El nuevo programa se ejecutara con ese directorio de trabajo. Esto es útil para ejecutar un programa en un directorio diferente al del padre.
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
int main(void)
{
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* Hijo: cambiar de directorio */
if (chdir("/tmp") != 0) {
perror("chdir");
_exit(1);
}
/* Ejecutar un comando que muestra el directorio */
execlp("pwd", "pwd", NULL);
perror("exec");
_exit(127);
}
wait(NULL);
printf("Padre: cwd sigue siendo el original\n");
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o cambiar_dir cambiar_dir.c
$ ./cambiar_dir
/tmp
Padre: cwd sigue siendo el original
El hijo cambio a /tmp y ejecuto pwd, Que mostro /tmp. El padre siguio en su directorio original. Esto demuestra que chdir en el hijo no afecta al padre.
8. Cambiar de usuario antes de exec
El hijo puede cambiar de usuario con setuid/setgid antes de exec. Esto es la base de los programás setuid: el programa se ejecuta con privilegios de root, pero antes de ejecutar el programa final, baja sus privilegios al usuario real.
El patron es:
- fork(): el padre (root) crea un hijo.
- El hijo baja privilegios: setuid(getuid()) cambia el UID efectivo al del usuario real.
- exec(): el hijo ejecuta el programa con los privilegios del usuario real.
Esto es importante para la seguridad: el programa final se ejecuta sin privilegios, aunque el padre los tenga. Si el programa tiene una vulnerabilidad, el atacante no obtiene privilegios de root.
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
int main(void)
{
printf("Padre: UID real=%d, efectivo=%d\n", (int) getuid(), (int) geteuid());
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* Hijo: bajar privilegios */
if (setuid(getuid()) != 0) {
perror("setuid");
_exit(1);
}
printf("Hijo: UID real=%d, efectivo=%d\n", (int) getuid(), (int) geteuid());
/* Ejecutar un comando */
execlp("id", "id", NULL);
perror("exec");
_exit(127);
}
wait(NULL);
return 0;
}
Si el programa se ejecuta como root, el hijo baja sus privilegios al usuario real y ejecuta id con esos privilegios. Si se ejecuta como usuario normal, no hay cambio (setuid(getuid()) no hace nada).
9. El patron en la shell
La shell es el ejemplo más complejo y más importante del patron fork + exec. Cada vez que ejecutas un comando, la shell hace:
- Leer la linea de comandos: la shell lee lo que el usuario escribio.
- Parsear: separa el comando, los argumentos, las redirecciónes (
>,<,2>), las tuberias (|), y los operadores (&&,||,;,&). - fork(): crea un proceso hijo.
- En el hijo: prepara el entorno (redirecciónes con dup2, cambio de directorio, etc.).
- En el hijo: llama a execvp para ejecutar el comando.
- En el padre: Sí el comando es en primer plano, espera con waitpid. Si es en segúndo plano (con
&), no espera. - Recoger el resultado: el padre obtiene el código de salida del hijo.
Para comandos con tuberias (cmd1 | cmd2), la shell crea dos hijos y una tuberia (pipe) entre ellos: el stdout del primero se conecta al stdin del segúndo. Esto requiere crear la tuberia antes de fork, y usar dup2 en ambos hijos.
Para comandos en segúndo plano (cmd &), la shell no espera al hijo. El hijo se ejecuta en paralelo, y cuándo termina, el shell recibe SIGCHLD y recoge el resultado (o lo deja como zombi sí no maneja la senal).
Todo lo que hace la shell (redirección, tuberias, Segúndo plano, variables de entorno, cambio de directorio) se implementa con fork + preparacion + exec + wait. Es el ejemplo más completo y más importante del patron de unix.
10. exec sin fork
A veces se llama a exec sin fork previo. En este caso, el programa actual se reemplaza por otro, y no hay vuelta atras. No se crea un proceso nuevo: el mismo proceso cambia de programa.
Esto se usa en varios casos:
- Un programa que se reemplaza a sí mismo: por ejemplo, un programa que detecta que necesita una version diferente de sí mismo y se reemplaza con exec.
- Un wrapper: un script o programa que prepara el entorno y luego exec al programa real.
- Un demonio que se reinicia: un demonio que se reemplaza a sí mismo con una nueva version (exec) Sin cambiar de PID.
El caso más comun es un wrapper: un script de shell que configura variables de entorno y luego exec al programa real:
#!/bin/bash
export LD_LIBRARY_PATH=/opt/mi_lib/lib
export MI_CONFIG=/etc/mi_app.conf
exec /opt/mi_app/bin/mi_app "$@"
El exec en el script hace que la shell se reemplace por mi_app, Sin crear un proceso intermedio. El PID de la shell se convierte en el PID de mi_app. Es más eficiente que llamar a mi_app sin exec (Que crearia un proceso hijo y esperaria).
Usar exec sin fork evita crear un proceso intermedio. El programa se reemplaza directamente, Sin el overhead de fork. Es la forma recomendada de escribir wrappers y scripts de arranque.
11. Un programa que demuestra fork + exec + redirección
El siguiente programa es una mini shell que ejecuta un comando con redirección de salida:
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
#include
#include
int main(void)
{
char linea[256];
printf("mini-shell> ");
fflush(stdout);
while (fgets(linea, Sizeof linea, stdin)) {
/* Eliminar newline */
linea[strcspn(linea, "\n")] = 0;
if (strlen(linea) == 0) { printf("mini-shell> "); fflush(stdout); continue; }
/* Parsear: comando y archivo de salida (Sí hay ">") */
char *cmd = strtok(linea, " ");
char *arg = strtok(NULL, " ");
char *archivo = NULL;
char *flecha = strtok(NULL, " ");
if (flecha && strcmp(flecha, ">") == 0)
archivo = strtok(NULL, " ");
pid_t pid = fork();
if (pid < 0) { perror("fork"); continue; }
if (pid == 0) {
/* Hijo: redirigir salida sí hay archivo */
if (archivo) {
int fd = open(archivo, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) { perror("open"); _exit(1); }
dup2(fd, STDOUT_FILENO);
close(fd);
}
/* Ejecutar el comando */
char *args[] = {cmd, arg, NULL};
execvp(cmd, args);
perror("execvp");
_exit(127);
}
/* Padre: esperar */
waitpid(pid, NULL, 0);
printf("mini-shell> ");
fflush(stdout);
}
printf("\n");
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o mini_shell mini_shell.c
$ ./mini_shell
mini-shell> echo hola > /tmp/salida.txt
mini-shell> cat /tmp/salida.txt
hola
mini-shell> ls /tmp
salida.txt
mini-shell> exit
La mini shell demuestra el patron completo: leer, parsear, fork, preparar (redirigir con dup2), exec, wait. Es una version simplificada de lo que hace bash.
12. Resumen
- fork y exec son dos llamadas separadas: fork crea el proceso, exec reemplaza el programa.
- La separación permite preparar al hijo entre fork y exec: redirigir, cerrar, chdir, setuid, etc.
- dup2 es la herramienta fundamental para redirigir stdin, stdout y stderr.
- O_CLOEXEC hace que los descriptores se cierren automaticamente con exec.
- El hijo puede cambiar de directorio o de usuario antes de exec.
- La shell implementa redirección, tuberias y segúndo plano con este patron.
- exec sin fork reemplaza el programa actual sin crear un proceso nuevo.
- Los wrappers usan exec para reemplazar la shell por el programa real.
13. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. Por que fork y exec son dos llamadas separadas?
Para permitir preparar al hijo entre fork y exec: redirigir entrada/salida, cerrar descriptores, cambiar de directorio, cambiar de usuario, etc. Si fuera una sola syscall, no habria oportunidad de preparar.
2. Qué hace dup2?
Duplica un descriptor de archivo: hace que newfd apunte a la misma estructura del núcleo que oldfd. Si newfd ya estaba abierto, se cierra primero. Es atominimo.
3. Cómo se redirige stdout a un archivo?
Abriendo el archivo (obteniendo fd), llamando a dup2(fd, STDOUT_FILENO), y cerrando fd. Luego exec carga el programa, Que hereda stdout redirigido.
4. Qué es O_CLOEXEC?
Un flag que hace que un descriptor se cierre automaticamente cuándo se ejecuta exec. Es la forma recomendada de evitar heredar descriptores.
5. Cómo redirige la shell la salida de un comando?
Abre el archivo, hace fork, en el hijo usa dup2 para redirigir stdout, cierra el descriptor original, y llama a exec. El padre espera al hijo.
6. Para que se usa exec sin fork?
Para reemplazar el programa actual sin crear un proceso nuevo: wrappers, scripts de arranque, programás que se reinician.
7. Por que usar exec en un wrapper?
Para evitar crear un proceso intermedio. La shell se reemplaza por el programa real, Sin overhead de fork.
8. Qué pasa sí no se cierran los descriptores antes de exec?
El nuevo programa hereda todos los descriptores del padre, incluyendo los que no necesita. Esto puede causar fugas de recursos y problemás de seguridad.
9. Cómo se redirige stderr a stdout?
Con dup2(STDOUT_FILENO, STDERR_FILENO). Esto hace que stderr apunte al mismo lugar que stdout.
10. Qué es una tuberia (pipe) en la shell?
Un mecanismo que conecta el stdout de un comando al stdin de otro. La shell crea una tuberia (pipe), hace fork para ambos comandos, y usa dup2 para conectar los descriptores.
Para prácticar
- Compila y ejecuta redirigir_stdout.c. Verifica que la salida va al archivo.
- Compila y ejecuta redirigir_completo.c. Verifica que stdin, stdout y stderr se redirigen correctamente.
- Compila y ejecuta cambiar_dir.c. Verifica que el hijo cambia de directorio sin afectar al padre.
- Compila y ejecuta mini_shell.c. Prueba redirecciónes y comandos.
- Escribe un programa que implemente una tuberia (cmd1 | cmd2) usando pipe() y dup2().
- Escribe un wrapper en C Que configure variables de entorno y luego exec al programa real.