← Programación en C para Sistemas Operativos Bloque 9 · Errores, archivos y depuración · Tema 38 de 42

Tema 38

Archivos con stdio y con llamadas al sistema

Un programa de sistemas pasa buena parte de su vida leyendo y escribiendo archivos, y en C hay dos formas de hacerlo que conviven y se confunden. La primera es la de <stdio.h>: fopen, fgets, fprintf, un FILE * y un búfer que la biblioteca administra por nosotros. La segunda es la del propio sistema operativo: open, read, write, close y un número entero, el descriptor de archivo. La primera está construida sobre la segunda. Entender las dos capas y qué hace el búfer entre ellas explica cosas que de otro modo parecen magia o mala suerte: por qué la salida de un programa sale en otro orden cuando se pasa por una tubería, por qué leer byte a byte con read puede ser miles de veces más lento, por qué read a veces devuelve menos de lo pedido, y qué cierra realmente fclose. Este tema recorre las dos interfaces, sus trampas y su costo medido, con todas las salidas verificadas en Linux; usa lo visto en el tema 37 para tratar cada error.

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:

Las capas entre un programa y el disco. El programa llama a fprintf o fwrite, que copian los datos al búfer de stdio dentro del propio proceso. Cuando el búfer se llena, o se llama a fflush o fclose, stdio invoca la llamada al sistema write con el descriptor de archivo. El núcleo copia los datos a su caché de páginas y, más tarde, los envía al disco. fsync fuerza ese último paso. Un programa que usa open, read y write directamente se salta la capa de stdio programa fprintf, fwrite búfer stdio en tu proceso núcleo caché de páginas disco dispositivo write(fd, …) fflush / fclose / búfer lleno fsync La línea punteada: open/read/write se saltan el búfer de stdio
Tres lugares donde pueden quedarse los datos: el búfer de stdio, la caché del núcleo y, por fin, el disco.

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):

fdNombreMacro (<unistd.h>)Flujo de stdio
0Entrada estándarSTDIN_FILENOstdin
1Salida estándarSTDOUT_FILENOstdout
2Salida de errorSTDERR_FILENOstderr

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:

fds.c
#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;
}
terminal
$ 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:

cierra1.c y terminal
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>:

LlamadaQué haceDevuelve
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:

BanderaSignificado
O_RDONLY / O_WRONLY / O_RDWRSolo lectura, solo escritura, o ambas.
O_CREATCrear el archivo si no existe (exige el tercer argumento, el modo).
O_TRUNCSi existe, vaciarlo al abrir.
O_APPENDCada escritura va al final, aunque se haya movido la posición.
O_EXCLCon 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:

perm.c (extracto) y terminal · open con 0666 y con 0600, y fopen, bajo dos umask
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:

lee.c y terminal
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:

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:

cpsys.c
#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;
}
terminal
$ ./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:

hueco.c (extracto) y terminal
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:

terminal
$ 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:

ModoLecturaEscrituraSi no existeSi existe
"r"SíNoError (ENOENT)Se abre al inicio.
"w"NoSíSe creaSe vacía.
"a"NoSí, siempre al finalSe creaSe conserva.
"r+"SíSíErrorSe conserva; se escribe sobre lo existente.
"w+" / "a+"SíSíSe creaVaciar / conservar.
"wx" (C11)NoSíSe creaError (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.c (resumen de cada paso) y terminal
$ ./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)):

feof.c y terminal · el archivo dos.txt contiene "a" y "b" en dos líneas
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):

larga.c y terminal · archivo con una línea de 26 letras y otra de 5
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):

bin.c y terminal
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:

ModoCuándo se vacíaEs 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:

orden.c
printf("1 stdio\n");
write(STDOUT_FILENO, "2 write\n", 8);
printf("3 stdio\n");
fprintf(stderr, "4 stderr\n");
terminal · el mismo programa, según dónde vaya su salida
$ ./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:

terminal · setvbuf a modo de líneas, y fflush tras el primer printf
$ ./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:

terminal · copia de 8 MiB (tiempos de una ejecución en esta máquina; varían entre equipos)
$ 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:

terminal · strace -c cuenta las llamadas read y write
$ 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):

fdop.c y terminal
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ónMejor opciónPor qué
Texto línea a línea, formatos con printf/scanfstdioEl formato y el búfer ya están hechos; getline cubre líneas largas.
Muchas lecturas o escrituras pequeñasstdioEl búfer reduce de miles a pocas las llamadas al sistema.
Bloques grandes en un solo pasoCualquieraCon bloques de decenas de KiB el costo ya es el mínimo.
Crear de forma atómica (O_EXCL), O_APPEND multiproceso, banderas específicasopenstdio no expone estas banderas (salvo "x").
Sockets, tuberías, dispositivos, poll, no bloqueoread/writeControl total de lecturas parciales y errores; sin búfer intermedio.
Salida que debe aparecer en orden o sobrevivir a una caídaSin búfer, o fflush/fsync explícitosEl búfer cambia el orden y se pierde si el programa cae.
Datos binarios portablesCampo por campo, orden de bytes definidoVolcar estructuras copia el relleno y la endianidad.

10. Errores frecuentes

ErrorQué pasaCorrección
No comprobar el resultado de open/fopenSe 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 todoDatos truncados, sobre todo en tuberías y sockets.Bucles hasta completar, usando el valor devuelto.
Confundir 0 con error en readSe 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 conservarSe 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 openPermisos al azar (se lee basura de la pila).Siempre pasar el modo con O_CREAT.
Esperar que el modo de open se aplique tal cualLa 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 \nLíneas partidas, procesadas como si fueran dos.Comprobar que el último carácter es \n, o usar getline.
Olvidar free(linea) tras getlineFuga de memoria.Liberar el búfer una vez, al terminar el bucle.
Mezclar printf y write sobre el mismo fdOrden distinto en tuberías y archivos.fflush antes de cada write, o usar solo una capa.
No comprobar fclose / close al escribirErrores 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/writeCientos 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 perdurarDepende del compilador, del relleno y de la endianidad.Escribir cada campo con un formato definido.
Usar lseek sobre una entrada que puede ser tuberíaESPIPE.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

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

  1. Escribe mi_cat con read y write que 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).
  2. Escribe mi_wc que cuente líneas, palabras y bytes con fgetc, y otra versión con read y un búfer de 64 KiB. Compara los tiempos en un archivo de 100 MB y las llamadas al sistema con strace -c.
  3. Reproduce el ejemplo del orden (printf, write, stderr) y comprueba cómo cambia con ./orden | cat, ./orden > archivo y stdbuf -oL ./orden | cat. ¿Qué hace stdbuf?
  4. Crea un archivo con huecos de 1 GiB usando lseek y un solo write. Compara su tamaño con ls -l y su ocupación con du. ¿Qué pasa si lo copias con cp y con tu cpsys?
  5. 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 con fopen("a") y fprintf y busca líneas entremezcladas. ¿Qué papel tiene el búfer?
  6. Escribe un programa que cree un archivo de bloqueo con O_CREAT | O_EXCL, lo ejecute dos veces y muestre que la segunda falla con EEXIST. ¿Por qué esta forma de comprobar «si existe» es mejor que access seguido de open?
  7. Guarda un arreglo de estructuras en binario, primero con fwrite de la estructura y luego campo por campo en little endian con tipos de ancho fijo. Muestra ambos con od -An -tx1 y explica las diferencias.
  8. Mide cuánto tarda fsync: escribe 1000 líneas pequeñas con y sin fflush+fsync tras cada una, y compara. ¿Cuándo vale la pena pagar ese costo?