jueves, 19 de julio de 2012

Cómo prepararse para enfrentar un segfault (parte 1)

Segfault (segmentation fault, violación de segmento) es un intento por parte de un proceso de hacer algo "ilegal" como por ejemplo desreferenciar un puntero a NULL, escribir en un arreglo en posiciones fuera de los límites válidos, etc. Usualmente el sistema operativo envía una señal al proceso y hace que el programa se cierre. Si lo habíamos invocado desde una consola vemos el discreto mensaje "Segmentation fault". En Windows veríamos esa molesta ventana de la que lo único que recuerdo es que tiene un botón que dice "No enviar" (otrora una escalofriante pantalla azul). Cuando un programa tiene que lidiar con estructuras de datos complejas, o que hacer operaciones complejas en estructuras no necesariamente tan complejas, suelen aparecer estos problemas, y muy frecuentemente están asociados al manejo de memoria dinámica, punteros, o índices en arreglos. Por ejemplo, a mi me pasa mucho cuando hago operaciones sobre mallas en mi trabajo (donde hay muchas referencias cruzadas que mantener), o muchas veces cuando cambio algo en el parser de ZinjaI (ya que la forma en que almacena su información es bastante rebuscada). En general estos errores son difíciles de depurar, ya que a menos que se hayan tomado los recaudos necesarios, el problema puede saltar en un punto de la ejecución alejado de una u otra forma del punto en donde realmente se metió la pata. La idea de estos post es comentar mi experiencia intentado cazar estos errores y tirar algunos tips muy útiles que aprendí en el camino.

Para empezar hay que saber que el preprocesador es  uno de nuestros mejores aliados en estos asuntos. La compilación condicional permite que un programa actúe de una forma en modo debug y de otra en modo release. Entonces podemos ejecutar y depurar con todas las comodidades en nuestro entorno de desarrollo, y eliminar todas las sobrecargas del ejecutable final que ponemos en producción sin esfuerzo adicional. Por ejemplo, el siguiente código define una macro para imprimir mensajes por consola para usar como log solamente cuando la constante de preprocesador _DEBUG_LOG está definida, y no hacer absoultamente nada en otro caso.
   #ifdef _DEBUG_LOG
      #define _LOG(mensaje) cerr<<mensaje<<endl;
   #else
      #define _LOG(mensaje)
   #endif
En nuestro IDE tendremos que definir en algún lado que queremos que el compilador utilice la constante de preprocesador _DEBUG_LOG al compilar en modo debug, y no al compilar en modo relase. En ZinjaI, por ejemplo, esta opción está entre las Opciones de Compilación y Ejecución del Pproyecto (menú Ejecutar->Opciones, arriba a la izquierda se elige el perfil Debug o Release, y en la pestaña Compilación hay un campo "Macros a definir"), y se traduce en el argumento -D_DEBUG_LOG para gcc en cada compilación.

Volviendo a las estructuras de datos, voy a tomar una malla como ejemplo. Una malla es normalmente un conjunto de polígonos/poliedros (triángulos, cuadriláteros, tetraedros, hexaedros, etc) en el espacio. Una forma básica de almacenar una malla consiste en tener un contenedor (arreglo o lista) con puntos y otro contenedor con elementos, donde cada elemento tienen referencias a los puntos que lo forman. Por ejemplo, en un elemento de tipo triángulo no guardo las coordenadas de sus tres vértices, sino referencias a tres elementos del contenedor de puntos. Esto es así porque en general los puntos son compartidos por varios elementos. En mi proyecto final de carrera implementé estas "referencias" como punteros; parece la forma más directa y eficiente de hacerlo, pero genera verdaderas pesadillas de direcciones de memoria cuando algo sale mal y hay que depurarlo. Mi director, Nestor, en su clase Malla implementó las referencias como simples enteros que indican una posición en el arreglo. Así se agrega un nivel de indirección más, ya que para obtener por ejemplo las coordenadas de un punto de un elemento, en el primer caso se las pido directamente al puntero con el operador ->, mientras que en el segundo tengo que ir primero al arreglo a buscar el verdadero punto (y tengo que tener fácil acceso al arreglo que no es un detalle menor), pero se simplifica enormemente la tarea de depurar. No es práctico intentar dibujar la estructura en papel mirando los inspecciones en el depurador, o imaginar la malla marcando cada nodo con una dirección de memoria que entre otras cosas podría no ser la misma en dos ejecuciones del mismo programa con los mismos datos. El índice en cambio se mantiene invariante, tiene un significado más concreto y permite en un golpe de vista saber si es válido o no. El incremento en el tiempo de ejecución que podría suponer esa indirección extra (que será constante, de unos pocos ciclos de procesador, y tiene otros beneficios colaterales) se paga solo y con creces. En el peor de los casos, se puede utilizar un set de macros que defina las cosas de una forma en la compilación debug y de otra en la compilación release. Entonces la primer moraleja del asunto es: no abuses de los punteros, analiza agregar un nivel más de indirección si esto ayuda a interpretar los datos en el depurador. Esto en general es válido cuando los contendores son arreglos (y si son dinámicos es obligatorio, ya que un realloc puede cambiar todas las direcciones de memoria), pero no cuando las estructuras son de tipo grafo/arbol donde los contenedores siguen una lógica más parecida a la de una lista enlazada y no se dispone de acceso aleatorio. En definitiva, todo esto no es más que un caso particular de un postulado más general que propuso Donald Knuth: "la optimización prematura es la raiz de todos los males", que solemos menospreciar cuando estamos a mitad camino de convertirnos en buenos programadores por sentirnos capaces de lidiar con la complejidad, pero que la experiencia se encarga de refrescarnos de mala manera de cuando en cuando.

 (tomado de http://xkcd.com/371/)

Otra grandiosa idea que tomé del código de Nestor es la de la macro _revienta. _revienta es un reemplazo para assert. assert es una macro de C/C++ que recibe como único argumento una expresión booleana; si en la ejecución del programa la expresión se resuelve en false el programa finaliza inmediatamente con una señal de tipo sigiot. Se utiliza para detectar errores de forma temprana. Por ejemplo, si una función busca un elemento en un arreglo sabiendo que el elemento está, podemos poner un assert al final de la misma para asegurarnos de que realmente lo encontró y que la presunción de que el elemento estaba se cumplió, de lo contrario devolverá un puntero/índice/iterador/loquesea erróneo y el problema saltará más adelante cuando alguna otra función quiera utilizar ese resultado. Así, detectamos el error de forma temprana y no nos alejamos tanto de la verdadera causa. Hay que notar que para esto hay que agregar algunos pasos, como inicializar variables (el índice/iterador de la ocurrencia en el ejemplo) en un valor especial, aún cuando sepamos que sí o sí se le debería asignar un valor luego. La macro assert, cuando algo sale mal, además muestra en pantalla la linea en código fuente donde ocurrió el problema, lo cual es interesante. Para desactivar todos los asserts (es decir que se ignoren al compilar) en modo release solo hay que definir la constante de preprocesador NDEBUG. Pero la macro _revienta que le copié a Nestor hace algo a mi gusto mucho más útil: genera la misma interrupción que utiliza el depurador para detenerse en un breakpoint. De esta forma, si corremos el programa por ejemplo en gdb, gdb creerá que alcanzó un punto de interrupción y nos permitirá inspeccionar las variables, la pila de llamadas a funciones, y demás, hasta continuar la ejecución si eso queremos. Como desventaja, si corremos el programa fuera del depurador no tendremos ninguna pista de donde estuvo el problema. La forma de hacer esto es utilizar una interrupción que insertamos con un poco de ensamblador. Normalmente cuando el depurador debe poner un breakpoint, por ejemplo en el comienzo de una función, busca el código de la función (código objeto, en el binario) y reemplaza el primer byte de la primer instrucción por 0xCC("int 3"). En gcc esto se hace así:
   #define _revienta(cond) if (cond) asm("int3"); asm("nop")
El nop (no operation) lo agregué para que el depurador marque efectivamente la linea del int3 ya que sino marca la siguiente, porque la señal salta luego de ejecutar el int3. Notar que por una cuestión semántica la condición funciona de forma inversa a la de assert, aquí se detiene si es verdadera. El único problema es que la forma de incluir instrucciones en ensamblador en medio de un código C/C++ no es estándar, sino que depende del compilador, y entonces tenemos que utilizar #ifdefs para elegir la que corresponda. El siguiente bloque de código funciona con gcc, mingw y Visual Studio en varias versiones, y tiene además un fallback que utiliza assert si no reconoce ninguno.
   #ifdef _DEBUG
     #if defined(_MSC_VER) // visual studio
       #if _MSC_VER < 1310 // visual studio anterior a 2003
         #define _revienta(cond) if (cond) __asm int 3;
       #else // visual studio 2003 en adelante (__asm no funciona en 64 bits)
         #include <intrin.h>
         #define _revienta(cond) if (cond) __debugbreak();
       #endif
     #elif defined(__GNUC__) // gcc
        #define _revienta(cond) if (cond) asm("int3"); asm("nop");
     #else // para otros compiladores desconocidos, usar assert
       #include <cassert>
       #define _revienta(cond) assert(!(cond));
     #endif
   #else // en release no hace nada
     #define _revienta(cond)
   #endif

Un ejemplo bien simple donde utilizo esto es en la sobrecarga del operador [] de las clases que representen arreglos. Sabemos que C++ no verifica la valides de los índices al acceder a un elemento de un arreglo, así que utilizo este mecanismo para verificarlos "manualmente" solo en versión debug. Otro ejemplo es cuando una clase tiene un método para inicializar al que se debería llamar siempre antes de utilizarla para otra cosa (y por alguna razón no es el constructor, por ejemplo cuando se usa en un arreglo), aquí puedo usar assert para verificar la inicialización (ver por ejemplo que las variables dinámicas se hayan creado y que los atributos punteros no apunten a NULL). Hay muchísimas otras situaciones donde utilizar esta macro y en general tiendo a utilizarla cada vez más, aún donde me resulta claro que no es necesario, para protegerme de mis propios errores futuros. Todo programador con un mínimo de experiencia sabe que el código que hoy escribió tan claramente puede convertirse en jeroglíficos dentro de algunas semanas.

Hasta aquí la primer parte, en la segunda planeo comentar algo sobre asserts en tiempo de compilación (estos eran en tiempo de ejecución), manejo de señales, y otros trucos útiles que vi alguna vez.

Este tema continúa en Cómo prepararse para enfrentar un segfault (parte 2)

1 comentario: