lunes, 23 de julio de 2012

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

En la primer parte comenté cómo hacer para que un programa simule un punto de interrupción si encuentra una condición inesperada. Buscando algo más en google, encontré algunas otras macros interesantes, diseñadas para generar errores en tiempo de compilación y ya no de ejecución. Si bien esto no cuadra con el título (más abajo hay otros ejemplos que sí), igual vale la pena comentarlo para completar el tema de los asserts y la macro _revienta que empezé en la primer parte. Se basan en errores como declarar arreglos con tamaños negativos, o tratar de utilizar especializaciones de una plantilla que no existen. La idea de la primer versión es (simplificado y con llaves extra para evitar un problema cuando hay mensajes repetidos):
    #define _compile_assert(x,mensaje) { char _compile_assert##mensaje [x?1:-1]; }
    ....
       _compile_assert(sizeof(T1)
            <sizeof(T2),EL_TAMANIO_DE_T1_DEBE_SER_MENOR_QUE_EL_DE_T2);
que va a generar un error del tipo"error: size of array '_compile_assert_EL_TAMANIO_DE_T1_DEBE_SER_MENOR_QUE_EL_DE_T2' is negative", pero que también va a generar un warning del tipo "unused variable '_compile_assert_EL_TAMANIO_DE_T1_DEBE_SER_MENOR_QUE_EL_DE_T2'" si estos warnings están habilitados. La ventaja de este enfoque es que permite insertar en el mensaje de error algún texto propio para agregar más información. El segundo enfoque no, pero aún así me parece más elegante:

   template <bool assertion> struct compile_assert;
   template <> struct compile_assert<false> {}; // specialized on true only
   ...
      compile_assert<sizeof(T1)<sizeof(T2)>();
declara un struct templatizado cuyo argumento es un bool, y lo especializa sólo para true. Cuando se necesita hacer la verificación se usa la expresión como argumento para declarar una instancia del struct. Está claro que estas macros sólo se pueden utilizar con expresiones que se resuelvan en tiempo de compilación, por lo que su uso esta mucho más restringido, pero se pueden ver algunos ejemplos como verificar cosas que sean plataforma-dependiente (como el tamaño de un tipo de datos), o dentro de una función/clase genérica, cosas que dependan del argumento de la plantilla (como el tamaño de un arreglo estático por ejemplo).

Todo lo que escribí antes tendía a analizar errores que podemos reproducir en el entorno de desarrollo, pero cuando los errores se dan en producción, o aún en el entorno pero cuando no los estamos esperando (ejecutando fuera del depurador) la historia es diferente. Algo muy útil y que no todos conocen es el manejo de señales en C/C++. Hay una función "signal" muy simple declarada en la cabecera <ccignal> que recibe dos argumentos: el tipo de señal, y un puntero a una función que será llamada cuando el proceso reciba esa señal. Supongamos que nuestro programa por error desreferencia un puntero a NULL. En este caso el sistema envía al programa la señal SIGSEGV, y normalmente el programa se cierra al recibirla. Con esta función, podemos hacer que el programa al recibirla llame a otra función (la del argumento), y que esta otra función intente recuperarse de la situación, o generar un log lo más detallado posible con información útil para el desarrollador. Lo segundo es lo que hago en ZinjaI, y además es allí donde guardo los archivos/proyectos abiertos para que puedan recuperarse los cambios sin guardar al abrir nuevamente el entorno luego de un fallo. Un ejemplo sería el siguiente:
   void funcion_de_emergencia(int cual_senial_llega) {
       cerr<<"El programa acaba de explotar, guardando todo para la autopsia..."<<endl;
       ...
   }
   int main(...) {
      ...
      signal(SIGSEGV,funcion_de_emergencia); // en algun lugar al principio del programa
      ...
   }
Cuando el programa meta la pata (para probar pueden forzarlo con algo como "int *p=NULL; (*p)++;") va a llamar a funcion_de_emergencia pasandole el código de la señal (SIGSEGV, que vale 11).

 
Investigando para hacer esto, tuve la suerte y puntería de ir a buscar un ejemplo justo al código fuente de mplayer, y al analizarlo me encontré con algo realmente genial:
   int gdb_pid = fork();
   if (gdb_pid == 0) { // We are the child
      char spid[20];
      snprintf(spid, sizeof(spid), "%i", getppid());
      execlp("gdb", "gdb", prog_path, spid, "-ex", "bt", NULL);
   } else if (gdb_pid > 0)
      waitpid(gdb_pid, NULL, 0);
Esto es una versión simplificada de lo que hay en mplayer.c. El reproductor de video, al recibir una de estas señales, lo que hace es ejecutar gdb (el depurador) y decirle que se adjunte al propio proceso de mplayer. O sea, se auto-manda a depurar! De esa forma, si un fallo nos agarra desprevenidos fuera del IDE igual podemos analizarlo en vivo antes de que el proceso se descargue de la memoria.

Por último, en GNU/Linux siempre se puede hacer un análisis post-morten del proceso abriendo un core file con gdb. El core file, resultado de la operación que se conoce como core dump, es un archivo que contiene una especie de instantánea de la memoria del proceso al momento de recibir la señal. El sistema operativo lo genera automáticamente en la carpeta del programa (con el nombre core o core.XXX donde XXX es el id del proceso) si el proceso finaliza de mala manera (notar que si el proceso intercepta la señal con la función signal y dentro de ella sale con algo como "exit(0);" no se cuenta como "de mala manera"). Para activar la generación de estos archivos se utiliza el comando "ulimit -c algo" donde "algo" es el tamaño máximo a volcar en el archivo, o la palabra "unlimited" para que no tenga un limite. Es una buena costumbre, si se usa software en versiones de prueba fuera del entorno, tener el core dump activado para esas sesiones. Por ejemplo, en mi pc ZinjaI siempre se ejecuta a través de un script de bash que incluye esta línea y habiendo desactivado el guardado de emergencia para poder analizar las cuelgues cuando ocurran. Para abrir un core file en ZinjaI hay una opción en el menú "Depuración", pero también hay otra para generarlos en medio de la ejecución de un proceso en el depurador en cualquier momento sin necesidad de esperar un error (todo esto solo disponible en GNU/Linux).

En fin, con estos dos posts les dejo algunas de ideas para prepararse para atacar el problema de los errores difíciles de rastrear. Está claro que el arma básica es el depurador, así que lo primero que hay que hacer es conseguir un depurador de confianza y estudiarlo a fondo. En otros posts escribiré más sobre gdb, y también sobre otras herramientas extremadamente útiles para estos casos y que ya no entran en este, como memcheck (un módulo de valgrind) o cppcheck (analizador estático de código), mientras tanto pueden investigarlos por su cuenta con el menú "Herramientas" de ZinjaI.

Este post es continuación de Cómo prepararse para enfrentar un segfault (parte 1)

1 comentario: