1. Dos capas, un mismo archivo
Cuando un programa escribe con fprintf(f, …), el texto no va directamente al disco ni al núcleo. Atraviesa varias etapas, y cada una tiene su propio búfer:
Cada etapa tiene un comando para empujar los datos hacia la siguiente: fflush (o fclose) vacía el búfer de stdio hacia el núcleo con write; fsync obliga al núcleo a llevar sus datos hasta el dispositivo. Tener presente esa cadena evita casi todas las sorpresas del resto del tema.
2. Descriptores de archivo
Para el núcleo, un archivo abierto de un proceso es simplemente un número entero pequeño, el descriptor (file descriptor, fd). Cada proceso empieza con tres abiertos, que heredó de quien lo lanzó (normalmente el shell):
| fd | Nombre | Macro (<unistd.h>) | Flujo de stdio |
|---|---|---|---|
| 0 | Entrada estándar | STDIN_FILENO | stdin |
| 1 | Salida estándar | STDOUT_FILENO | stdout |
| 2 | Salida de error | STDERR_FILENO | stderr |
Cada open devuelve el número libre más bajo. Se puede ver con un programa que abre dos archivos, lista a dónde apunta cada descriptor (el núcleo lo expone en /proc/self/fd/N, que readlink sabe leer), cierra el primero y abre un tercero:
#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
static void ruta(int fd)
{
char enlace[64], destino[256];
snprintf(enlace, sizeof enlace, "/proc/self/fd/%d", fd);
ssize_t n = readlink(enlace, destino, sizeof destino - 1);
if (n < 0)
snprintf(destino, sizeof destino, "(cerrado)");
else
destino[n] = '\0';
printf(" fd %d -> %s\n", fd, destino);
}
int main(void)
{
int a = open("uno.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
int b = open("dos.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
printf("a=%d b=%d\n", a, b);
for (int fd = 0; fd <= 4; fd++)
ruta(fd);
close(a);
int c = open("tres.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
printf("tras close(%d), open devuelve %d\n", a, c);
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o fds fds.c && ./fds
a=3 b=4
fd 0 -> /dev/pts/2
fd 1 -> /dev/pts/2
fd 2 -> /dev/pts/2
fd 3 -> /home/ana/c/uno.txt
fd 4 -> /home/ana/c/dos.txt
tras close(3), open devuelve 3
Los tres primeros apuntan a la terminal (/dev/pts/2); el siguiente archivo recibe el 3 y el otro el 4; y tras cerrar el 3, el siguiente open reutiliza el 3. Si la salida del programa se redirige (./fds > salida.txt), el descriptor 1 apunta a salida.txt sin que el programa lo sepa: la redirección del shell es, literalmente, abrir el archivo y dejarlo como descriptor 1 antes de ejecutar el programa.
Ese mecanismo se puede reproducir a mano, y de paso se confirma que printf escribe en el descriptor 1, sea lo que sea que haya detrás:
int main(void)
{
close(STDOUT_FILENO);
int fd = open("capturado.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
fprintf(stderr, "open devolvio %d\n", fd);
printf("este texto va al archivo\n");
return 0;
}
$ ./cierra1; cat capturado.txt
open devolvio 1
este texto va al archivo
Al cerrar el 1, el siguiente open recibe justamente el 1, y todo lo que printf escribe en «la salida estándar» termina en capturado.txt. (La llamada dup2 hace esta operación de forma ordenada y es la base de las tuberías y redirecciones; se verá en el curso Procesos e Hilos.)
3. open, read, write y close
La interfaz del sistema es mínima y se declara en <fcntl.h> y <unistd.h>:
| Llamada | Qué hace | Devuelve |
|---|---|---|
int open(ruta, banderas [, modo]) | Abre (y quizá crea) el archivo. | El fd, o -1 y errno. |
ssize_t read(fd, buf, n) | Lee hasta n bytes. | Los bytes leídos; 0 en fin de archivo; -1 en error. |
ssize_t write(fd, buf, n) | Escribe hasta n bytes. | Los bytes escritos, o -1. |
int close(fd) | Cierra el descriptor. | 0 o -1. |
Las banderas de open se combinan con |. Una y solo una de las tres primeras es obligatoria:
| Bandera | Significado |
|---|---|
O_RDONLY / O_WRONLY / O_RDWR | Solo lectura, solo escritura, o ambas. |
O_CREAT | Crear el archivo si no existe (exige el tercer argumento, el modo). |
O_TRUNC | Si existe, vaciarlo al abrir. |
O_APPEND | Cada escritura va al final, aunque se haya movido la posición. |
O_EXCL | Con O_CREAT: fallar con EEXIST si ya existe (creación atómica). |
El modo (el 0644 de los ejemplos) son los permisos que tendrá un archivo recién creado, en octal. No se aplican tal cual: el sistema les quita los bits de la umask del proceso. Por eso un mismo programa crea archivos distintos según el entorno:
close(open("p_open.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666));
close(open("p_open_600.txt", O_WRONLY | O_CREAT | O_TRUNC, 0600));
fclose(fopen("p_fopen.txt", "w"));
$ (umask 022; ./perm); stat -c '%a %n' p_open.txt p_open_600.txt p_fopen.txt
644 p_open.txt
600 p_open_600.txt
644 p_fopen.txt
$ rm p_*.txt; (umask 077; ./perm); stat -c '%a %n' p_open.txt p_open_600.txt p_fopen.txt
600 p_open.txt
600 p_open_600.txt
600 p_fopen.txt
Con 0666 y la umask habitual 022 resulta 644; con 077, 600. fopen crea los archivos con 0666 y obedece la misma umask. La regla: el programa pide el máximo que estaría dispuesto a dar (0666 para datos, 0600 para algo privado) y la umask del usuario lo recorta.
read y write pueden hacer menos de lo pedido
La regla más importante de esta interfaz: read(fd, buf, n) puede devolver menos de n bytes sin que haya error ni fin de archivo, y lo mismo vale para write. En un archivo normal casi nunca se nota, pero en una tubería, un socket o la terminal, sí. Se ve fácilmente con un lector que informa cuánto recibió en cada llamada:
int main(void)
{
char buf[100];
ssize_t n;
int i = 0;
while ((n = read(STDIN_FILENO, buf, sizeof buf)) > 0)
printf("lectura %d: %zd bytes\n", ++i, n);
printf("lectura %d: %zd (fin de archivo)\n", ++i, n);
return 0;
}
$ (echo uno; sleep 1; echo dos) | ./lee
lectura 1: 4 bytes
lectura 2: 4 bytes
lectura 3: 0 (fin de archivo)
$ head -c 250 /dev/zero | ./lee
lectura 1: 100 bytes
lectura 2: 100 bytes
lectura 3: 50 bytes
lectura 4: 0 (fin de archivo)
$ ./lee < /dev/null
lectura 1: 0 (fin de archivo)
En la primera ejecución, el búfer es de 100 bytes, pero el texto llega en dos tandas de 4 (separadas por un segundo): read devuelve lo que hay disponible, sin esperar a llenar el búfer. En la segunda, los 250 bytes se reparten en 100, 100 y 50, y el 0 final es el fin de archivo; en la tercera, el archivo vacío da 0 de inmediato. Por eso:
- Se lee en bucle, con el valor devuelto como cantidad real. Nunca se asume que se leyó
n. 0es el fin de archivo, no un error. El error es-1(y entonces hay que mirarerrno, incluido elEINTRque se vio en el tema 37).writenecesita el mismo cuidado: se repite con lo que falta hasta escribir todo. Es el equivalente de «escribir todo» questdiohace sin que se vea.
Con esos tres cuidados, la copia de archivos con llamadas al sistema queda así. Acepta el tamaño del búfer como tercer argumento para poder medirlo más adelante:
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static void morir(const char *que, const char *nombre)
{
fprintf(stderr, "cpsys: %s '%s': %s\n", que, nombre, strerror(errno));
exit(1);
}
static int escribir_todo(int fd, const char *p, size_t n)
{
while (n > 0) {
ssize_t w = write(fd, p, n);
if (w < 0) {
if (errno == EINTR)
continue;
return -1;
}
p += w;
n -= (size_t)w;
}
return 0;
}
int main(int argc, char **argv)
{
if (argc < 3) {
fprintf(stderr, "uso: cpsys origen destino [tamano]\n");
return 64;
}
size_t tam = argc > 3 ? (size_t)atol(argv[3]) : 65536;
char *buf = malloc(tam);
if (!buf)
morir("sin memoria para", argv[3]);
int in = open(argv[1], O_RDONLY);
if (in < 0)
morir("no se puede abrir", argv[1]);
int out = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (out < 0)
morir("no se puede crear", argv[2]);
ssize_t n;
while ((n = read(in, buf, tam)) != 0) {
if (n < 0) {
if (errno == EINTR)
continue;
morir("error al leer", argv[1]);
}
if (escribir_todo(out, buf, (size_t)n) < 0)
morir("error al escribir", argv[2]);
}
if (close(out) < 0)
morir("error al cerrar", argv[2]);
close(in);
free(buf);
return 0;
}
$ ./cpsys noexiste.bin x.bin; echo "rc=$?"
cpsys: no se puede abrir 'noexiste.bin': No such file or directory
rc=1
Hay dos detalles de estilo. El bucle principal compara con != 0 y no con > 0, para que el caso -1 entre en el cuerpo y reciba su tratamiento; y se comprueba el resultado de close(out), por la misma razón que fclose en el tema anterior (algunos sistemas de archivos, sobre todo los de red, informan allí los errores de escritura diferida).
4. Moverse en el archivo: lseek
Cada descriptor abierto tiene una posición actual: el punto donde ocurrirá la siguiente lectura o escritura. Avanza sola con cada read/write, y lseek(fd, desplazamiento, origen) la mueve. El origen es SEEK_SET (inicio), SEEK_CUR (posición actual) o SEEK_END (final), y devuelve la nueva posición. El programa siguiente escribe «inicio», salta a 1 MiB y escribe «fin»; SEEK_END con desplazamiento 0 sirve para preguntar el tamaño. Al final prueba lseek sobre la entrada estándar:
int fd = open("hueco.dat", O_WRONLY | O_CREAT | O_TRUNC, 0644);
write(fd, "inicio", 6);
off_t pos = lseek(fd, 1024 * 1024, SEEK_SET);
printf("lseek a 1 MiB -> %ld\n", (long)pos);
write(fd, "fin", 3);
printf("posicion actual: %ld\n", (long)lseek(fd, 0, SEEK_CUR));
printf("tamano (SEEK_END): %ld\n", (long)lseek(fd, 0, SEEK_END));
close(fd);
off_t r = lseek(STDIN_FILENO, 0, SEEK_CUR);
$ echo x | ./hueco
lseek a 1 MiB -> 1048576
posicion actual: 1048579
tamano (SEEK_END): 1048579
lseek en la entrada: -1 (Illegal seek)
$ ./hueco < /dev/null
lseek a 1 MiB -> 1048576
posicion actual: 1048579
tamano (SEEK_END): 1048579
lseek en la entrada: 0 (ok)
Una tubería no es movible: lseek falla con ESPIPE (Illegal seek); un archivo regular o /dev/null sí lo admiten. Los programas que usan lseek deben contemplar que su entrada puede ser una tubería.
El archivo que se creó tiene una propiedad curiosa. Entre el byte 6 y el 1048576 nunca se escribió nada, y el sistema de archivos no reserva espacio para ese tramo: es un archivo con huecos (sparse). El tamaño lógico es de 1 MiB, pero los bloques realmente asignados son pocos:
$ stat -c '%s bytes de tamano, %b bloques de %B bytes' hueco.dat
1048579 bytes de tamano, 16 bloques de 512 bytes
$ head -c 1048585 hueco.dat | od -An -c | head -2
i n i c i o \0 \0 \0 \0 \0 \0 \0 \0 \0 \0
\0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0
$ dd if=/dev/zero of=lleno.dat bs=1M count=1 2>/dev/null
$ stat -c '%s bytes de tamano, %b bloques de %B bytes' lleno.dat
1048576 bytes de tamano, 2048 bloques de 512 bytes
El archivo con huecos ocupa 16 bloques de 512 bytes (8 KiB) y el lleno de 1 MiB ocupa 2048. Al leerlo, el hueco aparece como bytes \0 (la segunda línea de od). Es la razón por la que el tamaño que muestra ls -l puede ser mucho mayor que el espacio en disco (imágenes de máquinas virtuales, bases de datos).
5. stdio: FILE * y sus funciones
La biblioteca estándar envuelve el descriptor en una estructura, FILE, que lleva además el búfer, la posición lógica y los indicadores de fin de archivo y de error. Se obtiene con fopen(ruta, modo), que devuelve NULL y fija errno si falla, y se libera con fclose. El modo es una cadena:
| Modo | Lectura | Escritura | Si no existe | Si existe |
|---|---|---|---|---|
"r" | Sí | No | Error (ENOENT) | Se abre al inicio. |
"w" | No | Sí | Se crea | Se vacía. |
"a" | No | Sí, siempre al final | Se crea | Se conserva. |
"r+" | Sí | Sí | Error | Se conserva; se escribe sobre lo existente. |
"w+" / "a+" | Sí | Sí | Se crea | Vaciar / conservar. |
"wx" (C11) | No | Sí | Se crea | Error (EEXIST), como O_EXCL. |
A cualquiera se le puede añadir una b ("rb", "wb") para indicar modo binario. En Linux no cambia nada (no existe distinción entre texto y binario), pero es la forma portable de escribirlo y deja clara la intención. Los modos "w", "a" y "r+" tienen comportamientos que conviene ver:
$ ./modos
tras "w" + uno: uno|
tras "a" (con fseek al inicio): uno|dos|
tras "r+" + UNO: UNO|dos|
tras "w" sin escribir:
"wx" sobre archivo existente: File exists
"r" y fputs: EOF (Bad file descriptor)
(El carácter | marca un salto de línea.) Primero, "w" escribe «uno». Con "a", aunque se haga fseek al inicio, la escritura va al final («uno|dos|»), como pedía el modo. Con "r+" se sobrescribe sin vaciar: «UNO» reemplaza a «uno». Un "w" sin escribir nada deja el archivo vacío: es el modo que más datos ha destruido por error. El último modo, "wx", falla con File exists, y una escritura en un archivo abierto con "r" falla con EBADF.
Leer texto: fgets, getline y el error de feof
Para leer líneas, fgets(buf, tam, f) lee como máximo tam-1 caracteres, hasta el salto de línea inclusive, y devuelve NULL en fin de archivo o error. Dos trampas se ven fácilmente. La primera, el bucle while (!feof(f)):
while (!feof(f)) { /* incorrecto */
fgets(linea, sizeof linea, f);
printf("con feof: %s", linea);
}
while (fgets(linea, sizeof linea, f)) /* correcto */
printf("correcto: %s", linea);
$ ./feof
con feof: a
con feof: b
con feof: b
$ ./feof bien
correcto: a
correcto: b
feof solo se vuelve verdadero después de que una lectura toque el fin del archivo. Tras leer «b», todavía no lo sabe; el siguiente fgets falla, deja intacto el búfer (que sigue conteniendo «b») y el programa lo imprime otra vez. La forma correcta es usar el valor devuelto por la lectura como condición del bucle. feof y ferror se consultan después, para saber por qué terminó.
La segunda trampa es la línea más larga que el búfer. fgets no se queja: entrega la línea por trozos. Para líneas de cualquier largo, POSIX ofrece getline, que reserva memoria y la hace crecer según haga falta (hay que liberarla al final):
char chico[10];
while (fgets(chico, sizeof chico, f))
printf(" [%s]\n", chico);
char *linea = NULL;
size_t cap = 0;
ssize_t n;
while ((n = getline(&linea, &cap, f)) != -1) {
linea[strcspn(linea, "\n")] = '\0';
printf(" [%s] (%zd bytes leidos)\n", linea, n);
}
free(linea);
$ ./larga
fgets con buffer de 10:
[abcdefghi]
[jklmnopqr]
[stuvwxyz
]
[corta
]
getline:
[abcdefghijklmnopqrstuvwxyz] (27 bytes leidos)
[corta] (6 bytes leidos)
Con fgets y un búfer de 10, la primera línea llegó en tres trozos, y solo el último trae el salto de línea: el programa que necesite la línea entera tiene que detectar eso. getline la entrega completa, con su largo (27 bytes: 26 letras y el salto). Para quitar el salto de línea se usa el truco linea[strcspn(linea, "\n")] = '\0', que no falla si no lo hay (tema 22).
Leer y escribir binario: fread y fwrite
fread(ptr, tamaño, cantidad, f) y fwrite transfieren bloques de memoria tal cual, y devuelven la cantidad de elementos procesados. Sirven para volcar estructuras, pero con una advertencia: lo que se escribe es la representación en memoria, con todo lo que eso implica (tema 25):
struct reg {
uint32_t id;
uint16_t nivel;
};
struct reg r = {1, 258};
FILE *f = fopen("reg.bin", "wb");
fwrite(&r, sizeof r, 1, f);
fclose(f);
$ ./bin
sizeof(struct reg) = 8
leido 1 registro: id=1 nivel=258
$ od -An -tx1 reg.bin
01 00 00 00 02 01 00 00
La estructura tiene 6 bytes de datos y se guardan 8: los dos últimos son el relleno de alineación. El entero 1 aparece como 01 00 00 00 y el 258 (0x0102) como 02 01: el byte menos significativo primero, es decir, little endian. Un archivo así solo se lee bien en la misma arquitectura y con el mismo compilador. Para formatos que deban durar o viajar entre máquinas, hay que definir el orden de bytes y escribir campo por campo, nunca la estructura entera.
6. Búferes: por qué cambia el orden de la salida
stdio acumula lo que se escribe en un búfer y lo entrega al núcleo de una vez. Hay tres modos, y cuál se usa depende de a dónde va el flujo:
| Modo | Cuándo se vacía | Es el modo por defecto de… |
|---|---|---|
Sin búfer (_IONBF) | En cada operación. | stderr. |
Por líneas (_IOLBF) | Al escribir un \n, o al llenarse. | stdout cuando es una terminal. |
Completo (_IOFBF) | Al llenarse (típico 4096 o más bytes), con fflush o al cerrar. | stdout cuando es un archivo o una tubería; los archivos abiertos con fopen. |
Esa dependencia del destino tiene un efecto muy visible. Un programa que mezcla printf, una llamada directa a write y un mensaje por stderr imprime en un orden en la terminal y en otro en una tubería:
printf("1 stdio\n");
write(STDOUT_FILENO, "2 write\n", 8);
printf("3 stdio\n");
fprintf(stderr, "4 stderr\n");
$ ./orden # en la terminal
1 stdio
2 write
3 stdio
4 stderr
$ ./orden 2>&1 | cat # por una tubería
2 write
4 stderr
1 stdio
3 stdio
En la terminal, stdout es de líneas y cada printf sale al instante, así que el orden coincide con el del código. En la tubería es de búfer completo: los dos printf esperan en el búfer, el write pasa directo, el stderr sale sin demora, y los dos printf se vuelcan al final, al terminar el programa. El resultado, 2, 4, 1, 3, es el más enigmático que produce este tema, y la causa es el búfer. Hay dos remedios, que se ven con dos variantes del mismo programa:
$ ./orden linea 2>&1 | cat # setvbuf(stdout, NULL, _IOLBF, 0) al comienzo
1 stdio
2 write
3 stdio
4 stderr
$ ./orden flush 2>&1 | cat # fflush(stdout) solo tras el primer printf
1 stdio
2 write
4 stderr
3 stdio
Con setvbuf(stdout, NULL, _IOLBF, 0), antes de cualquier salida, se obliga al modo de líneas y el orden vuelve a ser el esperado. Con un fflush(stdout) solo después del primer printf, ese texto queda en su lugar, pero el tercero sigue en el búfer hasta el final y aparece después del mensaje de error. La función es int setvbuf(FILE *f, char *buf, int modo, size_t tam); con buf = NULL, la biblioteca elige el búfer. Hay que llamarla antes de la primera operación sobre el flujo.
Una consecuencia que justifica lo dicho: si un programa termina de forma anormal (con _exit, abort o una señal), lo que seguía en los búferes se pierde. Y con fflush los datos solo llegan al núcleo: para que lleguen al disco hace falta fsync(fileno(f)) (después del fflush), una llamada costosa que se reserva para datos que no se pueden perder, como el registro de una base de datos.
7. Cuánto cuesta cada enfoque
Una llamada al sistema no es una llamada de función común: hay que cruzar al núcleo y volver. Cuesta del orden de cientos de nanosegundos a microsegundos, frente a unos nanosegundos de una función normal. Por eso importa cuántas se hacen. Se copió un archivo de 8 MiB con cpsys variando el tamaño del búfer, y con el programa cpstdio, que lee con fgetc y escribe con fputc un carácter por vez, apoyado en el búfer de stdio:
$ for t in 1 64 4096 65536; do /usr/bin/time -f "cpsys tam=$t: %e s (usuario %U, sistema %S)" ./cpsys grande.bin copia.bin $t; done
cpsys tam=1: 4.43 s (usuario 0.55, sistema 3.87)
cpsys tam=64: 0.07 s (usuario 0.00, sistema 0.06)
cpsys tam=4096: 0.00 s (usuario 0.00, sistema 0.00)
cpsys tam=65536: 0.00 s (usuario 0.00, sistema 0.00)
$ /usr/bin/time -f "fgetc/fputc: %e s (usuario %U, sistema %S)" ./cpstdio grande.bin copia.bin
fgetc/fputc: 0.03 s (usuario 0.03, sistema 0.00)
La diferencia entre el búfer de 1 byte y el de 4 KiB es de varios órdenes de magnitud, y casi todo el tiempo lo gasta el sistema (3,87 s de 4,43 s), es decir, en cruzar al núcleo. Lo notable es que fgetc/fputc carácter a carácter, que parecen equivalentes a la copia de 1 byte, terminan en 0,03 s. La razón se ve contando las llamadas al sistema con strace para un archivo de 64 KiB:
$ strace -c -e trace=read,write ./cpsys chico.bin c1.bin 1 2>&1 | tail -5
------ ----------- ----------- --------- --------- ----------------
50.72 1.313001 20 65536 write
49.28 1.275520 19 65538 read
------ ----------- ----------- --------- --------- ----------------
100.00 2.588521 19 131074 total
$ strace -c -e trace=read,write ./cpsys chico.bin c1.bin 4096 2>&1 | tail -5
------ ----------- ----------- --------- --------- ----------------
53.74 0.000388 24 16 write
46.26 0.000334 18 18 read
------ ----------- ----------- --------- --------- ----------------
100.00 0.000722 21 34 total
$ strace -c -e trace=read,write ./cpstdio chico.bin c2.bin 2>&1 | tail -5
------ ----------- ----------- --------- --------- ----------------
54.32 0.000390 24 16 write
45.68 0.000328 18 18 read
------ ----------- ----------- --------- --------- ----------------
100.00 0.000718 21 34 total
Con un byte por llamada son 131 074 llamadas; con 4096, solo 34; y cpstdio hace también 34: aunque el código pide un carácter cada vez, stdio lee y escribe en bloques de 4096 bytes por debajo. (Las 18 lecturas incluyen la del fin de archivo y las que usa el cargador; las 16 escrituras son 64 KiB ÷ 4 KiB.) Esa es, precisamente, la utilidad del búfer: permite escribir el código «un carácter cada vez» sin pagar por ello. Con read y write directos, el programador es quien tiene que elegir un tamaño razonable: del orden de 4 KiB a 64 KiB.
8. Pasar de una capa a la otra
Cada capa puede convertirse en la otra. fileno(f) devuelve el descriptor que hay debajo de un FILE *, y fdopen(fd, modo) envuelve un descriptor ya abierto en un FILE *. Es útil cuando se necesita fprintf sobre un descriptor que vino de open o de otra llamada (un socket, una tubería):
int fd = open("fdop.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
FILE *f = fdopen(fd, "w");
fprintf(f, "escrito con fprintf a traves de fdopen\n");
printf("fileno(f)=%d, fd original=%d, stdout=%d\n", fileno(f), fd, fileno(stdout));
fclose(f);
printf("write tras fclose: %zd\n", write(fd, "x", 1));
$ ./fdop; cat fdop.txt
fileno(f)=3, fd original=3, stdout=1
write tras fclose: -1
escrito con fprintf a traves de fdopen
El FILE * de fdopen usa el mismo descriptor (3), y fclose cierra también el descriptor: el write posterior falla (-1, EBADF). Dos reglas para mezclar sin sorpresas: (1) no operar sobre el mismo archivo con ambas capas a la vez salvo que se haga fflush antes de cada cambio de capa, por lo que se vio en la sección 6; (2) quedarse con un solo dueño de cada recurso, y cerrarlo una sola vez, ya sea con close(fd) o con fclose(f), pero no con ambos.
9. Cuál usar
| Situación | Mejor opción | Por qué |
|---|---|---|
Texto línea a línea, formatos con printf/scanf | stdio | El formato y el búfer ya están hechos; getline cubre líneas largas. |
| Muchas lecturas o escrituras pequeñas | stdio | El búfer reduce de miles a pocas las llamadas al sistema. |
| Bloques grandes en un solo paso | Cualquiera | Con bloques de decenas de KiB el costo ya es el mínimo. |
Crear de forma atómica (O_EXCL), O_APPEND multiproceso, banderas específicas | open | stdio no expone estas banderas (salvo "x"). |
Sockets, tuberías, dispositivos, poll, no bloqueo | read/write | Control total de lecturas parciales y errores; sin búfer intermedio. |
| Salida que debe aparecer en orden o sobrevivir a una caída | Sin búfer, o fflush/fsync explícitos | El búfer cambia el orden y se pierde si el programa cae. |
| Datos binarios portables | Campo por campo, orden de bytes definido | Volcar estructuras copia el relleno y la endianidad. |
10. Errores frecuentes
| Error | Qué pasa | Corrección |
|---|---|---|
No comprobar el resultado de open/fopen | Se usa -1 como descriptor o NULL como flujo: EBADF o SIGSEGV. | Comprobar siempre, con perror o strerror(errno) (tema 37). |
Suponer que read/write transfieren todo | Datos truncados, sobre todo en tuberías y sockets. | Bucles hasta completar, usando el valor devuelto. |
Confundir 0 con error en read | Se toma el fin de archivo por un fallo (o al revés). | 0 = fin; -1 = error. |
while (!feof(f)) | Una iteración de más: se procesa dos veces la última línea. | La lectura como condición: while (fgets(…)). |
Abrir con "w" un archivo que se quería conservar | Se vacía en el fopen, antes de escribir nada. | "a" para añadir, "r+" para modificar, "wx" para no pisar. |
O_CREAT sin el tercer argumento de open | Permisos al azar (se lee basura de la pila). | Siempre pasar el modo con O_CREAT. |
Esperar que el modo de open se aplique tal cual | La umask quita bits: 0666 queda en 644. | Pedir el máximo razonable; fchmod si se necesita exacto. |
Leer línea con fgets y un búfer chico sin revisar el \n | Líneas partidas, procesadas como si fueran dos. | Comprobar que el último carácter es \n, o usar getline. |
Olvidar free(linea) tras getline | Fuga de memoria. | Liberar el búfer una vez, al terminar el bucle. |
Mezclar printf y write sobre el mismo fd | Orden distinto en tuberías y archivos. | fflush antes de cada write, o usar solo una capa. |
No comprobar fclose / close al escribir | Errores de disco lleno o de red pasan inadvertidos (tema 37). | Comprobar y reflejar el error en el código de salida. |
Cerrar dos veces (fclose(f) y close(fileno(f))) | El segundo falla, o cierra un descriptor que ya se reutilizó. | Un solo cierre por recurso. |
Leer o escribir un byte por vez con read/write | Cientos de veces más lento (4,4 s frente a 0,00 s en 8 MiB). | Bloques de 4 KiB o más, o stdio. |
Volcar una struct con fwrite a un formato que debe perdurar | Depende del compilador, del relleno y de la endianidad. | Escribir cada campo con un formato definido. |
Usar lseek sobre una entrada que puede ser tubería | ESPIPE. | Comprobar el resultado y tener un camino alternativo. |
| Perder datos por una salida anormal con búferes llenos | _exit, abort o una señal descartan el búfer. | fflush en los puntos críticos, o búfer de líneas. |
11. Resumen
- Hay dos capas: las llamadas al sistema (
open,read,write,close,lseek), que usan un descriptor entero, y stdio (fopen,fgets,fprintf,fread…), que usa unFILE *con un búfer propio. stdio está construido sobre las llamadas. - Un proceso empieza con los descriptores 0 (entrada), 1 (salida) y 2 (error).
opendevuelve siempre el número libre más bajo;/proc/self/fdmuestra a qué apunta cada uno. Las redirecciones del shell son descriptores 0, 1 y 2 reasignados. opencombina banderas (O_RDONLY,O_WRONLY,O_CREAT,O_TRUNC,O_APPEND,O_EXCL); conO_CREATexige el modo, que laumaskrecorta.readywritepueden transferir menos de lo pedido.readdevuelve0en fin de archivo y-1en error; ambos se usan en bucles, reintentando conEINTR.lseekmueve la posición; falla conESPIPEen tuberías. Escribir más allá del final deja un archivo con huecos, cuyo tamaño lógico supera al espacio ocupado.- Los modos de
fopen:"w"vacía,"a"siempre añade al final,"r+"modifica sin vaciar,"wx"falla si existe. Las líneas se leen confgets(que parte las largas) ogetline, y la condición del bucle es el valor de la lectura, nofeof. - stdio tiene tres modos de búfer: sin búfer (
stderr), por líneas (stdouten terminal) y completo (stdouten archivo o tubería). Por eso el orden de la salida cambia al redirigir;setvbufyfflushlo controlan, yfsynclleva los datos al disco. - El búfer reduce las llamadas al sistema: 131 074 con un byte por vez, 34 con bloques de 4 KiB, y 34 también con
fgetc/fputcgracias a stdio.fdopenyfilenopasan de una capa a otra;fclosecierra también el descriptor.
12. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Qué es un descriptor de archivo y cuáles hay al empezar un programa?
Un entero pequeño que identifica, para el núcleo, un archivo abierto del proceso. Al comenzar existen el 0 (entrada estándar), el 1 (salida estándar) y el 2 (salida de error), heredados de quien lanzó el programa. open devuelve el número libre más bajo.
2. Un programa cierra el descriptor 1 y abre un archivo. ¿Qué pasa con lo que escribe printf?
El open recibe el número 1 (el más bajo libre), y printf, que escribe siempre en el descriptor 1, termina escribiendo en ese archivo. Es el mecanismo básico de una redirección.
3. ¿Qué valores puede devolver read y qué significa cada uno?
Un número positivo: los bytes leídos (puede ser menos de lo pedido); 0: fin de archivo; -1: error, con la causa en errno (si es EINTR, se reintenta).
4. ¿Por qué hay que escribir un bucle alrededor de write?
Porque puede escribir menos bytes de los pedidos (en una tubería, un socket, o interrumpida por una señal). El bucle avanza el puntero y reduce el contador con lo que realmente se escribió hasta completar, y reintenta con EINTR.
5. ¿Qué permisos tiene un archivo creado con open(…, O_CREAT, 0666) si la umask es 022? ¿Y con 077?
644 con 022 y 600 con 077. Los permisos finales son el modo con los bits de la umask eliminados. fopen obra igual, pidiendo 0666.
6. ¿Qué es un archivo con huecos y cómo se crea?
Uno donde parte del rango nunca se escribió y no ocupa espacio en disco; se crea moviendo la posición con lseek más allá del final y escribiendo. Su tamaño lógico (%s en stat) es mayor que el espacio asignado (%b); al leer, el hueco devuelve bytes cero.
7. ¿Qué diferencia hay entre los modos "w", "a" y "r+" de fopen?
"w" crea el archivo o lo vacía si existe. "a" lo crea o lo conserva y siempre escribe al final, aunque se haga fseek. "r+" exige que exista, lo conserva y permite leer y escribir sobre el contenido actual.
8. ¿Por qué while (!feof(f)) { fgets(…); procesar(); } procesa dos veces la última línea?
Porque feof solo es verdadero después de una lectura que haya llegado al final. Tras leer la última línea aún es falso; el siguiente fgets falla y no modifica el búfer, y el programa vuelve a procesar el contenido anterior. Se corrige usando el valor de fgets como condición del bucle.
9. ¿Cuándo conviene getline y no fgets?
Cuando las líneas pueden ser más largas que cualquier búfer fijo razonable: getline reserva y hace crecer la memoria, devuelve la línea completa y su largo. Hay que liberar el búfer con free al terminar.
10. ¿Cuáles son los tres modos de búfer de stdio y cuál usa stdout?
Sin búfer (cada operación sale al instante; es el de stderr), por líneas (se vacía en cada \n; el de stdout en una terminal) y completo (se vacía al llenarse, con fflush o al cerrar; el de stdout redirigido a archivo o tubería y el de los archivos de fopen).
11. Un programa muestra sus mensajes en orden en la terminal y desordenados con | cat. ¿Por qué?
En la terminal, stdout es de líneas y cada línea sale al instante. En la tubería es de búfer completo: lo que se imprime con printf espera hasta el final, mientras que stderr (sin búfer) y las llamadas directas a write salen enseguida. Se arregla con setvbuf en modo de líneas, con fflush o escribiendo todo por una misma capa.
12. ¿Qué diferencia hay entre fflush y fsync?
fflush vacía el búfer de stdio hacia el núcleo (con write); los datos quedan en la caché del núcleo. fsync(fd) obliga al núcleo a llevarlos al dispositivo. Para persistencia real se usan seguidos: fflush(f) y fsync(fileno(f)).
13. Leer un archivo con fgetc carácter a carácter fue casi tan rápido como con bloques de 4 KiB. ¿Por qué, si cada read de 1 byte fue lentísimo?
Porque fgetc lee del búfer de stdio, que se rellena con read de bloques de 4096 bytes. strace lo confirma: 34 llamadas al sistema en ambos casos, frente a 131 074 con un byte por read. El costo está en cruzar al núcleo, no en el número de caracteres.
14. ¿Qué hace fdopen y qué ocurre con el descriptor al hacer fclose?
fdopen(fd, modo) crea un FILE * sobre un descriptor ya abierto, para usar funciones de stdio. fclose vacía el búfer y cierra también el descriptor, por lo que un write posterior sobre ese número falla con EBADF, y no se debe llamar además a close(fd).
15. ¿Por qué no es buena idea guardar una struct con fwrite en un formato de intercambio?
Porque se copia la representación en memoria: incluye bytes de relleno por alineación (6 bytes de datos ocuparon 8), y el orden de bytes de la máquina (1 se guardó como 01 00 00 00, little endian). El archivo solo es válido en la misma arquitectura y con el mismo compilador. Conviene definir el formato y escribir campo por campo.
Para practicar
- Escribe
mi_catconreadywriteque muestre uno o varios archivos (o la entrada estándar si no hay argumentos), con bucles correctos y mensajes de error del tema 37. Pruébalo con una tubería lenta ((echo a; sleep 1; echo b) | ./mi_cat). - Escribe
mi_wcque cuente líneas, palabras y bytes confgetc, y otra versión conready un búfer de 64 KiB. Compara los tiempos en un archivo de 100 MB y las llamadas al sistema constrace -c. - Reproduce el ejemplo del orden (
printf,write,stderr) y comprueba cómo cambia con./orden | cat,./orden > archivoystdbuf -oL ./orden | cat. ¿Qué hacestdbuf? - Crea un archivo con huecos de 1 GiB usando
lseeky un solowrite. Compara su tamaño conls -ly su ocupación condu. ¿Qué pasa si lo copias concpy con tucpsys? - Implementa un registro (log) de línea a línea con
open(…, O_APPEND)y lanza dos procesos que escriban a la vez al mismo archivo. Repite confopen("a")yfprintfy busca líneas entremezcladas. ¿Qué papel tiene el búfer? - Escribe un programa que cree un archivo de bloqueo con
O_CREAT | O_EXCL, lo ejecute dos veces y muestre que la segunda falla conEEXIST. ¿Por qué esta forma de comprobar «si existe» es mejor queaccessseguido deopen? - Guarda un arreglo de estructuras en binario, primero con
fwritede la estructura y luego campo por campo enlittle endiancon tipos de ancho fijo. Muestra ambos conod -An -tx1y explica las diferencias. - Mide cuánto tarda
fsync: escribe 1000 líneas pequeñas con y sinfflush+fsynctras cada una, y compara. ¿Cuándo vale la pena pagar ese costo?