Archivos, módulos y make en C

Lección 7 de 8: archivos, módulos y make en C. Un programa útil lee y guarda datos, y uno grande se divide en varios archivos. Aquí verás cómo abrir, leer y escribir archivos de texto y binarios sin ignorar los errores, cómo separar el código en módulos con cabeceras y static, y cómo make compila solo lo que ha cambiado.

Abrir, escribir y cerrar un archivo

Los archivos se manejan con un FILE *, que devuelve fopen. Las funciones son las mismas que ya conoces, con una f delante y el archivo como argumento: fprintf, fgets, fputs. De hecho, la pantalla y el teclado son también FILE *: stdout, stderr y stdin.

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    FILE *f = fopen("lista.txt", "w");
    if (f == nullptr) {
        perror("lista.txt");
        return EXIT_FAILURE;
    }

    const char *compras[] = {"pan", "leche", "café"};
    for (int i = 0; i < 3; i++) {
        fprintf(f, "%d. %s\n", i + 1, compras[i]);
    }

    if (fclose(f) != 0) {
        perror("al cerrar lista.txt");
        return EXIT_FAILURE;
    }
    printf("Guardado\n");
    return 0;
}
salidaGuardado

El segundo argumento de fopen es el modo: "r" para leer, "w" para escribir (crea el archivo o lo vacía si existe), "a" para añadir al final, y con una b ("rb", "wb") para archivos binarios. C23 añade "wx": crear solo si no existe, sin machacar nada. Comprobar el fclose no es exageración: lo escrito se guarda en un búfer, y es al cerrar cuando de verdad llega al disco y cuando puede fallar, por ejemplo si se ha llenado.

Leer línea a línea

Para leer texto, fgets en un bucle, que termina cuando devuelve nullptr. Este programa lee un archivo de gastos con una categoría y un importe por línea:

cat gastos.txt
salidacomida 250
transporte 90
comida 120
luz 340
transporte 45
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    FILE *f = fopen("gastos.txt", "r");
    if (f == nullptr) {
        perror("gastos.txt");
        return EXIT_FAILURE;
    }

    char linea[128];
    int numero = 0, total = 0, comida = 0;
    while (fgets(linea, sizeof linea, f) != nullptr) {
        numero++;
        char categoria[32];
        int importe;
        if (sscanf(linea, "%31s %d", categoria, &importe) != 2) {
            fprintf(stderr, "Línea %d mal formada\n", numero);
            continue;
        }
        total += importe;
        if (strcmp(categoria, "comida") == 0) comida += importe;
    }
    if (ferror(f)) {
        perror("al leer gastos.txt");
    }
    fclose(f);

    printf("%d líneas, total %d, en comida %d\n", numero, total, comida);
    return 0;
}
salida5 líneas, total 845, en comida 370

sscanf es como scanf, pero lee de una cadena, y devuelve cuántos datos convirtió: si no son 2, la línea no tiene el formato esperado. El %31s limita la palabra a 31 caracteres más el nulo, el tamaño de categoria; un %s a secas sería un desbordamiento de búfer esperando a que alguien escriba una categoría larga.

Cuando algo falla: errno y perror

#include <stdio.h>
#include <errno.h>
#include <string.h>

int main(void)
{
    const char *rutas[] = {"no_existe.txt", "/etc/shadow", "/"};
    for (int i = 0; i < 3; i++) {
        FILE *f = fopen(rutas[i], "r+");
        if (f == nullptr) {
            printf("%-14s errno %2d: %s\n", rutas[i], errno, strerror(errno));
        } else {
            fclose(f);
        }
    }
    return 0;
}
salidano_existe.txt  errno  2: No such file or directory
/etc/shadow    errno 13: Permission denied
/              errno 21: Is a directory

Cuando una función de la biblioteca falla, deja en la variable global errno un código que dice por qué. strerror lo traduce a texto, y perror("contexto") lo escribe directamente en stderr con el contexto delante. Un mensaje como «no_existe.txt: No such file or directory» ahorra mucho tiempo frente a un genérico «error al abrir».

Archivos binarios

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

typedef struct {
    int32_t id;
    double temperatura;
} Lectura;

int main(void)
{
    Lectura datos[3] = {{1, 21.5}, {2, 22.25}, {3, 19.75}};

    FILE *f = fopen("lecturas.bin", "wb");
    if (f == nullptr) { perror("lecturas.bin"); return EXIT_FAILURE; }
    size_t escritas = fwrite(datos, sizeof datos[0], 3, f);
    fclose(f);

    Lectura leidas[3];
    f = fopen("lecturas.bin", "rb");
    if (f == nullptr) { perror("lecturas.bin"); return EXIT_FAILURE; }
    size_t n = fread(leidas, sizeof leidas[0], 3, f);
    fclose(f);

    printf("Escritas %zu, leídas %zu, de %zu bytes cada una\n", escritas, n, sizeof(Lectura));
    for (size_t i = 0; i < n; i++) {
        printf("%d: %.2f\n", leidas[i].id, leidas[i].temperatura);
    }
    return 0;
}
salidaEscritas 3, leídas 3, de 16 bytes cada una
1: 21.50
2: 22.25
3: 19.75

fwrite y fread copian bytes tal cual están en memoria, y devuelven cuántos elementos completos lograron. Es rápido, pero el archivo depende del ordenador que lo escribió: el relleno de la struct (aquí 16 bytes para 12 de datos), el orden de los bytes y el tamaño de los tipos. Para archivos que vayan a otras máquinas, mejor texto (CSV, JSON) o un formato binario definido campo a campo.

Dividir el programa en módulos

Un programa real no cabe en un archivo. En C se divide en módulos: cada uno con un .c, que tiene el código, y un .h, la cabecera, que declara lo que el módulo ofrece a los demás. Este pequeño inventario tiene tres archivos. La cabecera:

#ifndef INVENTARIO_H
#define INVENTARIO_H

#include <stddef.h>

typedef struct {
    char nombre[32];
    int cantidad;
} Articulo;

bool inventario_agregar(const char *nombre, int cantidad);
int inventario_total(void);
size_t inventario_cuantos(void);

#endif

El #ifndef / #define / #endif es una guarda de inclusión: si un archivo incluye la cabecera dos veces, directa o indirectamente, la segunda se salta. Muchos compiladores aceptan también #pragma once, más corto pero no estándar. La cabecera solo declara: tipos y prototipos, nunca el código de las funciones. El módulo:

#include <stdio.h>
#include "inventario.h"

#define MAXIMO 100

static Articulo articulos[MAXIMO];
static size_t cuantos = 0;

static bool nombre_valido(const char *nombre)
{
    return nombre != nullptr && nombre[0] != '\0';
}

bool inventario_agregar(const char *nombre, int cantidad)
{
    if (cuantos == MAXIMO || !nombre_valido(nombre) || cantidad < 0) {
        return false;
    }
    Articulo *a = &articulos[cuantos];
    int n = snprintf(a->nombre, sizeof a->nombre, "%s", nombre);
    if (n < 0 || (size_t) n >= sizeof a->nombre) {
        return false;
    }
    a->cantidad = cantidad;
    cuantos++;
    return true;
}

int inventario_total(void)
{
    int total = 0;
    for (size_t i = 0; i < cuantos; i++) {
        total += articulos[i].cantidad;
    }
    return total;
}

size_t inventario_cuantos(void)
{
    return cuantos;
}

Fíjate en los static fuera de las funciones: aquí significan algo distinto que dentro. Una variable o función static en un .c es privada del módulo: ningún otro archivo puede verla. El array articulos y la función nombre_valido son detalles internos; el resto del programa solo puede usar las tres funciones de la cabecera. Es la forma de encapsular de C. Y el programa principal:

#include <stdio.h>
#include "inventario.h"

int main(void)
{
    inventario_agregar("tornillos", 120);
    inventario_agregar("tuercas", 80);
    if (!inventario_agregar("", 5)) {
        printf("Rechazado: nombre vacío\n");
    }
    printf("%zu artículos, %d piezas\n", inventario_cuantos(), inventario_total());
    return 0;
}

Las cabeceras propias se incluyen con comillas ("inventario.h"), que buscan primero en la carpeta del archivo, y las del sistema con ángulos. Para compilarlo todo de una vez:

gcc -Wall -Wextra -g main.c inventario.c -o inventario
./inventario
salidaRechazado: nombre vacío
2 artículos, 200 piezas

El error que te ahorra: olvidar un .c al compilar

Si compilas solo main.c, el compilador no se queja (la cabecera le dice que las funciones existen), pero el enlazador, el último paso, no encuentra su código:

gcc -Wall -Wextra -g main.c -o inventario
gcc: error/usr/bin/ld: /tmp/ccXXXXXX.o: in function `main':
/tmp/p/main.c:6:(.text+0x14): undefined reference to `inventario_agregar'
/usr/bin/ld: /tmp/p/main.c:7:(.text+0x23): undefined reference to `inventario_agregar'
/usr/bin/ld: /tmp/p/main.c:8:(.text+0x32): undefined reference to `inventario_agregar'
/usr/bin/ld: /tmp/p/main.c:11:(.text+0x48): undefined reference to `inventario_total'
/usr/bin/ld: /tmp/p/main.c:11:(.text+0x4f): undefined reference to `inventario_cuantos'
collect2: error: ld returned 1 exit status

«undefined reference» siempre significa lo mismo: algo está declarado pero nadie lo ha compilado. Falta un .c o una biblioteca (-lm para las matemáticas, por ejemplo).

make: compilar solo lo que cambió

main.c e inventario.c se compilan por separado con gcc -c y producen main.o e inventario.o; el enlazador los une en el ejecutable inventario. La cabecera inventario.h la incluyen los dos. Si solo cambia main.c, make recompila solo main.oCompilar por separado y enlazarmain.cgcc -cmain.oinventario.cgcc -cinventario.oinventarioenlazadorinventario.h: lo incluyen los dos .cSi cambias solo main.c,make recompila solo main.o.

Cada .c se compila por separado a un .o, y el enlazador los une. Esquema: decodigo.com.

En un proyecto con cientos de archivos, recompilarlo todo por un cambio es lento. make lee un archivo llamado Makefile con reglas: qué archivo se construye, de qué depende y con qué orden. Solo rehace lo que es más antiguo que sus dependencias:

CC = gcc
CFLAGS = -std=c23 -Wall -Wextra -g

inventario: main.o inventario.o
	$(CC) $(CFLAGS) -o $@ $^

main.o: main.c inventario.h
inventario.o: inventario.c inventario.h

.PHONY: clean
clean:
	rm -f inventario *.o

La primera regla dice que inventario depende de los dos .o y se enlaza con la orden de debajo (que debe empezar con un tabulador, no con espacios: es el error más típico al escribir un Makefile). $@ es el objetivo y $^, todas las dependencias. Las dos líneas siguientes dicen de qué depende cada .o; cómo se compila, make ya lo sabe por su regla implícita, que usa CC y CFLAGS. .PHONY marca clean como una orden, no un archivo.

make
salidagcc -std=c23 -Wall -Wextra -g   -c -o main.o main.c
gcc -std=c23 -Wall -Wextra -g   -c -o inventario.o inventario.c
gcc -std=c23 -Wall -Wextra -g -o inventario main.o inventario.o
make
salidamake: 'inventario' is up to date.
touch main.c
make
salidagcc -std=c23 -Wall -Wextra -g   -c -o main.o main.c
gcc -std=c23 -Wall -Wextra -g -o inventario main.o inventario.o

La tercera vez, tras «tocar» main.c, solo se recompila main.o y se vuelve a enlazar. En proyectos grandes se usan generadores como CMake o Meson, que escriben los Makefiles (o los de Ninja) por ti, pero la idea es la misma.

Ojo con la seguridad: archivos temporales

Un programa que crea un archivo temporal con un nombre predecible (/tmp/informe.txt) o con tmpnam deja una ventana: entre que elige el nombre y lo abre, otro usuario puede crear ese archivo, o un enlace con ese nombre que apunte a otro sitio, y el programa acabaría escribiendo donde no debe. Es una condición de carrera clásica. glibc lo considera tan peligroso que hasta el enlazador avisa:

#include <stdio.h>

int main(void)
{
    char nombre[L_tmpnam];
    if (tmpnam(nombre) != nullptr) {
        printf("Usaría %s\n", nombre);
    }
    return 0;
}
gcc: aviso/curso/temporal.c:6:(.text+0x10): warning: the use of `tmpnam' is dangerous, better use `mkstemp'

La forma segura es mkstemp, que elige el nombre y crea el archivo en un solo paso, solo legible por ti, o tmpfile() si no necesitas el nombre. Lo mismo vale para el modo "wx" de C23: abrir de forma que falle si el archivo ya existía.

Más en Los fallos de seguridad típicos de C.

Resumen

  • ParaEscribeRecuerda
  • AbrirFILE *f = fopen(ruta, "r");comprueba nullptr y usa perror
  • Leer líneaswhile (fgets(buf, sizeof buf, f))sscanf con anchos: %31s
  • Escribirfprintf(f, ...)comprueba también fclose
  • Datos binariosfwrite / freaddependen de la máquina
  • Interfaz de un móduloun .h con guardasolo declaraciones
  • Algo privado del módulostatic fuera de funcionesencapsula
  • Compilar lo que cambióun Makefilelas órdenes, con tabulador
  • Temporalesmkstemp, tmpfilenunca tmpnam

Retos

Reto 1. Escribe un pequeño wc: cuenta las líneas, palabras y caracteres de gastos.txt leyendo carácter a carácter con fgetc.

Ver la solución
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>

int main(void)
{
    FILE *f = fopen("gastos.txt", "r");
    if (f == nullptr) { perror("gastos.txt"); return EXIT_FAILURE; }

    long lineas = 0, palabras = 0, caracteres = 0;
    bool dentro = false;
    int c;
    while ((c = fgetc(f)) != EOF) {
        caracteres++;
        if (c == '\n') lineas++;
        if (isspace(c)) {
            dentro = false;
        } else if (!dentro) {
            dentro = true;
            palabras++;
        }
    }
    fclose(f);
    printf("%ld %ld %ld\n", lineas, palabras, caracteres);
    return 0;
}
salida5 10 58

La variable c es int y no char a propósito: fgetc devuelve un byte (de 0 a 255) o EOF, que vale -1, y en un char no caben los 257 valores. Es un error muy frecuente que hace que el bucle no termine nunca o termine antes de tiempo.

Reto 2. Copia gastos.txt en copia.txt por bloques de 4 KB con fread y fwrite, y di cuántos bytes copiaste.

Ver la solución
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    FILE *origen = fopen("gastos.txt", "rb");
    if (origen == nullptr) { perror("gastos.txt"); return EXIT_FAILURE; }
    FILE *destino = fopen("copia.txt", "wb");
    if (destino == nullptr) { perror("copia.txt"); fclose(origen); return EXIT_FAILURE; }

    char bloque[4096];
    size_t n, total = 0;
    while ((n = fread(bloque, 1, sizeof bloque, origen)) > 0) {
        if (fwrite(bloque, 1, n, destino) != n) {
            perror("copia.txt");
            break;
        }
        total += n;
    }
    fclose(origen);
    if (fclose(destino) != 0) { perror("copia.txt"); return EXIT_FAILURE; }
    printf("Copiados %zu bytes\n", total);
    return 0;
}
salidaCopiados 58 bytes

Con elementos de 1 byte, fread devuelve directamente cuántos bytes leyó, y el último bloque, más corto, se escribe completo. Si falla la apertura del destino, hay que cerrar el origen antes de salir.

Reto 3. Si en inventario.c quitas el static de cuantos y en main.c declaras extern size_t cuantos;, ¿podría main leerla y cambiarla? ¿Y con el static puesto?

Ver la solución

Sin static, sí: extern declara que la variable existe en otro módulo, y el enlazador las une. Con el static, la variable es privada de inventario.c y el enlazador fallaría con un undefined reference to `cuantos'. Esa es precisamente la idea: que nadie pueda saltarse inventario_agregar y dejar el contador en un valor imposible.

Todo el código de esta lección se compiló con GCC 15.3 (C23, -Wall -Wextra) y se ejecutó en un contenedor limpio antes de publicarla; los avisos del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con gcc --version: hace falta GCC 15 o Clang 19 para todo lo de C23.