Valgrind: ¿Una herramienta olvidada desde las sombras o una herramienta de depuración seria?

Hace un par de meses, echamos un vistazo a desinfectantes de C – pequeñas y prácticas herramientas que nos ayudan a mantener nuestro código limpio y libre de errores de memoria.

Hoy daremos un paso atrás en el pasado y hablaremos de una herramienta más antigua, pero aún muy potente, llamada Valgrind. Lleva bastante tiempo sirviendo a los desarrolladores, ya que detecta una amplia gama de errores, como lecturas de memoria no inicializada, desbordamientos de búfer de montón, fugas de memoria, bloqueos, etc.

Después de que Clang lanzara su colección de desinfectantes, Valgrind pasó a un segundo plano, pero como verás en esta entrada del blog, hay algunos casos en los que Valgrind realmente deja a los desinfectantes en ridículo.

Una bestia bajo el capó

Una de las mayores ventajas de Valgrind sobre los desinfectantes es que no requiere que el software esté instrumentado antes de comprobarlo. Esto significa que puede utilizarse para depurar cualquier tipo de software «nada más sacarlo de la caja» sin tener acceso a su código fuente.

Esto no sería posible sin la bestia que hay bajo el capó de Valgrind. En resumen, una vez inicializado Valgrind, toma el control de tu programa y lo ejecuta en una CPU simulada proporcionada por el núcleo de Valgrind. A continuación, añade su propio código de instrumentación en función del tipo de errores que quieras detectar.

A diferencia de los desinfectantes, Valgrind utiliza la Instrumentación Binaria Dinámica (DPI) y la compilación Justo a Tiempo (JIT) para incrustar el código de nuestro software con el código de instrumentación, es decir, para interceptar las llamadas a funciones de asignación con el fin de almacenar alguna información adicional.

Funciona así. Cada bloque asignado se asigna a un bloque sombra donde se almacena la pila de llamadas en el momento de la llamada a la función malloc. Una vez que se llama a la función free, Valgrind intenta encontrar el bloque sombra correspondiente a la dirección pasada a free. Si no encuentra el bloque, Valgrind emite un mensaje de error. En caso contrario, el bloque se añade a la cola y se marca como inaccesible. De esta forma, es posible detectar accesos no válidos a la memoria liberada. Sin embargo, ten en cuenta que los bloques pueden eliminarse de la cola una vez que el sistema se queda sin espacio libre.

Flexibilidad proporcionada por una arquitectura en capas

Como se muestra en la siguiente figura, Valgrind se compone de dos capas: El núcleo de Valgrind y el complemento de herramientas, que puede ser cualquiera de las herramientas del conjunto de herramientas de Valgrind, entre las que se incluyen:

  • Memcheck – rastrea las asignaciones de memoria e informa de las fugas de memoria
  • Helgrind – detecta problemas relacionados con el multihilo (por ejemplo, bloqueos, carreras de datos, etc.)
  • Cachegrind – actúa como perfilador de caché y predicción de ramas
  • Macizo – analiza el uso de la memoria del montón

Ten en cuenta que Valgrind es de código abierto y que puedes escribir tu propia herramienta si quieres.

Estas dos capas tienen cada una su propia función. La parte central carga el software sometido a prueba en el proceso y desmonta su código. Una vez hecho esto, los fragmentos de código se pasan al complemento de la herramienta, que añade la instrumentación al código y, por último, lo vuelve a ensamblar.

Una forma más fácil de depurar

Como mencionamos en nuestra anterior entrada del blog, los sanitizadores de C++ requieren que tu código sea recompilado. Esto puede ser un inconveniente cuando quieres probar tu código tanto con AddressSanitizer como con MemorySanitizer. Como los dos no pueden funcionar juntos, tendrías que realizar varias pruebas para detectar lecturas de memoria no inicializada y otros errores relacionados con las direcciones. Por otro lado, Valgrind puede ejecutar prácticamente cualquier software tal cual. Lo único que necesita es soporte para todas las instrucciones que utiliza nuestro software.

Supongamos que quieres depurar una biblioteca cuyo código fuente es inaccesible. Utilizar sanitizadores no daría ningún resultado porque, a diferencia de Valgrind, funcionan a nivel del compilador. Con Valgrind, puedes tratar estos casos por defecto, pero ten en cuenta que puedes acabar con mensajes de error que significan muy poco para ti porque no tienes control sobre ese código. Sin embargo, eres libre de filtrar estos mensajes escribiéndolos en un archivo de supresiones que se lee cuando se inicia Valgrind.

Además de un proceso de depuración más ágil, Valgrind es un gran sustituto en plataformas que no admiten desinfectadores. Apple Clang, por ejemplo, no incluye LeakSanitizer, por lo que Valgrind es tu mejor alternativa, a menos que estés dispuesto a cambiar de compilador.

El rendimiento como mayor inconveniente

Veamos una versión ligeramente modificada del ejemplo de nuestro primer post sobre los saneadores C++:

char const * src{ "Hello world!" };
auto const dst{ std::make_unique< char[] >( std::strlen( src ) ) };

for ( auto i{ 0ul }; i < 1000000; ++i )
{
std::strcpy( dst.get(), src );
}

std::puts( dst.get() );

Se ha añadido un for loop para resaltar la diferencia de rendimiento entre AddressSanitizer y Valgrind. En ambos casos, el código se compila utilizando el siguiente comando (para ejecutar ASan también es necesario añadir -fsanitize=address):

clang++ example.cpp -g -o example.out

Un rápido vistazo a la siguiente tabla revela una enorme diferencia de rendimiento entre Valgrind y ASan, y ésta es la razón por la que muchos desarrolladores pueden ser reacios a utilizar la herramienta en primer lugar.

Comparación lado a lado

Para comprender mejor qué bichos son capaces de detectar Valgrind y los desinfectantes, echemos un vistazo a la siguiente tabla:

* MSan detecta lecturas de memoria no inicializadas
** UBSan detecta comportamientos indefinidos

Como puedes ver, Valgrind no te ayudará a detectar desbordamientos en la pila y en las variables globales. Esto se debe a que sólo tiene acceso a las asignaciones de la pila realizadas por la función malloc. Además, no cuentes con que detecte ningún comportamiento indefinido en tu código, aunque te advertirá si intentas acceder a la memoria resultante de un comportamiento indefinido.

Al mismo tiempo, ASan tampoco es tan perfecto. No detecta lecturas de memoria no inicializadas ni comportamientos indefinidos, pero MSan y UBSan sí.

Valgrind o desinfectantes: ¿qué debemos utilizar?

La pregunta sigue siendo: ¿debemos utilizar Valgrind o son los desinfectantes un sustituto mejor? No hay una respuesta correcta a esta pregunta, ya que estas dos herramientas funcionan de formas muy distintas. Lo ideal es que utilices ambas en función de tu entorno y de los errores que quieras detectar.

Aunque los saneadores actuales imponen una sobrecarga de CPU mucho menor y ofrecen una gama más amplia de errores detectados, siguen teniendo sus desventajas. Funcionan a nivel del compilador, lo que significa que necesitas el código fuente. También requieren que recompiles tu código con cada prueba, tardan más en integrarse y carecen de soporte en determinadas plataformas.

Si tienes un proyecto grande entre manos y no quieres preocuparte de recompilar tu código, optar por Valgrind puede tener más sentido. Lo encontramos especialmente útil para depurar bibliotecas de código cerrado y detectar con más facilidad cosas como errores de uso después de la liberación. Aunque como los desinfectantes son cada vez mejores, tendemos a preferirlos a Valgrind.

Al final, independientemente de las herramientas que utilices, lo más importante es que tu código sea lo más seguro posible. Con esto en mente, permanece atento a más posts interesantes sobre depuración en C++.

30 de julio de 2021

Descubre nuestras soluciones

Explorar nuestras soluciones está a un clic de distancia. Prueba nuestros productos o habla con nosotros con uno de nuestros expertos para profundizar en lo que ofrecemos.