01. Depuración: introducción a GDB

Publicado por

Nadie es perfecto, eso no es nada nuevo. Y los desarrolladores de software no somos la excepción, cometemos errores todo el tiempo, algo que llamamos “introducir bugs“.

Por cierto: el primer bug de computadora de la historia fue reportado hace casi cincuenta años y se trató de un bicho de verdad.

Es necesario detectar y reparar esos errores para entregar un producto final con calidad que se comporte exactamente como fue diseñado. Muchas veces estos bugs son tan pequeños o requieren de condiciones tan especiales para manifestarse, que resultan complicados no solo de arreglar, sino de diagnosticar, lo cual separa a la depuración de las ciencias exactas y la acerca más a las artes (por más pretencioso que suene, así es).

A lo largo de esta serie exploraremos cómo inspeccionar nuestros programas para detectar anomalías que provocan un funcionamiento no deseado y aprenderás cómo corregirlas. Si ya tienes algo de experiencia en la depuración de tu código fuente, al menos espero que las publicaciones de esta serie te sirvan como referencia para cuando necesites emplear alguna técnica en específico y recuerdes fácilmente cómo se hace.

Primeros pasos

El primer paso, suena obvio en apariencia, aunque en realidad no lo es realmente: debes conocer el código que pretendes depurar, y asegurarte de que las cosas que presupones como verdaderas lo son realmente o no. Cerciorarse si tus hipótesis son falsas o verdaderas te dará las primeras pistas de la naturaleza del error.

Toma como ejemplo el siguiente programa pila.c, el cual define una pila simple que almacena números enteros:

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

struct __pila { int tope; int contenido[5]; };
typedef struct __pila pila;

void push(pila *p, int n);
int  pop(pila *p);
void print(pila *p);

#define PILA_LIMPIAR(p) (p).tope = 0
#define PILA_VACIA(p) (p).tope == 0

int main(int argc, char *argv[])
{
  int  n;
  pila numeros;

  PILA_LIMPIAR(numeros);

  while (*++argv)
    push(&numeros, atoi(*argv));

  while (!PILA_VACIA(numeros))
  {
    printf("pop "); print(*numeros);
    printf(" -> %d\n", pop(*numeros));
  }

  print(*numeros); putchar('\n');
  return 0;
}

void push(pila *p, int n)
{
  p->contenido[(p->tope)++] = n;
}

int pop(pila *p)
{
  return p->contenido[--(p->tope)];
}

void print(pila *p)
{
  int t = p->tope;

  switch (t)
  {
  case 0:
    printf("[]");
    break;

  case 1:
    printf("[%d]", p->contenido[t-1]);
    break;

  default:
    printf("[%d", p->contenido[--t]);
    while (t > 0)
      printf(", %d", p->contenido[--t]);
    printf("]");
    break;
  }
}

La función principal de este programa declara e inicializa una pila, acto seguido, itera sobre los argumentos que se le han pasado al programa a través de la línea de comandos, y trata de convertirlos en números enteros que termina poniendo en la pila. Una vez que ha procesado todos los argumentos del programa, muestra el contenido de la pila en la salida estándar, mientras quita dichos números de la misma. Finalmente muestra estructura vacía.

A simple vista no hay nada malo con este programa. Si lo compilas y ejecutas, puede que en algunas ocasiones obtengas el resultado esperado.

➜ gcc -o pila pila.c
➜ pila 1 2 3 4
pop [4, 3, 2, 1] -> 4
pop [3, 2, 1] -> 3
pop [2, 1] -> 2
pop [1] -> 1
[]
➜ 

No obstante, con el código tal y como es mostrado arriba, existen casos en los que el programa no responde como se espera, por ejemplo: la función principal asume que los argumentos del programa serán cadenas de caracteres que solamente contienen dígitos, mientras que la interfaz del programa nos permite ingresar cualquier tipo de caracteres.

➜ pila 1 2 bla
pop [0, 2, 1] -> 0
pop [2, 1] -> 2
pop [1] -> 1
[]
➜

He aquí nuestro bug, con una entrada un poco diferente, nuestro programa no se comporta como esperábamos. En esta ocasión hemos sido afortunados y bastó nuestro conocimiento del código fuente para detectar el error rápidamente. Tal vez en un programa más complejo hubiéramos tenido que agregar código para imprimir en la salida estándar (o en un archivo de texto) los valores de aquellas variables que suponemos están involucradas en el defecto; sin embargo, proceder de este modo no siempre es adecuado, pues agregar código solamente para vigilar nuestras variables es una tarea costosa en tiempo, sin mencionar que muchas veces será necesario remover el código de diagnóstico cuando el bug haya sido corregido. Por esta y otras razones, a lo largo de esta serie me enfocaré en el uso de herramientas externas para ayudarnos a encontrar el origen del comportamiento anómalo de nuestros programas.

Depurador

Un depurador es un programa que nos ayuda a examinar el código fuente de otros programas, para encontrar errores. Tal vez las funciones más famosas de los depuradores son permitirle al programador ejecutar su programa instrucción por instrucción, detener su ejecución en un lugar específico del código y proporcionar una manera de visualizar el estado del programa a través de sus variables. Estos conceptos son comunes a muchos depuradores, pero siguiendo con nuestro ejemplo (que está escrito en lenguaje C) ilustraré el uso básico de un depurador utilizando GDB.

GDB: The GNU Project Debugger

El depurador del proyecto GNU es la herramienta mas popular de su tipo entre los desarrolladores UNIX. Es una herramienta con interfaz de texto, por lo que muchos desarrolladores se sientan más motivados por otras alternativas basadas en ambientes gráficos, como Eclipse, o Visual Studio, sin embargo, la línea de comandos tiene sus ventajas. La más vistosa a mi parecer, es la posibilidad de depurar dos programas al mismo tiempo, por ejemplo, un servidor y su cliente, simplemente vinculando una instancia de GDB a cada uno de los procesos que se quieren depurar (tal vez en una nota posterior veamos cómo hacerlo).

Imagina por un momento que no conoces cuál es el error con nuestro programa de ejemplo, solamente sospechas que el error está en la función main(), entonces podemos usar GDB para encontrar el bug.

Lo primero que necesitamos hacer es compilar nuestro programa incluyendo información extra que le ayudará al depurador a presentarnos el código fuente al tiempo que ejecuta el programa. Esta información es conocida como “símbolos de depuración” (debug symbols). Esto lo logramos a través de la opción -g del compilador.

➜ cc -o pila -g pila.c # Compila el programa para depurarlo
➜ 

Con el binario listo, ya podemos ejecutarlo dentro de GDB.

➜ gdb pila

Al iniciar el depurador, imprimirá un encabezado que contiene la versión de GDB que estás ejecutando, así como información sobre la licencia, la arquitectura de tu procesador, comandos básicos de ayuda, etcétera. Finalmente te mostrará un prompt como este:

...
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from pila...done.
(gdb)

En este punto es cuando establecemos los puntos de quiebre dentro de nuestro programa; como sospechamos que la falla ocurre dentro de la función main() es conveniente que coloquemos el punto de quiebre allí. El comando para hacerlo es b.

(gdb) b main
Breakpoint 1 at 0x4005a5: file pila.c, line 19.
(gdb)

Ahora hay que lanzar el programa con los datos de entrada que causaron el error. Para hacer esto, ejecutamos el comando run seguido de los argumentos con los que queremos depurar el programa. En nuestro caso los argumentos son: “1 2 bla”.

(gdb) run 1 2 blah
Starting program: /tmp/pila 1 2 bla

Breakpoint 1, main (argc=4, argv=0x7fffffffe258) at pila.c:19
warning: Source file is more recent than executable.
19    PILA_LIMPIAR(numeros);
(gdb)

Al momento de ingresar el comando run, GDB inicia el programa con los argumentos que le indicamos, y detiene la ejecución en el primer punto de quiebre que encuentre. Si no existe ningún punto de quiebre, el programa continúa su ejecución normalmente, por ello es importante definirlos antes de lanzar el programa. Dado que establecimos el punto de quiebre en la función main(), GDB detiene el programa en la primera instrucción de esa función. Además muestra los valores de los argumentos de la función activa, así como el número de línea en donde se pausó la ejecución del código.

Podemos examinar el estado del programa con otros dos comandos: l y p; el primero muestra la línea de código en donde está detenido el programa incluyendo unas cuantas líneas antes y después a modo de contexto.

(gdb) l
14  int main(int argc, char *argv[])
15  {
16    int  n;
17    pila numeros;
18
19    PILA_LIMPIAR(numeros);
20
21    while (*++argv)
22      push(&numeros, atoi(*argv));
23
(gdb)

Y el comando p nos muestra en pantalla el valor de la variable que le pasemos como argumento.

(gdb) p numeros
$1 = {tope = 4196176, contenido = {0, 4195488, 0, -7600, 32767}}
(gdb)

El siguiente comando n nos lleva hacia adelante en la ejecución del programa por una instrucción.

(gdb) n
21    while (*++argv)
(gdb) n
22      push(*numeros, atoi(*argv));
(gdb) p *argv
$2 = 0x7fffffffe542 "1"
(gdb) n
21    while (*++argv)
(gdb) p numeros
$3 = {tope = 1, contenido = {1, 4195488, 0, -7616, 32767}}
(gdb)

Creo que con esto es suficiente para que empieces a jugar con el depurador para encontrar posibles errores en tus programas. En las siguientes entregas profundizaré más en el uso de GDB, los depuradores para otros lenguajes y otras herramientas útiles para diagnosticar problemas más complicados. Hasta entonces.

Deja una respuesta

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