Llamadas al sistema en C — teoría con open, read, write, lseek y una partida guardada encima de otra (y los puntos que no eran míos)

El verano pasado le dejé a mi amigo Max probar un jueguecito que había hecho en C para practicar: guardaba la partida en un fichero, partida.sav, con una línea por jugador. Max llegó al nivel 12 con 2750 puntos y guardó. Después jugué yo, llegué al nivel 3 y guardé encima. Cuando cargué la partida, el juego me saludó así:

Bytes leídos: 20
Partida cargada: Lia   nivel 03 2750

Mi nombre, mi nivel… y los 2750 puntos de Max. No había ningún error de compilación ni ningún aviso. Este es el programa, reducido a lo esencial:

#include <stdio.h>
#include <fcntl.h>      /* open y las constantes O_... */
#include <unistd.h>     /* read, write, lseek, close */
#include <string.h>     /* strlen */

int main(void) {
    /* 1. La partida antigua de Max: una ranura de 20 bytes */
    int fd = open("partida.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    write(fd, "Max   nivel 12 2750\n", 20);
    close(fd);

    /* 2. Guardo MI partida encima... pero sin O_TRUNC */
    const char *mia = "Lia   nivel 03";       /* solo 14 bytes */
    fd = open("partida.sav", O_WRONLY | O_CREAT, 0644);
    write(fd, mia, strlen(mia));              /* pisa los 14 primeros */
    close(fd);

    /* 3. Cargo la partida */
    char buf[64];
    fd = open("partida.sav", O_RDONLY);
    ssize_t n = read(fd, buf, sizeof buf - 1);
    close(fd);
    buf[n] = '\0';                            /* read no pone el '\0' */
    printf("Bytes leídos: %zd\n", n);
    printf("Partida cargada: %s", buf);
    return 0;
}

¿Ves el fallo? En el segundo open falta O_TRUNC. Sin esa opción, abrir un fichero para escribir no lo vacía: el puntero empieza en el byte 0 y write va pisando byte a byte. Mi partida ocupaba 14 bytes ("Lia nivel 03") y la de Max 20, así que los 6 últimos (" 2750\n") sobrevivieron. Las cuentas cuadran: 20 − 14 = 6 bytes de Max, y read leyó los 20 del fichero entero.

Hoy vas a entender por qué pasa eso y a manejar ficheros como lo hace el propio sistema operativo: con llamadas al sistema. En el artículo anterior, sobre procesos, viste que fork() crea un proceso pidiéndoselo al kernel. Con los ficheros es igual: open, read, write, lseek y close son peticiones directas al kernel, sin la capa de fopen y fprintf. Al terminar tendrás todo listo para la práctica, donde guardaremos, copiaremos y editaremos un fichero de partidas de verdad.

Funciones de biblioteca frente a llamadas al sistema

En C tienes dos formas de trabajar con ficheros:

Funciones de bibliotecaLlamadas al sistema
Ejemplosfopen, fprintf, fgets, fcloseopen, read, write, lseek, close
Cabeceras<stdio.h><fcntl.h>, <unistd.h> (y <sys/stat.h> para los permisos)
Qué devuelven al abrirUn puntero FILE *Un número entero: el descriptor
BufferSí, la biblioteca acumula y escribe a bloquesNo: cada llamada va directa al kernel
Trabajan conTexto con formato (%d, %s)Bytes en bruto

Las funciones de biblioteca están construidas encima de las llamadas al sistema: fprintf acaba llamando a write. ¿Por qué aprender lo de abajo si lo de arriba es más cómodo? Porque es el acceso más directo que tiene un programa, porque funciona igual con ficheros, con la terminal y con cualquier cosa que envíe o reciba bytes (dispositivos, tuberías, conexiones de red) y porque es lo que te preguntan en IC2.

Las llamadas al sistema que vamos a usar no son estándar de C, sino de POSIX (Linux, macOS…). Compilan sin problema con gcc -Wall -std=c11 en Linux; en Windows necesitas WSL o una máquina virtual.

Las cabeceras de este artículo son siempre las mismas: <fcntl.h> trae open y las constantes O_...; <unistd.h> trae read, write, lseek y close.

Descriptores de fichero: 0, 1, 2… y el siguiente libre

Cuando un proceso abre un fichero, el kernel no le da el fichero: le da un número entero, el descriptor. A partir de ahí, todo se hace con ese número: read(3, …) significa «lee del fichero que tengo abierto como 3».

Antes de que empiece main, todo proceso tiene ya tres descriptores abiertos, por convención:

DescriptorNombreNormalmente es
0entrada estándar (stdin)el teclado
1salida estándar (stdout)la terminal
2error estándar (stderr)la terminal

Por eso el primer open de un programa devuelve casi siempre 3: el kernel da el descriptor libre más bajo, y 0, 1 y 2 ya están ocupados. Míralo:

#include <stdio.h>
#include <fcntl.h>      /* open y las constantes O_... */
#include <unistd.h>     /* read, write, lseek, close */
#include <string.h>     /* strlen */

int main(void) {
    /* 0, 1 y 2 ya están abiertos antes de que empiece main */
    const char *msg = "Escrito con write(1, ...)\n";
    write(1, msg, strlen(msg));               /* 1 = salida estándar */
    const char *err = "Escrito con write(2, ...)\n";
    write(2, err, strlen(err));               /* 2 = error estándar */

    /* Cada open devuelve el descriptor libre más bajo */
    int a = open("ranura1.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    int b = open("ranura2.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    int c = open("ranura3.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    printf("ranura1 -> fd %d, ranura2 -> fd %d, ranura3 -> fd %d\n", a, b, c);

    close(b);                                 /* el 4 queda libre */
    int d = open("ranura4.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    printf("Tras cerrar el fd %d, ranura4 -> fd %d\n", b, d);

    /* Un fichero que no existe: open devuelve -1 */
    int e = open("ranura9.sav", O_RDONLY);
    printf("open de ranura9.sav devuelve %d\n", e);
    if (e == -1)
        perror("Error al abrir ranura9.sav"); /* explica el motivo */

    close(a);
    close(c);
    close(d);
    return 0;
}

La salida de la única ejecución:

Escrito con write(1, ...)
Escrito con write(2, ...)
ranura1 -> fd 3, ranura2 -> fd 4, ranura3 -> fd 5
Tras cerrar el fd 4, ranura4 -> fd 4
open de ranura9.sav devuelve -1
Error al abrir ranura9.sav: No such file or directory

Vamos por partes:

  • Las dos primeras líneas no usan printf: son write(1, …) y write(2, …). Escribir en el descriptor 1 es escribir en la terminal, sin abrir nada. El 2 también acaba en la terminal, pero es otro canal (si rediriges la salida con > salida.txt, el 2 seguiría saliendo por pantalla).
  • ranura1, ranura2 y ranura3 reciben 3, 4 y 5: los siguientes libres, en orden.
  • Al cerrar el 4, ese hueco queda libre, y el siguiente open lo reutiliza: ranura4.sav recibe un 4, no un 6. Es la regla del «más bajo libre».
  • ranura9.sav no existe, así que open devuelve −1. El −1 es la forma que tienen todas estas llamadas de decir «algo ha fallado», y perror traduce el motivo a texto: No such file or directory.

No comprobar el −1. Si open falla y sigues adelante, haces read(-1, …), que también falla, y el programa sigue con datos que nunca se leyeron. Después de cada open, un if (fd == -1) { perror(...); return 1; }.

El texto que le pasas a perror va delante de los dos puntos; lo de detrás lo pone el sistema según el error. En la terminal del laboratorio puede salir en español (No existe el fichero o el directorio) si el sistema está en español; aquí lo verás en inglés.

Las tres tablas del kernel

Un descriptor es solo un índice. ¿Dónde está entonces la posición de lectura, el modo de apertura, el tamaño del fichero? En tres estructuras que mantiene el kernel:

  1. Tabla de descriptores (una por proceso): la posición 3 apunta a una entrada de la tabla siguiente. Es lo único que «es» tuyo.
  2. Tabla de ficheros abiertos (del sistema): una entrada por cada open. Guarda el puntero de lectura/escritura (en qué byte vas) y el modo (O_RDONLY, O_WRONLY…).
  3. Tabla de i-nodos: una entrada por fichero del disco, con su tamaño, sus permisos, su dueño y dónde están sus bloques.
Descriptores, ficheros abiertos e i-nodos El proceso padre tiene los descriptores 0, 1 y 2 en la terminal y 3 y 4 en partida.sav. El 3 apunta a la entrada A de ficheros abiertos (puntero 20), que comparte con el fd 3 del hijo creado con fork. El 4, de un segundo open, apunta a la entrada B (puntero 0). Las dos entradas apuntan al mismo i-nodo de partida.sav. Las tres tablas del kernel Tabla de descriptores una por proceso Ficheros abiertos puntero + modo Tabla de i-nodos una entrada por fichero Proceso padre 0 stdin → terminal 1 stdout → terminal 2 stderr → terminal 3 partida.sav 4 partida.sav Proceso hijo (fork) 0 stdin → terminal 1 stdout → terminal 2 stderr → terminal 3 partida.sav Entrada A (compartida) puntero: 20 modo: O_WRONLY Entrada B (segundo open) puntero: 0 modo: O_WRONLY partida.sav tamaño: 20 bytes permisos: rw-r–r– dueño, bloques… fork copia la tabla de descriptores: el fd 3 del hijo apunta a la MISMA entrada A
Un descriptor es solo un índice: el puntero vive en la entrada de ficheros abiertos, y los datos del fichero, en el i-nodo.

Fíjate en el dibujo en dos detalles que vamos a comprobar con código al final del artículo: si el mismo proceso hace dos open del mismo fichero, tiene dos entradas (A y B) con dos punteros distintos; si hace un open y después fork, el hijo copia la tabla de descriptores, pero su fd 3 apunta a la misma entrada A, así que padre e hijo comparten el puntero.

Cuando no entiendas qué hace un programa con ficheros, pregúntate «¿cuántas entradas de ficheros abiertos hay y dónde está el puntero de cada una?». Casi todos los misterios se resuelven ahí.

open: abrir (o crear) un fichero

int open(const char *ruta, int flags);
int open(const char *ruta, int flags, mode_t modo);   /* obligatorio si hay O_CREAT */

flags dice cómo quieres abrirlo. Es obligatorio uno de los tres primeros, y se le pueden sumar opciones con el operador |:

FlagSignificado
O_RDONLYSolo lectura
O_WRONLYSolo escritura
O_RDWRLectura y escritura
O_CREATCrea el fichero si no existe (necesita el tercer parámetro, los permisos)
O_TRUNCSi ya existía, lo vacía (tamaño 0) al abrir
O_APPENDCada write va al final del fichero, sin pisar nada
O_EXCLJunto con O_CREAT: falla si el fichero ya existe

¿Por qué |? Cada flag es un número con un solo bit encendido, y el OR los junta en un único int: O_WRONLY | O_CREAT | O_TRUNC es «escritura, créalo si hace falta y vacíalo». Ahora mira cómo se comportan los más importantes:

#include <stdio.h>
#include <fcntl.h>      /* open y las constantes O_... */
#include <unistd.h>     /* read, write, lseek, close */
#include <sys/stat.h>   /* stat y los bits de permisos */

/* Lee el fichero entero y lo enseña con su tamaño */
static void mostrar(const char *nombre) {
    char buf[128];
    int fd = open(nombre, O_RDONLY);
    ssize_t n = read(fd, buf, sizeof buf - 1);
    close(fd);
    buf[n] = '\0';
    printf(" (%zd bytes)\n%s", n, buf);
}

int main(void) {
    /* O_TRUNC: si ya existía, se vacía antes de escribir */
    int fd = open("partidas.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    write(fd, "Lia   nivel 03 0450\n", 20);
    close(fd);
    printf("1) Tras O_TRUNC:");
    mostrar("partidas.sav");

    /* O_APPEND: cada write va al final, sin pisar nada */
    fd = open("partidas.sav", O_WRONLY | O_APPEND);
    write(fd, "Max   nivel 12 2750\n", 20);
    close(fd);
    printf("2) Tras O_APPEND:");
    mostrar("partidas.sav");

    /* O_CREAT | O_EXCL: falla si el fichero ya existe */
    fd = open("partidas.sav", O_WRONLY | O_CREAT | O_EXCL, 0644);
    printf("3) O_CREAT | O_EXCL devuelve %d\n", fd);
    if (fd == -1)
        perror("   partidas.sav");

    /* creat(nombre, modo) = open(nombre, O_WRONLY | O_CREAT | O_TRUNC, modo) */
    fd = creat("copia.sav", 0644);
    printf("4) creat devuelve el fd %d\n", fd);
    close(fd);

    /* Los permisos que pedimos con 0644 */
    struct stat st;
    stat("partidas.sav", &st);
    printf("5) Permisos de partidas.sav: %o\n", st.st_mode & 0777);
    return 0;
}

Salida:

1) Tras O_TRUNC: (20 bytes)
Lia   nivel 03 0450
2) Tras O_APPEND: (40 bytes)
Lia   nivel 03 0450
Max   nivel 12 2750
3) O_CREAT | O_EXCL devuelve -1
   partidas.sav: File exists
4) creat devuelve el fd 3
5) Permisos de partidas.sav: 644

Entre líneas:

  • 1) Con O_TRUNC el fichero empieza vacío y queda con los 20 bytes de Lia. Es la combinación de «guardar partida»: la que me faltaba en la historia del principio.
  • 2) Con O_APPEND, el write de Max se coloca detrás: 20 + 20 = 40 bytes. No hizo falta mover nada; el kernel lleva el puntero al final antes de cada escritura.
  • 3) O_CREAT | O_EXCL es «créalo, pero solo si es nuevo». Como partidas.sav ya existía, open devuelve −1 y perror dice File exists. Sirve para no machacar una partida guardada sin querer.
  • 4) creat(ruta, modo) es una llamada antigua que equivale exactamente a open(ruta, O_WRONLY | O_CREAT | O_TRUNC, modo). Devuelve 3 porque los anteriores ya se cerraron dentro de mostrar.
  • 5) Los permisos salen 644, los que pedimos con 0644.

El tercer parámetro: los permisos en octal

El 0644 empieza por 0 porque está en octal (base 8): cada dígito son 3 bits, uno por permiso (lectura = 4, escritura = 2, ejecución = 1).

DígitoPara quiénCuentaPermisos
6dueño4 + 2rw-
4grupo4r--
4otros4r--

Así que 0644 es rw-r--r--, lo mismo que verías con ls -l. Ojo: sin el 0 delante, 644 sería un número decimal (1204 en octal) y los permisos saldrían rarísimos.

Usar O_CREAT sin el tercer parámetro. El compilador no se queja (con -Wall tampoco), pero open coge como permisos lo que haya en ese momento en la memoria: a veces sale un fichero que ni tú puedes leer. Con O_CREAT, siempre el modo.

El sistema aplica además la umask del usuario, que quita permisos. La habitual es 022 (quita la escritura a grupo y otros), por eso 0644 llega intacto. Si pidieras 0666, con esa umask saldría 644 igualmente.

read: leer bytes (y lo que read NO hace)

ssize_t read(int fd, void *buf, size_t cuantos);

read intenta leer cuantos bytes del descriptor fd y los copia en buf, a partir de donde esté el puntero. Devuelve:

  • el número de bytes que ha leído de verdad (puede ser menos de los que pediste);
  • 0 si el puntero ya estaba al final del fichero;
  • −1 si hay un error.

Y después de leer, el puntero avanza esos bytes. Por eso se lee en bucle hasta que devuelve 0:

#include <stdio.h>
#include <fcntl.h>      /* open y las constantes O_... */
#include <unistd.h>     /* read, write, lseek, close */

int main(void) {
    int fd = open("partida.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    write(fd, "Lia   nivel 03 0450\n", 20);
    close(fd);

    fd = open("partida.sav", O_RDONLY);
    if (fd == -1) {                           /* comprueba siempre el -1 */
        perror("partida.sav");
        return 1;
    }
    char buf[9];                              /* 8 bytes + sitio para el '\0' */
    ssize_t leidos;
    int vuelta = 1;
    /* read devuelve los bytes leídos; 0 = fin de fichero */
    while ((leidos = read(fd, buf, 8)) > 0) {
        buf[leidos] = '\0';                   /* lo pongo yo: read no lo hace */
        printf("vuelta %d: read devuelve %zd -> \"", vuelta, leidos);
        for (char *p = buf; *p != '\0'; p++)  /* recorro hasta el '\0' */
            if (*p == '\n')
                printf("\\n");                /* el salto también es un byte */
            else
                putchar(*p);
        printf("\"\n");
        vuelta++;
    }
    printf("vuelta %d: read devuelve %zd (fin de fichero)\n", vuelta, leidos);
    close(fd);
    return 0;
}

La salida, vuelta a vuelta:

vuelta 1: read devuelve 8 -> "Lia   ni"
vuelta 2: read devuelve 8 -> "vel 03 0"
vuelta 3: read devuelve 4 -> "450\n"
vuelta 4: read devuelve 0 (fin de fichero)
VueltaPuntero antesread devuelveBytes leídosPuntero después
108Lia ni8
288vel 03 016
3164450\n20
4200(nada: fin de fichero)20

Comprobación: 8 + 8 + 4 = 20 bytes, el tamaño de la ranura. En la vuelta 3 pedimos 8 pero solo quedaban 4: read no se inventa los otros 4, simplemente devuelve menos. Y el \n cuenta como un byte más.

El ‘\0’ lo pones tú

Fíjate en la línea buf[leidos] = '\0';. read copia bytes; no sabe nada de cadenas de C, así que no añade el carácter nulo. Si después usas buf como cadena (%s, strlen, o un bucle hasta el '\0' como aquí), tienes que cerrarla tú. Dos detalles que hacen que funcione:

  • El buffer mide 9, uno más que los 8 que pedimos, para que siempre quepa el '\0'.
  • Se pone en buf[leidos], no en buf[8]: en la vuelta 3 solo se leyeron 4 bytes, y lo que hay en las posiciones 4-7 son restos de la vuelta 2.

printf("%s", buf) justo después de read, sin el '\0'. A veces funciona (si de casualidad había un cero en memoria) y a veces imprime basura o restos de la lectura anterior. Lo verás en directo en los ejercicios.

write y close

ssize_t write(int fd, const void *buf, size_t cuantos);
int close(int fd);

write escribe cuantos bytes de buf en la posición del puntero (o al final, con O_APPEND), avanza el puntero y devuelve cuántos ha escrito, o −1. Si el puntero está en mitad del fichero, sobrescribe: no inserta, no desplaza lo que hay detrás. Es exactamente lo que pasó con mi partida.

Dos reglas para el tercer argumento:

  • Para escribir una cadena, strlen(texto), no sizeof(texto): si texto es un char *, sizeof vale 8 (lo que mide un puntero), no la longitud.
  • Para escribir lo que acabas de leer, el valor que devolvió read (leidos), no el tamaño del buffer. En la práctica copiaremos un fichero así.

close(fd) libera el descriptor (el número vuelve a estar libre, como viste con el 4) y la entrada de ficheros abiertos si nadie más la usa. Cuando un proceso termina, el kernel cierra todos sus descriptores, pero cerrar a mano es lo correcto: un proceso tiene un número máximo de descriptores abiertos y un programa largo que abre sin cerrar acaba quedándose sin ellos.

lseek: mover el puntero

Todo fichero abierto tiene su puntero, que marca dónde será el siguiente read o write. lseek lo mueve sin leer ni escribir nada:

off_t lseek(int fd, off_t desplazamiento, int desde);

El tercer argumento dice desde dónde se cuenta el desplazamiento (que puede ser negativo):

desdeCuenta desdeEjemplo con un fichero de 80 bytes
SEEK_SETel principio (byte 0)lseek(fd, 40, SEEK_SET) → 40
SEEK_CURla posición actualsi estás en 43, lseek(fd, 9, SEEK_CUR) → 52
SEEK_ENDel final (el tamaño)lseek(fd, -2, SEEK_END) → 78

Devuelve la nueva posición (o −1). De ahí salen dos trucos muy usados: lseek(fd, 0, SEEK_CUR) te dice dónde estás sin moverte, y lseek(fd, 0, SEEK_END) te dice cuánto mide el fichero.

Vamos a probarlo con un fichero de cuatro partidas. Cada una ocupa exactamente 20 bytes (lo llamaremos ranura), así que la ranura 2 empieza en el byte 2 · 20 = 40:

#include <stdio.h>
#include <fcntl.h>      /* open y las constantes O_... */
#include <unistd.h>     /* read, write, lseek, close */

#define TAM_RANURA 20
#define PARTIDAS "Lia   nivel 03 0450\n" "Max   nivel 12 2750\n" \
                 "Ane   nivel 05 0980\n" "Teo   nivel 07 1200\n"

int main(void) {
    int fd = open("partidas.sav", O_RDWR | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("partidas.sav");
        return 1;
    }
    write(fd, PARTIDAS, 4 * TAM_RANURA);      /* 80 bytes */
    printf("Tras escribir, el puntero está en %ld\n", (long) lseek(fd, 0, SEEK_CUR));

    /* SEEK_SET: desde el principio. Ranura 2 = byte 2 * 20 = 40 */
    char nombre[4];
    off_t pos = lseek(fd, 2 * TAM_RANURA, SEEK_SET);
    read(fd, nombre, 3);
    nombre[3] = '\0';
    printf("SEEK_SET 40 -> %ld, leo \"%s\"\n", (long) pos, nombre);
    printf("Tras leer 3 bytes, SEEK_CUR 0 -> %ld\n", (long) lseek(fd, 0, SEEK_CUR));

    /* SEEK_CUR: desde donde estoy. 43 + 9 = 52, donde empieza el nivel */
    char nivel[3];
    pos = lseek(fd, 9, SEEK_CUR);
    read(fd, nivel, 2);
    nivel[2] = '\0';
    printf("SEEK_CUR +9 -> %ld, nivel de Ane: %s\n", (long) pos, nivel);

    /* SEEK_END: desde el final. El último byte (79) es el '\n' */
    char c;
    pos = lseek(fd, -2, SEEK_END);            /* 80 - 2 = 78 */
    read(fd, &c, 1);
    printf("SEEK_END -2 -> %ld, último dígito: %c\n", (long) pos, c);
    printf("SEEK_END 0 -> %ld = tamaño del fichero\n", (long) lseek(fd, 0, SEEK_END));

    /* Más allá del final: el hueco se rellena con ceros */
    pos = lseek(fd, 5, SEEK_END);             /* 80 + 5 = 85 */
    write(fd, "!", 1);
    printf("SEEK_END +5 -> %ld; tras escribir 1 byte, tamaño %ld\n",
           (long) pos, (long) lseek(fd, 0, SEEK_END));
    unsigned char resto[6];
    lseek(fd, 80, SEEK_SET);
    read(fd, resto, 6);
    printf("Bytes 80 a 85:");
    for (int i = 0; i < 6; i++)
        printf(" %d", resto[i]);              /* 33 es el código de '!' */
    printf("\n");
    close(fd);
    return 0;
}

Salida:

Tras escribir, el puntero está en 80
SEEK_SET 40 -> 40, leo "Ane"
Tras leer 3 bytes, SEEK_CUR 0 -> 43
SEEK_CUR +9 -> 52, nivel de Ane: 05
SEEK_END -2 -> 78, último dígito: 0
SEEK_END 0 -> 80 = tamaño del fichero
SEEK_END +5 -> 85; tras escribir 1 byte, tamaño 86
Bytes 80 a 85: 0 0 0 0 0 33
Los tres puntos de referencia de lseek Una barra con las cuatro ranuras de 20 bytes de partidas.sav (bytes 0 a 80). Cuatro flechas: SEEK_SET 40 lleva el puntero de 0 a 40; SEEK_CUR +9 lo lleva de 43 a 52; SEEK_END -2 lo deja en 78; SEEK_END +5 lo deja en 85, y al escribir allí los bytes 80 a 84 se rellenan con ceros. lseek mueve el puntero de partidas.sav (80 bytes) ranura 0 · Lia ranura 1 · Max ranura 2 · Ane ranura 3 · Teo 0 20 40 60 80 85 bytes 80-84: hueco de ceros · 85: «!» lseek(fd, 40, SEEK_SET) → 40 lseek(fd, 9, SEEK_CUR) → 52 lseek(fd, -2, SEEK_END) → 78 lseek(fd, 5, SEEK_END) → 85 SEEK_SET cuenta desde 0 · SEEK_CUR desde el puntero · SEEK_END desde el tamaño (80)
Cada flecha sale de su punto de referencia: el principio, el puntero actual o el final del fichero.

Sigue las flechas del dibujo con la salida delante:

  1. Tras escribir los 80 bytes, el puntero está al final: 80.
  2. SEEK_SET 40 salta a la ranura 2 y read de 3 bytes lee Ane. El puntero avanza: 40 + 3 = 43.
  3. SEEK_CUR +9 cuenta desde 43: 43 + 9 = 52, justo donde está el nivel de Ane (05).
  4. SEEK_END -2 cuenta desde el tamaño: 80 − 2 = 78. ¿Por qué −2 y no −1? Porque el último byte (el 79) es el \n. El último dígito de Teo (1200) está uno antes.
  5. SEEK_END 0 devuelve 80: el tamaño.
  6. SEEK_END +5 lleva el puntero más allá del final, a 85. Eso no da error. Al escribir el ! allí, el fichero crece hasta 86 bytes y los bytes 80 a 84, que nunca escribimos, valen 0 (el 33 es el código ASCII de !). Es el «hueco de ceros».

lseek no lee ni escribe: solo cambia un número en la entrada de ficheros abiertos. Por eso puedes llevar el puntero fuera del fichero sin error; el problema (o la gracia) llega al escribir.

lseek devuelve off_t, un entero que puede ser de 64 bits. Para imprimirlo con printf se convierte a long y se usa %ld, como en el código.

Descriptores heredados con fork

Lo prometido en el dibujo de las tres tablas. Ahora que sabes que el puntero vive en la entrada de ficheros abiertos, ¿qué crees que pasa en estos dos casos?

  • A) Un proceso abre el mismo fichero dos veces, escribe a Lia con el primer descriptor y a Max con el segundo.
  • B) Un proceso abre el fichero una vez, hace fork, el hijo escribe a Lia y, cuando termina, el padre escribe a Max.
#include <stdio.h>
#include <fcntl.h>      /* open y las constantes O_... */
#include <unistd.h>     /* read, write, lseek, close */
#include <sys/types.h>  /* pid_t */
#include <sys/wait.h>   /* waitpid */

/* Lee el fichero entero y lo enseña con su tamaño */
static void mostrar(const char *nombre) {
    char buf[128];
    int fd = open(nombre, O_RDONLY);
    ssize_t n = read(fd, buf, sizeof buf - 1);
    close(fd);
    buf[n] = '\0';
    printf(" (%zd bytes)\n%s", n, buf);
}

int main(void) {
    /* A) Dos open del mismo fichero: dos punteros independientes */
    int a = open("dos_open.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    int b = open("dos_open.sav", O_WRONLY);
    write(a, "Lia   nivel 03 0450\n", 20);   /* el puntero de a pasa a 20 */
    write(b, "Max   nivel 12 2750\n", 20);   /* el de b sigue en 0: pisa a Lia */
    close(a);
    close(b);
    printf("A) Dos open:");
    mostrar("dos_open.sav");

    /* B) Un solo open y después fork: padre e hijo comparten el puntero */
    int fd = open("fork.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    pid_t pid = fork();
    if (pid == 0) {                            /* hijo: hereda el fd 3 */
        write(fd, "Lia   nivel 03 0450\n", 20);
        close(fd);                             /* cierra SU copia del fd */
        return 0;
    }
    waitpid(pid, NULL, 0);                     /* el padre espera al hijo */
    printf("B) El padre ve el puntero en %ld\n", (long) lseek(fd, 0, SEEK_CUR));
    write(fd, "Max   nivel 12 2750\n", 20);    /* escribe detrás, no encima */
    close(fd);
    printf("B) open + fork:");
    mostrar("fork.sav");
    return 0;
}

Salida:

A) Dos open: (20 bytes)
Max   nivel 12 2750
B) El padre ve el puntero en 20
B) open + fork: (40 bytes)
Lia   nivel 03 0450
Max   nivel 12 2750
Dos open frente a open + fork Panel A: dos open del mismo fichero tienen punteros independientes; a escribe Lia y queda en 20, b sigue en 0 y escribe Max encima: el fichero acaba con 20 bytes. Panel B: con un open y fork, padre e hijo comparten el puntero; el hijo escribe Lia en 0-19 y el padre Max en 20-39: 40 bytes. ¿Quién comparte el puntero? A) Dos open 1. write(a): Lia en 0-19; a pasa a 20 2. write(b): b seguía en 0 → pisa a Lia Max 0 20 40 a = 20 b = 0 → 20 Resultado: 20 bytes, Lia perdida B) Un open + fork 1. el hijo escribe Lia en 0-19 → 20 2. el padre escribe Max en 20-39 → 40 Lia Max 0 20 40 puntero compartido = 40 Resultado: 40 bytes, nada pisado
Cada open crea su propia entrada (su propio puntero); fork copia el descriptor, pero no la entrada.
  • En A, cada open creó su propia entrada con su propio puntero. El de a avanzó a 20, pero el de b seguía en 0, así que Max pisó a Lia: 20 bytes y Lia desaparecida. Es otra forma de perder una partida.
  • En B, el hijo heredó el descriptor 3, que apunta a la misma entrada que el del padre. Cuando el hijo escribió sus 20 bytes, el puntero compartido pasó a 20, y el padre lo ve así (El padre ve el puntero en 20). Su write va detrás: 40 bytes, nada pisado.

El waitpid del artículo de procesos es el que garantiza el orden: sin él, padre e hijo podrían escribir a la vez. Y cada uno hace su close: el hijo cierra su copia del descriptor, y la entrada sigue viva hasta que el padre cierra la suya.

Errores típicos

Abrir para escribir sin O_TRUNC cuando quieres reemplazar el contenido. Si lo nuevo es más corto, quedan restos de lo viejo (la partida de Max).

O_CREAT sin los permisos, o con 644 en lugar de 0644.

Usar el buffer como cadena sin buf[leidos] = '\0', o reservar justo los bytes que pides a read y no dejar sitio para el '\0'.

Escribir sizeof buf bytes en lugar de los leidos: copias basura del final del buffer.

lseek(fd, -1, SEEK_END) para leer el último carácter de un fichero de texto: el último byte suele ser el \n.

Abrir con O_WRONLY y después intentar leer (o al revés). read devuelve −1 con Bad file descriptor; si lo vas a hacer todo con el mismo descriptor, O_RDWR.

Míralo paso a paso en Python Tutor

Python Tutor ejecuta código línea a línea y dibuja las variables y la memoria; elige C en el desplegable. No está pensado para trabajar con ficheros reales del disco, así que aquí simulamos uno: un struct con los bytes, el tamaño y el puntero. mi_lseek y mi_read hacen lo mismo que las de verdad, y así ves moverse el puntero en cada paso:

#include <stdio.h>
#define SET 0
#define CUR 1
#define END 2
struct Fichero { char datos[16]; int tam; int pos; };  /* pos = el puntero */

int mi_lseek(struct Fichero *f, int off, int whence) {
    int base = whence == SET ? 0 : whence == CUR ? f->pos : f->tam;
    f->pos = base + off;                  /* solo se mueve el puntero */
    return f->pos;
}
int mi_read(struct Fichero *f, char *buf, int n) {
    int i = 0;
    while (i < n && f->pos < f->tam)
        buf[i++] = f->datos[f->pos++];    /* copia un byte y avanza */
    return i;                             /* 0 = fin de fichero */
}
int main(void) {
    struct Fichero f = {"Lia 03\nMax 12\n", 14, 0};
    char buf[8] = {0};
    int n = mi_read(&f, buf, 3);          /* "Lia": pos pasa a 3 */
    mi_lseek(&f, 4, CUR);                 /* salta " 03\n": pos 7 */
    n = mi_read(&f, buf, 3);              /* "Max" */
    int p = mi_lseek(&f, -3, END);        /* 14 - 3 = 11 */
    n = mi_read(&f, buf, 8);              /* pido 8, solo quedan 3 */
    printf("%d %d %c%c\n", p, n, buf[0], buf[1]);
    return 0;
}

Al ejecutarlo, la última línea que imprime es:

11 3 12

Qué observar:

  1. En el primer mi_read, mira cómo f.pos pasa de 0 a 3 mientras buf se llena con L, i, a: leer mueve el puntero.
  2. En mi_lseek(&f, 4, CUR), solo cambia f.pos (de 3 a 7). datos y buf no se tocan: eso es todo lo que hace lseek.
  3. En mi_lseek(&f, -3, END), la base es f->tam (14), y 14 − 3 = 11, el byte del 1 de 12.
  4. En el último mi_read se piden 8 bytes, pero el while se para cuando pos llega a tam: devuelve 3, como el read de verdad en la última vuelta.

Resumen

LlamadaQué haceDevuelveOjo con
open(ruta, flags[, modo])Abre o crea y da un descriptorEl descriptor libre más bajo, o −1O_TRUNC, y 0644 con O_CREAT
read(fd, buf, n)Lee hasta n bytes y avanzaBytes leídos, 0 al final, −1No pone el '\0'
write(fd, buf, n)Escribe n bytes y avanzaBytes escritos o −1Sobrescribe; strlen o leidos
lseek(fd, desp, desde)Mueve el punteroLa nueva posición o −1SEEK_END apunta tras el último byte
close(fd)Libera el descriptor0 o −1Cierra siempre lo que abres

Tres ideas para llevarte:

  1. Un descriptor es solo un número (0, 1 y 2 vienen de serie); el puntero y el modo viven en la entrada de ficheros abiertos, y dos open son dos punteros, pero un fork comparte el mismo.
  2. read y write trabajan con bytes y te dicen cuántos han movido: usa ese número, y pon tú el '\0' si vas a tratar lo leído como texto.
  3. Con lseek y registros de tamaño fijo puedes ir directo a cualquier dato: posición = tamaño del registro · número + desplazamiento dentro del registro.

En la práctica construimos el fichero de partidas completo: lo guardamos ranura a ranura, hacemos una copia de seguridad con un bucle de read y write y editamos los puntos de una partida sin tocar el resto. Después, en Linux, toca otra pieza que ya has visto asomar con el 0644: los permisos y el comando chmod.

Siguiente paso: prácticaLlamadas al sistema en C — práctica guardando, copiando y editando partidas con open, read, write y lseek (y la copia que medía 96 bytes)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Un comentario

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *