Tabla de contenidos
Uno de los principales problemas al escribir programas son los errores de ejecución. Compilar un programa no es garantía suficiente de que funciona de la manera prevista. Es más, el ciclo de desarrollo de un programa está ocupado, en su mayoría por las tareas de diagnosticar y corregir los errores de ejecución. A los errores de ejecución en programas en inglés se les suele denominar bugs (bichos).
El origen de la utilización del término bug para describir los errores en un program es un poco confuso, pero hay una referencia documentada a la que se le suele atribuir este mérito.
La invención del término se atribuye generalmente a la ingeniera Grace Hopper que en 1946 estaba en el laboratorio de computación de la universidad de Harvard trabajando en los ordenadores con nombre Mark II y Mark III. Los operadores descubrieron que la causa de un error detectado en el Mark II era una polilla que se había quedado atrapada entre los contactos de un relé (por aquel entonces el elemento básido de un ordenador) que a su vez era parte de la lógica interna del ordenador. Estos operadores estaban familiarizados con el término bug e incluso pegaron el insecto en su libro de notas con la anotación “First actual case of bug being found” (primer caso en el que realmente se encuentra un bug) tal y como ilustra la figura B.1.
Hoy en día, los métodos que se utilizan para depurar los errores de un programa son múltiples y con diferentes niveles de eficacia. El método consistente en insertar líneas de código que escriben en pantalla mensajes es quizás el más ineficiente de todos ellos. En realidad lo que se precisa es una herramienta que permita ejecutar de forma controlada un programa, que permita suspender la ejecución en cualquier punto para poder realizar comprobaciones, ver el contenido de las variables, etc.
Esta herramienta se conoce con el nombre de depurador o, su término inglés, debugger. El depurador es un ejecutable cuya misión es permitir la ejecución controlada de un segundo ejecutable. Se comporta como un envoltorio dentro del cual se desarrolla una ejecución normal de un programa, pero a la vez permite realizar una serie de operaciones específicas para visualizar el entorno de ejecución en cualquier instante.
Más concretamente, el depurador permite:
ejecutar un programa línea a línea
detener la ejecución temporalmente en una línea de código concreta
detener temporalmente la ejecución bajo determinadas condiciones
visualizar el contenido de los datos en un determinado momento de la ejecución
cambiar el valor del entorno de ejecución para poder ver su efecto de una corrección en el programa
Uno de los depuradores más utilizados en entornos Linux es
gdb (Debugger de GNU). En este
documento se describen los comandos más relevantes de este depurador para
ser utilizados con un programa escrito en C. Todos los ejemplos utilizados
en el resto de esta sección se basan en el programa cuyo código fuente se
muestra en la tabla B.1 y que se incluye en
el fichero gdbuse.s
Tabla B.1. Programa en ensamblador utilizado como ejemplo
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
.data # Comienza sección de datos nums: .int 2, 3, 2, 7, 5, 4, 9 # Secuencia de números a imprimir tamano: .int 7 # Tamaño de la secuencia formato:.string "%d\n" # String para imprimir un número .text # Comienza la sección de código .globl main # main es un símbolo global main: push %ebp # Bloque de activación mov %esp, %ebp push %eax # Guardar copia de los registros en la pila push %ebx push %ecx push %edx mov $0, %ebx bucle: cmp %ebx, tamano je termina push nums(,%ebx,4) # pone el número en la pila push $formato # pone el formato en la pila call printf # imprime los datos que recibe add $8, %esp # borra los datos de la cima de la pila inc %ebx jmp bucle termina:pop %edx # restaurar el valor de los registros pop %ecx pop %ebx pop %eax mov %ebp, %esp # Deshacer bloque de activación pop %ebp ret # termina el programa |
Para que un programa escrito en ensamblador pueda ser manipulado por gdb es preciso realizar una compilación que incluya como parte del ejecutable, un conjunto de datos adicionales. Esto se consigue incluyendo la opción -gstabs+ al invocar el compilador:
shell$
gcc -gstabs+ -o gdbuse gdbuse.s
Si el programa se ha escrito correctamente este comando ha generado el
fichero ejecutable con nombre gdbuse
. Una vez este
fichero se invoca el depurador con el comando:
shell$
gdb gdbuse
Tras arrancar el depurador se muestra por pantalla un mensaje seguido del
prompt (gdb)
:
shell$
gdb gdbuse GNU gdb Red Hat Linux (6.0post-0.20040223.19rh) Copyright 2004 Free Software Foundation, Inc. GDB is free software, covered by the GNU General Public License, and you are welcome to change it and/or distribute copies of it under certain conditions. Type "show copying" to see the conditions. There is absolutely no warranty for GDB. Type "show warranty" for details. This GDB was configured as "i386-redhat-linux-gnu"...Using host libthread_db library "/lib/tls/libthread_db.so.1".(gdb)
En este instante, el programa depurador ha arrancado, pero la ejecución del programa gdbuse (que se ha pasado como primer argumento) todavía no. La interacción con el depurador se realiza a través de comandos introducidos a continuación del prompt, de forma similar a como se proporcionan comandos a un shell o intérprete de comandos en Linux.
Para arrancar la ejecución del programa se utiliza el comando
run (o su abreviatura r). Tras
introducir este comando, el programa se ejecuta de forma normal y se
muestra por pantalla de nuevo el prompt
(gdb)
. Por ejemplo:
(gdb)
r Starting program: /home/test/gdbuse 2 3 2 7 5 4 9 Program exited normally.(gdb)
En el ejemplo, se puede comprobar como el programa termina correctamenta (tal y como denota el mensaje que aparece por pantalla). Cuando se produce un error en la ejecución, el depurador se detiene y muestra de nuevo el prompt.
Si se desea detener un programa mientras se está ejecutando se debe pulsar Crtl-C (la tecla control, y mientras se mantiene pulsada, se pulsa C). La interrupción del programa es capturada por el depurador, y el control lo retoma el intérprete de comandos de gdb. En este instante, la ejecución del programa ha sido detenida pero no terminada. Prueba de ello, es que la ejecución puede continuarse mediante el comando continue (que se puede abreviar simplemente con la letra c).
Para salir del depurador se utiliza el comando quit (abreviado por la letra q). Si se pretende terminar la sesión del depurador mientras el programa está en ejecución se pide confirmación para terminar dicha ejecución.
(gdb)
q The program is running. Exit anyway? (y or n) yshell$
El comando help muestra la información referente a todos los comandos y sus opciones. Si se invoca sin parámetros, se muestran las categorías en las que se clasifican los comandos. El comando help seguido del nombre de una categoría, proporciona información detallada sobre sus comandos. Si se invoca seguido de un comando, describe su utilización.
El código fuente del programa en ejecución se puede mostrar por pantalla mediante el comando list (abreviado l). Sin opciones, este comando muestra la porción de código alrededor de la línea que está siendo ejecutada en el instante en el que está detenido el programa. Si el programa no está en ejecución, se muestra el código a partir de la etiqueta main. El comando list acepta opciones para mostrar una línea en concreto, una línea en un fichero, una etiqueta en un fichero, e incluso el código almacenado en una dirección de memoria completa. El comando help list muestra todas las opciones posibles.
(gdb)
l main 3 # Secuencia de números a imprimir 4 tamano: .int 7 # Tamaño de la secuencia 5 formato:.string "%d\n" # String para imprimir un número 6 .text # Comienza la sección de código 7 .globl main # main es un símbolo global 8 main: push %ebp # Bloque de activación 9 mov %esp, %ebp 10 push %eax # Guardar copia de los registros en la pila 11 push %ebx 12 push %ecx(gdb)
Aparte de detener la ejecución de un programa con Crtl-C, lo más útil es detener la ejecución en una línea concreta del código. Para ello es preciso insertar un punto de parada (en inglés breakpoint). Dicho punto es una marca que almacena el depurador, y cada vez que la ejecución del programa pasa por dicho punto, suspende la ejecución y devuelve el control al usuario. Para insertar un punto de parada se utiliza el comando break (abreviado b) seguido de la línea en la que se desea introducir.
(gdb)
l 14 9 mov %esp, %ebp 10 push %eax # Guardar copia de los registros en la pila 11 push %ebx 12 push %ecx 13 push %edx 14 mov $0, %ebx 15 bucle: cmp %ebx, tamano 16 je termina 17 push nums(,%ebx,4) # pone el número en la pila 18 push $formato # pone el formato en la pila(gdb)
b 14 Breakpoint 1 at 0x8048377: file gdbuse.s, line 14.(gdb)
Se pueden introducir tantos puntos de parada como sean necesarios en diferentes lugares del código. El depurador asigna un número a cada uno de ellos comenzando por el 1. En la última línea del mensaje anterior se puede ver como al punto introducido en la línea 14 del fichero gdbuse.s se le ha asignado el número 1.
El comando info breakpoints (o su abreviatura info b) muestra por pantalla la lista de puntos de parada que contiene el depurador.
(gdb)
l 21 16 je termina 17 push nums(,%ebx,4) # pone el número en la pila 18 push $formato # pone el formato en la pila 19 call printf # imprime los datos que recibe 20 add $8, %esp # borra los datos de la cima de la pila 21 inc %ebx 22 jmp bucle 23 termina:pop %edx # restaurar el valor de los registros 24 pop %ecx 25 pop %ebx(gdb)
b 21 Breakpoint 2 at 0x8048398: file gdbuse.s, line 21.(gdb)
info breakpoints Num Type Disp Enb Address What 1 breakpoint keep y 0x08048377 gdbuse.s:14 2 breakpoint keep y 0x08048398 gdbuse.s:21(gdb)
Los puntos de parada se pueden introducir en cualquier momento de la ejecución de un proceso. Una vez introducidos, si se comienza la ejecución del programa mediante el comando run (o su abreviatura r), ésta se detiene en cuanto se ejecuta una línea con un punto de parada.
(gdb)
r Starting program: /home/test/gdbuse Breakpoint 1, main () at gdbuse.s:14 14 mov $0, %ebx(gdb)
c Continuing. 2 Breakpoint 2, bucle () at gdbuse.s:21 21 inc %ebx(gdb)
Nótese que el depurador primero se ha detenido en el punto de parada 1, tras introducir el comando continue se ha detenido en el punto de parada 2.
Cada punto de parada puede ser temporalmente desactivado/activado de manera independiente. Los comandos enable y disable seguido de un número de punto de parada activan y desactivan respectivamente dichos puntos.
Para reanudar la ejecución del programa previamente suspendida hay tres comandos posibles. El primero que ya se ha visto es continue (o c). Este comando continua la ejecución del programa y no se detendrá hasta que se encuentre otro punto de parada, se termine la ejecución, o se produzca un error. El segundo comando para continuar la ejecución es stepi (o su abreviatura si). Este comando ejecuta únicamente la instrucción en la que está detenido el programa y vuelve de nuevo a suspender la ejecución.
(gdb)
r The program being debugged has been started already. Start it from the beginning? (y or n) y Starting program: /home/test/gdbuse Breakpoint 1, main () at gdbuse.s:14 14 mov $0, %ebx(gdb)
si bucle () at gdbuse.s:15 15 bucle: cmp %ebx, tamano(gdb)
si 16 je termina(gdb)
Con la utilización de este comando se puede conseguir ejecutar un programa ensamblador instrucción a instrucción de forma que se pueda ver qué está sucediendo en los registros del procesador y en los datos en memoria. Mediante la combinación del mecanismo de puntos de parada y el comando stepi se puede ejecutar un programa hasta un cierto punto, y a partir de él ir instrucción a instrucción. Este proceso es fundamental para detectar los errores en los programas.
El comando stepi tiene un inconveniente. Cuando la
instrucción a ejecutar es una llamada a subrutina (por ejemplo la
instrucción call printf
), el depurador ejecuta la
instrucción call
y se detiene en la primera instrucción de
la subrutina. Este comportamiento es deseable siempre y cuando se quiera
ver el código de la subrutina, pero si dicho código pertenece a una
librería del sistema, lo que se necesita es un comando que permita
ejecutar la llamada a la subrutina entera y detenerse en la instrucción
que le sigue. Esto se puede conseguir si, al estar a punto de ejecutar
una instrucción call
se utiliza el comando
nexti en lugar de stepi.
(gdb)
r The program being debugged has been started already. Start it from the beginning? (y or n) y Starting program: /home/bin/gdbuse Breakpoint 1, main () at gdbuse.s:14 14 mov $0, %ebx(gdb)
si bucle () at gdbuse.s:15 15 bucle: cmp %ebx, tamano(gdb)
si 16 je termina(gdb)
si 17 push nums(,%ebx,4) # pone el número en la pila(gdb)
si bucle () at gdbuse.s:18 18 push $formato # pone el formato en la pila(gdb)
si bucle () at gdbuse.s:19 19 call printf # imprime los datos que recibe(gdb)
ni 2 20 add $8, %esp # borra los datos de la cima de la pila(gdb)
En general, cuando se produce un error en un programa ensamblador,
mediante la utilización de los puntos de parada se permite llegar al
programa al lugar aproximado del código en el que se supone que está el
error, y luego mediante la utilización de stepi se
ejecuta instrucción a instrucción teniendo cuidado de utilizar
nexti cuando se quiera ejecutar una instrucción
call
que incluya la llamada entera.
Los comandos descritos hasta ahora permiten una ejecución controlada de un programa, pero cuando el depurador es realmente eficiente es cuando hay que localizar un error de ejecución. Generalmente, ese error se manifiesta como una terminación abrupta (por ejemplo segmentation fault). Cuando el programa se ejecuta desde el depurador, esa terminación retorna el control al depurador con lo que es posible utilizar comandos para inspeccionar el estado en el que ha quedado el programa.
Uno de los comandos más útiles del depurador es print (o su abreviatura p). Como argumento recibe una expresión, y su efecto es imprimir el valor resultante de evaluar dicha expresión. Este comando puede recibir el nombre de cualquier símbolo que esté visible en ese instante en la ejecución del programa. El contenido de uno de estos símbolos se muestra por pantalla simplemente escribiendo el comando print seguido del nombre.
(gdb)
r The program being debugged has been started already. Start it from the beginning? (y or n) y Starting program: /home/bin/gdbuse Breakpoint 1, main () at gdbuse.s:14 14 mov $0, %ebx(gdb)
p tamano $7 = 7(gdb)
Aparte de nombres de etiquetas que apuntan a datos,
print acepta expresiones que se refieren a los
registros del procesador: $eax
, $ebx
, etc.
Breakpoint 1, main () at gdbuse.s:14 14 mov $0, %ebx(gdb)
p tamano $7 = 7(gdb)
p $eax $8 = 0(gdb)
p $ebx $9 = 9105372(gdb)
p/x $ebx $10 = 0x8aefdc(gdb)
Nótese que el último comando print tiene el sufijo /x que hace que el resultado se muestre en hexadecimal, en lugar de decimal. Si se quiere ver el contenido de todos los registros del procesador se puede utilizar el comando info registers.
(gdb)
info registers eax 0x0 0 ecx 0xfefff5ec -16779796 edx 0xfefff5e4 -16779804 ebx 0x8aefdc 9105372 esp 0xfefff548 0xfefff548 ebp 0xfefff558 0xfefff558 esi 0x1 1 edi 0x8b10dc 9113820 eip 0x8048377 0x8048377 eflags 0x200246 2097734 cs 0x73 115 ss 0x7b 123 ds 0x7b 123 es 0x7b 123 fs 0x0 0 gs 0x33 51(gdb)
Nótese que para cada registro se muestra su valor en hexadecimal seguido por su representación en decimal. No todos los registros que muestra este comando son manipulables desde un programa ensamblador, tan sólo los ocho primeros.
El comando print permite igualmente visualizar arrays
de valores consecutivos en memoria. Para ello es preciso especificar en
el comando el tipo de datos que contiene el array y su longitud. El
formato utilizado es incluir entre paréntesis el tipo seguido por el
tamaño entre corchetes. En el programa dado como ejemplo el comando para
imprimir los siete números enteros que se definen en la etiqueta
nums
es:
(gdb)
p/x (int[7])nums $13 = {0x2, 0x3, 0x2, 0x7, 0x5, 0x4, 0x9}(gdb)
Si lo que se necesita es visualizar los bytes almacenados en un lugar concreto de memoria, el comando examine (o su abreviatura “x”) imprime una determinada porción de memoria por pantalla. La sintaxis de este comando es “x/NFU dirección”. Las letras NFU representan opciones del comando. La N representa un entero que codifica el número de unidades de información en memoria a mostrar. La F representa el formato en el que se muestran los datos (al igual que el comando print, la “x” quiere decir hexadecimal). La letra U representa el tamaño de las unidades a mostrar. Sus posibles valores son “b” para bytes, “h” para palabras de 2 bytes, “w” para palabras de 4 bytes y “g” para palabras de ocho bytes.
La dirección a partir de la cual se muestra el contenido se puede dar como una constante en hexadecimal, o como el nombre de una etiqueta precedido del carácter “&”. Por ejemplo, para mostrar el contenido de las 7 palabras de 4 bytes almacenadas a partir de la etiqueta nums el comando es:
(gdb)
x/7xw &nums 0x804957c >nums<: 0x00000002 0x00000003 0x00000002 0x00000007 0x804958c >nums+16<: 0x00000005 0x00000004 0x00000009(gdb)
Este comando se puede utilizar para mostrar el contenido de una porción de memoria a la que apunta un determinado registro. Por ejemplo, para mostrar las cuatro palabras de memoria almacenadas en la cima de la pila se puede utilizar el siguiente comando:
(gdb)
x/16xb $esp 0xfefff548: 0xe4 0xf5 0xff 0xfe 0xec 0xf5 0xff 0xfe 0xfefff550: 0xdc 0xef 0x8a 0x00 0x00 0x00 0x00 0x00(gdb)
Además de visualizar datos en registros o memoria, el depurador permite también manipular estos datos mientras el programa está detenido. El comando set permite la asignación de un valor numérico tanto a porciones de memoria como a registros. Para asignar el valor 10 al registro %eax se utiliza el comando:
(gdb)
set $eax=10(gdb)
p $eax $14 = 10(gdb)
Este comando es útil cuando se detecta un valor erróneo en un registro y se puede corregir para mostrar si el programa puede continuar normalmente.
Al igual que se permite modificar datos en registros, también se pueden modificar datos en memoria. Para ello es necesario especificar el tipo de dato que se está almacenando entre llaves seguido de la dirección de memoria. De esta forma se especifica dónde almacenar el valor que se proporciona a continuación tras el símbolo de igual. Por ejemplo:
(gdb)
set {int}0x83040 = 4(gdb)
El comando anterior almacena el valor 4 en la posición de memoria cuya
dirección es 0x83040
y almacena 4 bytes porque se refiere a
ella como un entero.
Para la realización de los siguientes ejercicios se utiliza el código
fuente utilizado como ejemplo, mostrado en la tabla B.1 y contenido en el fichero gdbuse.s
. Se supone que el
programa ha sido compilado, el ejecutable producido y el depurador
arrancado.
¿Qué comando hay que utilizar para mostrar por pantalla
todos los bytes que codifican el string con
nombre formato
?
Se sabe que las instrucciones del tipo push :registro:
se codifican mediante un único byte y mov :registro:,
:registro:
mediante dos bytes. Utilizando únicamente el
depurador, decir cuál es el código hexadecimal de las siguientes
instrucciones:
Situar un punto de parada en la instrucción call printf
.
¿Qué comando es necesario para mostrar por pantalla el valor que
deposita en la cima de la pila la instrucción push
$formato
?
Introducir un punto de parada en la línea 10 del código (en la
instrucción push %eax
). Mostrar por pantalla mediante el
comando print el valor de los registros
%eax
, %ebx
, %ecx
y
%edx
. Apuntar estos valores.
A continuación introducir un segundo punto de parada en la línea 14
(en la instrucción mov $0, %ebx
). Mediante el comando
continue continuar la ejecución hasta ese punto.
¿Qué comando hay que utilizar para mostrar por pantalla el contenido de las cuatro palabras de memoria que se encuentran en la cima de la pila? Comprobar que estos valores son idénticos a los mostrados en el primer punto de parada.
La instrucción inc %ebx
aumenta el valor de dicho
registro en una unidad. Este registro contiene el índice del
siguiente elemento a imprimir. Poner un punto de parada en la
instrucción siguiente a esta y con el programa detenido modificar el
valor de este registro con un número entre cero y seis (ambos
inclusive). Explica qué es lo que sucede y por qué.
Utilizando la ejecución instrucción a instrucción que permite el
depurador, ¿qué instrucción se ejecuta justo antes de la instrucción
pop %edx
?
La instrucción push nums(,%ebx,4)
deposita un cierto
valor en la pila. Introducir un punto de parada en la siguiente
instrucción, y una vez detenido el programa, poner en la cima de la
pila otro número arbitrario mediante el comando
set. Explica qué efecto tiene esto y por qué.