Hace unos días, mientras revisaba el timeline de twitter, un tuit de mi amigo
@metaedd, llamo demasiado mi atención,
este decía lo siguiente:
Ese momento Épico cuando tu programa
compila sin errores y Fail cuando no corre
de manera adecuada y ya no te marca los errores!
— metaedd
Sin querer, mi amigo me dio un excelente tema para publicar en este su blog y
que ayudara a conocer a los debuggers, a ser mas productivos o mas mundana mente,
a minimizar esas noches de desvelo o de privarnos de la sociedad para poder
encontrar esos errores que en muchas ocasiones llegan a avergonzarnos y otras
veces son una buena razón para quedarnos meditando con un buen café.
Retomando el tema, partiremos de la idea del tuit de la cual podemos decir que
cuando un programa no muestra errores de compilación no necesaria mente es
indicio de que su ejecución y su lógica es correcta. Pero ¿como podemos llegar
a estar casi seguros de que el programa no tratara de accederá a locaciones de
memoria no permitidas (un típico segmentation fault), o no encenderá a la
computadora en llamas, entre otros casos menos dramáticos?.
La respuesta lógica a esta incógnita es depurar el programa, y ya respondida
esta surge otra pregunta y esa es el ¿como llegar a hacerlo?.
Una practica muy común es poner prints o puts tanteando a lo largo del código,
hasta encontrar el error.
¿Qué si es valida esta forma? Claro que es valida, de cualquier manera es una
forma de encontrar los errores, aunque muy poco elegante y eficiente.
No todo lo que este en la moda, es necesariamente lo correcto
Dado que siempre ha existido la necesidad de solucionar errores, puesto que somos
humanos, desarrolladores crearon herramientas que permitieran facilitar y
optimizar esta labor, a estos se les dio el nombre de debugger o depuradores.
Quisiera hondear un poco sobre la historia de la palabra debugger, pero sera
para alguna otra ocasión.
Veamos por el momento la definición de debugger o depurador, cortesía de
wikipedia.
Un debugger o depurador es usado para probar un programa objetivo y
eliminar sus errores.
Con base en la definición, un programa son instrucciones y datos, los cuales fueron
provistos por códigos fuente escritos en algún lenguaje de programación.
Hoy en día gran mayoría de los lenguajes de programación tienen herramientas
que permitan, ejecutar instrucción por instrucción, linea a linea del programa
y así facilitar así el desarrollo de software.
A las herramientas que realizan esta tarea, se les conoce como debuggers.
Dejando de lado la palabrería y pasando a la practica les presentare un clásico
de clásicos, el GNU Project Debugger - GDB.
Este debugger ademas de ser código abierto provee soporte para debuggear C/C++,
Ada, Pascal, Objective-C, entre otros.
Para empezar necesitamos código que compile pero que tenga algunos errores,
yo propongo algo sencillo para comenzar a aprender.
La función que debe cumplir este programa es crear un arreglo con longitud TAM,
con un ciclo asignar a cada posición su respectivo indice, y en otro ciclo imprimir
el contenido de cada locación.
#include <stdio.h>
#define TAM 3
int main(){
int i=TAM, numeros[TAM];
while(i-- >= 0)
numeros[i] = i;
while(i < TAM)
printf("%d\n", numeros[i++]);
return 0;
}
ahora compilemos el programa de la siguiente manera:
debug$ gcc debugger-1.c -ggdb3 -o debugger-1.c
Lo que indicamos en la linea de comando es, que incluya los símbolos de depuración
en el binario. Específicamente con la opción -ggdb indicamos que genere información
de depuración para ser usada por gdb la cual deberá de ser lo mas explicita posible,
de acuerdo al manual este podría ser alguno de los formatos dwarf 2, stabs o el
formato nativo y el numero tres indica el nivel de información que requerimos. El
nivel por defecto es el 2, este incluye descripción de funciones, de variables
externas e internas y numeros de linea. El tres va un poco mas halla, este incluye
todas las definiciones de macros presentes en el programa.
Ejecutemos el programa para verificar que tenemos un código erróneo.
debug$ ./debugger-1
-1074137012
-1
0
1
2
Nuestro código esta imprimiendo algo de basura, por lo que completamos la "difícil"
tarea de tener un código erróneo. Ahora solo queda llamar a gdb pasando le como
parámetro nuestro ejecutable, puede llegar a verse de la siguiente manera la
bienvenida.
debug$ gdb debugger-1
GNU gdb (GDB) 7.5.1
Copyright (C) 2012 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "i686-pc-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /home/zoek/code/c/debugger-1...done.
(gdb)
Se debe de tomar especial atención en la última linea de la bienvenida, ya que
se indica que la información de depuración es correcta y se puede proseguir con la
depuración correctamente. Cuando un ejecutable no incluye la información de
depuración, en esta misma linea nos indicara que no pudieron ser cargador los
símbolos de depuración, por lo cual no podremos hacer la depuración correctamente.
GDB cuenta con su propio prompt, este se muestra inicialmente como (gdb) y al igual
que el del shell, utiliza readline dando así acceso a las combinaciones de teclas de
acceso rápido de emacs o de vi, también te ofrece la posibilidad de auto-completar ya
sean los comando o las definiciones de los elementos del programa.
Para empezar a depurar nuestro programa, lo primero a hacer es establecer un punto
de interrupción, para así poder detener la ejecución del programa esto se hace con el
comando break mas un argumento el cual puede ser una definición de función, un numero
de linea, una etiqueta o una dirección valida del programa. En este momento
utilizaremos una definición de función detener la ejecución, pero en futuras ocasiones
describiré cada uno de los especificadores de locación validos. Estableceré
el punto de interrupción en la definición de la función main dado que es la única en
el código.
Algo que debo de destacar es que algunos de los comando de gdb tienen sus respectivas
abreviaciones como es el caso de break y su abreviación es 'b' o el de run que es 'r'.
Se debe tener en claro que solo son algunos de los comandos, los que tienen abreviación.
(gdb) break main
Breakpoint 1 at 0x8048405: file debugger-1.c, line 5.
La salida nos muestra que el punto de interrupción ha sido correctamente establecido
en la dirección 0x8048405 y que la definición se encuentra en el archivo debugger-1.c,
en la linea 5. La dirección depende de la posición en la que es colocado el programa.
Ahora que ya tenemos el punto de interrupción podemos ejecutar el programa con la
plena seguridad de que no terminara su ejecución antes de que parpadeemos, si no
que se detendrá cuando inicia la definición de la función.
El comando para empezar la ejecución del programa es run y puede ir seguido si
este los requiere de opciones como si fuese una orden en el shell, en nuestro caso
no las utilizaremos.
(gdb) r
Starting program: /home/zoek/code/c/debugger-1
Breakpoint 1, main () at debugger-1.c:5
5 int i=TAM, numeros[TAM];
La salida después de ejecutar el comando run, señala que el programa se ha detenido
en el punto de interrupción 1, en la definición de main, también muestra el numero
de linea y la siguiente instrucción a ejecutar, esto es que la linea 5 la cual tiene
la declaración de i y números, es la próxima instrucción a ejecutar.
Para reanudar la ejecución del programa una instrucción a la vez, existen varios
comando pero en esta ocasión solo expondremos los mas comunes y estos son step y
next. La diferencia entre estos dos se basa en que step ejecuta una instrucción a
la vez y si la siguiente instrucción fuese una función de la cual se tienen los
símbolos de depuración, entonces saltaría dentro de la definición de la función,
mientras que next ejecutaría la función sin adentrarse en ella. También estos dos
comandos cuentan con sus abreviaturas 's' para step y 'n' para next.
Continuando con el programa, ejecutemos step hasta llegar al ciclo, adentrémonos
en el ciclo e imprimamos el valor de i en cada iteración para asegurarnos que los
valores del 2 a 0 están siendo asignados correctamente.
Para obtener la impresión de los valores, contamos con el comando print el cual
imprime la información acorde al tipo de dato del elemento que le pasemos como
argumento o también existe el comando printf y su sintaxis es casi igual al printf
en c solo que sin paréntesis, la cadena de formato y la lista de argumentos de
donde se tomaran las representación se especifican de igual manera.
(gdb) s
7 while(i-- >= 0)
(gdb) print i
$1 = 3
(gdb) s
8 numeros[i] = i;
(gdb) s
7 while(i-- >= 0)
(gdb) printf "numeros[%d] = %d\n",i,numeros[i]
numeros[2] = 2
(gdb) s
8 numeros[i] = i;
(gdb) s
7 while(i-- >= 0)
(gdb) printf "numeros[%d] = %d\n",i,numeros[i]
numeros[1] = 1
(gdb) s
8 numeros[i] = i;
(gdb) s
7 while(i-- >= 0)
(gdb) printf "numeros[%d] = %d\n",i,numeros[i]
numeros[0] = 0
(gdb) s
8 numeros[i] = i;
(gdb) s
7 while(i-- >= 0)
(gdb) printf "numeros[%d] = %d\n",i,numeros[i]
numeros[-1] = -1
(gdb) s
10 while(i < TAM)
(gdb) print i
$2 = -2
Como se puede observar el valor de i después de el ciclo de asignación es -2, esto
es sin duda erróneo pues un índice negativo, se halla fuera del espacio reservado
para el array y puede llegar a modificar otros datos, pero es permisible debido a
que nos encontramos dentro del área designada al programa.
Entonces tenemos claro que hay un error de lógica, de lo observado podríamos hacer
algunas conjeturas:
- Las operaciones aplicadas sobre la variable i que actúa como índice,
nos da un numero menor al esperado que es 0.
- El segundo ciclo ejecuta correctamente su código, mostrando cada uno de los
valores almacenados.
Dadas estas conjeturas podemos concluir que si la variable i al terminar
el ciclo tuviera el valor de 0, el segundo while iteraría acorde a la lógica
del programa y accedería a las locaciones de memoria verdaderas.
Demostremos si esta conclusión es verdadera estableciendo el valor de i a 0 y
ejecutando la instrucción until dándole como argumento la posición a detenerse.
(gdb) set variable i = 0
(gdb) until 12
0
1
2
Efectivamente, la conclusión fue correcta pero lo que verdad importa son los
comandos set y until, hablemos un poco de ellos.
set es un comando de gdb que permite asignar valores a las variables, pero para
hacer distinción de las variable que usa gdb para las configuración y las del
programa se antepone la palabra var o variable antes de la asignación de la
variable del programa. Se puede ejecutar sin la palabra variable pero debemos
tener cuidado de que no entre en conflicto con los sub-comandos de set.
(gdb) set i = 0
Ambiguous set command "i = 0": inferior-tty, input-radix, interactive-mode.
(gdb) set i=0
Ambiguous set command "i=0": .
En este caso tenemos dos errores, pero son ocasionados por la misma razón, en el
primer caso no sabe que comando ejecutar de los tres disponibles, en el segundo
caso el signo '=' que actúa como delimitador, la i fue expandida para buscar
coincidencias dado que tiene comando que comienzan con esta letra, pero al momento
de mostrar las coincidencias no existe ninguna posible.
EL comando until al igual que muchos de los comandos tiene distintas funcionalidades.
Las de until son evitar el dar step en cada iteración mas de una vez, esto es que
si tenemos un ciclo solo tendremos que recorrerlo una vez y cuando nos encontremos
nuevamente en el inicio del ciclo podremos escribir until, esto activara la ejecución
y se detendrán las iteraciones solo hasta que la condicional no se cumpla o trate de
salir de su stack frame.
Otra forma de utilizar el comando until es indicarle una locación hasta donde deberá
parar su ejecución, esta puede ser una linea, una dirección, una etiqueta o una
definición de una función dentro del stack frame o al hasta que abandone su
stack frame.
Volvamos al código del programa ha corregir los errores, debemos de cambiar el
operador de '>=' cambiarlo ha '>' por la razón de que cuando sea cero habrá iterado
tres veces, el tamaño del arreglo, y transferiremos el decremento de la variable
localizado en la condicional, un poco antes del direccionamiento para así asignar
los índices correctos.
while(i > 0)
numeros[--i] = i;
Algo interesante de gdb es que puedes correr comandos del shell, y todo esto se
hace mediante el comando shell.
(gdb) shell gcc debugger-1.c -ggdb3 -o debugger-1
(gdb) r
Starting program: /home/zoek/code/c/debugger-1
Breakpoint 1, main () at debugger-1.c:10
10 int i=TAM, numeros[TAM];
Al compilar nuevamente el programa y volver a ejecutarlo, gdb leerá nuevamente
la información de depuración y los break que establecimos anteriormente seguirán
siendo validos. El programa detendrá su ejecución al inicio de la función main,
como no establecimos ningún otro punto de interrupción podremos ejecutar el
comando continue o su abreviación c para simular una ejecución común del programa.
Aclaro que si establecieron otros puntos de interrupción, continue se detendrá en
sus respectivas posiciones.
(gdb) continue
Continuing.
0
1
2
[Inferior 1 (process 27759) exited normally]
(gdb)
Cuando hallamos terminado el proceso de depuración, podemos llegar a retirar la
información de depuración sin tener que volver a compilar el programa sin las
flags, esto se hace por medio del programa strip
que forma parte de el paquete binutils y
una manera sencilla de eliminar los símbolos es:
debug$ strip --strip-debug debugger-1
Con la eliminación de la información de depuración, finalizo este post, en el
siguiente tocare los puntos de seguir a los procesos hijo, configurar un poco
a gdb y hacer snapshots del estado del proceso.
GDB TIP
El gdb tip consiste en poder visualizar el código fuente, si es que en el binario
ha sido incluida la información de depuración.
(gdb) l
3
4 int main(){
5 int i=TAM, numeros[TAM];
6
7 while(i > 0)
8 numeros[--i] = i;
9
10 while(i < TAM)
11 printf("%d\n", numeros[i++]);
12
El comando list es simple, cuando especificas list sin argumentos imprime diez o mas
lineas, si se ejecuto anteriormente list, imprime las siguientes diez lineas,
pero si se encuentra en la parte final del stack frame actual entonces se muestra
centrada la linea actual.
Con el argumento '-' imprime las diez lineas anteriores a las ya impresas si se
ejecuto list anteriormente o las 10 anteriores a la actual.
Si se especifica una linea o una función, la linea actual se encontrara centrada.
Cabe aclara que si se ejecuta un comando que cambie la linea actual, y se vuelve
a ejecutar list, este comando mostrara las lineas como si hubiese sido la primera
vez que se ejecuta.
Note
La secuencia de los comandos mostrados no es necesariamente la ideal, fue hecha
para fines de enseñanza y el usuario es libre de tomar la decisión de que comando
ejecutar en cada instancia.
EOF
Footnotes
There are comments.