Logo PointerLab

Comportement indéfini en C++ : comprendre l'undefined behavior, ses 7 pièges et comment les détecter

Comportement indéfini en C++ : comprendre l'undefined behavior, ses 7 pièges et comment les détecter

Amine Abidi - Lead Software Engineer C++/Qt - Co-fondateur PointerLab

Publié par Amine Abidi - Lead Software Engineer C++/Qt - Co-fondateur PointerLab

Comportement indéfini en C++ : comprendre l'undefined behavior, ses 7 pièges et comment les détecter

Votre code C++ compile sans warning, passe tous vos tests… et contient peut-être un comportement indéfini. C'est le défaut le plus coûteux du langage, parce qu'il ne se manifeste pas quand on l'attend : il se cache derrière un changement de compilateur, un -O2 ou une mise à jour de la bibliothèque standard.

Ce guide s'adresse aux développeurs et ingénieurs C et C++ qui veulent comprendre ce qu'est vraiment un comportement indéfini, reconnaître les sept cas les plus courants, les corriger avec des constructions modernes (std::span, std::bit_cast, std::atomic) et mettre en place des garde-fous automatiques dans leur chaîne de build. Il traite aussi un cas que peu de ressources abordent : les UB spécifiques à Qt (affinité de thread des QObject).


Qu'est-ce qu'un comportement indéfini en C++ ?

Selon cppreference, un comportement indéfini rend le programme entier dépourvu de sens lorsqu'une règle du langage est violée : la norme n'impose alors aucune restriction sur ce que fait le programme. Le compilateur n'est pas tenu de signaler l'erreur, et le programme compilé n'est pas tenu de produire un résultat cohérent.

Un UB n'est donc pas « une erreur qui plante ». C'est un contrat rompu entre le développeur et le compilateur. Tant que vous respectez les règles, le compilateur garantit un comportement. Dès que vous les violez, il ne garantit plus rien, y compris pour les lignes de code exécutées avant l'instruction fautive.

Comportement indéfini, non spécifié, défini par l'implémentation : la différence

Ces quatre notions sont souvent confondues, en entretien comme en revue de code :

Notion Ce que garantit la norme Exemple en C++
Comportement défini Un résultat unique, imposé par la norme unsigned : le dépassement boucle modulo 2ⁿ
Défini par l'implémentation (implementation-defined) Plusieurs choix possibles, le compilateur en choisit un et le documente sizeof(long)
Non spécifié (unspecified) Plusieurs choix possibles, sans obligation de documenter Ordre d'évaluation des arguments d'une fonction
Indéfini (undefined) Aucune garantie Dépassement d'un int signé, accès hors bornes

Retenez la règle : un comportement non spécifié ou défini par l'implémentation produit un résultat parmi plusieurs. Un comportement indéfini peut produire n'importe quoi, y compris une transformation du code que vous n'avez jamais écrite.

Et le « comportement erroné » de C++26 ?

C++26 introduit une quatrième catégorie, le comportement erroné (erroneous behaviour), pour les lectures de variables non initialisées. Selon l'analyse de Sandor Dargo publiée sur isocpp.org, ces lectures ne seront plus un UB à partir de C++26 (proposition P2795R5), et le changement s'applique en recompilant le code existant. Nous y revenons plus bas, dans la partie consacrée à ce que C++26 change.


Pourquoi un comportement indéfini ne se limite jamais à un crash

Trois mécanismes expliquent pourquoi les UB sont si difficiles à diagnostiquer.

1. L'optimiseur raisonne comme si l'UB était impossible. Le compilateur déduit des propriétés du code en supposant que le programme est valide. Si une propriété n'est vraie que lorsqu'un UB se produit, il peut la considérer comme fausse et supprimer le code correspondant.

Voici un exemple que nous avons compilé :

int f(int* p) {
    int v = *p;        // déréférencement : p ne peut pas être nul ici
    if (!p) return -1; // le compilateur peut donc supprimer ce test
    return v;
}

Avec GCC 13.3, le test if (!p) est présent dans l'assembleur à -O0 et absent à -O2. Le compilateur a raisonné : « p a été déréférencé, donc p n'est pas nul, donc ce test est inutile ». Le warning -Wnull-dereference signale d'ailleurs un « potential null pointer dereference » dans ce cas.

2. Le résultat dépend du compilateur, de sa version et des options. Un programme peut donner un résultat avec GCC, un autre avec Clang, et un troisième après une mise à jour. Aucun n'a tort : la norme ne dit rien.

3. « Ça marche chez moi » ne prouve rien. Un UB peut rester invisible pendant des années, puis se manifester après un changement d'optimisation ou d'architecture.

L'exemple canonique : x + 1 > x

La page cppreference sur les UB donne cet exemple : return x + 1 > x; vaut « soit true, soit UB » en cas de dépassement signé, et le compilateur peut le remplacer par la constante true.

#include <climits>
#include <cstdio>
bool ok(int x) { return x + 1 > x; }
int main() { std::printf("%d\n", ok(INT_MAX)); }

Notre test sous GCC 13.3 affiche 1 pour INT_MAX, même à -O0 (GCC simplifie cette expression sans attendre l'optimisation). Avec l'option -fwrapv, qui impose un comportement de bouclage modulo 2ⁿ, le même programme affiche 0.

Deux enseignements :

  • Un UB n'est pas un bouclage. Beaucoup de développeurs pensent que INT_MAX + 1 donne INT_MIN. C'est faux en C++ standard pour un type signé.
  • -fwrapv n'est pas une correction portable. C'est une extension de GCC et Clang qui change la sémantique du langage. Votre code devient dépendant d'une option de compilation.

Les 7 comportements indéfinis les plus fréquents en C++

Pour chaque cas : le piège, la raison pour laquelle c'est un UB, la correction, et l'outil qui le détecte. Pour diagnostiquer un plantage déjà survenu (core dump, backtrace gdb, valgrind), consultez notre guide dédié pour débogger un segfault : cet article se concentre sur la cause et la prévention.

1. Le dépassement d'entier signé

#include <climits>
#include <cstdio>
int next_id(int id) { return id + 1; }   // UB si id == INT_MAX
int main(int argc, char**) {
    int v = INT_MAX - 1 + (argc > 0);
    std::printf("%d\n", next_id(v));
}

Pourquoi c'est un UB. La norme définit le dépassement des types non signés (arithmétique modulo 2ⁿ), mais pas celui des types signés. Le compilateur peut donc supposer qu'une addition d'entiers signés ne déborde jamais.

Détection. UBSan signale l'erreur à l'exécution. Avec GCC 13.3 et -fsanitize=undefined, nous obtenons :

next_id.cpp:3:35: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'

Corrections.

  • Tester avant d'opérer, ou utiliser le builtin __builtin_add_overflow (GCC et Clang), qui signale le dépassement sans UB.
  • Utiliser un type non signé quand le bouclage est voulu (compteurs, hachages) : son comportement est défini.
  • Retourner un std::optional quand le débordement est une erreur métier.
#include <limits>
#include <optional>

std::optional<int> next_id(int id) {
    if (id == std::numeric_limits<int>::max()) return std::nullopt;
    return id + 1;
}

bool add_overflows(int a, int b, int& out) {
    return __builtin_add_overflow(a, b, &out);   // GCC / Clang
}

2. L'accès hors bornes

std::vector<int> v{1, 2, 3};
int x = v[3];   // UB : operator[] ne vérifie rien

Pourquoi c'est un UB. operator[] de std::vector a pour précondition n < size(). La violer n'est pas une erreur signalée : c'est un comportement indéfini. Le programme peut lire une valeur quelconque, corrompre la mémoire, ou planter bien plus tard.

Détection. AddressSanitizer interrompt l'exécution et localise l'accès :

ERROR: AddressSanitizer: heap-buffer-overflow on address 0x50200000001c ...
READ of size 4 at 0x50200000001c thread T0
    #0 ... in main oob.cpp:3

La bibliothèque standard durcie, activée avec -D_GLIBCXX_ASSERTIONS sous libstdc++, transforme aussi cet accès en erreur franche :

stl_vector.h:1128: ... operator[](size_type) ...: Assertion '__n < this->size()' failed.

Corrections.

  • .at() aux frontières du code, là où les indices viennent de l'extérieur : il lève std::out_of_range.
  • std::span (C++20) pour transmettre un pointeur et sa taille ensemble, au lieu de deux paramètres indépendants.
  • La bibliothèque standard durcie en test et en préproduction (voir plus bas).
#include <span>

int sum(std::span<const int> data) {
    int s = 0;
    for (int x : data) s += x;   // la boucle ne peut pas dépasser data.size()
    return s;
}

3. La référence ou l'itérateur invalidé

std::vector<int> v{1, 2, 3};
int& first = v[0];
v.push_back(4);          // peut réallouer : first pointe vers de la mémoire libérée
std::printf("%d\n", first);   // UB : use-after-free

Pourquoi c'est un UB. Quand un std::vector dépasse sa capacité, il alloue un nouveau bloc, y copie ou déplace ses éléments, puis libère l'ancien. Les références, pointeurs et itérateurs obtenus avant l'ajout deviennent invalides. Les utiliser est un comportement indéfini.

Détection. AddressSanitizer l'identifie précisément comme un heap-use-after-free :

ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010 ...
READ of size 4 at 0x502000000010 thread T0

Corrections.

  • Conserver un index plutôt qu'une référence ou un itérateur quand le conteneur peut changer.
  • Appeler reserve() en amont si des références doivent survivre à des ajouts, sans dépasser la capacité réservée.
  • Ne jamais construire un std::string_view ou un std::span à partir d'un temporaire : ces types ne possèdent pas les données.
std::vector<int> v{1, 2, 3};
std::size_t first = 0;     // index stable
v.push_back(4);
int x = v.at(first);       // toujours valide

4. Le déréférencement d'un pointeur nul

Reprenons l'exemple de la section précédente : après un déréférencement, tester la nullité n'a plus de sens pour le compilateur. Sur GCC 13.3 à -O2, le test disparaît.

Corrections.

  • Tester avant de déréférencer, jamais après.
  • Préférer les références pour les paramètres obligatoires et std::optional ou un pointeur explicitement documenté comme nullable pour les autres.
  • Activer -Wnull-dereference pour les cas détectables à la compilation.
int read_ptr(const int* p) {
    if (p == nullptr) return -1;   // test AVANT le déréférencement
    return *p;
}

Détection. UBSan inclut des contrôles de pointeur nul dans le groupe -fsanitize=undefined.

5. La violation de l'aliasing strict

L'aliasing strict est la règle selon laquelle un objet ne peut être accédé que par une expression de son type (ou de types compatibles, ou via char, unsigned char et std::byte). Le compilateur en tire la conclusion que deux pointeurs de types différents ne désignent jamais la même mémoire.

Voici un exemple que nous avons exécuté :

#include <cstdint>
#include <cstdio>

int bad(float* f, std::uint32_t* u) {
    *f = 1.0f;
    *u = 0;               // le compilateur suppose que u ne modifie pas *f
    return *f > 0.5f;     // et peut donc répondre 1 sans relire la mémoire
}

int main() {
    float x = 0;
    auto* u = reinterpret_cast<std::uint32_t*>(&x);   // UB : mauvais type d'accès
    std::printf("bad=%d\n", bad(&x, u));
}
Compilateur -O0 -O2
GCC 13.3 bad=0 bad=1
Clang 18.1 bad=0 bad=1

Le même programme donne deux résultats différents selon le niveau d'optimisation. À -O2, -fstrict-aliasing est actif sous GCC. Le compilateur suppose que *u = 0 ne peut pas toucher *f, et retourne 1 sans recharger la valeur.

Corrections.

  • std::bit_cast (C++20) pour réinterpréter les bits d'une valeur : c'est la solution standard, sans UB.
  • std::memcpy avant C++20 : les compilateurs l'optimisent en une simple instruction.
  • Éviter l'union de type punning : GCC et Clang la tolèrent en extension, mais ce n'est pas portable en C++ standard.
#include <bit>
#include <cstdint>
#include <cstring>

std::uint32_t bits_cpp20(float f) { return std::bit_cast<std::uint32_t>(f); }

std::uint32_t bits_cpp11(float f) {
    std::uint32_t u;
    std::memcpy(&u, &f, sizeof u);   // optimisé en une instruction
    return u;
}

Les deux versions retournent 0x3f800000 pour 1.0f dans notre test.

À éviter : -fno-strict-aliasing. Cette option masque le symptôme sans corriger le code, et pénalise les optimisations. Elle peut dépanner temporairement un legacy, pas servir de stratégie.

Détection. -Wstrict-aliasing (inclus dans -Wall sous GCC, actif avec -fstrict-aliasing) signale les cas simples, par exemple un reinterpret_cast<uint32_t*>(&f) déréférencé juste après. Il ne détecte pas les cas qui traversent des fonctions, comme l'exemple bad() ci-dessus.

6. La data race

#include <thread>
int counter = 0;
void work() { for (int i = 0; i < 100000; ++i) ++counter; }
int main() {
    std::thread a(work), b(work);
    a.join(); b.join();     // UB : deux threads écrivent counter sans synchronisation
}

Pourquoi c'est un UB. Deux accès concurrents à la même zone mémoire, dont au moins une écriture, sans synchronisation, constituent une data race. La norme la classe en comportement indéfini : le résultat n'est pas seulement imprévisible, il n'est plus garanti.

Détection. ThreadSanitizer la signale avec les deux piles d'appels concernées :

WARNING: ThreadSanitizer: data race
  Read of size 4 at 0x... by thread T2:
    #0 work() race.cpp:4

Corrections.

  • std::atomic<T> pour un compteur ou un drapeau.
  • std::mutex (avec std::lock_guard ou std::scoped_lock) pour protéger un état composite.
  • Ne pas utiliser volatile : en C++, il ne fournit aucune synchronisation entre threads.
#include <atomic>
#include <thread>

std::atomic<int> counter{0};
void work() { for (int i = 0; i < 100000; ++i) counter.fetch_add(1, std::memory_order_relaxed); }
int main() {
    std::thread a(work), b(work);
    a.join(); b.join();     // affiche 200000, et TSan ne signale plus rien
}

Dans notre test, la version atomique affiche 200000 et ThreadSanitizer ne produit aucun avertissement.

7. La lecture d'une variable non initialisée

int x;                       // valeur indéterminée
if (argc > 5) x = 1;
std::printf("%d\n", x == 0 ? 10 : 20);   // lecture d'une valeur indéterminée

Pourquoi c'est (encore) un UB avant C++26. Lire un objet de durée automatique non initialisé produit une valeur indéterminée, et l'utiliser est un comportement indéfini.

Détection. MemorySanitizer (Clang uniquement) détecte les lectures de mémoire non initialisée et termine le programme avec un code non nul. La documentation LLVM précise que tout le code du programme, bibliothèques comprises, doit être instrumenté, sinon des faux positifs peuvent apparaître. Les warnings -Wuninitialized et -Wmaybe-uninitialized couvrent certains cas à la compilation, mais pas tous : GCC 13.3 n'émet aucun avertissement sur l'extrait ci-dessus.

Correction. Initialiser systématiquement : int x{}; ou int x = 0;.


Tableau récapitulatif : UB, correction, détecteur

UB Correction moderne Détection à l'exécution Prévention à la compilation
Dépassement d'entier signé Test préalable, __builtin_add_overflow, type non signé UBSan -Wall -Wextra (partiel)
Accès hors bornes .at(), std::span, stdlib durcie ASan, stdlib durcie -Warray-bounds (partiel)
Référence ou itérateur invalidé Index, reserve() ASan Analyse statique, clang-tidy (partiel)
Déréférencement nul Test avant usage, références UBSan -Wnull-dereference
Aliasing strict std::bit_cast, memcpy (peu d'outils fiables) -Wstrict-aliasing
Data race std::atomic, std::mutex TSan (limité)
Lecture non initialisée Initialiser, int x{}; MSan (Clang) -Wuninitialized, -Wmaybe-uninitialized (partiel)

Détecter les comportements indéfinis : les sanitizers

Les sanitizers sont des instrumentations ajoutées par le compilateur (GCC et Clang) qui vérifient à l'exécution des propriétés que la norme laisse indéfinies. Ils ne remplacent pas les tests : ils rendent les tests capables de voir les UB.

Sanitizer Option Ce qu'il détecte Surcoût typique (documentation LLVM)
AddressSanitizer (ASan) -fsanitize=address Hors bornes (tas, pile, globales), use-after-free, double free, fuites Environ 2× en temps
UndefinedBehaviorSanitizer (UBSan) -fsanitize=undefined Dépassement signé, décalages invalides, pointeur nul, pointeur mal aligné, hors bornes de tableaux Faible en pratique (non chiffré par la documentation)
ThreadSanitizer (TSan) -fsanitize=thread Data races, certains deadlocks 5× à 15× en temps, 5× à 10× en mémoire
MemorySanitizer (MSan) -fsanitize=memory Lectures non initialisées (Clang uniquement) Environ 3× en temps

Ces ordres de grandeur proviennent de la documentation LLVM (AddressSanitizer, ThreadSanitizer, MemorySanitizer). Ils varient selon le code et la plateforme : mesurez sur votre projet.

Quelles combinaisons sont possibles ?

  • ASan + UBSan : combinables, c'est la configuration la plus rentable (-fsanitize=address,undefined).
  • TSan : seul, dans un job dédié.
  • MSan : seul, avec Clang, et avec des bibliothèques instrumentées.
  • ASan avec TSan : impossible. GCC 13.3 refuse la compilation avec l'erreur '-fsanitize=thread' is incompatible with '-fsanitize=address'.

La configuration CMake à copier

Cette configuration est compilée et exécutée avec GCC 13.3 dans notre test :

target_compile_options(app PRIVATE
  -Wall -Wextra
  -fsanitize=address,undefined
  -fno-sanitize-recover=all
  -fno-omit-frame-pointer -g -O1)
target_link_options(app PRIVATE
  -fsanitize=address,undefined)
  • -g et -fno-omit-frame-pointer : des rapports avec numéros de ligne et des piles d'appels lisibles.
  • -O1 : un bon compromis entre vitesse d'exécution et fidélité du diagnostic.
  • -fno-sanitize-recover=all : la première erreur arrête le programme et fait échouer le test. Sans cette option, UBSan affiche l'erreur et continue : un test peut « passer » avec un UB signalé dans les logs.

Placez ce profil dans un job CI dédié, et un second job pour TSan. Ne le réservez pas à la machine du développeur : un sanitizer qui ne tourne pas à chaque commit ne protège de rien.

Prévenir les UB avant l'exécution

Détecter, c'est bien. Empêcher qu'ils entrent dans le code, c'est mieux.

La bibliothèque standard durcie

Les trois grandes bibliothèques standard proposent un mode qui transforme certains UB de précondition en erreur franche :

  • libstdc++ : -D_GLIBCXX_ASSERTIONS (vérifié dans notre test : l'accès v[3] produit une assertion).
  • libc++ : des modes de durcissement activés via _LIBCPP_HARDENING_MODE.
  • C++26 : selon Sandor Dargo, la proposition P3471R4 standardise ce durcissement. Elle transforme certaines préconditions de sécurité mémoire de la bibliothèque standard en violations de contrat détectables à l'exécution, avant tout autre effet observable. Sont concernés notamment operator[] des conteneurs séquentiels, std::span, std::string_view, std::optional et std::expected. L'activation n'est pas standardisée et dépend de l'implémentation.

L'idée directrice : en production, un arrêt net avec un message clair vaut mieux qu'une corruption silencieuse.

Les warnings et l'analyse statique

  • Warnings : -Wall -Wextra -Wconversion -Wshadow -Wnull-dereference, avec -Werror en CI pour qu'aucun nouvel avertissement ne s'accumule.
  • clang-tidy : les familles bugprone-* et cppcoreguidelines-* détectent plusieurs motifs à risque.
  • Revue de code : une checklist UB explicite (indices externes, durée de vie des références, accès concurrents) attrape ce que les outils manquent.

Le fuzzing

Pour les parseurs, décodeurs et tout code qui traite des données externes, le fuzzing (par exemple libFuzzer), compilé avec ASan et UBSan, génère des entrées que vos tests n'imaginent pas. C'est une méthode très efficace pour faire apparaître des UB latents dans du code de lecture de formats.

Les constructions modernes comme garde-fous

Au lieu de… Préférer… UB évité
Pointeur + taille séparés std::span Hors bornes
reinterpret_cast pour lire des bits std::bit_cast Aliasing strict
Pointeur nullable partout Références, std::optional Déréférencement nul
new / delete manuels std::unique_ptr, conteneurs Use-after-free, double free
Variable non initialisée int x{}; Lecture indéterminée
Compteur partagé nu std::atomic Data race

Le cas Qt : les UB liés aux threads et aux QObject

Dans une application Qt, la plupart des UB liés aux threads viennent d'une règle que l'on contourne sans s'en rendre compte : l'affinité de thread. Selon la documentation Qt sur les threads et les QObject :

  • Un QObject appartient au thread dans lequel il a été créé et c'est ce thread qui distribue ses événements.
  • QObject et ses sous-classes ne sont pas thread-safe. Accéder à un objet depuis un autre thread alors qu'il peut recevoir des événements impose de protéger ses données avec un mutex.
  • Les classes d'interface graphique, QWidget en tête, ne doivent être utilisées que dans le thread principal.
  • Supprimer un QObject depuis un autre thread est risqué : il faut passer par deleteLater().
  • Un QTimer créé dans un thread sans boucle d'événements n'émettra jamais timeout().

Les types de connexion et le piège de DirectConnection

Avec le mode par défaut (Qt::AutoConnection), Qt choisit une connexion directe si l'émetteur et le récepteur sont dans le même thread, en file d'attente (Qt::QueuedConnection) sinon : le slot s'exécute alors dans le thread du récepteur, quand sa boucle d'événements reprend la main.

Le piège : forcer Qt::DirectConnection entre deux threads. Le slot s'exécute dans le thread de l'émetteur, tandis que le récepteur peut simultanément traiter des événements dans le sien. Qt prévient que ce cas est dangereux si une boucle d'événements tourne dans le thread du récepteur. Vous obtenez une data race, donc un UB.

Le schéma recommandé : un worker et moveToThread

auto* thread = new QThread(this);
auto* worker = new Worker;               // sans parent, pour pouvoir être déplacé
worker->moveToThread(thread);

connect(thread, &QThread::started,  worker, &Worker::process);
connect(worker, &Worker::finished,  this,   &MainWindow::onFinished);   // AutoConnection : mise en file entre threads
connect(thread, &QThread::finished, worker, &QObject::deleteLater);     // suppression par le thread propriétaire
thread->start();

Règles à retenir :

  1. Pas de parent pour l'objet qu'on déplace : un objet ayant un parent ne peut pas être déplacé.
  2. Communiquer par signaux et slots entre threads, pas par appels directs.
  3. Ne jamais toucher un widget depuis un autre thread que le thread principal.
  4. Supprimer avec deleteLater(), pas avec delete, quand l'objet vit dans un autre thread.

Pour aller plus loin sur l'écosystème, découvrez notre expertise Qt et notre analyse de Qt 6.10 : C++ et QML pour des interfaces modernes. PointerLab est partenaire de Qt Professional Services.


Ce que C++26 change pour les comportements indéfinis

C++26 ne supprime pas les UB, mais il en réduit sensiblement le nombre, sur deux fronts.

1. Les lectures non initialisées deviennent un comportement erroné. Selon l'analyse publiée sur isocpp.org, ces lectures ne sont plus un UB mais un erroneous behaviour (proposition P2795R5, auteur Thomas Köppe). Le changement fonctionne en recompilant le code existant, et l'implémentation signale les endroits où une variable n'est pas initialisée. Un suivi d'implémentation est ouvert côté GCC et côté Clang. Le code reste faux : on passe d'un UB à un bug diagnostiqué, ce qui est un net progrès.

2. La bibliothèque standard durcie est standardisée. Comme vu plus haut, P3471R4 transforme des préconditions de sécurité mémoire en violations de contrat. D'après Sandor Dargo, GCC 15 et MSVC 19.44 l'implémentent partiellement.

Pour une vue d'ensemble de C++26 (réflexion, contrats, sécurité mémoire, comparaison avec Rust), lisez notre article C++26 : réflexion, contrats et sécurité mémoire.


Pourquoi c'est critique dans le médical, l'aéronautique et l'automobile

Dans un logiciel embarqué critique, un UB n'est pas un défaut cosmétique : il peut invalider un argument de sûreté. Les normes de certification (DO-178C, IEC 62304, ISO 26262) exigent des démonstrations que le comportement du code est maîtrisé. Un code dont le comportement dépend de l'optimiseur est difficile à défendre devant un auditeur.

Les outils aident, mais ne suffisent pas : nous l'expliquons en détail dans DO-178C, IEC 62304, ISO 26262 : pourquoi les outils ne suffisent pas. Le sujet touche en particulier les secteurs médical, aéronautique et automobile, ainsi que le logiciel embarqué.


Checklist : installer une défense contre les UB dans votre équipe

  1. Compiler avec des warnings stricts (-Wall -Wextra -Wconversion) et -Werror en CI.
  2. Ajouter un job CI ASan + UBSan avec -fno-sanitize-recover=all.
  3. Ajouter un job CI TSan pour tout code multi-thread.
  4. Activer la bibliothèque standard durcie en test et en préproduction.
  5. Lancer clang-tidy (bugprone-*, cppcoreguidelines-*) à chaque pull request.
  6. Fuzzer les parseurs et décodeurs avec ASan et UBSan.
  7. Remplacer les motifs à risque : std::span, std::bit_cast, std::atomic, int x{};.
  8. Écrire une checklist de revue : indices externes, durée de vie des références, accès concurrents, reinterpret_cast.
  9. Former l'équipe : la plupart des UB viennent de croyances héritées (« INT_MAX + 1 boucle », « volatile suffit pour un drapeau »).

Besoin d'un regard extérieur ? PointerLab propose un audit technologique de votre code C++, du conseil en architecture logicielle, des formations C++ et Qt et du développement logiciel C++. Vous recrutez ? Découvrez notre service de recrutement de développeurs C++. Pour vous préparer aux questions classiques sur ce sujet, consultez nos 12 questions pièges d'entretien C++. Débutant ? Commencez par débuter avec le C++.


FAQ : comportement indéfini en C++

Qu'est-ce qu'un comportement indéfini en C++ ?

Un comportement indéfini (undefined behavior, UB) est une situation pour laquelle la norme C++ n'impose aucune contrainte au programme. Il peut planter, produire un résultat faux ou sembler correct. Le compilateur n'a aucune obligation de signaler l'erreur, et il optimise en supposant que l'UB ne se produit jamais.

Un comportement indéfini provoque-t-il toujours un crash ?

Non. C'est ce qui le rend dangereux. Un UB peut passer inaperçu pendant des années, produire un résultat plausible mais faux, ou se manifester seulement avec un autre compilateur, une autre version ou un autre niveau d'optimisation (-O2).

Quelle est la différence entre comportement indéfini, non spécifié et défini par l'implémentation ?

Un comportement défini par l'implémentation laisse le choix au compilateur, qui doit le documenter (ex. sizeof(long)). Un comportement non spécifié laisse aussi le choix, sans obligation de documenter (ex. ordre d'évaluation des arguments). Un comportement indéfini n'impose aucune garantie : tout est permis.

Le dépassement d'un int boucle-t-il en C++ ?

Non, pas pour un type signé : c'est un comportement indéfini. Seuls les types non signés ont un comportement défini (arithmétique modulo 2ⁿ). Le compilateur peut supposer qu'un dépassement signé ne se produit jamais et optimiser en conséquence.

Comment détecter les comportements indéfinis en C++ ?

Compilez vos tests avec les sanitizers : -fsanitize=address,undefined pour les accès mémoire et les UB arithmétiques, -fsanitize=thread pour les data races, -fsanitize=memory (Clang) pour les lectures non initialisées. Ajoutez des warnings stricts, clang-tidy et la bibliothèque standard durcie.

Peut-on utiliser ASan et TSan en même temps ?

Non. Leurs instrumentations de la mémoire sont incompatibles : GCC refuse de compiler avec les deux options ('-fsanitize=thread' is incompatible with '-fsanitize=address'). Utilisez deux jobs CI distincts. En revanche, ASan et UBSan se combinent sans problème.

Comment lire les bits d'un float sans violer l'aliasing strict ?

Utilisez std::bit_cast<std::uint32_t>(f) depuis C++20, ou std::memcpy avant. Évitez reinterpret_cast suivi d'un déréférencement : c'est une violation de l'aliasing strict, et l'option -fno-strict-aliasing ne fait que masquer le problème.

C++26 supprime-t-il les comportements indéfinis ?

Non, mais il en réduit le nombre : la lecture d'une variable non initialisée devient un « comportement erroné » (P2795R5), et la bibliothèque standard durcie (P3471R4) transforme certaines préconditions de sécurité mémoire en violations de contrat détectables.

Les QObject sont-ils thread-safe ?

Non. Selon la documentation Qt, QObject et ses sous-classes ne sont pas thread-safe. Un QObject appartient au thread qui l'a créé. Communiquez entre threads par signaux et slots (connexion en file d'attente), et utilisez les widgets uniquement dans le thread principal.


Sources et lectures complémentaires

Méthode : les extraits de code C++ standard et les sorties d'outils de cet article ont été compilés et exécutés avec GCC 13.3 (Ubuntu 24.04) ; la comparaison d'aliasing aux niveaux -O0 et -O2 a aussi été faite avec Clang 18.1. L'extrait Qt reprend le schéma recommandé par la documentation Qt. Les surcoûts des sanitizers sont les ordres de grandeur de la documentation LLVM.


Rejoignez la communauté C++ francophone

Vous avez croisé un UB mémorable ? Partagez-le avec d'autres développeurs sur la communauté C++ francophone. Un projet C++ ou Qt à fiabiliser ? Contactez PointerLab, ou parcourez nos références pour voir des projets livrés dans l'avionique, l'imagerie médicale et la simulation 3D.

Rejoignez la communauté C++ 🇫🇷 sur Discord !

Un espace convivial pour échanger et apprendre ensemble.