Sé prudente, sanea: Mantén tu código C++ libre de errores
A pesar de todas las pérdidas que ha infligido, esta pandemia al menos nos ha hecho más conscientes de nuestra higiene personal.
Rociamos espacios, superficies y nuestras manos con mucha más frecuencia, así que ¿por qué no desinfectar nuestro código mientras lo hacemos? Al fin y al cabo, el software dirige el mundo, y los errores que provocan el mal funcionamiento de los programas pueden causar graves daños, al igual que sus homólogos virales.
Si desarrollas en C y C++, lo sabes muy bien. Es fácil asignar un trozo de memoria y olvidarse de liberarlo más tarde, o escribir accidentalmente más allá del búfer de memoria. Estos problemas son muy difíciles de encontrar sin las herramientas adecuadas y a menudo provocan fallos esporádicos y repentinos.
Utilizar desinfectantes mientras construyes y pruebas tu programa puede ayudarte a detectar desde el principio una gran cantidad de problemas en tu código fuente, como fugas de memoria, desbordamientos de búfer y comportamientos indefinidos.
Hoy vamos a echar un vistazo a tres tipos de desinfectantes de Clang, cómo se utilizan y qué bichos pueden ayudarnos a cortar de raíz.
Limpiar tu espacio de direcciones con AddressSanitizer (ASan)
AddressSanitizer (abreviado ASan) sirve para detectar desbordamientos y subdesbordamientos de memoria (pila, montón y búfer global) de uso después de libre, doble libre y otros errores de memoria.
Consiste tanto en un módulo de instrumentación del compilador como en una biblioteca en tiempo de ejecución que inserta zonas rojas alrededor de cada conjunto de bytes asignados con la función malloc. También envenena los bytes liberados y realiza un seguimiento de la pila de llamadas para cada malloc/free pair.
Este es el aspecto de nuestro código sin ASan:
Y esto es lo que hace ASan para detectar los errores relacionados con la dirección:
Utilizar ASan es tan sencillo como añadir -fsanitize=address como bandera del compilador y del enlazador. También puedes establecer un gran número de opciones de ejecución a través de la variable de entorno ASAN_OPTIONS (aquí encontrarás una lista completa de las opciones de ASan que puedes utilizar).
Para comprender realmente lo útil que es esta herramienta, aquí tienes una pequeña prueba. Intenta encontrar un error en el fragmento de código siguiente:
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 fallo formara parte de una base de código mucho mayor. Nos llevaría siglos depurarlo a mano, mientras que con ASan activado, podemos identificar el desbordamiento de búfer de montón oculto en 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 haberlo liberado (una vulnerabilidad de seguridad común llamada error de uso después de libre). Aquí tienes un programa de ejemplo que contiene el 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 aquí tienes un informe que obtienes de ASan después de probar 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
Muy bonito, ¿verdad?
Ahora te estarás preguntando: ¿cuánto cuesta esto? Bueno, nada es gratis en el mundo actual y, por desgracia, lo mismo ocurre con ASan.
Aun así, si consideras la cantidad de tiempo que esta herramienta podría ahorrarte, no es tan cara en absoluto. La sobrecarga media es de ~2x tanto en rendimiento como en uso de memoria.
Hablando de rendimiento, el principal competidor de ASan, Valgrindimpondrá a tu programa una ralentización entre 10 y 100 veces mayor. Para nosotros, eso es una sólida mejora.
Ahora que hemos limpiado nuestro espacio de direcciones, vamos a sanear un poco más nuestra memoria.
Detectar lecturas de memoria no inicializadas con MemorySanitizer (MSan)
Podrías pensar que AddressSanitizer cubre todos los fallos relacionados con la memoria, pero no es así. No se ocupa de las lecturas de memoria no inicializada, que es donde entra en juego otro sanitizador llamado MemorySanitizer (abreviado MSan).
MSan te permitirá copiar memoria no inicializada y realizar operaciones lógicas y aritméticas sencillas sobre ella, pero cuando intentes utilizarla en una sentencia de toma de decisiones, obtendrás una bandera roja. Para entenderlo mejor, echa un vistazo al siguiente fragmento de código (demo):
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 en uso. Esa fuga de memoria que podrías haber notado no se marcará porque forma parte de lo que trata ASan: es importante que no nos confundamos con esto.
Para llegar al origen de este valor, utiliza la bandera -fsanitize-memory-track-origins junto con -fsanitize=memory.
Ahora, hablemos un poco de cómo funciona realmente el MSan. Implementa un mapeo sombra bit a bit, como se muestra en la siguiente figura, donde 1 significa bit «envenenado» o no inicializado. Esto permite un cálculo muy eficiente de la dirección de memoria sombra. Dada la dirección de memoria de la aplicación ProductAddr, se computa ShaddowAddr es ProductAddr & ShadowMask, donde ShadowMask es una constante específica de la plataforma.
Siempre que el acceso a uno de los bits envenenados tenga algún efecto secundario (por ejemplo, en la ramificación), se emitirá una advertencia. Este bit adicional introduce una sobrecarga de 2,5x en CPU y 2x en memoria. La sobrecarga será un poco mayor si también se rastrean los orígenes de memoria, 5x en CPU y 3x en memoria, para ser concretos.
Detección de comportamientos indefinidos con UndefinedBehaviorSanitizer (UBSan)
Por último, pero no por ello menos importante, UndefinedBehaviorSanitizer (UBSan para abreviar).
UBSan detectará el desbordamiento de enteros con signo, el uso de punteros nulos, la división por cero y otros comportamientos indefinidos mientras ejecutas tu programa. Aparte de la bandera del compilador -fsanitize=undefined, que comprueba todo tipo de fallos, hay muchas banderas adicionales que pueden ser útiles para encontrar fallos más específicos, entre ellas:
-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 trata bastante bien los errores simples como el que se muestra en el siguiente ejemplo (demo):
int main()
{
int m = std::numeric_limits< int >::max();
return m + 1;
}
Y éste es el informe que recibimos:
runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
UBSan demostrará mejor su potencia tanto en el desarrollo cotidiano como en grandes bases de código. En cuanto al rendimiento, hay aproximadamente 1,25 veces de sobrecarga de la CPU y ningún impacto en el uso de la memoria (UBSan no afecta a la distribución del espacio de direcciones).
Desventajas a tener en cuenta
Ahora que hemos visto cómo los desinfectantes pueden ayudarnos a construir un código mejor, todavía tenemos que mencionar un par de desventajas de su uso.
En primer lugar, a diferencia de Valgrind, que realiza todas las comprobaciones sin necesidad de recompilar el código fuente, los desinfectantes requieren que se recompile tu código. Esto puede llevar algún tiempo, especialmente si trabajas con una base de código grande.
El segundo inconveniente (y posiblemente el más importante), es el hecho de que ASan y MSan no pueden trabajar juntos. Esto significa que tendrás que realizar varias ejecuciones para probar tu software, lo que, de nuevo, puede llevar bastante tiempo. Tal vez ésta sea la razón por la que los desinfectadores todavía no reciben mucho amor de la comunidad C++.
A pesar de sus inconvenientes, recomendamos encarecidamente el uso de desinfectantes. Detectarán problemas que pueden parecer seguros para las herramientas de la competencia y no romperán tu flujo de trabajo con fuertes ralentizaciones.
Entonces tiene mucho sentido integrarlos con tus procesos de desarrollo «rápidos», como la integración continua o los pull request pipelines.
Sin embargo, no descartemos Valgrind todavía, ya que aún puede sernos muy útil. Hablaremos de sus ventajas en uno de los próximos posts.
Esto es todo por ahora. Permanece atento a las próximas publicaciones interesantes y recuerda que la creación de software fiable empieza por un código limpio.