wandres.dev
APRENDIZ · Setup y toolchain

Depurar con gdb y lldb

Arrancar el depurador, poner puntos de ruptura y observación, leer la pila y la memoria cruda, y diagnosticar un uso tras liberación real paso a paso hasta la línea culpable.

⏱ 16 min

Depurar con impresiones por pantalla es adivinar más rápido. Un depurador hace algo cualitativamente distinto: detiene el proceso vivo y te deja interrogar su estado real —la pila, los registros, cada byte del montículo— en el instante exacto del fallo. En C, donde el error no es una excepción con traza sino un puntero que apunta a nada, esa capacidad no es un lujo: es la única forma de trabajar.

🎯 Al terminar esta lección sabrás
  • Arrancar un programa bajo gdb y bajo lldb, con argumentos o desde un volcado.
  • Dominar puntos de ruptura, condiciones y puntos de observación.
  • Leer la pila con bt e inspeccionar memoria cruda byte a byte.
  • Diagnosticar un fallo de segmentación real hasta la línea culpable.

Arrancar el depurador

La condición previa es información de depuración. Sin -g el depurador solo ve direcciones; con -g ve nombres, tipos y líneas. Y -Og en vez de -O2 evita que el optimizador borre variables o reordene líneas hasta hacer la sesión incomprensible.

gcc -std=c23 -Og -g3 -fno-omit-frame-pointer crash.c -o crash

gdb ./crash                     # GNU, el estandar en Linux
lldb ./crash                    # LLVM, el estandar en macOS

Dentro, las tres formas de empezar son siempre las mismas: ejecutar desde cero, adjuntarse a un proceso vivo, o abrir un cadáver.

Objetivo gdb lldb
Ejecutar con argumentos run 3 datos.txt process launch -- 3 datos.txt
Adjuntar a un proceso attach 4213 process attach --pid 4213
Abrir un volcado gdb ./crash core lldb -c core ./crash

Los volcados de memoria no existen por defecto: hay que habilitarlos con ulimit -c unlimited. En sistemas con systemd, coredumpctl list te dice qué se ha caído y coredumpctl debug crash abre el último volcado directamente en gdb.

Puntos de ruptura y control del flujo

Un punto de ruptura detiene la ejecución; un punto de observación detiene cuando un dato cambia de valor, sin que necesites saber quién lo cambió. El segundo es el que resuelve los bugs difíciles.

(gdb) break lista.c:42          # por fichero y linea
(gdb) break crear               # por funcion
(gdb) tbreak main               # temporal: se borra al dispararse
(gdb) break lista.c:42 if n > 100   # condicional
(gdb) watch dev->contador       # parar cuando el valor cambie
(gdb) rwatch cabecera           # parar cuando se lea
(gdb) info breakpoints
(gdb) delete 2

Una vez detenido, el control del flujo tiene cuatro verbos que conviene no confundir: next ejecuta la línea entera saltando por encima de las llamadas, step entra dentro de la función llamada, finish termina la función actual y vuelve mostrando el valor devuelto, y continue reanuda hasta el siguiente punto de ruptura. En lldb los nombres se abrevian pero el modelo es idéntico.

💡
La interfaz de texto de gdb

Ctrl-x a en gdb abre una ventana con el código fuente sincronizado con la ejecución. Cambia por completo la experiencia frente a mirar una línea suelta cada vez. En lldb el equivalente es gui.

Inspeccionar: pila, valores y memoria

bt imprime la pila de llamadas: la pregunta “cómo he llegado hasta aquí” respondida en una orden. Cada marco tiene su propio conjunto de variables locales, y frame N te mueve entre ellos.

(gdb) bt                        # pila completa
(gdb) bt full                   # con las locales de cada marco
(gdb) frame 2                   # situarse en el marco 2
(gdb) info locals               # variables locales de ese marco
(gdb) info args                 # argumentos de la funcion
(gdb) print *p                  # desreferenciar e imprimir la estructura
(gdb) ptype p                   # que tipo tiene realmente

# y cuando el valor no cuadra, memoria cruda: contador, formato y unidad
(gdb) x/16xb p                  # 16 bytes en hexadecimal
(gdb) x/8xg $sp                 # 8 palabras de 8 bytes desde el puntero de pila
(gdb) x/s p->nombre             # interpretar como cadena
(gdb) x/4i $pc                  # 4 instrucciones desde el contador de programa
(gdb) info registers rax rsp rbp

El equivalente en lldb es memory read --size 1 --format x --count 16 p, más verboso pero con la misma semántica. Ver los bytes es lo que distingue un diagnóstico de una conjetura: un patrón de 0x00 donde esperabas texto, o el veneno que la libc escribe al liberar, te dicen de inmediato qué clase de error tienes.

Un fallo real, paso a paso

Este programa se cae. El error no está donde revienta.

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

struct nodo {
    char         nombre[16];
    struct nodo *sig;
};

static struct nodo *crear(const char *n) {
    struct nodo *p = malloc(sizeof *p);
    if (!p) return nullptr;
    snprintf(p->nombre, sizeof p->nombre, "%s", n);
    p->sig = nullptr;
    return p;
}

int main(void) {
    struct nodo *raiz = crear("raiz");
    raiz->sig = crear("hijo");
    free(raiz->sig);                      /* liberado aqui */
    printf("%s\n", raiz->sig->nombre);    /* usado despues */
    return 0;
}
flowchart TD
A[El programa aborta con SIGSEGV] --> B[Recompilar con -Og -g3]
B --> C[Ejecutar bajo gdb o lldb]
C --> D[bt para reconstruir la pila]
D --> E[frame y info locals]
E --> F[Examinar memoria cruda con x]
F --> H[Hipotesis: watch sobre el puntero]
H --> I[Correccion y verificacion]
style A fill:#f38ba8,color:#11111b
style I fill:#a6e3a1,color:#11111b

La sesión, en orden:

$ gdb ./crash
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x... in main () at crash.c:23

(gdb) bt
#0  main () at crash.c:23

(gdb) print raiz->sig
$1 = (struct nodo *) 0x5555555592a0

(gdb) x/16xb raiz->sig
0x5555555592a0: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00   <- ya no hay texto

El puntero no es nulo —por eso ninguna comprobación de nulidad habría salvado nada—, pero la memoria a la que apunta ya no te pertenece: la libc reutilizó esos bytes al liberarla. Es un uso tras liberación. Para probar quién la liberó, se pone un punto de observación sobre el puntero y se reejecuta:

(gdb) break main
(gdb) run
(gdb) watch raiz->sig
(gdb) continue     # se detiene en la asignacion, y luego en cada cambio

La corrección es de diseño, no de línea: quien libera debe invalidar. Poner raiz->sig = nullptr; justo después de free convierte un fallo silencioso e impredecible en un fallo determinista e inmediato.

El depurador no busca el error: refuta hipótesis

El error del principiante es abrir gdb y avanzar con next esperando que el bug se manifieste. Eso es leer el programa en voz alta, no depurarlo. Depurar es un método científico comprimido: observas un síntoma, formulas una hipótesis falsable sobre el estado del proceso, eliges la orden mínima que la refutaría, y ejecutas. En el caso de arriba, bt refuta “el error está en crear”; print raiz->sig refuta “el puntero es nulo”; x/16xb refuta “la estructura sigue intacta”. Cada orden elimina un universo de causas posibles, y en tres pasos pasas de un volcado opaco a la línea exacta. Esto importa especialmente en C porque aquí el punto de fallo y el punto de causa están sistemáticamente separados: la memoria se corrompe en un sitio y el proceso muere mucho después, en un lugar que no tiene nada que ver. Un depurador no te enseña dónde revienta —eso ya lo sabe el sistema operativo—: te enseña qué era verdad en el proceso justo antes de que reventara. Esa es la única evidencia que existe.

⚔️ Diagnostica el crash
  1. Compila crash.c con -Og -g3 y ejecútalo bajo gdb hasta reproducir el fallo de segmentación.
  2. Reconstruye la pila con bt full y localiza el marco donde muere.
  3. Imprime raiz->sig y examina sus 16 primeros bytes con x/16xb. Describe qué ves.
  4. Reejecuta poniendo watch raiz->sig y demuestra en qué línea se altera.
  5. Aplica la corrección, verifica que ya no falla, y luego repite el ejercicio con la asignación a nullptr en su sitio para ver el fallo determinista.
  6. Repite toda la sesión en lldb traduciendo cada orden, y anota las diferencias de sintaxis en tu propia chuleta.