lunes, 8 de abril de 2013

Depuración (parte 1): ¿para qué sirve y cómo funciona?

Si hay una habilidad imprescindible en todo buen programador, es la habilidad para depurar un programa. La depuración es en general el proceso de corregir errores, pero cuando hablamos de programación, yendo a la práctica, la clave es ejecutar el programa con un "depurador", una herramienta que nos permite interrumpirlo en cualquier momento para ver qué está haciendo o qué tiene en su memoria. Un programador habilidoso en la depuración, sabe dónde interrumpirlo o qué buscar en la memoria a la hora de encontrar la causa de un problema, o entender cierto comportamiento. Y esto se adquiere con la práctica, como todo en la programación. Hay que conocer qué pueden hacer en teoría las herramientas, pero la práctica y la experiencia nos ayudarán a decidir cómo utilizarlas. Sin embargo, hay muchas funcionalidades básicas de un depurador que puede aprovechar incluso el principiante para entender cómo funciona un ejemplo o una estructura en particular.

El problema es que a pesar de lo importante que nos parezca a muchos programadores, cuando se enseña a programar en general se le da poco y nada de importancia. En la mayoría de los casos, los libros de programación no dedican un capítulo a esto, ni los docentes dedican una de sus clases. Pareciera que es algo para aprender cuando ya sabemos programar, para dejar para cursos avanzados y segundas partes, y esa idea no puede estar más equivocada. Las nociones básicas y el hábito de usar un depurador se deben inculcar desde temprano (desde que dejamos el pseudocódigo y pasamos a un lenguaje real diría yo). Por eso, en esta serie les voy a empezar a contar a los principiantes qué se puede hacer con un depurador, y daré algunas pistas para los más curiosos o avanzados de cómo hace lo que hace, aunque esto último no sea necesario saberlo para comenzar a utilizarlo.

Voy a tomar por referencia a gdb, el depurador de gnu, el depurador de facto en GNU/Linux y en casi cualquier plataforma libre, y el que usa ZinjaI detrás de las cortinas (por ende, el que mejor conozco). Pero las ideas básicas aplican a cualquiera.

Lo primero que hay que saber es que el depurador es un programa, que se encarga de ejecutar a otro programa, al que queremos analizar. El "se encarga de ejecutar" significa que lo lanza, lo modifica y lo interrumpe a gusto. Por ejemplo, si quiero ver cuanto vale una variable en algún punto de mi programa X, tengo que cargar el depurador, decirle que voy analizar X, y que voy a querer que se detenga en cierto punto. El depurador entonces va a modificar a X para que al ejecutarse se detenga donde yo quiero. Una vez detenido, el depurador será capaz de analizar la memoria de trabajo de X y decirme cuanto valen las variables de mi programa. Luego de ver las variables, podré seguir ejecutándolo hasta el final, hasta otro punto de interés que le indique, o incluso linea por linea (de a una instrucción por vez).

Pero el depurador no trabaja solo. Al usuario le interesa analizar el código fuente (decirle que se detenga en cierta línea del código fuente, o preguntarle cuanto vale cierta variable denominada con cierto identificador en el código fuente). Pero el depurador ejecuta un programa ya compilado, y ya sabemos que una vez compilado, no se puede volver atrás; que a partir de un código de máquina no puedo obtener nuevamente un código fuente. Entonces ¿cómo hace cuando le damos indicaciones según el código fuente para aplicarlas en el ejecutable y su memoria? La respuesta está en la compilación. El que sabe qué instrucción de máquina vino de qué instrucción C++, o en qué lugar de la memoria fue a parar cada variable, es el compilador, en el momento en que hace su trabajo de "traducción". Entonces, es el compilador el que anota todas estas cosas a medida que traduce, y las introduce camufladas dentro del ejecutable (o a veces en otro archivo separado), para que luego el depurador sepa. En el caso de gcc por ejemplo, el argumento -gX (donde X es 0, 1, 2 o 3) en la linea de compilación es el que indica cuanta información de depuración colocar dentro del ejecutable. Obviamente para que compilador y depurador se entiendan, deben estar de acuerdo en la forma en que se guarda esta información, y para ello hay algunas formas más o menos estándar (dwarf y stab creo que son los más usados, pero no hace falta entenderlos), y otras propias de cada compilador. Por eso, van a ver que un ejecutable compilado en modo "debug" es más grande que uno compilado en modo "release". La herramienta "strip" sirve para eliminar esa información de un ejecutable con el fin de achicarlo cuando ya no queremos depurarlo. En ZinjaI, pueden ir a Herramientas->Propiedades del Ejecutable, y hacer click en el botón Strip para ver cómo se reduce el tamaño de sus programas. Pero a pesar de su tamaño, es importante saber que la información de depuración no incluye el código fuente en sí, sino referencias a este (posiciones y nombres de archivos), por lo que debemos disponer del los archivos fuentes que generaron un ejecutable para poder depurarlo correctamente (para interpretar esas referencias).

El mismo ejecutable, con (derecha) y sin (izquierda, "stripped")  información de depuración incluida.

Entonces, para depurar un programa, no hay que ejecutarlo como siempre (F9 en ZinjaI), sino a través del depurador (F5 en ZinjaI), y el depurador necesita además que el exe tenga información de depuración colocada por el compilador (se incluye por default en ZinjaI). Para luego poder detener el programa donde queremos, antes de que el depurador realmente lo ejecute, tenemos que colocar "puntos de interrupción". Esto es, decirle al depurador dónde vamos a querer que lo pare (lo cual logramos en ZinjaI haciendo click sobre el margen izquierdo de una instrucción para que aparezca un punto rojo). Una vez que el programa a depurar empieza a ejecutarse, el depurador (debido a los mecanismos que proporciona el sistema operativo) solo puede retomar el control del programa cuando se genere un señal o interrupción. Entonces, cuando le pedimos que coloque un punto de interrupción en una línea de código, lo que hace es buscar en la información de depuración que extrajo del ejecutable a dónde fue a parar esa línea de código en la compilación, y reemplazar la instrucción de máquina que sea que haya en esa posición por otra que genere una interrupción (la int 3 es la que usan los depuradores). De esta forma, al llegar allí, el programa genera una interrupción y se detiene, dándole paso al depurador, que volverá a reemplazar la interrupción por la instrucción que estaba originalmente para luego seguir ejecutando como si nada hubiese ocurrido.
 
Sesión de depuración en ZinjaI. El punto de interrupción se marca en rojo en el margen de la linea 6, y la flecha verde indica que ahí se detuvo el programa (justo antes de ejecutar ese línea). El trazado inverso muestra como llegamos hasta allí (main invocó a suma en la línea 15), y la tabla de inspecciones muestra los valores y tipos de algunas variables y expresiones en ese momento.

Lo realmente útil es que esa pausa, puede explorar la memoria del programa. Viendo qué hay en la memoria, el depurador extrae dos cosas muy importantes: la pila de llamadas a funciones, y los valores actuales de las variables. Para lo primero, el depurador debe saber cómo organiza el programa en memoria la pila de llamadas a funciones (lo cual está también estandarizado), y en qué dirección de memoria está el código de cada función (lo cual está en la información de depuración del ejecutable). Esto se conoce como backtrace o trazado inverso, y permite ver cómo es que el programa llegó hasta esa instrucción. Esto es, qué función llamó a cual otra, y esta otra a cual otra, y así hasta llegar al punto de interrupción, además de en qué linea se hizo cada llamada, y con qué argumentos. Para lo segundo, el depurador debe entender un poco de C++ para darse cuenta cuando le pedimos una variable o expresión de a qué hace referencia (consideran operadores, scopes, atributos de clases y structs, algunos casts, etc), ir a ver tabla de símbolos (parte de la información de depuración del ejecutable) dónde fue a parar en la memoria ese o esos valores, y luego ir a ese lugar de la memoria del programa e interpretar el contenido (traducir los bits en números, caracteres, lo que sea que necesitemos ver).

Ejemplo de uso del depurador solo (sin IDE de por medio)

Habiendo presentado las nociones más elementales de qué hace y para qué sirve, queda una pregunta importante que puede que a muchos no les sea útil saber, pero igual les interese: ¿cómo se ve un depurador? Pues bien, en el caso de gdb (y creo que la mayoría) como una linea de comandos. En esa linea de comandos ingresamos órdenes para decirle qué ejecutable cargar, dónde colocar los puntos de interrupción, cuando empezar a ejecutarlo realmente, o qué variable buscar para ver su valor por ejemplo. Estas órdenes, como toda buena consola, son en modo texto, siguiendo cierto formato, y las respuesta las recibimos de la misma manera. Un IDE lanza este proceso de forma oculta y va enviándole comandos y recibiendo respuestas que debe analizar para obtener la información que luego muestra en sus ventanas. Por eso, en general, hay dos formas de ejecutar el programa: una "normal", y otra para depuración. La segunda es más lenta, ya que involucra cargar el depurador, charlar un rato con él, esperar que analice la información de depuración, etc. Por eso, si no queremos depurar tenemos la primera.

Este tema continúa en Depuración (parte 2): Controlando la ejecución I

2 comentarios:

  1. Hola, hace ya tiempo que estoy usando Zinjai y es un compilador muy bueno. Te doy concejo constructivo, te recomiendo que en las pestañas del compilador tmb tengan un acceso rapido como Mozilla o GoogleChrome (Ctrl+1: Pestaña1; Ctrl+2: Pestaña2...y asi sucesivamente)

    ResponderEliminar
    Respuestas
    1. Gracias por la sugerencia. Por ahora se puede cambiar de pestaña con Ctrl+Tab/Ctrl+Shift+Tab, y Ctrl+AvPag/Ctrl+RePag (igual que en Firefox, donde no me funciona Ctrl+Número). Los atajos Ctrl+Número ya están ocupados para el plegado de código. Igual se puede pensar en agregar alguna combinación para ir directo a una pestaña sin pasar de a una hasta llegar.

      Eliminar