Depurar código C tiene fama de difícil, y no sin razón: el comportamiento indefinido, los errores de memoria y los bugs que solo aparecen en producción pueden costar horas. Pero el ecosistema de herramientas de debugging en C es maduro y potente. Con gdb, AddressSanitizer y alguna técnica sistemática, la mayoría de los bugs se localizan en minutos.
Compilar para debugging
El primer paso siempre es compilar con la información de depuración completa y sin optimizaciones (las optimizaciones pueden reordenar código, inlinear funciones y eliminar variables, confundiendo al debugger):
gcc -g -O0 -Wall -Wextra -o programa programa.c
La flag -g genera información DWARF: mapeo de instrucciones a líneas de código fuente, nombres de variables y tipos. Sin ella, gdb solo puede mostrar direcciones de memoria.
gdb: los comandos esenciales
Iniciar gdb con el binario:
gdb ./programa
Comandos más usados dentro de gdb:
run [args](r): ejecutar el programa.break funcion/break archivo.c:42(b): establecer punto de interrupción.next(n): ejecutar la línea siguiente sin entrar en funciones.step(s): ejecutar la línea siguiente entrando en funciones.continue(c): continuar hasta el siguiente breakpoint.print variable(p): mostrar el valor de una variable.backtrace(bt): mostrar la pila de llamadas.info locals: mostrar todas las variables locales del frame actual.list(l): mostrar código fuente alrededor de la línea actual.quit(q): salir.
Sesión de ejemplo
(gdb) break main
Breakpoint 1 at 0x1149: file programa.c, line 10.
(gdb) run
Starting program: ./programa
Breakpoint 1, main () at programa.c:10
10 int arr[5] = {1, 2, 3, 4, 5};
(gdb) next
11 for (int i = 0; i <= 5; i++) { /* bug: i <= 5 */
(gdb) print arr[5]
$1 = 32767 /* basura fuera de límites */
(gdb) backtrace
#0 main () at programa.c:11
Watchpoints: detectar cuándo cambia un valor
Un watchpoint para cuando el programa modifica una variable específica:
(gdb) watch variable_sospechosa Hardware watchpoint 2: variable_sospechosa (gdb) continue Hardware watchpoint 2: variable_sospechosa Old value = 0 New value = 666 main () at programa.c:45 45 variable_sospechosa = calcular();
Los watchpoints de hardware son muy eficientes: no añaden casi overhead porque el procesador los gestiona directamente.
Core dumps: depurar crashes post-mortem
Cuando un programa falla en producción sin debugger adjunto, el core dump guarda el estado de memoria en el momento del crash:
/* Habilitar core dumps (por defecto desactivados) */ ulimit -c unlimited ./programa /* si hace segfault, genera un archivo 'core' */ /* Cargar el core en gdb */ gdb ./programa core (gdb) backtrace /* ver la pila en el momento del crash */
En sistemas con systemd, los cores van a journalctl -xe o a /var/lib/systemd/coredump/. Se recuperan con coredumpctl gdb.
AddressSanitizer: bugs de memoria en tiempo de ejecución
ASan detecta errores de memoria con ~2x overhead, mucho menos que Valgrind. Solo requiere recompilar:
gcc -g -fsanitize=address -o programa programa.c ./programa
Qué detecta ASan:
- Heap buffer overflow: escritura fuera de un bloque de malloc.
- Stack buffer overflow: desbordamiento de array local.
- Use-after-free: acceso a memoria después de free().
- Use-after-return: retornar puntero a variable local.
- Double free: liberar el mismo bloque dos veces.
- Memory leaks: con
ASAN_OPTIONS=detect_leaks=1.
/* Ejemplo: buffer overflow */ int arr[10]; arr[10] = 1; /* fuera de límites */ /* ASan reporta: */ /* ERROR: AddressSanitizer: stack-buffer-overflow */ /* WRITE of size 4 at 0x... */ /* #0 main programa.c:5 */
UBSan: comportamiento indefinido
UndefinedBehaviorSanitizer detecta UB en tiempo de ejecución: desbordamiento de enteros con signo, desplazamientos ilegales, punteros nulos desreferenciados:
gcc -g -fsanitize=undefined -o programa programa.c ./programa /* Detecta, por ejemplo: */ int x = INT_MAX; x++; /* UB: overflow de entero con signo */ /* runtime error: signed integer overflow: 2147483647 + 1 */
ASan y UBSan se pueden combinar: -fsanitize=address,undefined. El artículo sobre sanitizers en C++ cubre también TSan (ThreadSanitizer) para detectar carreras de datos, aplicable igualmente a C.
Técnicas sistemáticas para bugs difíciles
- Bisección del código: si el bug aparece en un commit reciente,
git bisectlocaliza el commit exacto. - Printf debugging estratégico: en embebido o cuando gdb no está disponible,
fprintf(stderr, ...)con flush explícito (fflush(stderr)) antes del punto de fallo. - Reproducción determinista: fijar la semilla de rand(), deshabilitar ASLR (
setarch $(uname -m) -R ./programa) para que las direcciones sean reproducibles. - Reducir el caso de prueba: aislar el bug en el mínimo código que lo reproduce. Facilita tanto el análisis como el informe de bug.
- Leer los warnings del compilador:
-Wall -Wextra -Wpedanticdetectan muchos bugs antes de ejecutar.
Para fugas de memoria más complejas que ASan no cubre, Valgrind con memcheck ofrece análisis más exhaustivo. En proyectos de red, combinar estas herramientas con el servidor del artículo de sockets BSD en C permite depurar condiciones de carrera y corrupciones de memoria en código concurrente.
Imagen: Pexels / Daniil Komov
