martes, 23 de abril de 2013

Depuración (parte 2): Controlando la ejecución I

Habiendo introducido las nociones básicas en la parte 1, vamos a profundizar sobre los dos aspectos prácticos más importantes: el control de la ejecución y la inspección de los datos en memoria. En esta parte 2 (y la que sigue) voy a hablar del control de la ejecución. Con el control de la ejecución me refiero a cómo definimos cuándo y hasta dónde se ejecuta. Hay que saber que el mecanismo básico es el punto de interrupción, pero cualquier depurador nos provee un conjunto de instrucciones para avanzar de a poco sin tener que explicitar nuevos puntos de interrupción. Por ejemplo, si estoy detenido en cierto punto y quiero que ejecute una sola linea y vuelva a detenerse (ejecución paso a paso), tendría que poner un punto de interrupción en la siguiente línea y decirle luego que continúe ejecutando, y una vez detenido nuevamente quitar ese punto de interrupción. Si bien es un mecanismo válido, los depuradores tienen instrucciones para hacer todo esto junto y de forma transparente, de modo que al usuario el usuario ni se entere de ese punto de interrupción auxiliar. Y así, hay comandos para avanzar por linea o por instrucción (que no es lo mismo), hasta llegar a cierta línea, o hasta salir de cierta función, etc. También hay mecanismos para alterar la ejecución y volver a ejecutar una línea que ya pasamos, y otros trucos útiles. De eso habla este post.

Empecemos por lo básico. Cuando ejecutamos con el depurador (F5 en ZinjaI), el programa comienza a ejecutarse y sigue así hasta que se produzca una señal o alcance un punto de interrupción. Por eso, hay que colocar los puntos de interrupción antes de comenzar la ejecución. El IDE lanzará el depurador indicandole qué programa leer, le dirá donde poner los puntos de interrupción, y luego le pedirá que lo ejecute. Podemos poner todos los que querramos y el programa se detendrá en el primero de ellos que alcance. Y si lo seguimos ejecutando se seguirá deteniendo cada vez que alcance alguno.

Por ejemplo, supongamos que un programa no da el resultado que esperamos, digamos que queríamos que un if de falso y ejecute lo que dice el else, para más adelante mostrarlo, pero lo que vemos en la salida no concuerda. Podemos poner un punto de interrupción dentro del else, y esperar a que el programa se detenga allí adentro para ver qué es lo que hace mal mirando las variables. Pero si sucede que ejecutamos el programa y no se detiene, entonces lo que pasa es que nunca entró al else, o sea que el if dió verdadero, o tal vez ni siquiera llegó al if. El siguiente paso sería poner el punto de interrupción en el if para ver si al menos llega al if, y en caso de ser así para analizar las variables que intervienen en la condición y ver cual no tiene el contenido que esperábamos que tenga. Así podemos usar puntos de interrupción para rastrear el problema, recordando que si el programa no se detiene, hay que ir cada vez más atrás (antes) a ver por qué no llegó hasta ahí. El problema real puede estar mucho antes de lo que pensamos o de donde se manifiesta.

 La imágen muestra el programa detenido en el punto de interrupción de la línea 29. El de la línea 27 se muestra en gris porque en realidad esa línea no es una ubicación válida para un punto de interrupción. En la barra de herramientas se encuentran los botones para avanzar paso a paso, y el mensaje en azul a la izquierda indica el estado de la depuración.

Pero ojo, que un programa podría no detenerse en un punto de interrupción porque este está mal ubicado, y no porque la ejecución no pasa por esa línea de código. Por ejemplo, la instrucción "int x;" no genera código al compilar, sino que indica que cierto lugar de la memoria se reserva para un valor entero que llamaremos x, que usarán las instrucciones que operen sobre x, pero no hay instrucciones de máquina que se correspondan con esa definición. Por lo tanto, un punto de interrupción ahí es inválido. El depurador puede intentar colocarlo en la próxima línea que sí sea una instrucción, comportamiento por defecto en gdb. Pero en ese caso no se detendrá donde esperabamos y esto podría generar cierta confusión (la siguiente línea podría estar hasta en otro scope). Por eso ZinjaI verifica si existe esa "instrucción" en el ejecutable antes de colocar el punto, y si no existe muestra un aviso al iniciar la depuración y pinta el punto de gris para hacer notar que no estará habilitado. Sin embargo, la instrucción "int x=5;" sí genera código de máquina, el código que asigna el 5 en ese lugar de la memoria, y por lo tanto sí es un punto válido para detenerse. Hay que tener en cuenta estos detalles (llamadas implícitas a operadores, constructores, casts, etc) para saber dónde realmente ocurre algo y dónde no.

Suponiendo ahora que el programa se detuvo en un punto de interrupción válido. Podemos ver las variables y luego continuar ejecutando hasta el próximo punto de interrupción o hasta el fin del programa (F5 nuevamente en ZinjaI), o podemos avanzar de a poco. Hay varias formas de avanzar de a poco, y la más común es avanzar por línea. Pero aquí hay dos formas básicas: "step in" y "step over". Con step over (F7 en ZinjaI), el depurador ejecutará hasta la siguiente línea de código válido de esa función (digamos que estabamos en la 105 y vamos a la 106). Pero para llegar a la siguiente línea de código, puede tener que ejecutar en realidad muchas otras líneas. ¿Por qué? Porque la linea que queremos ejecutar (105) puede incluir una llamada a una función, un método, una sobrecarga de un operador, un constructor, o una combinación de todos ellos. Entonces, step over, ejecutará todo lo que tenga que ejecutar para avanzar a la siguiente línea en el fuente. Mientras que step in (F6 en ZinjaI) irá a la próxima linea ejecutable, sea cual sea. Es decir, si no hay llamada a función ni nada de lo que dije antes, equivale a step over. Pero si la hay, irá a la primer línea de esa función/método/constructor. Es más, si por ejemplo es una función que recibe por copia una clase, antes de meterse en la función, step in se meterá en el constructor de copia de la clase, que se ejecuta para copiar el argumento actual al formal de la función antes de que esta comience.

En el ejemplo, si avanzamos con "Step  Over" pasamos a la siguiente línea del main (23),
mientras que avanzando con "Step In" entraríamos primero al constructor de copia de UnaClase
(linea 10, al copiar el argumento para la función), y luego a la primer línea de la función foo.

Ahora supongamos que usando step in entramos en una función/método que no queríamos entrar. Por ejemplo en el constructor del parámetro de una función, cuando en realidad queríamos entrar en la función. Si avanzamos con step over, vamos a salir de ese constructor, pero nos vamos a saltear la llamada a función, ya que vamos a avanzar a la siguiente línea cuando salgamos del constructor. Si avanzamos con step in vamos a llegar a donde queremos, pero puede que antes nos metamos en otros constructores, operadores y funciones que no queríamos ver. Una forma de salir de donde estamos, pero sin avanzar de línea en el scope anterior (en la llamada donde dimos el primer step in), es con step out (Shift+F6 en ZinjaI). Este comando avanza hasta salir del frame actual. El frame digamos que es la función o el método actual. Entonces, en el ejemplo, vuelve a la primer función, la que nos interesaba, pero con el argumento ya construido. Allí, con step in puede que nos metamos en la función, o en el constructor/operador/etc de otro argumento. Si la función solo tiene uno o dos argumentos de este tipo, este mecanismo es muy útil. Si tiene muchos será más fácil buscar el código de la función (ctrl+click sobre la llamada en ZinjaI) y poner un punto de interrupción en su primer instrucción. Como ventaja adicional, el comando step out en ZinjaI, hace que se muestre al lado de la barra de herramientas (donde se indica el estado de la depuración) el valor de retorno de la función, valor que no siempre se almacena en una variable y por ende no siempre es fácil de rastrear de otra forma.

En esto de meternos dentro, hay que tener ciertas consideraciones. Puede que nos metamos sin querer en código que ni siquiera es nuestro y probablemente nunca nos interese depurar (por ejemplo dentro de una biblioteca estándar o externa). Esto en ZinjaI se puede salvar con el truco que comenté aquí (de click derecho en el backtrace), que hará que ZinjaI llamé automáticamente a step out y step in para saltearlas, aunque esto puede llegar a ser algo lento. Por otro lado, puede ocurrir lo contrario, que hagamos step in para meternos en alguna función, pero el depurador no se meta porque no tiene información de depuración para esa función. Nuevamente, el caso común es una biblioteca externa. Podemos en un programa enlazar objetos nuestros compilados con diferentes argumentos de forma de que algunos tengan información de depuración y algunos no. Lo usual es que las bibliotecas externas no tengan esta información, mientras que los demás objetos generados por nuestro programa sí. Es decir, asumir que los errores son propios, que las bibliotecas ya están probadas.

Y una última consideración, pero no menos importante, es la siguiente: todos los depuradores que conozco referencian a los lugares del código por número de línea. Entonces, si una línea tiene más de una instrucción, no pueden distinguirlas. Es decir, usualmente no se puede poner un punto de interrupción en la segunda instrucción de una línea (se pone para "toda" la linea). Por eso, es conveniente escribir una instrucción por línea, para tener mejor control en la depuración. Y lo mismo ocurre para las condiciones de las estructuras de control. Si en una linea tengo un if y su acción por verdadero, no puedo distinguir el punto donde se evalúa la condición del punto donde se ejecuta la acción por verdadero. La alternativa al avance por línea de código, que puede resultar muy "grueso", es el avance por instrucción de máquina, que en general resulta demasiado fino, y no es de utilidad a menos que sepamos bastante de ensamblador y entendamos cómo el compilador traduce las instrucciones de C++ a lenguaje de máquina. Esto último es un tópico más interesante que necesario en la mayoría de los casos, así que para otro día en otro post. Por el momento escriban código prolijo y eviten líneas compuestas.

Ambos programas son 100% equivalentes (generan exactamente el mismo ejecutable) pero el de la derecha permite controlar mejor la ejecución al depurar, mientras que en el de la izquierdo no se distingue la evaluación de la condición del if de cualquiera de sus dos posibles asignacions.

En la siguiente entrega voy a seguir con este tema de cómo detenerse y cómo avanzar, comentando algunos trucos menos usuales (que hasta permiten alterar el flujo del programa), pero no por ello menos útiles.

Este post es continuación de Depuración (parte 1): ¿para qué sirve y cómo funciona? 

No hay comentarios:

Publicar un comentario