Apéndice B. El depurador

Tabla de contenidos

B.1. Arranque y parada del depurador
B.2. Visualización de código
B.3. Ejecución controlada de un programa
B.4. Visualización de datos
B.5. Ejercicios

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.

Figura B.1. Primer caso en el que realmente se encuentra un bug (Fuente: U.S. Naval Historical Center Photograph)

Primer caso en el que realmente se encuentra un bug (Fuente: U.S. Naval Historical Center Photograph)

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:

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

B.1. Arranque y parada del depurador

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) y
shell$

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.

B.2. Visualización de código

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) 

B.3. Ejecución controlada de un programa

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.

B.4. Visualización de datos

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.

B.5. Ejercicios

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.

  1. ¿Qué comando hay que utilizar para mostrar por pantalla todos los bytes que codifican el string con nombre formato?

    ¿Cuál es el valor del último byte?

  2. 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:

    push %ebp
    push %eax
    push %ebx
    push %ecx
    push %edx

  3. 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?

  4. 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.

  5. 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é.

  6. 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?

  7. 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é.

Envío de errata