Lección 8 de 8: un proyecto completo en C. Para terminar, un programa real de principio a fin: un contador de palabras con una tabla hash, dividido en módulos, con opciones, pruebas automáticas y todas las herramientas del curso trabajando juntas. Verás dos fallos de verdad que tuvo este proyecto, uno encontrado por las pruebas con AddressSanitizer y otro por el análisis estático, y cómo compilarlo para producción con las protecciones del compilador.
Qué vamos a construir
Un programa de terminal, frecuencias, que lee un texto y dice qué palabras se repiten más. Es pequeño, pero tiene todo lo de un programa real en C: varios módulos, memoria dinámica, una estructura de datos, opciones en la línea de órdenes, gestión de errores, pruebas automáticas y una forma de compilarlo para producción. Así se usa:
./frecuencias -n 8 quijote.txt
salida71 palabras distintas
12 de
5 los
3 las
3 más
3 y
2 con
2 en
2 loEl archivo es el primer párrafo de Don Quijote. El proyecto tiene tres módulos:
palabras.c: separa un texto en palabras en minúsculas.tabla.c: una tabla hash que cuenta cuántas veces aparece cada palabra.main.c: lee las opciones y el archivo, cuenta y ordena los resultados.
El módulo de palabras
#ifndef PALABRAS_H #define PALABRAS_H #include <stddef.h> /* Copia en destino (de tam bytes, tam > 0) la siguiente palabra a partir de *cursor, en minúsculas, y deja *cursor justo detrás de ella. Las palabras más largas se recortan. Devuelve la longitud copiada, o 0 si no quedan palabras. Los bytes de 0x80 en adelante (letras con tilde en UTF-8) cuentan como letras. */ size_t siguiente_palabra(const char **cursor, char *destino, size_t tam); #endif
#include <ctype.h>
#include "palabras.h"
static bool es_letra(unsigned char c)
{
return isalpha(c) || c >= 0x80;
}
size_t siguiente_palabra(const char **cursor, char *destino, size_t tam)
{
const unsigned char *p = (const unsigned char *) *cursor;
while (*p != '\0' && !es_letra(*p)) {
p++;
}
size_t n = 0;
while (es_letra(*p)) {
if (n < tam - 1) {
destino[n++] = (char) tolower(*p);
}
p++;
}
destino[n] = '\0';
*cursor = (const char *) p;
return n;
}La función avanza un cursor sobre el texto: salta lo que no es letra, copia la palabra y deja el cursor detrás. Dos detalles importan. El (unsigned char): las funciones de ctype.h como isalpha solo aceptan valores de 0 a 255 (o EOF), y un char con una letra acentuada en UTF-8 es negativo en x86, lo que sería comportamiento indefinido. Y el n < tam - 1, que deja sitio para el '\0'; como veremos, ese - 1 no estuvo siempre ahí.
La tabla hash
Para contar palabras hace falta buscar rápido si una ya apareció. Una tabla hash lo hace en tiempo casi constante: una función de hash convierte la palabra en un número, y ese número, módulo el tamaño de la tabla, dice en qué cubeta buscar. Si dos palabras caen en la misma cubeta, se encadenan en una lista.
#ifndef TABLA_H
#define TABLA_H
#include <stddef.h>
typedef struct Tabla Tabla; /* opaco: los campos solo se ven en tabla.c */
typedef struct {
const char *clave;
int cuenta;
} Entrada;
Tabla *tabla_crear(size_t cubetas);
void tabla_destruir(Tabla *t);
bool tabla_sumar(Tabla *t, const char *clave);
int tabla_cuenta(const Tabla *t, const char *clave);
size_t tabla_tamano(const Tabla *t);
/* Devuelve un array nuevo con todas las entradas (lo libera quien llama, con free) y deja su largo en *n. */
Entrada *tabla_entradas(const Tabla *t, size_t *n);
#endifLa cabecera declara struct Tabla pero no dice qué tiene dentro: es un tipo opaco. Quien use el módulo solo puede manejar punteros a la tabla y llamar a sus funciones, nunca tocar sus campos. Es la encapsulación más fuerte que permite C. La implementación:
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include "tabla.h"
typedef struct Nodo {
struct Nodo *siguiente;
int cuenta;
char clave[]; /* miembro flexible: la clave va en el mismo bloque */
} Nodo;
struct Tabla {
Nodo **cubetas;
size_t num_cubetas;
size_t tamano;
};
static uint64_t fnv1a(const char *s)
{
uint64_t h = 14695981039346656037u;
for (; *s != '\0'; s++) {
h ^= (unsigned char) *s;
h *= 1099511628211u;
}
return h;
}
Tabla *tabla_crear(size_t cubetas)
{
if (cubetas == 0) return nullptr;
Tabla *t = malloc(sizeof *t);
if (t == nullptr) return nullptr;
t->cubetas = calloc(cubetas, sizeof *t->cubetas);
if (t->cubetas == nullptr) {
free(t);
return nullptr;
}
t->num_cubetas = cubetas;
t->tamano = 0;
return t;
}
void tabla_destruir(Tabla *t)
{
if (t == nullptr) return;
for (size_t i = 0; i < t->num_cubetas; i++) {
Nodo *n = t->cubetas[i];
while (n != nullptr) {
Nodo *sig = n->siguiente;
free(n);
n = sig;
}
}
free(t->cubetas);
free(t);
}
static Nodo *buscar(const Tabla *t, const char *clave, size_t *cubeta)
{
*cubeta = fnv1a(clave) % t->num_cubetas;
for (Nodo *n = t->cubetas[*cubeta]; n != nullptr; n = n->siguiente) {
if (strcmp(n->clave, clave) == 0) return n;
}
return nullptr;
}
bool tabla_sumar(Tabla *t, const char *clave)
{
size_t c;
Nodo *n = buscar(t, clave, &c);
if (n != nullptr) {
n->cuenta++;
return true;
}
size_t largo = strlen(clave);
n = malloc(sizeof *n + largo + 1);
if (n == nullptr) return false;
memcpy(n->clave, clave, largo + 1);
n->cuenta = 1;
n->siguiente = t->cubetas[c];
t->cubetas[c] = n;
t->tamano++;
return true;
}
int tabla_cuenta(const Tabla *t, const char *clave)
{
size_t c;
const Nodo *n = buscar(t, clave, &c);
return n != nullptr ? n->cuenta : 0;
}
size_t tabla_tamano(const Tabla *t)
{
return t->tamano;
}
Entrada *tabla_entradas(const Tabla *t, size_t *n)
{
Entrada *e = calloc(t->tamano ? t->tamano : 1, sizeof *e);
if (e == nullptr) return nullptr;
size_t k = 0;
for (size_t i = 0; i < t->num_cubetas; i++) {
for (const Nodo *nd = t->cubetas[i]; nd != nullptr; nd = nd->siguiente) {
e[k++] = (Entrada) {nd->clave, nd->cuenta};
}
}
*n = k;
return e;
}Lo más interesante:
char clave[]al final delNodoes un miembro flexible (C99): un array sin tamaño que ocupa lo que reserves de más. Con un solomalloc(sizeof *n + largo + 1)se guardan el nodo y su texto juntos, y un solofreelos libera.fnv1aes una función de hash conocida, FNV-1a, sencilla y suficiente para esto. Usauint64_tporque el desbordamiento de los enteros sin signo está definido (da la vuelta), que es justo lo que se quiere aquí.tabla_crearlibera lo que ya había reservado si falla la segunda reserva. Cada función deja todo como estaba si algo sale mal.tabla_destruirguardan->siguienteantes de liberarn: leerlo después sería un uso tras liberar.
El programa principal
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "palabras.h"
#include "tabla.h"
static int por_cuenta(const void *a, const void *b)
{
const Entrada *x = a, *y = b;
if (x->cuenta != y->cuenta) return (y->cuenta > x->cuenta) - (y->cuenta < x->cuenta);
return strcmp(x->clave, y->clave);
}
static bool contar(FILE *f, Tabla *t)
{
char *linea = nullptr;
size_t cap = 0;
char palabra[64];
bool bien = true;
while (bien && getline(&linea, &cap, f) != -1) {
const char *cursor = linea;
while (siguiente_palabra(&cursor, palabra, sizeof palabra) > 0) {
if (!tabla_sumar(t, palabra)) {
bien = false;
break;
}
}
}
free(linea);
return bien && !ferror(f);
}
int main(int argc, char *argv[])
{
long cuantas = 10;
int op;
while ((op = getopt(argc, argv, "n:")) != -1) {
if (op == 'n') {
char *fin;
cuantas = strtol(optarg, &fin, 10);
if (*fin != '\0' || cuantas <= 0) {
fprintf(stderr, "-n necesita un número positivo\n");
return EXIT_FAILURE;
}
} else {
fprintf(stderr, "uso: %s [-n N] [archivo]\n", argv[0]);
return EXIT_FAILURE;
}
}
Tabla *t = tabla_crear(1024);
if (t == nullptr) {
fprintf(stderr, "sin memoria\n");
return EXIT_FAILURE;
}
FILE *f = stdin;
bool propio = false;
if (optind < argc) {
f = fopen(argv[optind], "r");
propio = true;
if (f == nullptr) {
perror(argv[optind]);
tabla_destruir(t);
return EXIT_FAILURE;
}
}
int resultado = EXIT_SUCCESS;
if (!contar(f, t)) {
perror("al leer");
resultado = EXIT_FAILURE;
}
if (propio) fclose(f);
size_t n;
Entrada *e = tabla_entradas(t, &n);
if (e == nullptr) {
resultado = EXIT_FAILURE;
} else {
qsort(e, n, sizeof *e, por_cuenta);
printf("%zu palabras distintas\n", n);
for (size_t i = 0; i < n && i < (size_t) cuantas; i++) {
printf("%5d %s\n", e[i].cuenta, e[i].clave);
}
free(e);
}
tabla_destruir(t);
return resultado;
}Algunas decisiones:
getlinelee líneas de cualquier longitud reservando la memoria que haga falta. No es del estándar de C sino de POSIX, comogetopt, por eso el#define _POSIX_C_SOURCEde la primera línea. Funciona en Linux y macOS; en Windows con MSVC habría que sustituirlas.strtolcon*fin != '\0':-n 5xno se acepta como 5.- La tabla se crea antes de abrir el archivo, y cada salida con error libera lo que había. El orden no es casual: lo veremos con el análisis estático.
qsortordena de más a menos veces y, a igualdad, alfabéticamente, para que la salida sea siempre la misma.
./frecuencias -n cero quijote.txt ./frecuencias no_existe.txt
salida con error-n necesita un número positivo rc 1 no_existe.txt: No such file or directory
El Makefile
CC = gcc
CFLAGS = -std=c23 -Wall -Wextra -Wpedantic -g -O2
SAN = -fsanitize=address,undefined -fno-sanitize-recover
OBJ = main.o tabla.o palabras.o
TIDY = -*,clang-analyzer-*,bugprone-*,-bugprone-easily-swappable-parameters,-bugprone-reserved-identifier,\
-bugprone-multi-level-implicit-pointer-conversion,\
-clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling
frecuencias: $(OBJ)
$(CC) $(CFLAGS) -o $@ $(OBJ)
main.o: main.c tabla.h palabras.h
tabla.o: tabla.c tabla.h
palabras.o: palabras.c palabras.h
pruebas: pruebas.c tabla.c palabras.c tabla.h palabras.h
$(CC) $(CFLAGS) $(SAN) -o $@ pruebas.c tabla.c palabras.c
.PHONY: test analizar clean
test: pruebas
./pruebas
analizar:
$(CC) -std=c23 -fanalyzer -c -o /dev/null tabla.c
$(CC) -std=c23 -fanalyzer -c -o /dev/null palabras.c
$(CC) -std=c23 -fanalyzer -c -o /dev/null main.c
clang-tidy --quiet --checks='$(TIDY)' \
tabla.c palabras.c main.c -- -std=c23
clean:
rm -f frecuencias pruebas *.oAdemás de compilar, tiene tres objetivos de calidad: make test compila las pruebas con los sanitizadores y las ejecuta; make analizar pasa dos analizadores estáticos; make clean borra lo generado. El programa normal se compila con -O2 y también con -Wpedantic, que avisa de las extensiones de GCC que no son C estándar.
make
salidagcc -std=c23 -Wall -Wextra -Wpedantic -g -O2 -c -o main.o main.c gcc -std=c23 -Wall -Wextra -Wpedantic -g -O2 -c -o tabla.o tabla.c gcc -std=c23 -Wall -Wextra -Wpedantic -g -O2 -c -o palabras.o palabras.c gcc -std=c23 -Wall -Wextra -Wpedantic -g -O2 -o frecuencias main.o tabla.o palabras.o
Pruebas con sanitizadores
C no trae un sistema de pruebas, y hay muchos (Unity, Check, CMocka, criterion). Para un proyecto pequeño basta una macro que cuente comprobaciones y fallos:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "palabras.h"
#include "tabla.h"
static int total = 0, fallos = 0;
#define COMPROBAR(cond) \
do { \
total++; \
if (!(cond)) { \
fallos++; \
fprintf(stderr, "%s:%d: falló: %s\n", __FILE__, __LINE__, #cond); \
} \
} while (0)
static void prueba_palabras(void)
{
char p[16];
const char *c = " Hola, MUNDO! adiós";
COMPROBAR(siguiente_palabra(&c, p, sizeof p) == 4 && strcmp(p, "hola") == 0);
COMPROBAR(siguiente_palabra(&c, p, sizeof p) == 5 && strcmp(p, "mundo") == 0);
COMPROBAR(siguiente_palabra(&c, p, sizeof p) == 6 && strcmp(p, "adiós") == 0);
COMPROBAR(siguiente_palabra(&c, p, sizeof p) == 0);
const char *vacia = "";
COMPROBAR(siguiente_palabra(&vacia, p, sizeof p) == 0 && p[0] == '\0');
}
static void prueba_palabra_larga(void)
{
char p[8];
const char *c = "electroencefalografista fin";
COMPROBAR(siguiente_palabra(&c, p, sizeof p) == 7);
COMPROBAR(strcmp(p, "electro") == 0);
COMPROBAR(siguiente_palabra(&c, p, sizeof p) == 3 && strcmp(p, "fin") == 0);
}
static void prueba_tabla(void)
{
Tabla *t = tabla_crear(2);
COMPROBAR(t != nullptr);
const char *palabras[] = {"sol", "mar", "sol", "luna", "sol", "mar"};
for (size_t i = 0; i < 6; i++) {
COMPROBAR(tabla_sumar(t, palabras[i]));
}
COMPROBAR(tabla_tamano(t) == 3);
COMPROBAR(tabla_cuenta(t, "sol") == 3);
COMPROBAR(tabla_cuenta(t, "mar") == 2);
COMPROBAR(tabla_cuenta(t, "estrella") == 0);
size_t n;
Entrada *e = tabla_entradas(t, &n);
COMPROBAR(e != nullptr && n == 3);
free(e);
tabla_destruir(t);
COMPROBAR(tabla_crear(0) == nullptr);
}
int main(void)
{
prueba_palabras();
prueba_palabra_larga();
prueba_tabla();
printf("%d comprobaciones, %d fallos\n", total, fallos);
return fallos == 0 ? 0 : 1;
}El #cond de la macro convierte la condición en texto, para que el mensaje de fallo diga qué se comprobaba, y __FILE__ y __LINE__ dicen dónde. El do { ... } while (0) es el truco habitual para que una macro de varias líneas se comporte como una sola instrucción. Lo importante del Makefile es que las pruebas se compilan con -fsanitize=address,undefined:
make test
salidagcc -std=c23 -Wall -Wextra -Wpedantic -g -O2 -fsanitize=address,undefined -fno-sanitize-recover -o pruebas pruebas.c tabla.c palabras.c ./pruebas 21 comprobaciones, 0 fallos
Lo que las pruebas encontraron
La primera versión de palabras.c tenía esta línea:
if (n < tam) {Con palabras normales funcionaba perfectamente, y el programa daba resultados correctos. Pero la prueba prueba_palabra_larga usa un búfer de 8 bytes con una palabra de 23 letras, y ASan la detuvo en seco:
salida con error./pruebas
…
==1==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x74d579e00058 at pc 0x000000403705 bp 0x7ffc9096c2b0 sp 0x7ffc9096c2a8
WRITE of size 1 at 0x74d579e00058 thread T0
#0 0x000000403704 in siguiente_palabra /tmp/v1/palabras.c:22
#1 0x00000040178d in prueba_palabra_larga /tmp/v1/pruebas.c:35
#2 0x00000040178d in main /tmp/v1/pruebas.c:64
…
#0 0x00000040136f in main /tmp/v1/pruebas.c:62
…
[80, 88) 'p' (line 33) <== Memory access at offset 88 overflows this variable
…
SUMMARY: AddressSanitizer: stack-buffer-overflow /tmp/v1/palabras.c:22 in siguiente_palabraAl recortar una palabra larga, el bucle llenaba los 8 bytes y el destino[n] = '\0' escribía uno más, fuera del array: un desbordamiento de un byte (off-by-one). Sin sanitizador, ese byte machacaba la variable de al lado y nadie se habría enterado hasta que alguien pasara un texto con una palabra muy larga. Es exactamente el tipo de fallo que, en un programa que lee datos de internet, acaba siendo una vulnerabilidad. La corrección es el tam - 1, y la prueba se quedó para que no vuelva.
Análisis estático
Los sanitizadores solo ven los caminos que se ejecutan. Los analizadores estáticos leen el código sin ejecutarlo y recorren todos los caminos posibles, incluidos los que casi nunca pasan, como que falle malloc. El Makefile usa dos: el de GCC, -fanalyzer, y clang-tidy con las comprobaciones de clang-analyzer y bugprone.
En la versión anterior de main.c, el archivo se abría antes de crear la tabla. clang-tidy encontró esto:
clang-tidy/tmp/antes/main.c:64:9: warning: Opened stream never closed. Potential resource leak [clang-analyzer-unix.Stream] … /tmp/antes/main.c:55:13: note: Stream opened here … /tmp/antes/main.c:63:9: note: Assuming the condition is true … /tmp/antes/main.c:64:9: note: Opened stream never closed. Potential resource leak
Si fopen funcionaba pero luego tabla_crear fallaba por falta de memoria, el programa salía sin cerrar el archivo. Aquí no es grave, porque el sistema cierra todo al terminar el proceso, pero el mismo patrón en una función de un servidor, que se ejecuta millones de veces sin terminar, agota los descriptores de archivo. Ninguna prueba lo habría encontrado, porque provocar un fallo de malloc es difícil. La versión final crea la tabla primero y el análisis queda limpio:
make analizar
salida[1/3] Processing file /tmp/p/tabla.c. [2/3] Processing file /tmp/p/palabras.c. 1 warning generated. [3/3] Processing file /tmp/p/main.c. 12 warnings generated. rc 0
Los «warnings generated» que cuenta clang-tidy son de las cabeceras del sistema, que se filtran. Hemos desactivado tres comprobaciones que en C solo hacen ruido: las que piden usar fprintf_s o memcpy_s (del anexo K del estándar, que casi ninguna biblioteca implementa, tampoco glibc), la que se queja de _POSIX_C_SOURCE por empezar con guion bajo y la que pide casts explícitos de void *. Configurar un analizador es también decidir qué avisos no aportan. Cppcheck, otro analizador muy usado, lo descartamos porque su versión 2.17 aún no entiende el nullptr de C23 y llenaba la salida de falsos positivos.
Valgrind: ninguna fuga
valgrind --leak-check=full ./frecuencias -n 3 quijote.txt
salida==1== Memcheck, a memory error detector
==1== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.
==1== Using Valgrind-3.24.0 and LibVEX; rerun with -h for copyright info
==1== Command: ./frecuencias -n 3 quijote.txt
==1==
71 palabras distintas
12 de
5 los
3 las
==1==
==1== HEAP SUMMARY:
==1== in use at exit: 0 bytes in 0 blocks
==1== total heap usage: 80 allocs, 80 frees, 21,442 bytes allocated
==1==
==1== All heap blocks were freed -- no leaks are possible
==1==
==1== For lists of detected and suppressed errors, rerun with: -s
==1== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)80 reservas y 80 liberaciones: los 71 nodos de la tabla, el array de cubetas, la propia tabla y el array de entradas, más las que hacen por dentro getline, fopen y printf para sus búferes. «All heap blocks were freed» es lo que quieres leer siempre.
Compilar para producción
Para desarrollar, -g y los sanitizadores. Para el ejecutable que se entrega, optimización y las protecciones del compilador, que no corrigen fallos pero los hacen mucho más difíciles de explotar. Antes había que recordar media docena de opciones; desde GCC 14 basta una, -fhardened:
gcc -std=c23 -O2 -fhardened -o frecuencias main.c tabla.c palabras.c readelf -h frecuencias | grep Type readelf -d frecuencias | grep -E 'BIND_NOW|FLAGS_1' nm frecuencias | grep _chk
salida Type: DYN (Position-Independent Executable file)
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW PIE
U __fprintf_chk@GLIBC_2.3.4
U __memcpy_chk@GLIBC_2.3.4
U __printf_chk@GLIBC_2.3.4
U __stack_chk_fail@GLIBC_2.4Lo que comprobamos en el ejecutable:
- PIE (Position-Independent Executable): el programa se carga en una dirección distinta cada vez, lo que estropea los ataques que necesitan saber dónde está el código.
- RELRO y BIND_NOW: las tablas de saltos a funciones de bibliotecas se resuelven al arrancar y se dejan de solo lectura.
__stack_chk_fail: el protector de pila. Pone un valor secreto (un «canario») junto a los arrays locales, y si al volver de la función ha cambiado, aborta en lugar de seguir con la pila corrompida.__printf_chk,__memcpy_chk:_FORTIFY_SOURCE=3sustituye funciones peligrosas por versiones que comprueban el tamaño del destino cuando el compilador lo conoce.
Además, en x86 añade las instrucciones de protección del flujo de control (CET) y la protección contra stack clash. Si tu compilador no tiene -fhardened, el equivalente aproximado es -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fstack-clash-protection -fcf-protection -fPIE -pie -Wl,-z,relro,-z,now.
Ojo con la seguridad: una lista para tu propio proyecto
Este es el resumen de todo el curso, aplicado: compila siempre con -Wall -Wextra (y de vez en cuando con -O2, que destapa más avisos); ejecuta las pruebas con -fsanitize=address,undefined; pasa un analizador estático en cada cambio; comprueba las fugas con Valgrind o con el detector de fugas de ASan; y entrega con -fhardened. Para código que lee datos de fuera, el siguiente paso es el fuzzing: herramientas como libFuzzer o AFL++ generan millones de entradas raras buscando la que rompe el programa. Y si el proyecto empieza de cero y la seguridad es crítica, plantéate si C es el lenguaje adecuado o si te conviene Rust. Más en Seguridad en C.
Retos
Reto 1. Añade a tabla.c una función double tabla_carga(const Tabla *t) que devuelva el factor de carga (elementos entre cubetas). ¿Cuánto vale con el texto del Quijote?
Ver la solución
Basta con return (double) t->tamano / t->num_cubetas;. Con 71 palabras y 1,024 cubetas vale 0.07: la mayoría de las cubetas están vacías y casi no hay colisiones. Cuando el factor de carga pasa de 0.75 o 1, las listas se alargan y las búsquedas se frenan; las tablas reales duplican entonces el número de cubetas y redistribuyen los nodos (rehashing). Implementarlo es un buen ejercicio de punteros.
Reto 2. Las pruebas no comprueban qué pasa con un texto que solo tiene signos de puntuación, como "¿¡...!?". Escribe la prueba antes de leer la solución: ¿qué devolverá siguiente_palabra?
Ver la solución
Sorpresa: no devuelve 0. ¿ y ¡ no son ASCII; en UTF-8 son dos bytes cada uno, de 0x80 en adelante, y es_letra los cuenta como letras. La prueba fallaría y destaparía un límite de diseño: tratar «todo lo que no es ASCII» como letra es un atajo que funciona para las tildes y la ñ, pero no para la puntuación española. Arreglarlo bien exige decodificar UTF-8 y consultar las propiedades Unicode de cada carácter, que es lo que hacen bibliotecas como ICU. Por eso las pruebas se escriben pensando en los casos raros, no solo en los normales.
Reto 3. Compila el proyecto con -fsanitize=address y ejecútalo con un archivo grande. Después, quita el free(e) de main.c. ¿Qué dice ASan al terminar?
Ver la solución
Al acabar el programa, el detector de fugas de ASan (LeakSanitizer) informa de un Direct leak con el tamaño del array de entradas y la pila de llamadas hasta el calloc de tabla_entradas. No hace falta Valgrind: con ASan, las fugas se detectan solas en cada ejecución de las pruebas.
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.
