07. Depuración: visualizando variables II

Publicado por

Hace unas semanas te mostré cómo puedes utilizar GDB para observar los valores que toman las variables en un programa. Sin embargo, lo más complejo que observamos en aquella ocasión fueron arreglos. ¿Qué pasa cuando trabajamos con tipos de dato más complejos, como registros u objetos? En esta entrega te mostraré algunas herramientas que te ayudarán a examinar mejor estructuras de datos un poco más complejas.

El programa listado a continuación servirá de ejemplo. En él se utiliza un árbol binario para almacenar valores ordenados lexicográficamente por una clave:

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

#define TAM_CLAVE 16
struct __nodo
{
  char   clave[TAM_CLAVE];
  int    valor;
  struct __nodo *izq;
  struct __nodo *der;
};
typedef struct __nodo *nodo;

nodo raiz;

nodo nodo_nuevo(char *clave, int x)
{
  nodo nd;

  nd = (nodo)malloc(sizeof(struct __nodo));
  nd->valor = x;
  strcpy(nd->clave, clave);
  nd->izq = nd->der = (nodo)NULL;
  return nd;
}

void nodo_inserta(nodo *arbol, char *clave, int x)
{
  nodo nd = *arbol;

  if (!nd)
  {
    *arbol = nodo_nuevo(clave, x);
    return;
  }

  while (1)
  {
    if (strcmp(clave, nd->clave) < 0)
    {
      if (nd->izq)
        nd = nd->izq;
      else
      {
        nd->izq = nodo_nuevo(clave, x);
        break;
      }
    }
    else
    {
      if (nd->der)
        nd = nd->der;
      else
      {
        nd->der = nodo_nuevo(clave, x);
        break;
      }
    }
  }
}

void arbol_imprime(nodo arbol)
{
  if (!arbol) return;

  arbol_imprime(arbol->izq);
  printf("%s : %d\n", arbol->clave, arbol->valor);
  arbol_imprime(arbol->der);
}

int main()
{
  raiz = (nodo)0;
  int i;

  nodo_inserta(&raiz, "uno",    1);
  nodo_inserta(&raiz, "dos",    2);
  nodo_inserta(&raiz, "tres",   3);
  nodo_inserta(&raiz, "cuatro", 4);
  nodo_inserta(&raiz, "cinco",  5);
  arbol_imprime(raiz);
  return 0;
}

Asumiendo que el código fuente anterior está almacenado en un archivo llamado arbol.c, construye el binario con símbolos de depuración como es usual. Una vez generado el binario, inicia una sesión de GDB con él y crea un punto de quiebre en la función nodo_inserta():

➜ gdb arbol
Reading symbols from arbol...done.
(gdb) break nodo_inserta
Breakpoint 1 at 0x400655: file arbol.c, line 30.
(gdb) run
Starting program: /home/user/arbol 

Breakpoint 1, nodo_inserta (arbol=0x600ca0 , clave=0x40088d "uno", x=1) at arbol.c:30
30    nodo nd = *arbol;
(gdb)

En este punto podemos visualizar nuestras variables utilizando el comando print, solo que en este caso, los datos que nos interesan son un poco más complejos que los típicos datos primitivos con los que hemos trabajado hasta ahora.

(gdb) print arbol
$1 = (nodo *) 0x600ca0 
(gdb) print nd
$2 = (nodo) 0x4004f0 
(gdb)

Dado que arbol y nd son en realidad apuntadores, lo que obtenemos al aplicarles el comando print es simplemente la dirección de memoria que contienen. Si queremos examinar el interior de la estructura, podemos utilizar el operador flecha -> para ver miembro por miembro:

(gdb) print nd->valor
$3 = 141607111
(gdb) next
32    if (!nd)
(gdb) next
34      *arbol = nodo_nuevo(clave, x);
(gdb) next
35      return;
(gdb) print arbol->valor
$4 = 1
(gdb) print arbol->clave
$5 = "uno", '\000' 
(gdb)

O utilizar el operador * para mostrar toda la estructura de una vez:

(gdb) print *nd
$8 = {clave = "uno", '\000' , valor = 1, izq = 0x0, der = 0x0}
(gdb)

De esta forma podemos apreciar mejor el estado de nuestras variables, sin embargo, aún necesitamos ejecutar el comando print cada vez que lleguemos al breakpoint correspondiente. Esto puede evitarse utilizando un nuevo comando: display (o su abreviatura disp). Este comando le dice al depurador que queremos desplegar el valor de cierta expresión cada vez que el programa detenga su ejecución.

(gdb) display *nd
1: *nd = {clave = "uno", '\000' , valor = 1, izq = 0x0, der = 0x0}
(gdb) continue
Continuing.

Breakpoint 1, nodo_inserta (arbol=0x600ca0 , clave=0x400895 "tres", x=3) at arbol.c:30
30    nodo nd = *arbol;
1: *nd = {clave = "uno", '\000' , valor = 1, izq = 0x601040, der = 0x0}
(gdb) continue
Continuing.

Breakpoint 1, nodo_inserta (arbol=0x600ca0 , clave=0x40089a "cuatro", x=4) at arbol.c:30
30    nodo nd = *arbol;
1: *nd = {clave = "dos", '\000' , valor = 2, izq = 0x0, der = 0x601070}

Y listo, ya tenemos una visión panorámica de la información contenida en las estructuras de nuestro programa. Todo bien hasta ahora, pero supongamos por un momento que la estructura que queremos examinar es bastante compleja y solo unos cuantos miembros de esta nos interesan para diagnosticar el problema ¿volvemos a la otra forma e imprimimos miembro por miembro? Sí y no: recuerda que anteriormente te expliqué cómo asociar comandos a determinado breakpoint utilizando el comando commands. Bueno, eso es lo que nos va a servir aquí en combinación con una nueva instrucción para formatear la salida: printf.

Inicia una nueva sesión de GDB y crea un breakpoint justo al final de nodo_inserta(), en la línea 60:

➜ gdb arbol
...
Reading symbols from arbol...done.
(gdb) break 60
Breakpoint 1 at 0x400710: file arbol.c, line 60.
(gdb) commands 1
Type commands for breakpoint(s) 1, one per line.
End with a line saying just "end".
>printf "nodo: %p, clave: %s, valor: %d", nd, nd->clave, nd->valor
>if (nd->izq != 0)
 >printf " izq: %p", nd->izq
 >end
>if (nd->der != 0)
 >printf " der: %p", nd->der
 >end
>printf "\n"
>end
(gdb) run
Starting program:
/home/user/arbol 

Breakpoint 1, nodo_inserta (arbol=0x600ca0 , clave=0x400895 "tres", x=3) at arbol.c:60
60    }
nodo: 0x601040, clave: dos, valor: 2
(gdb) continue
Continuing.

Breakpoint 1, nodo_inserta (arbol=0x600ca0 , clave=0x40089a "cuatro", x=4) at arbol.c:60
60    }
nodo: 0x601040, clave: dos, valor: 2 der: 0x601070
(gdb) continue
Continuing.

Breakpoint 1, nodo_inserta (arbol=0x600ca0 , clave=0x4008a1 "cinco", x=5) at arbol.c:60
60    }
nodo: 0x601040, clave: dos, valor: 2 izq: 0x6010a0 der: 0x601070
(gdb)

Nota cómo el printf dentro de GDB es muy similar al que tenemos disponible en la biblioteca estándar de C. De esta manera es posible decidir qué y cuánta información mostrar cada vez que alcancemos un punto de quiebre, personalizando además el formato que queremos utilizar para facilitarnos su lectura.

Finalmente, también es posible utilizar funciones del propio programa dentro de la sesión del depurador. Esto nos sirve para visualizar datos, cuando ya tenemos rutinas en nuestro programa como la función arbol_imprime() de nuestro ejemplo. Para utilizar estas funciones, GDB proporciona el comando call:

(gdb) call arbol_imprime(nd)
cuatro : 4
dos : 2
tres : 3
(gdb)

Espero que las herramientas que te presenté en esta nota te sean de utilidad para explotar mejor el depurador y que te ayuden a ahorrar tiempo al momento de depurar tus programas. Sigue practicando y nos vemos en la siguiente nota.

¡Hasta la próxima!

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *