Sé prudente, depura: cómo mantener tu código en C++ libre de errores
A pesar de todas las pérdidas que ha causado, esta pandemia al menos nos ha hecho ser más conscientes de nuestra higiene personal.
Ahora desinfectamos espacios, superficies y nuestras manos con mucha más frecuencia, así que, ¿por qué no desinfectamos también nuestro código de paso? Al fin y al cabo, el software mueve el mundo, y los errores que provocan fallos en los programas pueden causar graves daños, al igual que sus homólogos virales.
Si desarrollas en C y C++, lo sabes de sobra. Es fácil asignar un bloque de memoria y olvidarse de liberarlo más tarde, o escribir accidentalmente más allá del límite del búfer de memoria. Estos problemas son extremadamente difíciles de detectar sin las herramientas adecuadas y suelen provocar fallos esporádicos y repentinos.
El uso de sanitizadores mientras desarrollas y pruebas tu programa puede ayudarte a detectar desde el principio gran parte de los problemas del código fuente, como fugas de memoria, desbordamientos de búfer y comportamientos indefinidos.
Hoy vamos a analizar tres tipos de sanitizadores de Clang, cómo se utilizan y qué errores nos pueden ayudar a atajar de raíz.
Limpiar el espacio de direcciones con AddressSanitizer (ASan)
AddressSanitizer (ASan, para abreviar) se utiliza para detectar errores de uso tras liberación, doble liberación, desbordamientos y subdesbordamientos de búfer (de pila, de montón y globales), así como otros errores de memoria.
It consists of both a compiler instrumentation module and a run-time library that inserts red zones around each set of bytes allocated with the malloc function. It also poisons the freed bytes and keeps track of the call stack for each malloc/free pair.
Así es como se ve nuestro código sin ASan:
Y esto es lo que hace ASan para detectar errores relacionados con las direcciones:
Using ASan is as simple as adding the -fsanitize=address as both compiler and linker flag. You can also set a large number of run-time options via the ASAN_OPTIONS environment variable (here is a full list of ASan flags you can use).
Para que te hagas una idea real de lo útil que es esta herramienta, aquí tienes una pequeña prueba. Intenta encontrar un error en el fragmento de código que aparece a continuación:
char const * src{ "Hello world!" };
auto const dst{ std::make_unique< char[] >( std::strlen( src ) ) };
std::strcpy( dst.get(), src );
std::puts( dst.get() );
Es difícil, ¿verdad? Ahora, imagina que este error formara parte de un código fuente mucho más extenso. Nos llevaría una eternidad depurarlo a mano, mientras que, con ASan activado, podemos identificar el desbordamiento de búfer del montón oculto en cuestión de segundos (mira la demostración):
==1==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000001c at pc 0x00000044b754 bp 0x7ffc9ea586d0 sp 0x7ffc9ea57e80
WRITE of size 13 at 0x60200000001c thread T0
#0 0x44b753 (/app/output.s+0x44b753)
ASan también te avisará si tu programa sigue utilizando un puntero después de que haya sido liberado (una vulnerabilidad de seguridad habitual denominada «error de uso tras liberación»). A continuación se muestra un programa de ejemplo que contiene este error:
constexpr std::size_t bodyOffset{ 96u };
char * message{ new char[ 1024 ] };
// fill in the message
char * bodyBegin{ message + bodyOffset };
delete [] message;
// many lines of code ...
std::puts( bodyBegin ); // heap-use-after-free bug
Y este es el informe que te proporciona ASan tras analizar el código:
==1==ERROR: AddressSanitizer: heap-use-after-free on address 0x6190000000e0 at pc 0x000000470a69 bp 0x7ffe047e6b90 sp 0x7ffe047e6340
READ of size 929 at 0x6190000000e0 thread T0
Está muy chulo, ¿verdad?
Ahora, quizá te estés preguntando: ¿cuánto cuesta esto? Bueno, en el mundo actual nada es gratis y, por desgracia, lo mismo ocurre con ASan.
Aun así, si tienes en cuenta el tiempo que esta herramienta te podría ahorrar, no resulta nada cara. La sobrecarga media es de aproximadamente el doble, tanto en cuanto al rendimiento como al uso de memoria.
En cuanto al rendimiento, Valgrind, el principal competidor de ASan, ralentizará tu programa entre 10 y 100 veces más. En nuestra opinión, eso supone una mejora considerable.
Ahora que hemos limpiado nuestro espacio de direcciones, vamos a seguir depurando nuestra memoria.
Detección de lecturas de memoria no inicializada con MemorySanitizer (MSan)
Podrías pensar que AddressSanitizer detecta todos los errores relacionados con la memoria, pero no es así. No detecta las lecturas de memoria no inicializada, y ahí es donde entra en juego otro sanitizador llamado MemorySanitizer (MSan, para abreviar).
MSan te permite copiar memoria no inicializada y realizar operaciones lógicas y aritméticas sencillas con ella, pero si intentas utilizarla en una instrucción de toma de decisiones, aparecerá una advertencia. Para entenderlo mejor, echa un vistazo al siguiente fragmento de código (demostración):
int *src{ new int[ 5 ] };
int dst[ 5 ];
std::memcpy( dst, src, 5 ); // copying uninitialized memory, OK
if ( src[ 0 ] ) // MSan warning: use-of-uninitialized-value
{
// more code ...
}
Como puedes ver, MSan nos avisará de cualquier valor no inicializado que se esté utilizando. Esa fuga de memoria que quizá hayas detectado no se señalará porque eso es algo de lo que se encarga ASan; es importante que no nos confundamos con esto.
To get to the origin of this value, use -fsanitize-memory-track-origins flag along with the -fsanitize=memory.
Now, let’s talk a bit about how MSan actually works. It implements a bit to bit shadow mapping, as shown in the figure below, where 1 means “poisoned” or uninitialized bit. This allows for very efficient computation of the shadow memory address. Given the application memory address ProductAddr, computed ShaddowAddr is ProductAddr & ShadowMask, where ShadowMask is a platform-specific constant.
Siempre que el acceso a uno de los bits «envenenados» tenga algún efecto secundario (por ejemplo, en una ramificación), se generará una advertencia. Este bit adicional supone una sobrecarga de 2,5 veces en la CPU y de 2 veces en la memoria. La sobrecarga será algo mayor si también se realiza un seguimiento de los orígenes de memoria; concretamente, 5 veces mayor en la CPU y 3 veces mayor en la memoria.
Detección de comportamientos indefinidos con UndefinedBehaviorSanitizer (UBSan)
Por último, pero no por ello menos importante, UndefinedBehaviorSanitizer (UBSan, para abreviar).
UBSan will catch signed integer overflow, use of null pointers, division by zero and other undefined behavior as you’re executing your program. Apart from -fsanitize=undefined compiler flag, which checks for all kinds of bugs, there are many additional flags that can be helpful in finding more specific bugs, including:
-fsanitize=bounds
-fsanitize=vptr
-fsanitize=enum
-fsanitize=signed-integer-overflow
-fsanitize=null
-fsanitize=unsigned-integer-overflow
-fsanitize=return
-fsanitize=integer-divide-by-zero
-fsanitize=unreachable
-fsanitize=alignment
Como puedes ver, UBSan resuelve bastante bien errores sencillos como el que se muestra en el siguiente ejemplo (demostración):
int main()
{
int m = std::numeric_limits< int >::max();
return m + 1;
}
Y este es el informe que recibimos:
runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
UBSan demostrará todo su potencial tanto en el desarrollo diario como en bases de código de gran tamaño. En cuanto al rendimiento, supone una sobrecarga de CPU de aproximadamente 1,25 veces y no tiene ningún impacto en el uso de la memoria (UBSan no afecta a la disposición del espacio de direcciones).
Aspectos negativos a tener en cuenta
Ahora que hemos visto cómo los sanitizadores pueden ayudarnos a escribir un código mejor, aún debemos mencionar un par de inconvenientes que conlleva su uso.
En primer lugar, a diferencia de Valgrind, que realiza todas las comprobaciones sin necesidad de recompilar el código fuente, los sanitizadores requieren que se recompile el código. Esto puede llevar algún tiempo, sobre todo si se trabaja con una base de código extensa.
El segundo inconveniente (y podría decirse que el más importante) es que ASan y MSan no pueden funcionar conjuntamente. Esto significa que tendrás que realizar varias ejecuciones para probar tu software, lo que, una vez más, puede llevar bastante tiempo. Quizá esta sea la razón por la que los sanitizadores siguen sin gozar de mucha popularidad entre la comunidad de C++.
A pesar de sus inconvenientes, recomendamos encarecidamente el uso de herramientas de saneamiento. Detectarán problemas que otras herramientas de la competencia podrían considerar inofensivos y no interrumpirán tu flujo de trabajo con ralentizaciones importantes.
Por lo tanto, tiene mucho sentido integrarlos en tus procesos de desarrollo «rápidos», como la integración continua o los flujos de trabajo de pull requests.
Sin embargo, no descartemos todavía a Valgrind, ya que aún puede resultarnos muy útil. Hablaremos de sus ventajas en una de las próximas entradas.
Esto es todo por ahora. Estad atentos a las próximas publicaciones, que prometen ser muy interesantes, y recordad que para crear software fiable hay que empezar por un código limpio.