Soyez sage, désinfectez : Préserver votre code C++ des bogues
Malgré toutes les pertes qu’elle a causées, cette pandémie nous a au moins sensibilisés à notre hygiène personnelle.
Nous pulvérisons de plus en plus souvent les espaces, les surfaces et nos mains, alors pourquoi ne pas désinfecter notre code pendant que nous y sommes ? Après tout, ce sont les logiciels qui font tourner le monde, et les bogues qui provoquent un dysfonctionnement des programmes peuvent causer de graves dommages, tout comme leurs homologues viraux.
Si vous développez en C et C++, vous ne le savez que trop bien. Il est facile d’allouer un morceau de mémoire et d’oublier de le libérer plus tard, ou d’écrire accidentellement au-delà de la mémoire tampon. Ces problèmes sont extrêmement difficiles à détecter sans les outils appropriés et sont souvent à l’origine de plantages sporadiques et soudains.
L’utilisation d’assainisseurs lors de la construction et du test de votre programme peut vous aider à détecter rapidement un grand nombre de problèmes dans votre code source, notamment les fuites de mémoire, les débordements de mémoire tampon et les comportements indéfinis.
Aujourd’hui, nous allons examiner trois types d’assainisseurs Clang, comment ils sont utilisés et quels bogues ils peuvent nous aider à tuer dans l’œuf.
Nettoyer votre espace d’adressage avec AddressSanitizer (ASan)
AddressSanitizer (ASan en abrégé) est utilisé pour détecter les erreurs de type use-after-free, double-free, overflows et underflows de la mémoire tampon (pile, tas et mémoire tampon globale), ainsi que d’autres erreurs de mémoire.
Il se compose à la fois d’un module d’instrumentation du compilateur et d’une bibliothèque d’exécution qui insère des zones rouges autour de chaque ensemble d’octets alloués avec la fonction malloc. Il empoisonne également les octets libérés et garde une trace de la pile d’appels pour chaque malloc/free pair.
Voici à quoi ressemble notre code sans ASan :
Voici ce que fait ASan pour détecter les bogues liés à l’adresse :
L’utilisation d’ASan est aussi simple que l’ajout de -fsanitize=address comme drapeau du compilateur et de l’éditeur de liens. Vous pouvez également définir un grand nombre d’options d’exécution via la variable d’environnement ASAN_OPTIONS (Vous trouverez ici une liste complète des options ASan que vous pouvez utiliser. est une liste complète des drapeaux ASan que vous pouvez utiliser).
Pour bien comprendre l’utilité de cet outil, voici un petit test. Essayez de trouver un bogue dans l’extrait de code ci-dessous :
char const * src{ "Hello world!" };
auto const dst{ std::make_unique< char[] >( std::strlen( src ) ) };
std::strcpy( dst.get(), src );
std::puts( dst.get() );
C’est difficile, n’est-ce pas ? Maintenant, imaginez que ce bogue fasse partie d’une base de code beaucoup plus importante. Il nous faudrait des siècles pour le déboguer à la main, alors qu’avec ASan activé, nous pouvons identifier le débordement de tampon du tas caché en quelques secondes (voir la démo) :
==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 vous avertira également si votre programme continue d’utiliser un pointeur après qu’il a été libéré (une faille de sécurité courante appelée erreur “use-after-free”). Voici un exemple de programme contenant le bogue :
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
Et voici un rapport que vous recevez d’ASan après avoir testé le code :
==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
Pas mal, non ?
Vous vous demandez peut-être : combien cela coûte-t-il ? Dans le monde d’aujourd’hui, rien n’est gratuit et, malheureusement, il en va de même pour ASan.
Cependant, si vous considérez le temps que cet outil peut vous faire gagner, il n’est pas si cher que cela. Le surcoût moyen est de ~2x en termes de performances et d’utilisation de la mémoire.
En ce qui concerne les performances, le principal concurrent d’ASan, Valgrindimposera un ralentissement de 10 à 100 fois plus important à votre programme. Pour nous, c’est une solide amélioration.
Maintenant que nous avons nettoyé notre espace d’adressage, procédons à un assainissement supplémentaire de notre mémoire.
Détection des lectures de mémoire non initialisée avec MemorySanitizer (MSan)
Vous pourriez penser que AddressSanitizer couvre tous les bogues liés à la mémoire, mais ce n’est pas le cas. Il ne gère pas les lectures de mémoire non initialisée, et c’est là qu’intervient un autre assainisseur appelé MemorySanitizer (MSan en abrégé).
MSan vous permet de copier de la mémoire non initialisée et d’effectuer des opérations logiques et arithmétiques simples sur cette mémoire, mais lorsque vous essayez de l’utiliser dans une prise de décision, vous obtenez un drapeau rouge. Pour mieux comprendre, jetez un coup d’œil à l’extrait de code suivant (démo) :
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 ...
}
Comme vous pouvez le voir, MSan nous avertit de toute valeur non initialisée en cours d’utilisation. La fuite de mémoire que vous auriez pu remarquer ne sera pas signalée parce qu’elle fait partie de ce que traite ASan – il est important que nous ne soyons pas déroutés par cela.
Pour obtenir l’origine de cette valeur, utilisez le drapeau -fsanitize-memory-track-origins avec le drapeau -fsanitize=memory.
Maintenant, parlons un peu du fonctionnement de MSan. Il met en œuvre un mappage d’ombre bit à bit, comme le montre la figure ci-dessous, où 1 signifie “empoisonné” ou bit non initialisé. Cela permet un calcul très efficace de l’adresse de la mémoire fictive. Étant donné l’adresse de la mémoire d’application ProductAddr, le calcul de ShaddowAddr est ProductAddr & ShadowMask, où ShadowMask est une constante spécifique à la plate-forme.
Chaque fois que l’accès à l’un des bits empoisonnés a un effet secondaire (par exemple, lors d’un branchement), un avertissement est émis. Ce bit supplémentaire introduit un surcoût de 2,5 fois pour l’unité centrale et de 2 fois pour la mémoire. Le surcoût sera un peu plus élevé si les origines de la mémoire sont également suivies, 5x sur le CPU et 3x sur la mémoire, pour être plus précis.
Rattraper un comportement indéfini avec UndefinedBehaviorSanitizer (UBSan)
Enfin, le dernier mais non le moindre, UndefinedBehaviorSanitizer (UBSan en abrégé).
UBSan détectera les débordements d’entiers signés, l’utilisation de pointeurs nuls, la division par zéro et d’autres comportements indéfinis lors de l’exécution de votre programme. Outre le drapeau de compilation -fsanitize=undefined, qui vérifie tous les types de bogues, il existe de nombreux drapeaux supplémentaires qui peuvent être utiles pour trouver des bogues plus spécifiques, notamment :
-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
Comme vous pouvez le constater, UBSan traite assez bien les bogues simples comme celui présenté dans l’exemple suivant (démo) :
int main()
{
int m = std::numeric_limits< int >::max();
return m + 1;
}
Et voici le rapport que nous obtenons :
runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
UBSan démontrera au mieux sa puissance dans le développement quotidien ainsi que dans les grandes bases de code. En ce qui concerne les performances, la surcharge du processeur est d’environ 1,25x et l’utilisation de la mémoire n’est pas du tout affectée (UBSan n’affecte pas l’agencement de l’espace d’adressage).
Les inconvénients à prendre en compte
Maintenant que nous avons vu comment les assainisseurs peuvent nous aider à construire un meilleur code, nous devons encore mentionner quelques inconvénients liés à leur utilisation.
Tout d’abord, contrairement à Valgrind qui effectue toutes les vérifications sans nécessiter de recompilation du code source, les assainisseurs exigent que votre code soit recompilé. Cela peut prendre un certain temps, surtout si vous travaillez sur une base de code importante.
Le deuxième inconvénient (et sans doute le plus important) est que ASan et MSan ne peuvent pas fonctionner ensemble. Cela signifie que vous devrez effectuer plusieurs exécutions pour tester votre logiciel, ce qui, une fois de plus, peut prendre un certain temps. C’est peut-être la raison pour laquelle les assainisseurs ne sont pas encore très appréciés par la communauté C++.
Malgré leurs inconvénients, nous vous recommandons vivement d’utiliser les assainisseurs. Ils détecteront des problèmes qui peuvent sembler sans danger pour les outils concurrents et ils n’interrompront pas votre flux de travail avec des ralentissements importants.
Il est donc tout à fait logique de les intégrer à vos processus de développement “rapides” tels que l’intégration continue ou les pipelines de demandes d’extraction.
Cependant, ne rejetons pas Valgrind tout de suite, car il peut encore nous être très utile. Nous parlerons de ses avantages dans l’un de nos prochains articles.
C’est tout pour l’instant – restez à l’écoute pour d’autres articles intéressants à venir et n’oubliez pas que la construction d’un logiciel fiable commence par un code propre.