← Programación en C para Sistemas Operativos Bloque 10 · Integración y cierre · Tema 41 de 42

Tema 41

Proyecto: una herramienta de sistema en C

Este tema junta todo el curso en un programa completo, construido de principio a fin: cuenta, una versión propia de wc que cuenta líneas, palabras y bytes de uno o varios archivos o de la entrada estándar. Es una herramienta pequeña a propósito, porque lo que importa no es el algoritmo sino todo lo que rodea al algoritmo en un programa real: decidir qué hace y cómo falla, separar una biblioteca reutilizable de un programa que la usa (temas 13 y 32), leer archivos con las llamadas del sistema (tema 38), tratar opciones con getopt (tema 23), devolver códigos de salida con sentido (tema 37), construirlo con un Makefile que sabe de variantes y dependencias (temas 35 y 36), probarlo con pruebas unitarias y con una comparación contra el wc del sistema, y pasarlo por las herramientas de calidad del tema 40. El proyecto cometió errores de verdad durante su desarrollo; se muestran tal como las pruebas los encontraron. Todo el código y todas las salidas son de Linux con gcc 15 y GNU Make 4.4.

1. Qué se construye, antes de escribir código

Lo primero en un proyecto no es abrir el editor sino escribir, en pocas líneas, qué debe hacer el programa y cómo debe fallar. Para cuenta:

Código de salidaSituación
0 (EX_OK)Todo salió bien.
64 (EX_USAGE)Opción inválida.
66 (EX_NOINPUT)Algún archivo no existe o no se tiene permiso para leerlo.
74 (EX_IOERR)Error de lectura (por ejemplo, un directorio) o de escritura de la salida (disco lleno).

Con varios errores, el programa devuelve el mayor de los códigos. Escribir esto de antemano parece burocracia y es lo contrario: son las pruebas de la sección 6 antes de existir. Cada línea de esta especificación se convertirá más adelante en una comprobación.

Una segunda decisión de diseño es elegir una referencia. Existe un programa que hace exactamente esto, wc, y se puede ejecutar a la par con el nuestro sobre los mismos archivos: es la prueba de integración más barata que se puede tener. (Con un matiz que se descubrirá en la sección 8: la referencia también tiene sus propias reglas.)

2. La estructura del proyecto

cuenta/
cuenta/
├── Makefile
├── include/
│   └── cuenta.h          ← la interfaz pública de la biblioteca
├── src/
│   ├── cuenta.c          ← la biblioteca: cuenta bytes, líneas y palabras
│   └── main.c            ← el programa: opciones, archivos, mensajes y códigos
├── tests/
│   ├── prueba.c          ← pruebas unitarias de la biblioteca
│   └── integracion.sh    ← compara el programa con wc y comprueba los errores
└── build/                ← lo genera make (debug, release, san)

La separación en dos partes tiene una razón de fondo. La biblioteca (cuenta.c) sabe contar y nada más: no imprime, no mira argv, no llama a exit; recibe datos o un descriptor y devuelve un resultado o un código de error. El programa (main.c) es la interfaz con el usuario: interpreta opciones, decide qué mensaje mostrar y con qué código salir. Esa división permite probar la lógica sin lanzar procesos, reutilizarla en otro programa (por ejemplo, uno que cuente varios archivos a la vez con hilos) y, sobre todo, mantener cada parte entendible. Son 477 líneas en total, entre código, pruebas y Makefile.

3. La biblioteca

La interfaz es el encabezado. Define los tipos de datos y las cinco funciones, con un comentario que dice el contrato (qué devuelven y en qué casos):

include/cuenta.h
#ifndef CUENTA_H
#define CUENTA_H

#include <stddef.h>

/* Resultado de contar: lineas, palabras y bytes. */
struct conteo {
    size_t lineas;
    size_t palabras;
    size_t bytes;
};

/* Estado de un conteo en marcha: permite alimentarlo por trozos. */
struct contador {
    struct conteo total;
    int dentro_de_palabra;
};

void contador_iniciar(struct contador *k);
void contador_alimentar(struct contador *k, const void *datos, size_t n);

/* Cuentan todo lo que se pueda leer de un descriptor o de una ruta ("-" es la
 * entrada estandar). Devuelven 0 si salio bien o un codigo de errno. */
int contar_fd(int fd, struct conteo *resultado);
int contar_ruta(const char *ruta, struct conteo *resultado);

void conteo_sumar(struct conteo *destino, const struct conteo *otro);

#endif

El diseño tiene tres decisiones que conviene señalar. Primera: el conteo es incremental. struct contador guarda, además del resultado, si el último byte visto estaba dentro de una palabra; eso permite alimentarlo con trozos de cualquier tamaño y obtener el mismo resultado que con el archivo completo (es lo que se prueba y lo que falló en el primer intento, sección 8). Segunda: los errores se devuelven con la convención 3 del tema 37, un código de errno (0 es éxito), de modo que la biblioteca no depende de variables globales ni imprime nada. Tercera: contar_ruta entiende el nombre especial «-» como la entrada estándar, igual que las herramientas de Unix.

src/cuenta.c
#define _POSIX_C_SOURCE 200809L
#include <ctype.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>

#include "cuenta.h"

enum { TAM_BUFER = 65536 };

void contador_iniciar(struct contador *k)
{
    memset(k, 0, sizeof *k);
}

void contador_alimentar(struct contador *k, const void *datos, size_t n)
{
    const unsigned char *p = datos;

    for (size_t i = 0; i < n; i++) {
        if (p[i] == '\n')
            k->total.lineas++;
        if (isspace(p[i])) {
            k->dentro_de_palabra = 0;
        } else if (!k->dentro_de_palabra) {
            k->dentro_de_palabra = 1;
            k->total.palabras++;
        }
    }
    k->total.bytes += n;
}

int contar_fd(int fd, struct conteo *resultado)
{
    struct contador k;
    char buf[TAM_BUFER];

    contador_iniciar(&k);
    for (;;) {
        ssize_t n = read(fd, buf, sizeof buf);

        if (n < 0) {
            if (errno == EINTR)
                continue;
            return errno;
        }
        if (n == 0)
            break;
        contador_alimentar(&k, buf, (size_t)n);
    }
    *resultado = k.total;
    return 0;
}

int contar_ruta(const char *ruta, struct conteo *resultado)
{
    if (strcmp(ruta, "-") == 0)
        return contar_fd(STDIN_FILENO, resultado);

    int fd = open(ruta, O_RDONLY);

    if (fd < 0)
        return errno;

    int r = contar_fd(fd, resultado);

    close(fd);
    return r;
}

void conteo_sumar(struct conteo *destino, const struct conteo *otro)
{
    destino->lineas += otro->lineas;
    destino->palabras += otro->palabras;
    destino->bytes += otro->bytes;
}

Cada decisión aprovecha algo del curso. Se pasa (unsigned char) a isspace para evitar el comportamiento indefinido con bytes mayores que 127 (tema 21). El búfer de 64 KiB vive en la pila (tema 12) y es una constante con nombre, no un número mágico; con 64 KiB las llamadas a read son pocas (sección 9). El bucle de read es el del tema 38: un valor negativo es un error (con el reintento para EINTR del tema 37), 0 es el fin, y un valor positivo es la cantidad real leída, sin suponer nada más. En contar_ruta, el resultado de contar_fd se guarda antes de llamar a close, para que ningún otro fallo pise el error original. Y la función contar_fd no sabe de dónde viene el descriptor: sirve igual para un archivo, una tubería o la terminal.

4. El programa

main.c es la interfaz con el usuario. Interpreta las opciones con getopt, recorre los archivos, y traduce los códigos de error de la biblioteca en mensajes y en un código de salida:

src/main.c
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <sysexits.h>
#include <unistd.h>

#include "cuenta.h"

struct opciones {
    int lineas;
    int palabras;
    int bytes;
};

static void uso(FILE *f)
{
    fputs("uso: cuenta [-lwc] [archivo...]\n"
          "  -l  cuenta lineas\n"
          "  -w  cuenta palabras\n"
          "  -c  cuenta bytes\n"
          "  sin opciones muestra las tres; sin archivos (o con \"-\") lee la\n"
          "  entrada estandar\n", f);
}

static void imprimir(const struct conteo *c, const struct opciones *o,
                     const char *nombre)
{
    if (o->lineas)
        printf(" %6zu", c->lineas);
    if (o->palabras)
        printf(" %6zu", c->palabras);
    if (o->bytes)
        printf(" %6zu", c->bytes);
    if (nombre)
        printf(" %s", nombre);
    putchar('\n');
}

int main(int argc, char **argv)
{
    struct opciones o = {0, 0, 0};
    int opt;

    while ((opt = getopt(argc, argv, "lwch")) != -1) {
        switch (opt) {
        case 'l': o.lineas = 1; break;
        case 'w': o.palabras = 1; break;
        case 'c': o.bytes = 1; break;
        case 'h': uso(stdout); return EX_OK;
        default: uso(stderr); return EX_USAGE;
        }
    }
    if (!o.lineas && !o.palabras && !o.bytes)
        o.lineas = o.palabras = o.bytes = 1;

    static const char *const entrada[] = {"-"};
    const char *const *archivos = entrada;
    int cantidad = 1;

    if (optind < argc) {
        archivos = (const char *const *)&argv[optind];
        cantidad = argc - optind;
    }

    struct conteo total = {0, 0, 0};
    int estado = EX_OK;

    for (int i = 0; i < cantidad; i++) {
        struct conteo c;
        int e = contar_ruta(archivos[i], &c);

        if (e != 0) {
            int codigo = (e == ENOENT || e == EACCES) ? EX_NOINPUT : EX_IOERR;

            fprintf(stderr, "cuenta: %s: %s\n", archivos[i], strerror(e));
            if (codigo > estado)
                estado = codigo;
            continue;
        }
        conteo_sumar(&total, &c);
        imprimir(&c, &o, optind < argc ? archivos[i] : NULL);
    }
    if (cantidad > 1)
        imprimir(&total, &o, "total");

    if (fflush(stdout) != 0 || ferror(stdout)) {
        fprintf(stderr, "cuenta: error al escribir la salida: %s\n",
                strerror(errno));
        return EX_IOERR;
    }
    return estado;
}

Los puntos de diseño de este archivo: uso recibe el flujo como parámetro, para escribir en stdout con -h (petición del usuario, éxito) y en stderr con una opción inválida (error). Los contadores se imprimen con " %6zu": una columna de al menos 7 caracteres con un espacio delante, de modo que dos columnas nunca se peguen (la primera versión no lo hacía, sección 8). La lista de archivos por omisión es un arreglo constante con el nombre «-»; así, el caso sin argumentos pasa por el mismo código que los demás. Ante un error, el programa no se detiene: avisa, anota el código más grave y sigue con los demás archivos, como hacen wc y cat. Y al final comprueba fflush(stdout) y ferror(stdout): sin esta comprobación, un disco lleno (> /dev/full) daría una salida vacía y un código de éxito (tema 37).

Con _POSIX_C_SOURCE definido (la convención del curso), getopt en glibc no reordena los argumentos: las opciones deben ir antes de los nombres de archivo. Es el comportamiento estricto de POSIX y se documenta en la ayuda.

5. El Makefile

El Makefile es el del tema 36, adaptado a este proyecto: una sola biblioteca estática, tres modos (debug, release y san con los sanitizadores del tema 40, cada uno en su directorio de build/), dependencias automáticas, salida limpia con V=1, y un objetivo test que ejecuta las pruebas unitarias y las de integración:

Makefile
CC       = gcc
MODE    ?= debug
BUILD    = build/$(MODE)
PREFIX  ?= /usr/local

CPPFLAGS = -Iinclude -MMD -MP
CFLAGS   = -std=c17 -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wstrict-prototypes -Wformat=2
ifeq ($(MODE),release)
CFLAGS  += -O2 -DNDEBUG
else ifeq ($(MODE),san)
CFLAGS  += -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=all
LDFLAGS += -fsanitize=address,undefined
else
CFLAGS  += -O0 -g
endif

ifeq ($(V),1)
Q    =
say  = @:
else
Q    = @
say  = @echo "  $(1)  $(2)"
endif

LIBOBJ  = $(BUILD)/cuenta.o
LIB     = $(BUILD)/libcuenta.a
PROG    = $(BUILD)/cuenta
TEST    = $(BUILD)/prueba
DEPS    = $(LIBOBJ:.o=.d) $(BUILD)/main.d $(BUILD)/prueba.d

.DEFAULT_GOAL := all
.PHONY: all test install clean help
.DELETE_ON_ERROR:

all: $(PROG)  ## compila la herramienta

$(PROG): $(BUILD)/main.o $(LIB)
	$(call say,LINK,$@)
	$(Q)$(CC) $(LDFLAGS) -o $@ $< -L$(BUILD) -lcuenta $(LDLIBS)

$(LIB): $(LIBOBJ)
	$(call say,AR,$@)
	$(Q)ar rcs $@ $^

$(BUILD)/%.o: src/%.c | $(BUILD)
	$(call say,CC,$<)
	$(Q)$(CC) $(CPPFLAGS) $(CFLAGS) -c -o $@ $<

$(BUILD)/prueba.o: tests/prueba.c | $(BUILD)
	$(call say,CC,$<)
	$(Q)$(CC) $(CPPFLAGS) $(CFLAGS) -c -o $@ $<

$(TEST): $(BUILD)/prueba.o $(LIB)
	$(call say,LINK,$@)
	$(Q)$(CC) $(LDFLAGS) -o $@ $< -L$(BUILD) -lcuenta $(LDLIBS)

$(BUILD):
	$(Q)mkdir -p $@

test: $(TEST) $(PROG)  ## pruebas unitarias y de integracion
	$(Q)$(TEST)
	$(Q)tests/integracion.sh $(PROG)

install: $(PROG)  ## instala en $(PREFIX)/bin
	$(Q)install -D -m 755 $(PROG) $(DESTDIR)$(PREFIX)/bin/cuenta

clean:  ## borra lo generado
	$(Q)rm -rf build

help:  ## muestra esta ayuda
	@grep -hE '^[a-z]+:.*##' Makefile | awk -F':.*## ' '{printf "%-8s %s\n", $$1, $$2}'

-include $(DEPS)

Se compila con un conjunto estricto de avisos (el recomendado en el tema 40) y el proyecto no produce ninguno. Dos objetivos fueron decisivos durante el desarrollo: make test, que se ejecutó decenas de veces, y make MODE=san test, que vuelve a correr toda la batería con AddressSanitizer y UBSan.

terminal
$ make
  CC  src/main.c
  CC  src/cuenta.c
  AR  build/debug/libcuenta.a
  LINK  build/debug/cuenta
$ make
make: Nothing to be done for 'all'.
$ make help
all      compila la herramienta
test     pruebas unitarias y de integracion
install  instala en $(PREFIX)/bin
clean    borra lo generado
help     muestra esta ayuda

6. Las pruebas

Hay dos niveles, y cada uno encuentra errores que el otro no ve.

Pruebas unitarias: la biblioteca

tests/prueba.c usa la macro VERIFICAR del tema 36 y ejercita la biblioteca sin tocar el programa. Incluye los casos básicos (vacío, una palabra sin salto de línea, solo espacios, un byte no ASCII, un \0 en medio), los dos errores previstos (ENOENT y EISDIR), la suma, y dos pruebas que merecen verse enteras:

tests/prueba.c · los casos de borde y la prueba de trozos
static void casos_basicos(void)
{
    iguales("", 0, 0, 0, 0);
    iguales("hola", 4, 0, 1, 4);
    iguales("a\nb\n", 4, 2, 2, 4);
    iguales("  a \t b\n\n", 9, 2, 2, 9);
    iguales("   \n", 4, 1, 0, 4);
    iguales("\xc3\xa9", 2, 0, 1, 2);
    iguales("a\0b", 3, 0, 1, 3);
}

/* El resultado no debe depender de como se parta la entrada en trozos. */
static void trozos_aleatorios(void)
{
    enum { N = 20000 };
    static const char alfabeto[] = "ab \n\t";
    char datos[N];

    srand(12345);
    for (int i = 0; i < N; i++)
        datos[i] = alfabeto[rand() % 5];

    struct conteo entero = contar_texto(datos, N);

    for (int ronda = 0; ronda < 50; ronda++) {
        struct contador k;
        size_t pos = 0;

        contador_iniciar(&k);
        while (pos < N) {
            size_t t = 1 + (size_t)(rand() % 700);

            if (t > N - pos)
                t = N - pos;
            contador_alimentar(&k, datos + pos, t);
            pos += t;
        }
        VERIFICAR(k.total.lineas == entero.lineas);
        VERIFICAR(k.total.palabras == entero.palabras);
        VERIFICAR(k.total.bytes == entero.bytes);
    }
}

/* Una palabra mas larga que el bufer de lectura debe contar una sola vez. */
static void palabra_mas_larga_que_el_bufer(void)
{
    enum { N = 200000 };
    char *datos = malloc(N);
    char ruta[] = "/tmp/cuenta_prueba_XXXXXX";
    struct conteo r;

    VERIFICAR(datos != NULL);
    if (datos == NULL)
        return;
    memset(datos, 'a', N);
    VERIFICAR(archivo_temporal(datos, N, ruta) == 0);
    VERIFICAR(contar_ruta(ruta, &r) == 0);
    VERIFICAR(r.lineas == 0 && r.palabras == 1 && r.bytes == N);
    unlink(ruta);
    free(datos);
}

La prueba de trozos es una prueba de propiedad: en lugar de verificar un resultado concreto, verifica algo que debe cumplirse siempre (dividir la entrada de cualquier manera no cambia la respuesta) con 50 particiones aleatorias, de semilla fija para que sea reproducible. La tercera crea un archivo de 200 000 bytes con una sola palabra, el caso que cruza varios límites del búfer de 64 KiB, y comprueba que cuenta una palabra.

Pruebas de integración: el programa completo

El script tests/integracion.sh ejecuta el programa tal como lo haría un usuario y lo compara con wc sobre seis archivos de forma distinta (vacío, sin salto de línea final, con espacios y tabuladores, 100 000 bytes ASCII aleatorios, 200 000 líneas, y con letras acentuadas), con las ocho combinaciones de opciones, por archivo, por la entrada estándar y por una tubería, y con varios archivos a la vez. También verifica uno por uno los códigos de salida de la tabla de la sección 1:

tests/integracion.sh
#!/bin/sh
# Compara cuenta con wc en archivos de distintas formas y revisa los errores.
# uso: tests/integracion.sh ruta/al/programa

PROG=$1
export LC_ALL=C
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
total=0
fallos=0

norm() { awk '{$1=$1} 1'; }

comparar() { # descripcion; comando de referencia; comando propio
    total=$((total + 1))
    esperado=$(sh -c "$2" 2>/dev/null | norm)
    obtenido=$(sh -c "$3" 2>/dev/null | norm)
    if [ "$esperado" != "$obtenido" ]; then
        fallos=$((fallos + 1))
        echo "FALLO: $1"
        echo "  wc:     $esperado"
        echo "  cuenta: $obtenido"
    fi
}

verificar() { # descripcion; valor obtenido; valor esperado
    total=$((total + 1))
    if [ "$2" != "$3" ]; then
        fallos=$((fallos + 1))
        echo "FALLO: $1 (esperado $3, obtenido $2)"
    fi
}

: > "$TMP/vacio"
printf 'hola mundo' > "$TMP/sin_salto"
printf 'uno  dos\ttres\n\n  cuatro \n' > "$TMP/texto"
head -c 400000 /dev/urandom | tr -d '\200-\377' | head -c 100000 > "$TMP/binario"
seq 1 200000 > "$TMP/grande"
printf '\303\241\303\251 \303\261 caf\303\251\n' > "$TMP/utf8"

for f in vacio sin_salto texto binario grande utf8; do
    for o in "" -l -w -c -lw -lc -wc -lwc; do
        comparar "$f $o" "wc $o $TMP/$f" "$PROG $o $TMP/$f"
    done
    comparar "$f por la entrada estandar" "wc < $TMP/$f" "$PROG < $TMP/$f"
    comparar "$f por una tuberia" "cat $TMP/$f | wc" "cat $TMP/$f | $PROG"
done
comparar "varios archivos y total" "wc $TMP/texto $TMP/grande $TMP/utf8" \
    "$PROG $TMP/texto $TMP/grande $TMP/utf8"

"$PROG" -h > /dev/null;                    verificar "-h" $? 0
"$PROG" -z "$TMP/texto" > /dev/null 2>&1;  verificar "opcion invalida" $? 64
"$PROG" "$TMP/no_existe" > /dev/null 2>&1; verificar "archivo inexistente" $? 66
"$PROG" "$TMP" > /dev/null 2>&1;           verificar "directorio" $? 74
"$PROG" "$TMP/no_existe" "$TMP/texto" > /dev/null 2>&1
verificar "sigue tras un error" $? 66
comparar "cuenta lo que si se pudo leer" "wc $TMP/no_existe $TMP/texto" \
    "$PROG $TMP/no_existe $TMP/texto"
if [ -w /dev/full ]; then
    "$PROG" "$TMP/texto" > /dev/full 2>/dev/null
    verificar "salida a un disco lleno" $? 74
fi

echo "$total comprobaciones, $fallos fallos"
[ "$fallos" -eq 0 ]

Detalles que hacen robusta esta prueba: usa LC_ALL=C para fijar el entorno de comparación, un directorio temporal que se borra solo con trap … EXIT, la función norm que quita los espacios sobrantes para no depender del ancho de columna de cada herramienta, y termina con un código de salida distinto de cero si algo falló, que es lo que hace fallar a make test. Todo junto:

terminal
$ make test; echo "rc=$?"
  CC  tests/prueba.c
  LINK  build/debug/prueba
164 verificaciones, 0 fallos
68 comprobaciones, 0 fallos
rc=0

7. Primeras ejecuciones

Con las pruebas en verde, el programa se usa como cualquier otra herramienta. Un archivo de tres líneas con espacios y tabuladores irregulares:

terminal
$ printf 'uno  dos\ttres\n\n  cuatro \n' > t.txt
$ cuenta t.txt
      3      4     25 t.txt
$ cuenta -l t.txt
      3 t.txt
$ cuenta -lw t.txt
      3      4 t.txt
$ cuenta < t.txt
      3      4     25
$ cat t.txt | cuenta -w
      4
$ cuenta t.txt t.txt
      3      4     25 t.txt
      3      4     25 t.txt
      6      8     50 total
$ wc t.txt
 3  4 25 t.txt

El resultado coincide con wc (cambia solo el ancho de las columnas). Y los casos de error, con sus códigos de salida:

terminal
$ cuenta noexiste.txt; echo "rc=$?"
cuenta: noexiste.txt: No such file or directory
rc=66
$ cuenta /tmp; echo "rc=$?"
cuenta: /tmp: Is a directory
rc=74
$ cuenta -z t.txt; echo "rc=$?"
./build/debug/cuenta: invalid option -- 'z'
uso: cuenta [-lwc] [archivo...]
  -l  cuenta lineas
  -w  cuenta palabras
  -c  cuenta bytes
  sin opciones muestra las tres; sin archivos (o con "-") lee la
  entrada estandar
rc=64
$ cuenta noexiste.txt t.txt; echo "rc=$?"
cuenta: noexiste.txt: No such file or directory
      3      4     25 t.txt
      3      4     25 total
rc=66
$ cuenta t.txt > /dev/full; echo "rc=$?"
cuenta: error al escribir la salida: No space left on device
rc=74
$ chmod 000 t.txt; cuenta t.txt; echo "rc=$?"; chmod 644 t.txt
cuenta: t.txt: Permission denied
rc=66

Todos son los mensajes y códigos de la especificación. El de la opción inválida lleva dos líneas: la primera la imprime getopt (su texto es de glibc, en inglés) y la segunda es la ayuda del programa. Un archivo inexistente no impide contar el siguiente, y el código de salida lo recuerda.

8. Lo que las pruebas encontraron

Un proyecto de verdad no sale bien a la primera. Estos son los tres problemas que aparecieron, en el orden en que aparecieron, y qué prueba los delató.

Hallazgo 1: columnas pegadas

La primera versión de imprimir usaba "%7zu": cada contador en 7 caracteres, sin separador. Con archivos pequeños es idéntica a wc; con un archivo de 1,2 MB, cuyo contador de bytes ocupa 7 dígitos, las columnas se pegan. Lo detectó la prueba de integración sobre el archivo grande en la primera ejecución:

terminal · la primera versión y la corregida, sobre seq 1 200000
FALLO: grande -lwc
  wc:     200000 200000 1288895
  cuenta: 200000 2000001288895

$ ./v1/build/debug/cuenta g.txt        # "%7zu"
 200000 2000001288895 g.txt
$ ./build/debug/cuenta g.txt                # " %6zu"
 200000 200000 1288895 g.txt

La corrección es de una línea: un espacio separador y un ancho de 6 (" %6zu"). Ningún caso pequeño la habría encontrado; lo hizo un archivo de tamaño realista. La lección: las pruebas deben incluir datos del tamaño del mundo real, no solo ejemplos de juguete.

Hallazgo 2: la referencia también tiene reglas

La segunda ejecución falló en los datos aleatorios: cuenta contaba 2242 palabras donde wc contaba 2244. Con la hipótesis lógica de que el error estaba en cuenta, se buscó, pero la función es simple y su definición de palabra (seis bytes) es la de la especificación. La investigación fue contrastar la referencia con un caso mínimo:

terminal · «a», el byte 0xC2 0xA0 (un espacio de no separación Unicode), «b»
$ printf 'a\xc2\xa0b\n' > nbsp.txt
$ od -An -tx1 nbsp.txt
 61 c2 a0 62 0a
$ LC_ALL=C wc nbsp.txt
1 2 5 nbsp.txt
$ LC_ALL=C cuenta nbsp.txt
      1      1      5 nbsp.txt

El wc de este sistema es el de uutils (una reimplementación en Rust de las herramientas de GNU, que Ubuntu usa por omisión en esta versión) y interpreta UTF-8 aunque se le diga LC_ALL=C: la secuencia C2 A0 es un espacio Unicode y separa palabras. El wc de GNU, en la configuración «C», debería contarlas como una sola (no está instalado en este equipo para comprobarlo). Entre 100 000 bytes aleatorios aparecen algunos pares así. No era un error de cuenta, sino una diferencia de especificación con la referencia. La decisión (que es de diseño, no de programación) fue: el programa sigue contando bytes y espacios ASCII, como dice la sección 1, y la prueba de integración usa datos ASCII (el filtro tr -d '\200-\377' de la línea que genera binario), de modo que la comparación mida lo que se quiere medir. El caso de los bytes no ASCII quedó cubierto por la prueba unitaria ("\xc3\xa9" es una palabra de dos bytes) y por el archivo utf8, que no contiene espacios Unicode. La lección tiene dos partes: una herramienta de referencia no es la verdad absoluta, y una diferencia entre dos programas hay que entenderla antes de «arreglar» cualquiera de los dos.

Hallazgo 3: el estado entre lecturas (un error provocado a propósito)

El tercero no apareció solo: para mostrar para qué sirven las pruebas de borde se reproduce a propósito el error clásico de esta clase de programas, olvidar que una palabra puede cruzar el límite entre dos lecturas. Se simula una implementación que reinicia «estoy dentro de una palabra» en cada lectura (una línea cambiada en contar_fd):

terminal · la variante defectuosa y las pruebas
$ diff src/cuenta.c cuenta_bug.c
50c50
<         contador_alimentar(&k, buf, (size_t)n);
---
>         k.dentro_de_palabra = 0; contador_alimentar(&k, buf, (size_t)n);
$ gcc -std=c17 -Wall -Wextra -Iinclude -o prueba_bug tests/prueba.c cuenta_bug.c && ./prueba_bug
FALLO: r.lineas == 0 && r.palabras == 1 && r.bytes == N (linea 109)
164 verificaciones, 1 fallos
$ tests/integracion.sh ./cuenta_bug_main | head -4
FALLO: binario
  wc:     792 4460 100000 /tmp/tmp.TQD8uScx2x/binario
  cuenta: 792 4461 100000 /tmp/tmp.TQD8uScx2x/binario
FALLO: binario -w

Se contó una palabra de más cada vez que el límite de 64 KiB partía una. Es un error que no se ve nunca con archivos pequeños y que aparece de forma casi aleatoria con datos grandes. Dos pruebas distintas lo atrapan: la unitaria de la palabra más larga que el búfer, que falla en la línea 109 con una causa evidente, y la de integración, que muestra la diferencia con wc en los datos de 100 000 bytes. Hacen falta las dos: la primera dice dónde está el problema, la segunda confirma que afecta al programa completo.

9. Calidad y rendimiento

Con las pruebas pasando, se aplican las herramientas del tema 40.

strace confirma que el programa hace lo que se diseñó. Un archivo de 1 288 895 bytes se lee en bloques de 64 KiB:

terminal · seq 1 200000 > grande.txt
$ strace -c -e trace=read,openat ./build/release/cuenta grande.txt 2>&1 | tail -8
 200000 200000 1288895 grande.txt
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0        22           read
  0.00    0.000000           0         3           openat
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000           0        25           total
$ strace -e trace=openat ./build/release/cuenta noexiste.txt 2>&1 | grep -v -e '\.so' -e ld.so | head -3
openat(AT_FDCWD, "noexiste.txt", O_RDONLY) = -1 ENOENT (No such file or directory)
cuenta: noexiste.txt: No such file or directory
+++ exited with 66 +++

Son 22 lecturas: unas 20 de 64 KiB (1 288 895 / 65 536 ≈ 19,7), una final que devuelve 0 (fin de archivo) y la del cargador. Si el búfer fuera de un byte, serían 1,3 millones. Y el fallo de un archivo inexistente se ve como un único openat con ENOENT, que se corresponde con el mensaje y el código 66.

Rendimiento: para un archivo de 300 MB de texto repetitivo, comparado con el wc del sistema (tiempos de una ejecución en esta máquina, solo orientativos):

terminal · la variante release (-O2 -DNDEBUG)
$ time ./build/release/cuenta enorme.txt
 6000000 54000000 300000000 enorme.txt
real	0m0.333s
$ time wc enorme.txt
  6000000  54000000 300000000 enorme.txt
real	0m1.417s
$ time ./build/release/cuenta -c enorme.txt
 300000000 enorme.txt
real	0m0.295s
$ time wc -c enorme.txt
300000000 enorme.txt
real	0m0.002s

El programa, simple y sin trucos, cuenta líneas, palabras y bytes más rápido que la referencia de esta máquina (que hace más cosas: interpreta UTF-8). Pero hay un caso donde pierde por mucho: -c solo. wc -c no lee el archivo: le pregunta su tamaño al sistema de archivos (con fstat), y tarda 2 milisegundos; cuenta -c lee los 300 MB. Es una optimización clara y un buen ejercicio (sección de ejercicios). Un rasgo del proyecto bien diseñado es que la mejora tocaría un solo lugar de la biblioteca y las pruebas existentes verificarían que no rompe nada. La variante release pesa 17 048 bytes, frente a 23 776 de la de depuración (con símbolos).

10. Entrega

Para entregar, se compila la variante de producción y se instala con las convenciones PREFIX y DESTDIR (tema 36), de modo que quien empaque el programa pueda ensayar la instalación sin tocar el sistema:

terminal
$ make MODE=release
  CC  src/main.c
  CC  src/cuenta.c
  AR  build/release/libcuenta.a
  LINK  build/release/cuenta
$ make install DESTDIR=stage PREFIX=/usr/local
$ find stage -type f
stage/usr/local/bin/cuenta
$ stage/usr/local/bin/cuenta -l t.txt
      3 t.txt

La lista de verificación antes de dar por terminada una herramienta:

11. Hacia dónde crecer

La herramienta quedó deliberadamente acotada. Cada una de estas extensiones usa una parte del curso y es un buen ejercicio:

12. Errores frecuentes en un proyecto

ErrorQué pasaCorrección
Empezar a programar sin especificar el comportamiento y los erroresNo hay criterio para decir si algo está bien; las pruebas se escriben al final y reflejan lo que el código ya hacía.Escribir antes uso, formato y códigos de salida; que cada línea sea una prueba.
Mezclar la lógica con la entrada/salida de usuarioNo se puede probar sin lanzar el programa; no se reutiliza.Biblioteca que devuelve datos y códigos; programa que imprime y decide.
Biblioteca que imprime o llama a exitEl programa que la usa pierde el control de sus mensajes y de su terminación.Devolver el error (errno) a quien llama.
Probar solo con ejemplos pequeñosLos errores de tamaño y de límites del búfer (columnas pegadas, palabras partidas) se escapan.Archivos grandes, aleatorios, vacíos y más largos que el búfer.
Confiar en una herramienta de referencia sin entenderlaDiferencias de especificación se toman por errores propios (o al revés).Investigar con un caso mínimo y decidir cuál es la especificación.
Detenerse en el primer archivo que fallaUn solo error impide ver los demás resultados.Avisar, continuar y reflejar el peor error en el código de salida.
No comprobar fflush/fclose de la salida«Éxito» con un disco lleno.Comprobar y devolver EX_IOERR.
Usar números mágicos (65536, 7, 64…)Cambios dispersos y errores al modificar.Constantes con nombre (TAM_BUFER, EX_USAGE).
Un único modo de compilaciónLos errores de memoria o de comportamiento indefinido pasan; el binario de entrega no se prueba.Modos debug, san y release, cada uno con su directorio.
Probar solo el modo de depuraciónEl de entrega (-O2 -DNDEBUG) puede comportarse distinto.Ejecutar las pruebas también en release.
Dependencias a mano en el MakefileUn cambio en un encabezado no recompila y el binario queda viejo.-MMD -MP y -include (tema 36).
Pruebas que dependen de rutas o archivos del sistema del autorFallan en otra máquina.Directorio temporal propio, datos generados por la prueba y limpieza con trap.
Optimizar antes de medirEsfuerzo en el lugar equivocado.Medir (time, strace -c) y optimizar lo que importa, con las pruebas como red de seguridad.

13. Resumen

14. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Por qué se escribe la especificación (uso, formato, códigos de salida) antes del código?

Porque da un criterio objetivo de lo que es correcto, y porque cada línea se convierte en una prueba. Si se escriben las pruebas al final, tienden a reflejar lo que el código ya hacía en lugar de lo que debía hacer.

2. ¿Por qué se separó el proyecto en una biblioteca y un programa?

La biblioteca contiene la lógica (contar) sin saber nada de la interfaz: no imprime, no lee argv, no sale; devuelve datos y códigos. Así se prueba sin lanzar procesos, se reutiliza en otros programas (por ejemplo, desde hilos) y cada parte es más simple. El programa se ocupa de lo que depende del usuario: opciones, mensajes y códigos de salida.

3. ¿Por qué struct contador guarda dentro_de_palabra?

Porque los datos llegan por trozos (cada read entrega a lo sumo 64 KiB) y una palabra puede quedar partida entre dos trozos. Recordar si el último byte estaba dentro de una palabra evita contarla dos veces. Es lo que verifican la prueba de trozos aleatorios y la de la palabra más larga que el búfer.

4. ¿Cómo informa la biblioteca de los errores y por qué así?

contar_fd y contar_ruta devuelven 0 en éxito o el código de errno (clase 3 del tema 37). Sin imprimir ni usar variables globales: el programa decide el mensaje (strerror) y el código de salida. Se guarda el valor antes de llamar a close para que no se pise.

5. ¿Qué hace el programa cuando un archivo no se puede leer?

Escribe «cuenta: nombre: motivo» por stderr, anota el código (66 si es ENOENT o EACCES, 74 en los demás casos), continúa con los archivos restantes y al final sale con el mayor de los códigos registrados. Así un fallo no oculta los demás resultados.

6. ¿Para qué se comprueban fflush(stdout) y ferror(stdout) al final?

Porque la salida se acumula en un búfer y los errores de escritura (disco lleno, tubería rota) aparecen al vaciarlo. Sin la comprobación, cuenta t.txt > /dev/full terminaría con éxito sin haber escrito nada; con ella se informa y se devuelve EX_IOERR (74).

7. ¿Qué hay de particular en la prueba «trozos aleatorios»?

Es una prueba de propiedad: no verifica un resultado concreto, sino que el conteo no cambia por partir la entrada de cualquier manera. Genera una cadena pseudoaleatoria con semilla fija (reproducible), la cuenta de una vez y luego en 50 particiones de tamaños distintos, y exige que todas coincidan.

8. ¿Por qué hacen falta pruebas unitarias y de integración?

Las unitarias ejercitan la biblioteca con casos de borde y señalan dónde falla (la línea de la verificación). Las de integración ejecutan el programa completo como lo haría un usuario y lo comparan con una referencia (wc), verificando opciones, entrada estándar, tuberías, formato y códigos de salida. Cada nivel encuentra errores que el otro no ve.

9. ¿Cómo se manifestó el error de las columnas pegadas y qué lo detectó?

Con "%7zu" cada contador ocupa 7 caracteres sin separador; con un archivo de 1,2 MB el contador de bytes tiene 7 dígitos y se pega al anterior (2000001288895). Lo detectó la comparación con wc en el archivo grande, no ninguna prueba con archivos pequeños. Se corrigió con " %6zu".

10. La salida de cuenta y la de wc diferían en 2 palabras con datos aleatorios. ¿Cuál era el error?

No era un error de cuenta: el wc de este sistema (uutils) trata como espacios algunas secuencias UTF-8 (por ejemplo C2 A0) aun con LC_ALL=C, y el cuenta solo considera los seis espacios ASCII, como dice su especificación. Se resolvió usando datos ASCII en la prueba de integración. La lección: entender una diferencia con la referencia antes de «arreglar» cualquiera de los programas.

11. ¿Qué tres modos de compilación tiene el Makefile y para qué sirve cada uno?

debug (-O0 -g) para el desarrollo y gdb; san (-O1 -g -fsanitize=address,undefined -fno-sanitize-recover=all) para ejecutar las pruebas con comprobaciones de memoria y comportamiento indefinido; release (-O2 -DNDEBUG) para entregar. Cada modo tiene su directorio en build/, por lo que alternar entre ellos no recompila.

12. ¿Qué muestra strace -c sobre este programa y qué se concluye?

Que un archivo de 1,2 MB se lee con 22 llamadas a read (unas 20 de 64 KiB, una de fin de archivo y la del cargador), lo que confirma que el búfer se usa como se diseñó. Con un búfer de un byte habría más de un millón.

13. ¿Por qué wc -c tarda 2 ms y cuenta -c 0,3 s sobre un archivo de 300 MB?

Porque wc -c sobre un archivo regular no lo lee: le pregunta el tamaño al sistema de archivos con fstat. cuenta lee todos los bytes aunque solo se pida su cantidad. La mejora sería consultar st_size en ese caso, tocando solo la biblioteca.

14. ¿Qué incluye la lista de verificación antes de entregar una herramienta?

Especificación escrita y reflejada en pruebas; compilación sin avisos con opciones estrictas; pruebas unitarias y de integración pasando en debug, san y release; comprobación de todas las llamadas que pueden fallar (incluidos close y fflush); mensajes por stderr y códigos de salida que distingan las causas; casos límite probados; y ayuda e instalación documentadas.

Para practicar

  1. Reconstruye el proyecto desde cero, archivo por archivo, ejecutando make test tras cada paso. Provoca los tres hallazgos de la sección 8 a propósito (el formato "%7zu", la reinicialización del estado, un espacio Unicode) y observa qué prueba falla en cada caso.
  2. Implementa el -c instantáneo con fstat: si solo se pide -c y el archivo es regular, usa st_size. Añade la prueba que compare con wc -c y comprueba el tiempo sobre un archivo de varios cientos de MB. ¿Qué pasa con la entrada estándar de una tubería?
  3. Agrega la opción -L (longitud de la línea más larga) a la biblioteca y al programa. ¿Qué campo hay que agregar a struct contador y por qué necesita estado entre trozos igual que las palabras?
  4. Haz que cuenta acepte -- y nombres que empiecen con guion (-- -rarisimo.txt), y escribe la prueba de integración correspondiente. ¿Qué dice POSIX de --?
  5. Escribe un modo make coverage que compile con --coverage, ejecute las pruebas y muestre con gcov qué líneas de la biblioteca no cubren las pruebas. ¿Qué casos faltan?
  6. Recorre directorios: con cuenta -r directorio, cuenta todos los archivos regulares que haya dentro (usa opendir, readdir y stat). Decide la especificación (¿se siguen enlaces simbólicos? ¿qué nombre se imprime?) antes de escribir código.
  7. Pasa el proyecto por cppcheck y por -fanalyzer con un error introducido a propósito (una fuga, un descriptor sin cerrar en contar_ruta) y comprueba que lo encuentran.
  8. Escribe el archivo de manual cuenta.1, haz que make install lo copie a $(DESTDIR)$(PREFIX)/share/man/man1 y comprueba con man -l cuenta.1 que se ve bien.