Soyez prudent, nettoyez votre code : comment éviter les bogues dans votre code C++
Malgré toutes les pertes qu'elle a causées, cette pandémie nous a au moins rendus plus attentifs à notre hygiène personnelle.
Nous désinfectons les espaces, les surfaces et nos mains bien plus souvent qu’auparavant, alors pourquoi ne pas en profiter pour « désinfecter » notre code ? Après tout, les logiciels font tourner le monde, et les bugs qui provoquent des dysfonctionnements dans les programmes peuvent causer de graves dommages – tout comme leurs homologues viraux.
Si vous développez en C et en C++, vous ne le savez que trop bien. Il est facile d’allouer un bloc de mémoire et d’oublier de le libérer par la suite, ou d’écrire accidentellement au-delà des limites du tampon mémoire. Ces problèmes sont extrêmement difficiles à détecter sans outils adaptés et provoquent souvent des plantages sporadiques et soudains.
L'utilisation de « sanitizers » lors du développement et des tests de votre programme peut vous aider à détecter très tôt un grand nombre de problèmes dans votre code source, notamment les fuites de mémoire, les débordements de tampon et les comportements indéfini.
Aujourd'hui, nous allons nous intéresser à trois types de « sanitizers » Clang, à leur utilisation et aux bogues qu'ils peuvent nous aider à éliminer dès leur apparition.
C'est parti pour la pulvérisation !
Nettoyer votre espace d'adressage avec AddressSanitizer (ASan)
AddressSanitizer (ou ASan) sert à détecter les erreurs de type « use-after-free », les doubles libérations, les débordements et sous-débordements de tampon (pile, tas et tampon global), ainsi que d'autres erreurs de mémoire.
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.
Voici à quoi ressemble notre code sans ASan :
Et voici comment ASan procède pour détecter les bogues liés aux adresses :
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).
Pour bien saisir toute l'utilité de cet outil, voici un petit test pour vous. Essayez de repérer une erreur 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 ? Imaginez maintenant que ce bogue fasse partie d'une base de code bien plus vaste. Il nous faudrait une éternité pour le déboguer manuellement, alors qu'avec ASan activé, nous pouvons identifier en quelques secondes le débordement de tampon du tas qui se cache (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 que celui-ci a été libéré (une faille de sécurité courante appelée « erreur d'utilisation après libération »). Voici un exemple de programme contenant ce 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 le rapport généré par 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
Plutôt sympa, non ?
Vous vous demandez peut-être : combien cela coûte-t-il ? Eh bien, rien n'est gratuit de nos jours, et malheureusement, il en va de même pour ASan.
Cela dit, si l'on considère le gain de temps que cet outil pourrait vous apporter, il n'est pas si cher que ça. La surcharge moyenne est d'environ le double, tant en termes de performances que d'utilisation de la mémoire.
En matière de performances, Valgrind, le principal concurrent d'ASan, ralentira votre programme de 10 à 100 fois plus. À nos yeux, c'est une nette amélioration.
Maintenant que nous avons nettoyé notre espace d'adressage, poursuivons l'assainissement de notre mémoire.
Détection des lectures de mémoire non initialisée avec MemorySanitizer (MSan)
On pourrait penser qu’AddressSanitizer couvre tous les bugs 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 outil de vérification appelé MemorySanitizer (ou MSan en abrégé).
MSan vous permet de copier de la mémoire non initialisée et d'y effectuer des opérations logiques et arithmétiques simples, mais si vous essayez de l'utiliser dans une instruction de prise de décision, un drapeau rouge s'affichera. Pour mieux comprendre ce phénomène, jetez un œ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 constater, MSan nous signalera toute valeur non initialisée utilisée. La fuite de mémoire que vous avez peut-être remarquée ne sera pas signalée, car c'est précisément le domaine de compétence d'ASan — il est important de ne pas s'y méprendre.
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.
Chaque fois que l'accès à l'un des bits « empoisonnés » entraîne un effet secondaire (par exemple lors d'une branche), un avertissement sera émis. Ce bit supplémentaire entraîne une surcharge de 2,5 fois en termes de CPU et de 2 fois en termes de mémoire. Cette surcharge sera légèrement plus importante si les origines mémoire sont également suivies : 5 fois plus en termes de CPU et 3 fois plus en termes de mémoire, pour être précis.
Détecter les comportements indéfini avec UndefinedBehaviorSanitizer (UBSan)
Enfin, n’oublions pas UndefinedBehaviorSanitizer (ou UBSan, pour faire court).
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
Comme vous pouvez le constater, UBSan gère très bien les bugs 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 qui nous a été transmis :
runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
C'est dans le cadre du développement quotidien ainsi que sur des bases de code volumineuses qu'UBSan démontrera le mieux toute sa puissance. En termes de performances, on observe une surcharge CPU d'environ 1,25 fois, sans aucun impact sur l'utilisation de la mémoire (UBSan n'affecte pas la disposition de l'espace d'adressage).
Inconvénients à prendre en compte
Maintenant que nous avons vu comment les « sanitizers » peuvent nous aider à écrire un meilleur code, il convient toutefois de 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 « sanitizers » exigent que votre code soit recompilé. Cela peut prendre un certain temps, surtout si vous travaillez sur une base de code volumineuse.
Le deuxième inconvénient (et sans doute le plus important) réside dans le fait qu’ASan et MSan ne peuvent pas fonctionner ensemble. Cela signifie que vous devrez effectuer plusieurs exécutions pour tester votre logiciel, ce qui, là encore, peut prendre un certain temps. C’est peut-être la raison pour laquelle les « sanitizers » ne sont toujours pas très appréciés par la communauté C++.
Malgré leurs inconvénients, nous recommandons vivement l'utilisation de ces outils de validation. Ils détectent des problèmes qui pourraient passer inaperçus avec des outils concurrents et n'entraînent pas de ralentissements importants susceptibles de perturber votre flux de travail.
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 pull requests.
Cependant, ne mettons pas Valgrind de côté pour l'instant, car il peut encore nous être très utile. Nous aborderons ses avantages dans l'un de nos prochains articles.
C'est tout pour le moment — restez à l'écoute pour découvrir d'autres articles intéressants à venir et n'oubliez pas que la création de logiciels fiables commence par un code soigné.