File descriptors e I/O de bajo nivel (open/read/write)
Un file descriptor no es un archivo. Es un entero chico que indexa una tabla por proceso
mantenida por el kernel. Esa entrada apunta a una open file description: el objeto del
kernel que guarda el offset actual, flags de estado, modo de acceso y una referencia al
archivo, pipe, socket, terminal o dispositivo subyacente. open crea un descriptor,
read y write mueven bytes a través de él, y close suelta tu referencia.
El reset:
fd = 3significa "slot 3 en la tabla de descriptors de este proceso". El slot no es el archivo; apunta a estado del kernel.dupcrea otro slot que apunta al mismo estado, mientras que unopenseparado crea otro objeto de estado.
El modelo Unix de I/O
Unix intenta hacer que muchos objetos de I/O parezcan streams de bytes detrás de una API minúscula:
int fd = open("data.bin", O_RDONLY);
ssize_t n = read(fd, buf, sizeof buf);
ssize_t m = write(STDOUT_FILENO, buf, (size_t)n);
close(fd);
Archivos regulares, pipes, sockets, terminales, /dev/null y muchos device nodes entran
por la interfaz de descriptors. El tipo de objeto igual importa: los archivos regulares
soportan lseek; pipes y sockets no. Una terminal puede bloquear esperando input humano.
Un socket puede devolver EAGAIN en modo no bloqueante. Pero la forma es suficientemente
estable para que funcione la redirección de la shell: un programa escribe en fd 1, y el
proceso padre decide si fd 1 apunta a una terminal, un archivo o un pipe antes de
exec.
Todo proceso normalmente arranca con tres descriptors:
| Descriptor | Nombre convencional | Significado |
|---|---|---|
0 |
STDIN_FILENO |
standard input |
1 |
STDOUT_FILENO |
standard output |
2 |
STDERR_FILENO |
standard error |
Son convenciones, no magia. Una shell puede cerrar fd 1 y duplicar un archivo sobre el
slot 1 antes de correr tu programa. Tu código sigue llamando write(1, ...); el kernel
manda los bytes a donde apunte el slot 1.
Tabla de descriptors vs open file description
Esta distinción es toda la nota:
| Capa | Dueño | Contiene |
|---|---|---|
| número de file descriptor | un proceso | un entero chico como 3 |
| entrada en la descriptor table | un proceso | puntero a una open file description más descriptor flags |
| open file description | kernel | file offset, file status flags, access mode, objeto subyacente |
| objeto subyacente | filesystem/device/socket layer | inode/vnode/socket/pipe/device state |
Los descriptor flags viven en la entrada de la tabla de descriptors. FD_CLOEXEC es el
importante: cerrar este descriptor durante un exec exitoso. Los file status flags viven
en la open file description. O_APPEND, O_NONBLOCK y el offset actual se comparten
entre descriptors que apuntan a la misma open file description.
Por eso dup(fd) no es lo mismo que hacer open(path) otra vez. dup devuelve un
descriptor nuevo que apunta a la misma open file description, así que ambos descriptors
comparten offset. Un segundo open devuelve un descriptor que apunta a una open file
description nueva, así que su offset arranca independiente.
Esto también explica fork. Después de fork, padre e hijo tienen descriptor tables
separadas, pero las entradas refieren a las mismas open file descriptions. Si ambos
procesos escriben en la misma description heredada de un archivo regular, comparten el
offset. Si heredaron extremos de un pipe, comparten estado de pipe en el kernel. Esa es
la plomería detrás de shells, pipelines, supervisors y test harnesses.
open: path lookup más flags
open(path, flags, mode) le pide al kernel que resuelva un pathname y cree una open file
description. Si tiene éxito, el kernel instala una entrada en la tabla de descriptors y
devuelve el número de descriptor libre más bajo del proceso.
Flags comunes:
| Flag | Significado |
|---|---|
O_RDONLY, O_WRONLY, O_RDWR |
modo de acceso |
O_CREAT |
crear el archivo si falta; requiere argumento mode |
O_TRUNC |
truncar un archivo regular existente a longitud cero |
O_APPEND |
cada write append-ea atómicamente al final del archivo |
O_EXCL con O_CREAT |
fallar si el archivo ya existe |
O_CLOEXEC |
setear close-on-exec atómicamente |
O_NONBLOCK |
hacer que las operaciones vuelvan en vez de bloquear cuando aplica |
El argumento mode no son los bits de permiso finales. Se filtra por el umask del
proceso. open("x", O_CREAT | O_WRONLY, 0666) podría crear 0644 si el umask remueve
permiso de escritura para group/other.
Preferí O_CLOEXEC al crear descriptors. Setear FD_CLOEXEC después con fcntl
funciona en código single-threaded, pero en programas con threads hay una race: otro
thread podría hacer fork y exec entre open y fcntl, filtrando el fd al programa
nuevo. Close-on-exec atómico es higiene chica que evita bugs raros.
read y write: bytes, conteos e interrupción
read(fd, buf, count) pide hasta count bytes. Puede devolver:
- un conteo positivo: bytes realmente leídos, quizá menos de los pedidos;
0: end-of-file para archivos regulares, o peer cerrado para algunos objetos stream;-1: error, conerrnoexplicando por qué.
write(fd, buf, count) le pide al kernel que consuma hasta count bytes. Puede devolver:
- un conteo positivo: bytes realmente escritos, quizá menos de los pedidos;
-1: error, conerrnoexplicando por qué.
La palabra peligrosa es hasta. Las lecturas de archivos regulares suelen llenar el buffer pedido hasta EOF, y las escrituras de archivos regulares suelen completarse enteras, pero la API no promete eso en general. Pipes, sockets, terminales, señales, quotas, modo no bloqueante y límites de recursos hacen que el I/O corto sea parte del contrato. El código robusto escribe loops así:
static void write_all(int fd, const char *buf, size_t len) {
while (len > 0) {
ssize_t n = write(fd, buf, len);
if (n < 0) {
if (errno == EINTR) {
continue;
}
die("write");
}
buf += (size_t)n;
len -= (size_t)n;
}
}
Las señales importan. Un read, write o close bloqueante puede devolver -1 con
errno == EINTR si corrió un signal handler. Algunas plataformas y flags reinician
algunas llamadas automáticamente; no apoyes la correctitud en eso. Decidí si reintentás,
abortás o propagás la interrupción.
Offsets, lseek y acceso random
Los archivos regulares tienen un offset actual en la open file description. read
empieza ahí y lo avanza por la cantidad de bytes leídos. write empieza ahí y lo avanza
por la cantidad de bytes escritos, salvo que O_APPEND haga que cada write busque primero
el final del archivo. lseek(fd, off, whence) cambia el offset sin transferir bytes.
pread y pwrite son la vía de escape útil: leen o escriben en un offset explícito sin
cambiar el offset actual de la open file description. Eso los hace más amigables para
código multi-threaded y para código donde compartir offsets sería un bug.
Pipes, sockets y terminales no son seekable. lseek sobre ellos falla con ESPIPE
porque no hay offset de acceso random para mover.
Lifetime: close, reutilización y leaks
close(fd) libera una entrada de la descriptor table. Si esa era la última referencia a
la open file description, el kernel puede liberar el estado de archivo abierto subyacente.
Los números de descriptor se reutilizan agresivamente: después de cerrar fd 4, el
siguiente open o dup puede devolver 4 otra vez. Por eso los bugs con fds viejos son
tan feos. El entero puede parecer válido mientras ahora apunta a un objeto completamente
distinto.
close puede reportar errores, especialmente con filesystems de red o writeback
diferido. La parte difícil es que reintentar close(fd) suele estar mal en Unix moderno:
el número de fd quizá ya fue liberado y reutilizado, así que un retry podría cerrar el
descriptor nuevo de otro. Chequeá el error para diagnóstico, pero diseñá writes y
fsync/fdatasync para que los errores importantes de datos aparezcan antes del close
final cuando importe.
Los descriptors también se filtran a través de exec salvo que close-on-exec esté
seteado. Esto conecta directo con la nota anterior: un hijo puede heredar deliberadamente
fd 4 como pipe, pero un server no debería pasarle por accidente un listening socket
privado o un archivo secreto a un helper ejecutado.
Stdio es una capa sobre descriptors
Los streams FILE * de <stdio.h> envuelven file descriptors con buffering en user
space, formatting y conveniencia. printf quizá no llama write inmediatamente; puede
copiar bytes a un buffer de libc y flushear después. fread puede leer del kernel más
bytes de los que pidió tu código y guardarse los extras en user space.
Por eso mezclar I/O por descriptor y stdio sobre el mismo fd subyacente es peligroso salvo
que flushees y coordines con cuidado. Si llamás read(fd, ...) detrás de un FILE * que
ya bufferizó datos, el offset del kernel y la vista bufferizada de libc pueden
sorprenderte.
Existen funciones puente: fileno(FILE *) obtiene el fd subyacente, y fdopen(fd, "r")
envuelve un descriptor existente en un stream. Usalas deliberadamente, con un solo dueño
de buffering a la vez.
Artefacto ejecutable: el offset se comparte
El ejemplo vive en examples/systems-programming/file-descriptors-and-low-level-io/.
Crea un archivo chico, escribe bytes con write_all, duplica el fd, lee a través de ambos
descriptors, abre el mismo path por separado y finalmente muestra que los números de
descriptor se reutilizan después de close.
El corazón del demo:
int fd = open(path, O_CREAT | O_TRUNC | O_RDWR | O_CLOEXEC, 0600);
write_all(fd, "abcdef\n", 7);
lseek(fd, 0, SEEK_SET);
int dupfd = dup(fd);
read_and_print("original fd", fd, 2); /* reads "ab", offset becomes 2 */
read_and_print("dup fd ", dupfd, 2); /* reads "cd", shared offset */
int separate = open(path, O_RDONLY | O_CLOEXEC);
read_and_print("separate fd", separate, 2); /* reads "ab", independent */
Compilá y corré:
cd examples/systems-programming/file-descriptors-and-low-level-io
./run.sh
Salida real de esta máquina:
== build ==
== run ==
open("fd-demo-data.txt") -> fd=3, close-on-exec=yes
write_all wrote 7 bytes
dup(fd) -> fd=4 (same open file description)
original fd read 2 bytes: "ab"; offset now 2
dup fd read 2 bytes: "cd"; offset now 4
open("fd-demo-data.txt") again -> fd=5 (new open file description)
separate fd read 2 bytes: "ab"; offset now 2
after close(4), open("/dev/null") -> fd=4
Leelo con atención: dupfd no leyó "ab" porque compartía la open file description
original y por lo tanto compartía el offset. El open separado sí leyó "ab" porque creó
una open file description nueva. El open de /dev/null reutilizó el número de fd 4
porque los descriptor numbers son solo slots de tabla.
macOS/BSD vs Linux: núcleo POSIX, extensiones de plataforma
El modelo central de descriptors es Unix portable: descriptors, open, read, write,
lseek, dup, fcntl, close y close-on-exec existen en sistemas POSIX. El contrato
portable es suficientemente fuerte para shells, programas C y la mayoría de herramientas
systems.
La capa de extensiones difiere:
- Linux tiene APIs Linux-only que producen fds.
eventfd,timerfd,signalfd,pidfd,memfd_create,epoll,openat2yO_PATHson poderosos pero no portables. - BSD/macOS usan otro mecanismo de readiness.
kqueuees la interfaz nativa de readiness en BSD/macOS; Linux usaepoll. Ambos operan sobre descriptors, pero sus APIs y modelos de eventos difieren. - El soporte de close-on-exec atómico varía por API. Preferí
O_CLOEXECy funciones que crean fds con variantes close-on-exec cuando existan. Si una plataforma no tiene una variante, repará confcntly entendé la race multi-threaded. - Algunos flags tienen detalles distintos.
O_DIRECT,O_SYNC,F_FULLFSYNC, locks asesorios y APIs de file cloning difieren lo suficiente como para que código portable serio los aisle.
La buena regla: escribí el camino común contra POSIX, aislá aceleraciones de plataforma y mantené el mismo modelo de lifetime de descriptors en todos lados.
Modos de falla y trade-offs
- Tratar los números de fd como identidades estables. Son slots reutilizables. Después
de
close, no sigas usando el entero. - Olvidar close-on-exec. Herencia accidental de fds puede impedir EOF en pipes, filtrar secretos o mantener sockets abiertos mucho después de que el padre creyó cerrarlos.
- Asumir que
writeescribe todo. Los short writes son legales. Usá un loop para buffers que deban escribirse completos. - Asumir que
readllena el buffer.readdevuelve lo que está disponible, lo que entra, o lo que el objeto puede dar antes de EOF/interrupción. - Ignorar
EINTRyEAGAIN. Descriptors bloqueantes y no bloqueantes tienen reglas de retry distintas. Diseñalas explícitamente. - Mezclar stdio e I/O crudo por descriptor casualmente. Estado bufferizado en user space y offsets del kernel pueden divergir de formas que parecen bytes perdidos o duplicados.
- Compartir offsets accidentalmente.
dup,forky descriptor passing pueden hacer que dos caminos de código compartan una open file description. Usáopenseparado,preadopwritecuando importen offsets independientes. - Reintentar
closea ciegas. El número de fd quizá ya fue reutilizado. Reportá errores de close, pero no escribas un loop que pueda cerrar un descriptor no relacionado.
En la práctica
- Chequeá el retorno de cada syscall.
open,read,write,lseek,dup,fcntlyclosefallan en programas reales. - Hacé visible el ownership. Si una función recibe un fd, decí si lo toma prestado o lo consume y lo cierra.
- Seteá close-on-exec al crear. Usá
O_CLOEXECo APIs equivalentes por defecto. - Usá
dup2odup3para redirección deliberada. Así las shells ponen un archivo o pipe sobre fd0,1o2antes deexec. - Usá
pread/pwritepara acceso random en código compartido. Evitan acoplamiento escondido por offset. - Usá stdio para texto formateado y bufferizado, descriptors para fronteras OS. Ambas herramientas son buenas; la confusión empieza cuando un fd tiene dos dueños.
- Pensá como el kernel que vas a construir. Una descriptor table, una open-file table, reference counts, offsets, flags y bits close-on-exec son la forma mínima del I/O Unix-like.
Apéndice ARM64
Nada del modelo de descriptors es específico de x86-64. La API C y los objetos del kernel
son la misma idea en ARM64 Linux y Apple Silicon. Lo que cambia es la capa de entrada de
syscalls: ARM64 Linux usa svc #0 con números de syscall ARM64, mientras x86-64 Linux usa
syscall con la tabla x86-64. macOS mantiene la frontera estable en libSystem, no en
números de syscall crudos.
Para el proyecto OS, la parte específica de arquitectura es cómo user mode entra al kernel y cómo llegan los argumentos. La maquinaria de descriptors después del dispatch es mayormente neutral a la arquitectura: validar el fd, encontrar la open file description, ejecutar la operación específica del objeto, actualizar offsets y devolver un conteo o error.
Conecta con: Systems Programming · La syscall: cruzar la frontera hacia el kernel · Procesos: fork, exec y wait · La biblioteca estándar mínima · Qué es realmente un puntero · OS desde Cero
Fuentes
- Michael Kerrisk - The Linux Programming Interface, caps. 4-5 - el modelo universal de I/O, file descriptors, open file descriptions y duplicación de descriptors. https://man7.org/tlpi/
open(2)- man7.org - flags, modes,O_CLOEXEC, open file descriptions y extensiones específicas de Linux. https://man7.org/linux/man-pages/man2/open.2.htmlread(2)- man7.org - valores de retorno, short reads, errores, interrupción y comportamiento por tipo de objeto. https://man7.org/linux/man-pages/man2/read.2.htmlwrite(2)- man7.org - partial writes, errores, interrupción y notas de atomicidad. https://man7.org/linux/man-pages/man2/write.2.htmllseek(2)- man7.org - file offsets, seekability, sparse files yESPIPE. https://man7.org/linux/man-pages/man2/lseek.2.htmldup(2)- man7.org - descriptors duplicados y open file descriptions compartidas. https://man7.org/linux/man-pages/man2/dup.2.htmlfcntl(2)- man7.org - descriptor flags comoFD_CLOEXECy manipulación de file status flags. https://man7.org/linux/man-pages/man2/fcntl.2.html- Stevens & Rago - Advanced Programming in the UNIX Environment (APUE) - tratamiento clásico de file I/O Unix, lifetime de descriptors e interacciones con stdio. http://www.apuebook.com/