Inicio / Blog / Valgrind: no more printfs for code analysis

Valgrind: no more printfs for code analysis

Publicado el 02/03/2015, por Jesús Díaz (INCIBE)
Valgrind

Tras ser testigos día tras día de los efectos causados por fallos de programación, cada vez somos más conscientes de la importancia de producir código fuente de calidad. Pero ¿cómo hacerlo? Siempre quedará utilizar printf, para pruebas y depuración, aunque hay herramientas evidentemente mucho más potentes. En este post, resumimos las principales características de Valgrind, la herramienta por excelencia para depuración y optimización de código en C/C++.

En realidad, Valgrind es un conjunto de herramientas (un framework) para análisis dinámico de programas escritos en C/C++. Para ello, por cada instrucción que ejecuta el programa, Valgrind añade una serie de instrucciones adicionales para analizar el comportamiento del programa. En concreto, la técnica principal que utiliza es conocida como shadow memory. Resumiendo, consiste en asociar un conjunto de bits a cada porción de memoria, especificando si los datos correspondientes son accesibles, si han sido correctamente inicializados, etc.

De esta manera, la funcionalidad principal ofrecida por Valgrind es la de analizar el uso que hace el programa bajo análisis de la memoria dinámica.

Para iniciar un análisis, es suficiente ejecutar el comando «valgrind ./programa» [Nota: para que la salida producida por Valgrind sea más informativa, se debe utilizar la opción -g al compilar el programa, y evitar flags de optimización como -O1 y -O2)]. En este caso, Valgrind avisará por defecto de eventos como accesos a zonas de memoria no inicializada, fallos de tipo double free, accesos a memoria fuera de límites, etc. Pero también ofrece más opciones. Por ejemplo, si se especifica la opción «--leak-check=full», avisa de memoria dinámica que no ha sido liberada al finalizar la ejecución, si se indica «--track-fds» mantiene un control de los descriptores de ficheros utilizados, indicando cuáles se han quedado abiertos, etc. Para más detalles, lo mejor es consultar la página del man (man valgrind) o su manual online.

Por ejemplo, al analizar el siguiente programa (memorytest) con «valgrind --leak-check=full ./memorytest», valgrind informa que no hay problemas.


1.	#include <stdio.h>

2.	#include <stdlib.h>

3.	#include <assert.h>

4.	

5.	int main () {

6.	

7.	   int *array, i;

8.	

9.		   /* fprintf(stdout, "Accessing uninitialized array element: %d\n", array[1]); */

10.	

11.		   assert(array = (int *) malloc(sizeof(int)*100));

12.	

13.		   for (i=0; i<100; i++) {

14.		   /* for (i=0; i<=100; i++) { */

15.			      array[i] = i;

16.		   }

17.	

18.		   fprintf(stdout, "\n");

19.	

20.		   free(array);

21.		   /* free(array); */

22.		   array = NULL;

23.	

24.		   return 0;

25.	}

Pero, si descomentamos las líneas 9 y 21, y cambiamos la línea 13 por la 14, valgrind empieza a llamarnos la atención:

En concreto, nos avisa de que:

  1. Estamos usando una zona de memoria no inicializada (y que esto ocurre en la línea 9 del programa).
  2. Estamos escribiendo fuera de los límites de una zona de memoria dinámica que hemos reservado (y que la escritura se hace en la línea 15, sobre una variable reservada en la línea 11).
  3. Estamos liberando una variable (en la línea 21) que ya fue liberada anteriormente (en la línea 20).

Por si esto fuera poco, se pueden prevenir muchos más errores. Otro caso fundamental en el caso de programación concurrente es el de prevenir deadlocks o condiciones de carrera al acceder a zonas de memoria compartidas por varios recursos. Para ejecutar esta funcionalidad, hay que utilizar la herramienta helgrind, que se puede invocar con «valgrind --tool=helgrind ./programa».

En Pastebin está disponible un código de prueba para el caso de condiciones de carrera. Es el ejemplo clásico de dos hilos concurrentes, donde uno de ellos simula un ingreso en una cuenta bancaria y otro simula una retirada de dinero: si la operación de actualización del balance no se protege (mediante, por ejemplo, semáforos binarios), el resultado final puede ser inconsistente.

Si se analiza el programa con Valgrind («valgrind --tool=helgrind ./threadtest»), la herramienta nos avisa de la siguiente forma:

En concreto, nos dice que hay una posible condición de carrera producida en el hilo #3 (en la línea 47 del código) y otra en el hilo #2 (en la línea 31). Para analizar la solución, sólo hay que descomentar las líneas comentadas en el código fuente.

Pero todavía queda más, también es posible analizar cómo de eficiente es un programa en cuanto al uso de memoria caché. Para este caso, la herramienta interna es Cachegrind y, siguiendo el patrón, se ejecuta con «valgrind --tool=cachegrind ./programa».

Para ilustrar su uso, recurrimos a otro clásico: el orden de almacenamiento de arrays multidimensionales. En el caso de C/C++, los arrays se cargan en caché por filas (es decir, si se accede al 3er elemento de la 2ª fila, se cargará la 2ª fila completa). Por lo tanto, si se recorre un array bidimensional por columnas en lugar de por filas, el procesador hará muchas más actualizaciones de caché, provocando (si los datos son lo suficientemente grandes) el efecto conocido como cache thrashing. Para comprobarlo, otro programa sencillito:

1.	#include <stdio.h>

2.	

3.	int main () {

4.	

5.		   int array[1024][1024], i, j;

6.	

7.		   for (i=0; i<1024; i++) {

8.			      for (j=0; j<1024; j++) {

9.				         array[j][i] = (i*1024)+j;

10.				         /* array[i][j] = (i*1024)+j; */

11.			      }

12.		   }

13.	

14.		   return 0;

15.	

16.	}

Sobre este código, Valgrind nos informa como sigue:

Es decir, la tasa de fallos de escritura en la caché de datos de nivel 1 (D1 miss rate) es del 98.7%. Como se observa a continuación, si se cambia el orden de acceso al array (comentando la línea 9 y descomentando la línea 10), la tasa de fallo se reduce drásticamente, al 6,2%.

Estas son quizá tres de las herramientas principales dentro de la suite de Valgrind, pero tiene más:

  • Callgrind, para analizar las llamadas a funciones realizadas por el programa, ofreciendo por ejemplo un análisis del número de instrucciones asociadas (muy útil para optimización de código).
  • Massif, un heap profiler.
  • DRD, similar a helgrind, pero con un uso más eficiente de la memoria.

Como curiosidad final, la justificación del nombre de la herramienta es esta.