lunes, 20 de mayo de 2013

Depuración (parte 3): Controlando la ejecución II

En el post anterior mencioné los mecanismos básicos para controlar la ejecución del programa: puntos de interrupción, step over y step in. En esta tercera parte voy a completar la idea con algunos más para detener el programa y una aclaración importante sobre el nivel de granularidad con que se pueden indicar estos puntos de la ejecución en el código fuente. También, les voy a contar sobre algunos otras formas especiales para volver atrás en el tiempo o alterar (casi) arbitrariamente la ejecución, cosas que en la práctica permiten analizar varias veces un mismo error, o ensayar algunas soluciones antes de codificarlas. La ventaja radica en no tener que reiniciar el programa y llegar nuevamente hasta el punto conflictivo, lo cual puede llevar bastante tiempo o ser tedioso. Pero además, como efecto colateral, esto sentará una base que permitirá hacer algunos trucos para sortear ciertas limitaciones más adelante cuando hablemos de inspección de expresiones y otras acciones similares.

El último mecanismo usual para detener el programa según las lineas del código fuente que falta mencionar es el que permite ejecutar hasta cierta línea. Este mecanismo, válido cuando el programa ya se ha detenido previamente por alguna otra razón. Podemos colocar el cursor en una línea de código e indicarle al depurador que continúe ejecutando normalmente hasta alcanzar esa línea (Shift+F7). Antes de hacerlo, ZinjaI verifica que la línea del cursor sea válida, y si no lo es, avisa y no avanza, para evitar sorpresas.

Pero si consideramos que una instrucción de código C++ fuente se puede traducir al compilar en muchas instrucciones de código de máquina, hay un nivel de granularidad más fino que el depurador podría controlar (y de hecho lo hace). Sin embargo, hacer esto solo tiene sentido si sabemos mucho de ensamblador y de como funcionan los compiladores y estamos mirando detalles muy muy finos. Por ejemplo, si estamos optimizando hasta el nivel de código de máquina (cosa que normalmente solo se hace en motores de videojuegos muy específicos, o en drivers), o si tenemos la osadía de depurar código para el cual no tenemos información de depuración o sus fuentes. Ambas situaciones no son para nada comunes entre usuarios de ZinjaI, y todavía no me han tocado a mi tampoco, así que ZinjaI todavía no tiene un conjunto de controles para ver y ejecutar instrucciones de ensamblador. Entonces es importante a saber es que los puntos de interrupción y demás "lugares" del código fuente generalmente se le indican al depurador por número de línea, y en una línea puede haber muchas instrucciones. Por ejemplo, una línea "if (a>b) max=a; else max=b;" solo permitirá detener la ejecución justo antes de evaluar la condición, y avanzar hasta después del if completo. Pero si dividimos esa linea en tres, una con el if, otra con la acción por verdadero y otra con la acción por falso, podemos ahora distinguir las tres partes. Por esto, para depurar, conviene evitar lineas largas con muchas instrucciones, y colocar solo una instrucción/condición por línea.

 Ambos códigos son equivalentes, pero el de la derecha permite 
controlar mejor al ejecución desde el depurador

La forma que queda de detener el programa es recibiendo una señal. La señal la puede generar el usuario (por ejemplo, Ctrl+C en la consola), el lanzamiento de una excepción (esas cosas de try-catch-throw-etc estilo java), o el sistema operativo (como por ejemplo, en una violación de segmento). Los dos últimos casos son los más comunes, porque implican errores que usualmente queremos corregir y por ello estamos depurando. El primero es el que simula ZinjaI cuando usamos el botón de Pausa para detener la ejecución en un momento dado (y no por llegar a cierto punto), simula una señal de interrupción y esto hace que gdb retome el control. En realidad hay una forma más y poco común de detener el programa que es generando una de estas situaciones desde el mismo programa que está siendo depurado. En este artículo hablé de la macro _revienta, que utiliza este mecanismo para hacerse pasar por un punto de interrupción, y es muy útil para reemplazar a assert.

Ahora que sabemos ejecutar y detener, podemos pensar en cosas más raras. Una de ellas es volver el tiempo atrás. Desde hace algunas versiones gdb incorporó un mecanismo para deshacer la ejecución. Este mecanismo consiste en realidad en ir registrando todos los cambios de cada instrucción, guardando los valores viejos de las variables que se van modificando, y unas cuantas cosas más que llevan mucho tiempo y memoria (todo tarea del depurador). En ZinjaI figura como "Ejecución hacia atrás" en el menú de depuración. Primero hay que habilitarla para que registre todos los cambios, luego ejecutar normalmente paso a paso hacia adelante, y en algún momento que querramos, podemos deshacer esos pasos. Esto suena muy prometedor cuando uno lo lee, y por eso lo incorporé en ZinjaI ni bien lo implementó gdb, pero personalmente casi nunca lo he usado, y en varios casos no funciona correctamente (el depurador se cierra sin aviso, tal vez en versiones futuras de gdb se torne más estable).

Otras dos opciones más interesantes y que sí uso mucho son las de salir de una función sin terminar de ejecutarla, y la de saltar arbitrariamente por el código. La primera (return, Ctrl+F6 en ZinjaI) sirve para salir de una función sin ejecutar los que falte de la misma. Para ello, si la función no es de tipo void, debemos ingresar el valor de retorno que el depurador incluirá en la memoria como si lo hubiese retornado la función. Esto es útil cuando una función no retorna lo que necesitamos para continuar depurando.

Pero más útil todavía es poder pausar la ejecución en un punto, y continuar desde otro. Supongamos que veníamos analizando una función, y empezamos a avanzar sin prestar mucha atención, y de pronto la función sale, o saltea una estructura de control, o una variable toma un valor inesperado, etc. Una forma de averiguar qué sucedió es volver a ejecutar el programa, esta vez deteniendonos antes, o avanzando paso a paso con más cuidado. Pero volver a ejecutar el programa puede ser tedioso y llevar tiempo. En muchos casos basta con decirle al depurador que modifique los registros para que el programa crea que estaba en otra posición (antes del error) y continúe desde allí, volviendo así a ejecutar la parte que nos interesa. Un caso típico es cuando damos step over en una llamada a función y vemos que retorna algo inesperado. Si nos interesa ver qué pasa dentro de la función, podemos volver a ejecutarla, pero esta vez con step in. Para eso, en ZinjaI, vamos con el cursor a la linea de la llamada y presionamos Ctrl+F5 (Continuar desde aquí). Ahora, el programa pensará que estaba por ejectuar esa línea, porque el depurador habrá modificado los registros que dicen donde va la ejecución. Y allí podemos continuar con step in para ver que ocurre. No siempre es posible, y hay que usarlo con cuidado (nos permite por ejemplo saltar de una función a otra, pero eso no se debe, probablemente destruyamos el stack al salir de la función), pero es una de las opciones que más uso cuando depuro. Hay que tener cuidado porque a diferencia de la ejecución hacia atrás, aquí al volver el punto de ejecución a una instrucción previa, no volvemos los estados de las variables al estado que tenían cuando se ejecutó esa instrucción por primera vez, así que usualmente conviene volver un poco más a algún punto donde se asignen las variables (por ejemplo al comienzo de una función) para que la segunda ejecución de la instrucción de interés sea realmente útil.

En la consola se observa que el programa ya avanzó hasta el final mostrando el resultado (y con es_primo quedando en falso), pero en ZinjaI la flecha verde en el margen indica que se volvió el punto de ejecución (Ctrl+F5) a la línea donde inicializa la bandera.

Finalmente, quedarían por cubrir las opciones del breakpoint (shift+click sobre el punto rojo), que permiten definir entre otras cosas una condición a evaluar, de forma que la ejecución continúe si la condición no se cumple. Esto es útil, pero hay que notar que el depurador internamente detendrá siempre el programa, evaluará la condición, y continuará automáticamente si no se cumple, dando la sensación de que en realidad no se detuvo. Si la linea en cuestión se ejecuta muchas veces, este procedimiento ralentizará notablemente (mucho) la ejecución del programa. Una opción más simple y eficiente, si se sabe de antemano cuantas veces la condición será falsa antes de ser verdadera, es introducir esa cantidad en el campo de "ignorar" para que ignore las primeras pasadas por esa línea. Nuevamente el depurador se detendrá, contará y seguirá, pero esto es más rápido, ya que lo lento en el caso anterior es en general la evaluación de la condición.

Para cerrar, voy a hacer una aclaración importante que ya estaba implícita de alguna forma. En la  mayoría de los casos (siempre en ZinjaI), gdb no puede hacer absolutamente nada mientras el programa a depurar se está ejecutando, más que esperar a que se detenga. Por eso los puntos de interrupción deben colocarse de antemano. Sin embargo, ZinjaI permite colocarlos mientras el programa se ejecuta. Pues bien, esto no es del todo cierto, parece que fuera así, pero lo que hace en realidad es detener el programa enviandole una señal, una vez detenido (y ahora gdb retoma el control) modificar el punto de interrupción, y luego pedirle que continúe la ejecución. Todo se hace sin que el usuario lo vea y parece que se pudiera hacer sin detener el programa, pero no es cierto. Como en muchos casos, es un truco que hace el IDE para sortear ciertas limitaciones del depurador o del sistema

No hay comentarios:

Publicar un comentario