1. Qué es un descriptor de archivo
Un programa necesita alguna manera de decir «lee del archivo que abrí antes» sin repetir la búsqueda por nombre en cada operación. La interfaz de UNIX utiliza para eso un descriptor de archivo, o file descriptor, abreviado fd. En C se representa mediante un int.
Un descriptor de archivo es un entero no negativo que identifica una entrada válida en la tabla de descriptores de un proceso. Esa entrada referencia un objeto de apertura mantenido por el núcleo. El número solo tiene sentido dentro del contexto de la tabla que lo interpreta.
El recorrido básico de un programa es abrir, operar y cerrar. Veremos todos los parámetros y errores de open y close en el tema 5. De momento basta con reconocer esta secuencia:
int fd = open("notas.txt", O_RDONLY);
if (fd == -1) {
/* La apertura fallo: no hay un descriptor nuevo valido. */
}
/* Si la apertura tuvo exito, usar fd con read, fstat, etc. */
/* Al terminar, cerrar esa referencia con close(fd). */
Una apertura correcta devuelve un valor como 3, 4 o 7. El error se señala con -1, acompañado de errno. No compruebes el éxito con fd > 0: 0 también puede ser un descriptor válido. El código completo de las secciones siguientes comprueba expresamente el valor -1.
El número no es una identidad global
El descriptor 3 de un editor puede representar un documento; el 3 de un reproductor, un socket; el 3 de otro proceso, un archivo de registro. No hay un «archivo número 3 del equipo». Incluso en un mismo proceso, cerrar el 3 y abrir otra cosa puede hacer que el nuevo objeto reciba de nuevo ese número.
Tampoco es un número de inodo, una dirección de memoria ni una posición dentro del contenido. Un inodo identifica un objeto dentro de un sistema de archivos; el descriptor identifica una referencia que un proceso puede usar para realizar operaciones. Si dos rutas son enlaces duros al mismo objeto, pueden abrirse mediante descriptores diferentes. Si un descriptor se duplica, dos enteros pueden llegar a compartir la misma apertura.
Por qué esta interfaz sirve para más que documentos
Los descriptores también se utilizan para tuberías, sockets, terminales, nodos de dispositivo y otros objetos. No todas las operaciones son válidas para todos: se puede leer de una tubería, pero no desplazar su posición mediante lseek como en un archivo regular. Un directorio puede estar abierto sin que eso lo convierta en texto para read.
Esta uniformidad permite conectar programas que no conocen de antemano el dispositivo final. Un filtro puede leer de su descriptor 0 y escribir en el 1 tanto si está conectado a una terminal como a otros procesos mediante tuberías. El shell prepara esas conexiones antes de ejecutar el programa.
2. Los descriptores 0, 1 y 2
Por convención, un programa iniciado desde un shell suele recibir tres descriptores ya preparados. La biblioteca y las herramientas utilizan sus números para la entrada, la salida y los mensajes de error:
| Número | Nombre | Constante en C | Uso convencional |
|---|---|---|---|
0 | Entrada estándar, stdin. | STDIN_FILENO | Leer datos de entrada. |
1 | Salida estándar, stdout. | STDOUT_FILENO | Escribir el resultado normal. |
2 | Error estándar, stderr. | STDERR_FILENO | Escribir diagnósticos. |
Las constantes se declaran en <unistd.h>. En una terminal interactiva los tres pueden llegar a la misma terminal, con modos apropiados. Pero el 0 no es el teclado por definición ni el 1 es la pantalla por definición: el padre puede conectarlos a archivos, tuberías, dispositivos o sockets.
$ cat
Lee de la entrada estandar y escribe en la salida estandar.
$ cat < entrada.txt > salida.txt
El shell conecta 0 a entrada.txt y 1 a salida.txt.
$ printf 'hola\n' | cat
hola
Las dos primeras notas describen el comportamiento y no son salidas literales de cat. Si ejecutas cat sin argumentos en la terminal, esperará entrada; puedes finalizarla con Ctrl+D cuando no haya una línea pendiente. Usaremos órdenes que terminan por sí solas en el laboratorio reproducible.
Separar resultado y diagnóstico
Una herramienta que produce un listado o un documento debería enviar esos datos por stdout y reservar stderr para advertencias. Así se puede guardar el resultado sin mezclarlo con una frase como «no se pudo abrir el archivo». Que los dos canales se vean juntos en la terminal no significa que sean una sola referencia.
La salida de error no es un canal que el núcleo detecte como «texto malo». Es un descriptor que el programa elige para sus mensajes. Si escribe un resultado en el 2, irá por stderr; si escribe un error en el 1, se mezclará con stdout. La separación nace de la convención y del comportamiento del programa.
Descriptor y FILE* son niveles diferentes
stdin, stdout y stderr en la biblioteca estándar de C son objetos de tipo FILE *. Las funciones printf y fgets trabajan con ese nivel de biblioteca, que puede incluir búferes en memoria del proceso. Las llamadas read y write usan enteros fd. No pases un FILE * a una función que espera un descriptor.
En el tema 10 estudiaremos la biblioteca stdio y sus búferes. Aquí mantendremos claro que un búfer de usuario, una entrada de descriptor y una apertura del núcleo son cosas distintas.
3. Laboratorio: ver y redirigir descriptores
Los ejemplos usan Bash en Linux o WSL y no requieren permisos de administrador. Crea una carpeta nueva y un archivo de diez bytes, sin salto de línea, para que las posiciones posteriores sean fáciles de calcular:
$ LAB4=$(mktemp -d "$HOME/tema4.XXXXXX")
$ cd "$LAB4"
$ printf 'ABCDEFGHIJ' > letras.txt
$ wc -c < letras.txt
10
$ ls -l /proc/$$/fd/0 /proc/$$/fd/1 /proc/$$/fd/2
lrwx------ 1 ana ana 64 Oct 29 10:00 /proc/1234/fd/0 -> /dev/pts/0
lrwx------ 1 ana ana 64 Oct 29 10:00 /proc/1234/fd/1 -> /dev/pts/0
lrwx------ 1 ana ana 64 Oct 29 10:00 /proc/1234/fd/2 -> /dev/pts/0
$$ es el PID del shell; 1234 y los destinos son ilustrativos. Si la terminal está conectada a una aplicación, un contenedor o una automatización, puedes ver pipe:[...], otros dispositivos o archivos. No necesitas obtener /dev/pts/0 para que el laboratorio sea correcto.
Un descriptor adicional en el propio shell
$ exec 3<letras.txt
$ readlink /proc/$$/fd/3
/home/ana/tema4.A1b2C3/letras.txt
$ IFS= read -r -N 3 -u 3 parte
$ printf '%s\n' "$parte"
ABC
$ cat /proc/$$/fdinfo/3
pos: 3
flags: 0100000
mnt_id: 29
ino: 80001
$ exec 3<&-
exec sin un programa, acompañado de redirecciones, cambia los descriptores del shell actual. 3<letras.txt abre para lectura y conecta esa apertura al descriptor 3. El builtin read obtiene tres caracteres ASCII desde él. La posición observada queda en 3. Las banderas, el montaje y el inodo concretos pueden variar; no copies esos valores como identificadores universales.
exec 3<&- cierra la referencia. El descriptor 3 se usa aquí expresamente para la práctica; Bash puede utilizar otros números para su funcionamiento interno. En scripts generales también existe la asignación dinámica de descriptores con sintaxis como exec {fd}<archivo.
Ver dos canales por separado
$ emitir() { printf 'resultado\n'; printf 'diagnostico\n' >&2; }
$ emitir >salida.txt 2>errores.txt
$ cat salida.txt
resultado
$ cat errores.txt
diagnostico
El shell no le pasó a la función el nombre del archivo de salida como argumento. Preparó sus descriptores. La función sigue usando su salida normal y su error estándar, y esas referencias ya conducen a destinos distintos.
El orden de las redirecciones importa
$ emitir >juntos.txt 2>&1
$ cat juntos.txt
resultado
diagnostico
$ emitir 2>&1 >solo-salida.txt
diagnostico
$ cat solo-salida.txt
resultado
En la primera orden se conecta 1 al archivo y después se hace que 2 llegue a la misma apertura que 1 tiene en ese momento. En la segunda se hace primero que 2 comparta el destino original de 1; luego se cambia 1 al archivo. El descriptor 2 no se actualiza por ese cambio posterior.
Las redirecciones se procesan de izquierda a derecha, como explica el manual de redirecciones de Bash. En el tema 9 las reproduciremos con dup2; ahora nos interesa entender las referencias que quedan al terminar cada paso.
4. Los tres niveles del modelo
Para explicar una apertura usaremos tres niveles. Es un modelo conceptual útil; no implica que Linux tenga exactamente tres arreglos globales sencillos con todos los objetos del equipo. El núcleo utiliza estructuras, referencias y cachés más ricas.
- Tabla de descriptores. Relaciona números como 3 o 4 con objetos de apertura. Habitualmente hablamos de una tabla por proceso; los hilos de un mismo proceso suelen compartirla.
- Descripción de archivo abierta. Representa una apertura concreta: guarda, entre otros datos, la posición actual y las banderas de estado. En Linux corresponde al nivel de
struct file. - Objeto del sistema de archivos. Representa el archivo y sus metadatos, asociado al inodo en un sistema de archivos de tipo UNIX. Distintas aperturas pueden llegar al mismo objeto.
Primera consecuencia: abrir no copia el documento
Dos procesos pueden abrir el mismo video sin que el núcleo cree dos copias persistentes del video. Cada apertura mantiene el estado que necesita, pero ambas llegan al mismo objeto. Las lecturas pueden beneficiarse de cachés comunes; las posiciones actuales siguen siendo independientes si proceden de aperturas independientes.
Segunda consecuencia: un nombre deja de ser necesario para cada lectura
open resuelve una ruta y obtiene una referencia al objeto. Después read(fd, ...) utiliza el descriptor; no vuelve a buscar esa ruta desde la raíz en cada llamada. Renombrar una entrada no cambia por sí solo el objeto ya abierto ni su posición actual.
Esto conecta con el tema 3: si se elimina el último nombre de un archivo que sigue abierto, sus datos pueden seguir accesibles mediante la referencia existente. El árbol de nombres y las referencias de apertura son dos estructuras con vidas relacionadas, pero distintas.
El nivel del inodo también es una simplificación
En Linux una apertura mantiene referencias a otros objetos, como el montaje y la entrada de directorio utilizada, y dispone de operaciones adecuadas al tipo de archivo. Un socket o una tubería no es simplemente un documento persistente con una lista de bloques. El modelo de tres niveles sigue siendo útil para distinguir el número local, el estado de apertura y el objeto al que llega.
5. Dónde vive cada dato
La pregunta decisiva es «¿qué referencias comparten este dato?». Si la posición perteneciera al inodo, dos aperturas independientes se estorbarían siempre al leer. Si perteneciera solo al número fd, una duplicación no podría compartirla. Está en el nivel intermedio.
| Dato | Nivel | Consecuencia |
|---|---|---|
| Número fd. | Tabla de descriptores. | El 3 puede tener otro significado en otro proceso. |
FD_CLOEXEC. | Entrada de descriptor. | Puede diferir entre dos descriptores que comparten una apertura. |
| Posición actual u offset. | Descripción de archivo abierta. | Una lectura avanza la posición que observan sus duplicados. |
| Modo de acceso de la apertura. | Descripción de archivo abierta. | Una apertura de solo lectura no permite escribir por duplicar su descriptor. |
Banderas de estado como O_APPEND o O_NONBLOCK. | Descripción de archivo abierta. | Los descriptores que llegan a ella comparten ese estado, cuando es aplicable al objeto. |
| Tamaño, propietario, permisos y contador de enlaces duros. | Objeto del sistema de archivos. | Se refieren al archivo, no a una apertura concreta. |
| Nombre en un directorio. | Entrada de directorio. | No es un campo del inodo ni el entero devuelto por open. |
Búfer de FILE *. | Biblioteca en memoria del proceso. | No es el offset del núcleo ni se gestiona con close como si fuera un fd. |
Modo de acceso y permisos no son lo mismo
Un archivo puede tener permisos que permitan escribir y, aun así, estar abierto con O_RDONLY. Esa apertura concreta es de solo lectura. Duplicar su descriptor no la convierte en una apertura de escritura. A la inversa, que exista una referencia abierta con cierta capacidad no significa que cualquier otro proceso pueda obtener una igual mediante la ruta.
No conviertas esta distinción en una regla universal de «los permisos se comprueban una sola vez»: otras operaciones y mecanismos de seguridad pueden realizar comprobaciones adicionales. La idea que necesitamos aquí es que los atributos del archivo y el modo de una apertura pertenecen a niveles distintos.
Banderas del descriptor y de la apertura
FD_CLOEXEC indica que ese descriptor debe cerrarse cuando una operación exec sustituye correctamente el programa. No cambia las lecturas ni la posición de sus duplicados. O_APPEND, en cambio, pertenece al estado de apertura y afecta a las escrituras que usan cualquiera de sus referencias.
Las interfaces fcntl(fd, F_GETFD) y fcntl(fd, F_GETFL) permiten consultar respectivamente banderas del descriptor y estado de apertura. No son dos nombres para la misma consulta. Para solicitar el cierre al ejecutar otro programa en el momento de abrir, se usa habitualmente O_CLOEXEC; el detalle de su utilidad con concurrencia lo retomaremos en el tema 5.
6. Dos aperturas y una duplicación
Supongamos que letras.txt contiene ABCDEFGHIJ. El proceso ya tiene sus descriptores estándar y ejecuta dos aperturas independientes:
int a = open("letras.txt", O_RDONLY); /* por ejemplo, fd 3 → A */
int b = open("letras.txt", O_RDONLY); /* por ejemplo, fd 4 → B */
Ambas aperturas empiezan con posición 0. Si se leen tres bytes con a, se obtiene ABC y A queda en 3. B continúa en 0; leer dos bytes con b obtiene AB. No hay contradicción en que el mismo archivo tenga «dos posiciones»: hay dos estados de apertura.
Ahora se duplica a con dup:
int c = dup(a); /* por ejemplo, fd 5 → A, no crea otra A */
Después de la lectura anterior, A sigue en 3. Leer dos bytes con c obtiene DE y mueve A a 5. Una consulta de posición realizada con a también observa 5. La duplicación creó otro descriptor, pero no otra posición independiente.
| Operación | Resultado | Posición A | Posición B |
|---|---|---|---|
| open dos veces, después dup(a). | a y c → A; b → B. | 0 | 0 |
| Leer 3 con a. | ABC. | 3 | 0 |
| Leer 2 con c. | DE. | 5 | 0 |
| Leer 2 con b. | AB. | 5 | 2 |
| Cerrar a. | c conserva A. | 5 | 2 |
| Leer 2 con c. | FG. | 7 | 2 |
La descripción de open(2) precisa la relación entre descriptor y descripción de archivo abierta. Es la distinción que explica tanto las duplicaciones como la herencia de aperturas tras fork.
Identidad compartida no demuestra apertura compartida
Si consultas fstat mediante a, b y c, los tres pueden mostrar el mismo dispositivo e inodo. Eso demuestra que llegan al mismo objeto del sistema de archivos. No demuestra que a y b compartan la descripción de apertura: acabamos de ver que sus posiciones pueden avanzar por separado.
Por el contrario, dos descriptores duplicados llegan a la misma apertura aunque sus números sean distintos. Concluir «son aperturas distintas porque el fd es distinto» confunde el primer nivel con el segundo.
7. Experimento en C: posiciones compartidas
El programa siguiente convierte la tabla anterior en observaciones. Usa dos open, un dup, lecturas cortas de un archivo regular y consultas con lseek(fd, 0, SEEK_CUR). Esta última forma permite conocer la posición actual sin moverla.
Guárdalo como mapa-fd.c en $LAB4. Se comprueban errores y se acumulan las lecturas hasta reunir los bytes pedidos o llegar a EOF. Las operaciones de E/S se estudiarán en los temas 6 y 7; aquí sirven para hacer visible la diferencia entre las aperturas.
#define _POSIX_C_SOURCE 200809L
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <inttypes.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
static void fallo(const char *mensaje)
{
perror(mensaje);
exit(EXIT_FAILURE);
}
static void posiciones(int a, int b, int c)
{
off_t pa = lseek(a, 0, SEEK_CUR);
off_t pb = lseek(b, 0, SEEK_CUR);
off_t pc = lseek(c, 0, SEEK_CUR);
if (pa == (off_t)-1 || pb == (off_t)-1 || pc == (off_t)-1)
fallo("lseek");
printf("posiciones: a=%jd b=%jd c=%jd\n",
(intmax_t)pa, (intmax_t)pb, (intmax_t)pc);
}
static void leer(const char *nombre, int fd, size_t pedido)
{
char texto[16];
size_t total = 0;
if (pedido >= sizeof texto) {
fprintf(stderr, "pedido demasiado grande\n");
exit(EXIT_FAILURE);
}
while (total < pedido) {
ssize_t n = read(fd, texto + total, pedido - total);
if (n == -1) {
if (errno == EINTR)
continue;
fallo("read");
}
if (n == 0)
break;
total += (size_t)n;
}
texto[total] = '\0';
printf("leer %zu con %s (fd=%d): [%s]\n",
pedido, nombre, fd, texto);
}
static void identidad(const char *nombre, int fd)
{
struct stat s;
if (fstat(fd, &s) == -1)
fallo("fstat");
printf("%s: fd=%d dev=%ju inodo=%ju\n", nombre, fd,
(uintmax_t)s.st_dev, (uintmax_t)s.st_ino);
}
int main(int argc, char *argv[])
{
if (argc != 2) {
fprintf(stderr, "uso: %s ARCHIVO\n", argv[0]);
return EXIT_FAILURE;
}
int a = open(argv[1], O_RDONLY);
if (a == -1)
fallo("open a");
int b = open(argv[1], O_RDONLY);
if (b == -1)
fallo("open b");
int c = dup(a);
if (c == -1)
fallo("dup");
identidad("a", a);
identidad("b", b);
identidad("c", c);
posiciones(a, b, c);
leer("a", a, 3);
posiciones(a, b, c);
leer("c", c, 2);
posiciones(a, b, c);
leer("b", b, 2);
posiciones(a, b, c);
int cerrado = a;
if (close(a) == -1)
fallo("close a");
a = -1;
printf("cerrado fd=%d; c conserva la apertura\n", cerrado);
leer("c", c, 2);
int nuevo = open(argv[1], O_RDONLY);
if (nuevo == -1)
fallo("open nuevo");
printf("nuevo open: fd=%d\n", nuevo);
leer("nuevo", nuevo, 2);
if (close(b) == -1)
fallo("close b");
if (close(c) == -1)
fallo("close c");
if (close(nuevo) == -1)
fallo("close nuevo");
return EXIT_SUCCESS;
}
$ cd "$LAB4"
$ gcc -std=c11 -Wall -Wextra -Wpedantic -O2 mapa-fd.c -o mapa-fd
$ ./mapa-fd letras.txt
a: fd=3 dev=2049 inodo=80001
b: fd=4 dev=2049 inodo=80001
c: fd=5 dev=2049 inodo=80001
posiciones: a=0 b=0 c=0
leer 3 con a (fd=3): [ABC]
posiciones: a=3 b=0 c=3
leer 2 con c (fd=5): [DE]
posiciones: a=5 b=0 c=5
leer 2 con b (fd=4): [AB]
posiciones: a=5 b=2 c=5
cerrado fd=3; c conserva la apertura
leer 2 con c (fd=5): [FG]
nuevo open: fd=3
leer 2 con nuevo (fd=3): [AB]
Los números de descriptor de la salida suponen que 0, 1 y 2 estaban abiertos y que no había otras aperturas ocupando esos lugares. Los de dispositivo e inodo variarán. El resultado importante es el patrón: a y c avanzan juntos; b mantiene su posición; después de cerrar a, c sigue pudiendo leer. La apertura nueva puede reutilizar 3 y comienza con su propia posición 0.
Qué demuestra y qué no demuestra el experimento
- Los tres descriptores llegan al mismo archivo. Las fichas de fstat comparten dispositivo e inodo.
- Hay dos aperturas iniciales. La posición de b no cambia al leer con a o c.
- Duplicar no copia el estado a una apertura independiente. El cambio de posición se observa mediante cualquiera de los duplicados.
- Cerrar elimina una referencia. La otra referencia a A continúa válida.
- El entero puede reutilizarse. Un 3 posterior puede corresponder a una apertura nueva del mismo archivo o de otro.
El programa espera un archivo regular adecuado para consultar posiciones. Si le pasas una tubería o un objeto no posicionable, lseek puede fallar con ESPIPE. Si el archivo contiene menos de diez bytes, las lecturas mostrarán lo que realmente se haya recibido hasta EOF; no se inventan letras para completar el ejemplo.
Para simplificar los errores fatales se termina el proceso; el núcleo recupera sus referencias de descriptor al finalizar. En la ruta normal se comprueban los cierres. No se reintenta automáticamente close: las sutilezas del cierre, especialmente con concurrencia y errores, merecen su propio tratamiento en el tema 5.
8. Observar las referencias con proc
Linux expone información de cada proceso bajo /proc/PID. El directorio fd contiene nombres numerados que permiten observar sus descriptores; fdinfo ofrece información adicional, como posición y banderas. Son vistas de estado, no un directorio de copias de los documentos abiertos.
$ cd "$LAB4"
$ exec 3<letras.txt
$ exec 4<letras.txt
$ exec 5<&3
$ IFS= read -r -N 3 -u 3 parte
$ printf '%s\n' "$parte"
ABC
$ awk '/^pos:/{print FILENAME, $2}' /proc/$$/fdinfo/3 /proc/$$/fdinfo/4 /proc/$$/fdinfo/5
/proc/1234/fdinfo/3 3
/proc/1234/fdinfo/4 0
/proc/1234/fdinfo/5 3
$ IFS= read -r -N 2 -u 5 parte
$ printf '%s\n' "$parte"
DE
$ exec 3<&- 4<&- 5<&-
El shell mantiene dos aperturas independientes para 3 y 4. La redirección 5<&3 duplica la referencia de 3 en 5. Esta vez los dos duplicados dejan visible la posición compartida a través de fdinfo, sin cambiar el programa C.
Leer los campos sin confundir sus unidades
posmuestra la posición como número decimal. En un archivo regular es la posición actual en bytes.flagspresenta banderas en octal. No lo interpretes como un entero decimal de posición; puede incluir información del modo de acceso, estado y cierre al ejecutar.mnt_ididentifica el montaje correspondiente dentro de la vista del proceso.- Otros campos dependen de la versión del núcleo y del tipo de objeto; no todos los descriptores tienen la misma ficha.
El manual de proc_pid_fdinfo(5) describe los campos comunes y los específicos de distintos objetos. Una coincidencia de posición y banderas entre dos descriptores es compatible con compartir apertura, pero no constituye por sí sola una prueba: dos aperturas independientes también pueden estar casualmente en la misma posición.
Por qué usamos el PID del shell
En una orden como ls -l /proc/self/fd, self se refiere al proceso que accede a proc; normalmente será el propio ls. Además, listar ese directorio puede abrir un descriptor adicional durante la observación. Para estudiar las referencias persistentes de la terminal usamos /proc/$$/fd, donde Bash expande su PID antes de ejecutar la herramienta.
Los permisos y las reglas de proc pueden restringir la observación de otros procesos. No disponer de una ficha no significa que ese proceso no tenga archivos abiertos. Tampoco hay que usar la apertura de /proc/PID/fd/N como si fuera una sustitución universal de dup: acceder a una ruta de proc y duplicar una referencia son operaciones distintas.
9. Qué cambia con fork y exec
La vida de los descriptores no termina en un único proceso. Para ejecutar herramientas, el shell crea procesos y prepara sus conexiones. Dos operaciones del mundo UNIX resultan especialmente importantes:
fork: otra tabla que llega a las mismas aperturas
fork crea un proceso hijo. En el modelo ordinario de fork, el hijo recibe una tabla con los mismos números abiertos que el padre, y esas entradas referencian las mismas descripciones de archivo abiertas. No equivale a ejecutar de nuevo un open independiente por cada archivo heredado.
Si padre e hijo conservan el descriptor 3 de una apertura de lectura, una lectura del hijo avanza la posición que usará después el padre. Sus tablas son independientes: cerrar 3 en el hijo no cierra la entrada 3 del padre. La apertura continúa mientras queden las referencias que la mantienen.
Los hilos añaden otra forma de compartir: en Linux, los hilos habituales de un proceso comparten la tabla misma. Si uno cierra un descriptor, ese cierre afecta al resto de hilos que usen esa tabla. No confundas compartir una tabla con tener dos tablas cuyas entradas apuntan a la misma apertura.
exec: cambia el programa del proceso
Una operación de la familia exec sustituye la imagen del programa que se ejecuta en el proceso. No crea por sí misma otro proceso ni vuelve a abrir automáticamente las rutas. Los descriptores ordinarios permanecen abiertos si la sustitución tiene éxito, salvo los marcados con FD_CLOEXEC.
Así puede el shell conectar el descriptor 1 a un archivo y ejecutar después un programa que solo sabe escribir en su salida estándar. El programa nuevo recibe una tabla ya preparada. Tampoco es necesario que 0, 1 y 2 estén abiertos en todo proceso imaginable; su disponibilidad es una expectativa de muchos programas y una responsabilidad de quien los inicia.
| Operación | Tabla de descriptores | Estado de apertura |
|---|---|---|
| open. | Añade una entrada. | Crea una descripción de archivo abierta nueva. |
| dup. | Añade otra entrada. | Comparte la apertura existente y su posición. |
| fork ordinario. | El hijo recibe una tabla con referencias heredadas. | Las aperturas heredadas se comparten. |
| exec correcto. | Se conservan las entradas salvo las marcadas para cierre. | Las referencias que sobreviven mantienen su apertura. |
| close. | Elimina la entrada indicada de esa tabla. | Puede liberar la apertura si era su última referencia. |
La marca de cierre pertenece a una entrada
Un dup ordinario no copia activada la marca de cierre al ejecutar: el nuevo descriptor queda con FD_CLOEXEC desactivado, aunque el original la tenga. Los dos siguen compartiendo posición y estado de apertura. Interfaces como dup3 o ciertas operaciones de fcntl permiten solicitar duplicados con la marca activada.
Estas diferencias son precisamente la razón para separar los dos primeros niveles del modelo. Cambiar la marca de una entrada no debe afectar a la otra como si fuera una propiedad del objeto A compartido.
10. Experimento en C: leer después de fork
El siguiente programa abre letras.txt una vez y después crea un hijo. Para que el orden sea reproducible, el padre espera a que el hijo termine antes de leer. El hijo consume ABC; el padre debe continuar con DE. No usamos retardos para adivinar quién lee primero.
Guárdalo como herencia-fd.c. Los mensajes se escriben con dprintf sobre el descriptor de salida, y el hijo termina mediante _exit, evitando que una salida normal de la biblioteca vuelva a vaciar búferes heredados.
#define _POSIX_C_SOURCE 200809L
#include <sys/wait.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
static int leer_texto(int fd, char *texto, size_t pedido)
{
size_t total = 0;
while (total < pedido) {
ssize_t n = read(fd, texto + total, pedido - total);
if (n == -1) {
if (errno == EINTR)
continue;
return -1;
}
if (n == 0)
break;
total += (size_t)n;
}
texto[total] = '\0';
return 0;
}
int main(int argc, char *argv[])
{
if (argc != 2) {
fprintf(stderr, "uso: %s ARCHIVO\n", argv[0]);
return EXIT_FAILURE;
}
int fd = open(argv[1], O_RDONLY);
if (fd == -1) {
perror("open");
return EXIT_FAILURE;
}
pid_t hijo = fork();
if (hijo == -1) {
perror("fork");
close(fd);
return EXIT_FAILURE;
}
if (hijo == 0) {
char texto[4];
if (leer_texto(fd, texto, 3) == -1) {
perror("read hijo");
_exit(1);
}
if (dprintf(STDOUT_FILENO, "hijo lee: [%s]\n", texto) < 0)
_exit(1);
if (close(fd) == -1)
_exit(1);
_exit(0);
}
int estado;
pid_t resultado;
do {
resultado = waitpid(hijo, &estado, 0);
} while (resultado == -1 && errno == EINTR);
if (resultado == -1) {
perror("waitpid");
close(fd);
return EXIT_FAILURE;
}
if (!WIFEXITED(estado) || WEXITSTATUS(estado) != 0) {
fprintf(stderr, "el hijo no termino correctamente\n");
close(fd);
return EXIT_FAILURE;
}
char texto[3];
if (leer_texto(fd, texto, 2) == -1) {
perror("read padre");
close(fd);
return EXIT_FAILURE;
}
if (dprintf(STDOUT_FILENO, "padre lee: [%s]\n", texto) < 0) {
close(fd);
return EXIT_FAILURE;
}
if (close(fd) == -1) {
perror("close padre");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
$ cd "$LAB4"
$ gcc -std=c11 -Wall -Wextra -Wpedantic -O2 herencia-fd.c -o herencia-fd
$ ./herencia-fd letras.txt
hijo lee: [ABC]
padre lee: [DE]
$ ./herencia-fd letras.txt >lecturas.txt
$ cat lecturas.txt
hijo lee: [ABC]
padre lee: [DE]
El hijo ya cerró su entrada y terminó cuando el padre lee. La apertura sigue existiendo gracias a la referencia del padre, y su posición permanece en 3. El padre no obtiene AB porque no se creó otra apertura independiente al hacer fork.
Por qué esperar es parte del experimento
Sin coordinación, el planificador podría dar paso primero al padre o al hijo. Compartir posición no impone que el hijo lea antes. waitpid crea aquí una secuencia clara y además permite comprobar si el hijo falló. Un sleep en el padre no sería una demostración robusta de ese orden.
Este ejemplo es de un proceso con un único hilo antes de fork. En programas con varios hilos hay restricciones importantes sobre lo que debe hacer el hijo antes de exec; no uses este pequeño laboratorio como patrón general para lanzar hijos desde cualquier programa concurrente.
Qué ocurriría al abrir después de fork
Si cada proceso ejecutara su propio open después de separarse, tendría una apertura independiente con posición 0. El hijo podría leer ABC y el padre AB. Mantener el mismo nombre o incluso recibir el mismo entero fd no los obligaría a compartir estado. El momento en que se crea y se transmite la apertura importa más que el nombre de la variable en C.
11. Cierre, reutilización y límites
Un descriptor consume una entrada; una apertura consume otros recursos; el archivo puede consumir inodo y bloques. No todos esos recursos nacen y desaparecen a la vez. Cuando se cierra una entrada, el núcleo retira esa referencia y actualiza los objetos afectados. Una apertura puede seguir viva gracias a un duplicado o a una referencia heredada.
open devuelve el menor número libre
En la interfaz habitual, open devuelve el menor descriptor libre de la tabla. Si 0, 1 y 2 están ocupados, el primero nuevo suele ser 3. Pero si el proceso cerró 0, una apertura posterior puede devolver 0. No debes tratar «menor que 3» como sinónimo de error o de recurso que no se obtuvo mediante open.
dup también elige el menor número libre. dup2, que estudiaremos en el tema 9, permite indicar el número de destino. Con ello un shell puede instalar una referencia precisamente en 0, 1 o 2 sin que el programa ejecutado deba adivinar dónde quedó.
Una variable con un número viejo no conserva una referencia
int guardado = fd;
close(fd);
fd = -1;
/* guardado aun contiene el entero, pero no mantiene una apertura. */
/* Si otro open reutiliza ese numero, guardado designaria otra cosa. */
Copiar un entero a otra variable no equivale a dup. El núcleo no recibe una operación nueva por esa asignación de C. Si luego se hace close(guardado), puede aparecer EBADF si no hay entrada válida, o se puede cerrar por equivocación una referencia nueva que reutilizó el número. Este último caso resulta especialmente delicado con varios hilos.
Asignar -1 a la variable después de un cierre correcto ayuda a expresar que ya no contiene un descriptor válido, pero no arregla las otras copias existentes de ese entero. La propiedad y la vida útil del recurso deben quedar claras en el diseño del programa.
Cerrar no es borrar ni sincronizar
close no elimina el nombre del archivo; unlink hace esa clase de cambio en el directorio. Tampoco es una promesa general de que todos los datos estén ya en almacenamiento estable: los búferes de la biblioteca y las cachés del núcleo son otros niveles. Trataremos fsync, cachés y persistencia en los temas correspondientes.
Si el proceso termina, el núcleo cierra sus referencias abiertas. Eso evita que sigan ocupadas por un proceso inexistente, pero no sustituye un cierre normal bien organizado, que permite detectar errores y liberar recursos antes de agotar límites. En procesos de larga vida, una pequeña fuga repetida puede terminar bloqueando nuevas operaciones.
Límites por proceso y límites del sistema
$ ulimit -Sn
$ ulimit -Hn
$ grep 'Max open files' /proc/$$/limits
$ cat /proc/sys/fs/file-max
ulimit -Sn consulta el límite blando de descriptores y -Hn, el duro, para el shell. En Linux, RLIMIT_NOFILE limita los números utilizables: un valor de 1024 permite números menores que 1024, no significa que la próxima apertura vaya a devolver 1024. Los descriptores ya abiertos, incluidos los estándar, forman parte de la situación de la tabla.
Una apertura puede fallar con EMFILE al alcanzar el límite de descriptores del proceso y con ENFILE por agotamiento de recursos de archivos abiertos a nivel del sistema. No son lo mismo que ENOSPC, relacionado con espacio u otros recursos del sistema de archivos al crear o escribir. Duplicar consume descriptores sin crear otra apertura independiente; abrir de nuevo consume ambos niveles.
No necesitamos agotar los límites de tu equipo para observar la diferencia. El simulador usará una tabla deliberadamente pequeña. El resto del curso profundizará en el manejo de errores sin confundir un límite de referencias con la capacidad de los bloques del disco.
12. Simulador de descriptores y aperturas
El modelo tiene un único archivo regular de diez bytes: ABCDEFGHIJ. El proceso padre empieza con el descriptor 3 asociado a la apertura A1 en posición 0. Cada nuevo open crea otra apertura del mismo archivo; dup crea otra referencia a una existente. Puedes crear un hijo una vez, cambiar de proceso y observar los efectos de leer, cerrar o marcar un descriptor para exec.
Tres niveles, referencias visibles
| Tabla de descriptores | fd | Apertura | FD_CLOEXEC |
|---|
| Apertura | Posición | Referencias del modelo | Objeto |
|---|
Objeto único: inodo ilustrativo 80001 · tamaño 10 · contenido ABCDEFGHIJ · todas las aperturas son de solo lectura.
Historial de operaciones
Para mantener pequeño el modelo, 0, 1 y 2 se reservan y no se pueden modificar. Se permiten solo los descriptores 3 a 8 por proceso; llenarlos produce un EMFILE simulado. Las tablas muestran únicamente esos descriptores de práctica. El hijo recibe una copia de la tabla y puede crearse una sola vez desde el padre. Cada proceso puede hacer exec varias veces sin cambiar de identidad.
Las referencias mostradas cuentan solo entradas de las dos tablas del modelo. El simulador elimina una apertura al quedarse sin ellas. Un núcleo real tiene más clases de referencias y reglas de vida útil. Tampoco se modelan hilos, errores de disco, permisos, señales o escritura; se trata de visualizar el estado compartido de lectura.
Secuencias para comprobar
- Desde el estado inicial ejecuta dup de 3. Se crea 4 → A1. Lee dos bytes con 4: obtienes AB y la única posición de A1 queda en 2.
- Haz un open nuevo. Se crea otro descriptor hacia A2, en 0. Leer allí vuelve a obtener AB sin retroceder A1.
- Reinicia, crea el hijo y lee con su descriptor 3. Al volver al padre, su 3 observa A1 en 2. Cierra el 3 del hijo: el del padre continúa abierto.
- Reinicia, duplica 3 y marca solo 3 con FD_CLOEXEC. Ejecuta exec: desaparece 3 y continúa 4, con la misma A1. La marca era de una entrada, no de toda la apertura.
- Llena la tabla mediante dup y observa el límite. Cierra una entrada y haz open: se reutiliza el menor número disponible y aparece una apertura nueva con posición 0.
- Lee hasta EOF y repite una lectura. Obtener cero bytes no cierra el descriptor; puedes volver a posición 0 con lseek y leer otra vez.
13. Resumen
- Un descriptor es un entero no negativo interpretado mediante la tabla del proceso; no es el inodo ni una identidad global.
- 0, 1 y 2 representan entrada, salida y error estándar por convención. Pueden llegar a terminales, archivos o tuberías.
- Hay que separar tabla de descriptores, descripción de archivo abierta y objeto del sistema de archivos.
- La posición y las banderas de estado pertenecen a la apertura. FD_CLOEXEC pertenece a la entrada de descriptor.
- Dos open del mismo archivo crean estados de apertura independientes; dup crea otra referencia a una apertura existente.
- fork transmite referencias a las mismas aperturas; el cierre de una entrada del hijo no elimina la del padre.
- exec sustituye el programa y conserva normalmente descriptores, salvo los marcados para cierre al ejecutar.
- proc permite observar descriptores y posiciones, teniendo en cuenta el PID y las limitaciones de acceso.
- close retira una referencia y permite reutilizar el número. No equivale a borrar el archivo ni a garantizar persistencia.
- Copiar el entero en C no duplica la referencia del núcleo. Los límites de descriptores, aperturas y espacio son recursos distintos.
14. Preguntas de repaso y práctica
Responde pensando en los tres niveles. Después despliega la solución y, cuando sea posible, comprueba la predicción en el simulador.
1. ¿El descriptor 3 de dos procesos identifica necesariamente el mismo archivo?
No. Cada número se interpreta en su tabla. Pueden llegar a objetos distintos o, si hay referencias heredadas, a una misma apertura. El número por sí solo no permite decidirlo.
2. ¿Por qué comprobar fd > 0 es incorrecto para saber si open tuvo éxito?
Porque 0 es válido y puede ser el menor descriptor disponible. La comprobación habitual es fd == -1 para detectar el error.
3. ¿Dónde está la posición de lectura de un archivo regular abierto?
En la descripción de archivo abierta, el nivel intermedio. Dos aperturas independientes tienen posiciones propias; los descriptores duplicados de una apertura comparten la suya.
4. Dos open del mismo nombre muestran el mismo inodo. ¿Comparten posición?
No por ese motivo. Llegan al mismo objeto, pero cada open crea su propio estado de apertura. El inodo coincidente no demuestra que las descripciones abiertas sean la misma.
5. Después de dup(a), leer dos bytes mediante el duplicado cambia lo que leerá a. ¿Por qué?
Porque ambos descriptores referencian la misma apertura y su única posición. La duplicación no crea una copia independiente de ese estado.
6. ¿int b = a equivale a dup(a)?
No. La asignación copia un entero en memoria del programa. dup solicita al núcleo otra entrada de descriptor. Cerrar a después de una simple asignación deja b con un número que ya no garantiza una referencia válida.
7. Si cierro un descriptor duplicado, ¿se invalida el original?
No. Se retira la entrada que se cerró. La referencia original sigue manteniendo la apertura mientras continúe válida. El número liberado puede reutilizarse.
8. ¿Por qué 2>&1 no significa que 2 seguirá cualquier cambio futuro de 1?
Porque duplica la referencia que tiene 1 en el momento de esa redirección. Cambiar después la entrada 1 no vuelve a modificar la entrada 2. Por eso el orden de las redirecciones importa.
9. ¿Qué hereda un hijo después de fork: nuevas aperturas o referencias a las existentes?
En el fork ordinario recibe una tabla cuyas entradas referencian las aperturas existentes. Las posiciones compartidas pueden cambiar al operar cualquiera de los procesos. Las entradas de las dos tablas pueden cerrarse por separado.
10. ¿Por qué el programa de herencia espera al hijo antes de leer en el padre?
Para fijar el orden del experimento y comprobar su terminación. Sin coordinación el planificador podría hacer leer primero a cualquiera; compartir posición no determina por sí solo ese orden.
11. ¿Exec crea otro proceso y vuelve a abrir sus archivos?
No. Sustituye el programa dentro del proceso. Los descriptores que sobreviven mantienen sus referencias; los marcados FD_CLOEXEC se cierran cuando exec tiene éxito.
12. Si marco FD_CLOEXEC en a, ¿se marca también un duplicado existente?
No. Esa marca pertenece a la entrada de descriptor. Pueden compartir apertura y posición y mantener marcas diferentes. Además, un dup ordinario crea el nuevo descriptor con esa marca desactivada.
13. ¿Por qué /proc/self/fd puede mostrar descriptores de ls y no del shell?
Porque self se interpreta para el proceso que consulta proc. Para observar la tabla del shell desde una herramienta externa, usamos su PID, como en /proc/$$/fd después de la expansión realizada por Bash.
14. Dos fdinfo tienen la misma posición. ¿Demuestra que comparten apertura?
No. Dos aperturas independientes pueden encontrarse casualmente en el mismo offset. Una operación controlada que avanza uno de los duplicados ayuda a observar el estado compartido, pero la coincidencia aislada no lo prueba.
15. ¿Qué diferencia hay entre EMFILE, ENFILE y ENOSPC?
EMFILE señala el límite de descriptores del proceso; ENFILE, recursos de archivos abiertos del sistema; ENOSPC se relaciona con recursos de espacio del sistema de archivos en operaciones como creación o escritura. No se solucionan necesariamente liberando el mismo tipo de recurso.
16. ¿Cerrar el último descriptor borra siempre el archivo?
No. Si quedan nombres, el objeto normalmente continúa en el sistema de archivos para futuras aperturas. Si perdió su último enlace y ya no hay referencias que lo retengan, pueden liberarse sus recursos. Cierre y borrado de nombres son operaciones distintas.
Para practicar
- Ejecuta la función emitir con salida y error en archivos separados. Después invierte el orden de
>archivoy2>&1. Dibuja las referencias finales de 1 y 2 en cada caso. - Repite el laboratorio de Bash con 3, 4 y 5. Lee primero con 4 y después con 5; predice las tres posiciones antes de consultar fdinfo. Cierra los tres al terminar.
- Compila mapa-fd.c y comprueba las lecturas ABC, DE y AB. Cambia el segundo open por un dup del primero. Antes de ejecutar, escribe qué lecturas y posiciones deberían cambiar.
- Añade un
lseek(c, 0, SEEK_SET)antes de cerrar a y muestra de nuevo las posiciones. Explica por qué retroceder c también afecta a a y no a b. - Crea un enlace duro a letras.txt y úsalo para la apertura b. Compara dispositivo e inodo y explica por qué el nombre alternativo no convierte esa apertura en un duplicado de a.
- Ejecuta mapa-fd con un archivo vacío y con otro de dos bytes. Observa las lecturas en EOF y comprueba que el programa no utiliza bytes que no recibió.
- Ejecuta herencia-fd.c varias veces y redirige su salida a un archivo. Explica por qué waitpid permite obtener un orden reproducible sin depender de la velocidad de los procesos.
- Modifica el programa de herencia para que cada proceso abra el archivo por su cuenta después de fork. Mantén la coordinación y compara las lecturas del padre con las originales.
- En el simulador, duplica 3, marca solo uno de los dos con FD_CLOEXEC y crea el hijo. Ejecuta exec únicamente en el hijo. Comprueba qué referencias conserva cada proceso y cuántas apuntan a la apertura.
- En el simulador, crea dos aperturas del mismo archivo, lee cantidades distintas y cierra todos los descriptores de una de ellas. Explica por qué desaparece esa apertura sin desaparecer el inodo del modelo.
En el tema 5 estudiaremos open y close como interfaces completas: modos de acceso, creación, truncado, anexado, permisos iniciales y errores. Ya tienes el modelo para entender qué recurso crea una apertura, qué devuelve a tu programa y qué referencia libera un cierre.