Article de reference

Comportement indéfini

En programmation informatique , un comportement indéfini ( UB ) est le résultat de l'exécution d'un programme dont le comportement est prescrit comme étant imprévisible, dans la...

En programmation informatique , un comportement indéfini ( UB ) est le résultat de l'exécution d'un programme dont le comportement est prescrit comme étant imprévisible, dans la spécification du langage de programmation dans lequel le code source est écrit. Il diffère du comportement indéfini , pour lequel la spécification du langage ne prescrit pas de résultat, et du comportement défini par l'implémentation qui s'en remet à la documentation d'un autre composant de la plateforme (comme l' ABI ou la documentation du traducteur ).

Dans la communauté de programmation C , le comportement indéfini peut être appelé avec humour « démons nasaux », d'après un article sur comp.std.c qui expliquait que le comportement indéfini permettait au compilateur de faire tout ce qu'il voulait, même « de faire sortir les démons de votre nez ».

Aperçu

Certains langages de programmation permettent à un programme de fonctionner différemment ou même d'avoir un flux de contrôle différent du code source, à condition qu'il présente les mêmes effets secondaires visibles par l'utilisateur , si un comportement indéfini ne se produit jamais pendant l'exécution du programme . Le comportement indéfini est le nom d'une liste de conditions que le programme ne doit pas respecter.

Dans les premières versions de C , le principal avantage du comportement indéfini était la production de compilateurs performants pour une grande variété de machines : une construction spécifique pouvait être associée à une fonctionnalité spécifique à la machine, et le compilateur n'avait pas besoin de générer de code supplémentaire pour l'exécution afin d'adapter les effets secondaires à la sémantique imposée par le langage. Le code source du programme était écrit avec une connaissance préalable du compilateur spécifique et des plates-formes qu'il prendrait en charge.

Cependant, la standardisation progressive des plateformes a rendu cet avantage moins intéressant, en particulier dans les versions plus récentes de C. Désormais, les cas de comportement indéfini représentent généralement des bogues non ambigus dans le code, par exemple l'indexation d'un tableau en dehors de ses limites. Par définition, le runtime peut supposer qu'un comportement indéfini ne se produit jamais ; par conséquent, certaines conditions non valides n'ont pas besoin d'être vérifiées. Pour un compilateur , cela signifie également que diverses transformations de programme deviennent valides, ou que leurs preuves d'exactitude sont simplifiées ; cela permet divers types d'optimisations dont l'exactitude dépend de l'hypothèse que l'état du programme ne remplit jamais une telle condition. Le compilateur peut également supprimer les vérifications explicites qui peuvent avoir été dans le code source, sans en avertir le programmeur ; par exemple, la détection d'un comportement indéfini en testant s'il s'est produit n'est pas garantie de fonctionner, par définition. Cela rend difficile voire impossible la programmation d'une option de sécurité portable (des solutions non portables sont possibles pour certaines constructions).

Le développement actuel des compilateurs évalue et compare généralement les performances du compilateur avec des tests de performance conçus autour de micro-optimisations, même sur des plateformes principalement utilisées sur le marché des ordinateurs de bureau et portables à usage général (comme amd64). Par conséquent, un comportement indéfini offre une marge de manœuvre suffisante pour améliorer les performances du compilateur, car le code source d'une instruction de code source spécifique peut être mappé à n'importe quoi au moment de l'exécution.

Pour C et C++, le compilateur est autorisé à fournir un diagnostic au moment de la compilation dans ces cas, mais n'est pas obligé de le faire : l'implémentation sera considérée comme correcte quoi qu'elle fasse dans de tels cas, de manière analogue aux termes « don't care » de la logique numérique. Il est de la responsabilité du programmeur d'écrire du code qui n'invoque jamais de comportement indéfini, bien que les implémentations du compilateur soient autorisées à émettre des diagnostics lorsque cela se produit. Les compilateurs disposent aujourd'hui d'indicateurs qui permettent de tels diagnostics, par exemple, -fsanitize=undefinedils activent le « undefined behavior sanitizer » (UBSan) dans gcc 4.9 et dans clang . Cependant, cet indicateur n'est pas la valeur par défaut et son activation est un choix de la personne qui construit le code.

Dans certaines circonstances, des restrictions spécifiques peuvent s'appliquer à un comportement indéfini. Par exemple, les spécifications du jeu d'instructions d'un processeur peuvent laisser le comportement de certaines formes d'instruction indéfini, mais si le processeur prend en charge la protection de la mémoire , la spécification inclura probablement une règle générale stipulant qu'aucune instruction accessible à l'utilisateur ne peut provoquer une faille dans la sécurité du système d'exploitation . Ainsi, un processeur réel serait autorisé à corrompre les registres utilisateur en réponse à une telle instruction, mais ne serait pas autorisé, par exemple, à passer en mode superviseur .

La plateforme d'exécution peut également fournir certaines restrictions ou garanties sur le comportement indéfini, si la chaîne d'outils ou l' exécution documente explicitement que des constructions spécifiques trouvées dans le code source sont mappées à des mécanismes spécifiques bien définis disponibles au moment de l'exécution. Par exemple, un interpréteur peut documenter un comportement particulier pour certaines opérations qui ne sont pas définies dans la spécification du langage, alors que d'autres interpréteurs ou compilateurs pour le même langage ne le peuvent pas. Un compilateur produit du code exécutable pour une ABI spécifique , comblant le vide sémantique de manière qui dépend de la version du compilateur : la documentation de cette version du compilateur et la spécification ABI peuvent fournir des restrictions sur le comportement indéfini. S'appuyer sur ces détails d'implémentation rend le logiciel non portable , mais la portabilité peut ne pas être un problème si le logiciel n'est pas censé être utilisé en dehors d'un environnement d'exécution spécifique.

Un comportement indéfini peut entraîner un blocage du programme ou même des échecs plus difficiles à détecter et qui donnent l'impression que le programme fonctionne normalement, comme une perte silencieuse de données et la production de résultats incorrects.

Avantages

La documentation d'une opération en tant que comportement indéfini permet aux compilateurs de supposer que cette opération ne se produira jamais dans un programme conforme. Cela donne au compilateur plus d'informations sur le code et ces informations peuvent conduire à davantage d'opportunités d'optimisation.

Un exemple pour le langage C :

int foo ( unsigned char x ) { int value = 2147483600 ; /* en supposant un int 32 bits et un char 8 bits */ value += x ; if ( value < 2147483600 ) bar (); return value ; }

La valeur de xne peut pas être négative et, étant donné que le dépassement d'entier signé est un comportement indéfini en C, le compilateur peut supposer que value < 2147483600sera toujours faux. Ainsi, l' ifinstruction, y compris l'appel à la fonction bar, peut être ignorée par le compilateur puisque l'expression de test dans ifn'a pas d'effets secondaires et sa condition ne sera jamais satisfaite. Le code est donc sémantiquement équivalent à :

int foo ( unsigned char x ) { int valeur = 2147483600 ; valeur += x ; valeur de retour ; }

Si le compilateur avait été obligé de supposer que le dépassement d’entier signé avait un comportement de bouclage , la transformation ci-dessus n’aurait pas été légale.

De telles optimisations deviennent difficiles à repérer par les humains lorsque le code est plus complexe et que d'autres optimisations, comme l'inlining , ont lieu. Par exemple, une autre fonction peut appeler la fonction ci-dessus :

void run_tasks ( unsigned char * ptrx ) { int z ; z = foo ( * ptrx ); while ( * ptrx > 60 ) { run_one_task ( ptrx , z ); } }

Le compilateur est libre d'optimiser la whileboucle ici en appliquant une analyse de plage de valeurs : en inspectant foo(), il sait que la valeur initiale pointée par ptrxne peut pas dépasser 47 (car plus déclencherait un comportement indéfini dans foo()) ; par conséquent, la vérification initiale de *ptrx > 60sera toujours fausse dans un programme conforme. En allant plus loin, puisque le résultat zn'est désormais jamais utilisé et foo()n'a aucun effet de bord, le compilateur peut optimiser run_tasks()pour être une fonction vide qui retourne immédiatement. La disparition de la whileboucle peut être particulièrement surprenante si foo()est défini dans un fichier objet compilé séparément .

Un autre avantage de permettre au dépassement d'entier signé d'être indéfini est qu'il permet de stocker et de manipuler la valeur d'une variable dans un registre de processeur plus grand que la taille de la variable dans le code source. Par exemple, si le type d'une variable tel que spécifié dans le code source est plus étroit que la largeur du registre natif (comme intsur une machine 64 bits , un scénario courant), alors le compilateur peut utiliser en toute sécurité un entier signé 64 bits pour la variable dans le code machine qu'il produit, sans modifier le comportement défini du code. Si un programme dépendait du comportement d'un dépassement d'entier 32 bits, alors un compilateur devrait insérer une logique supplémentaire lors de la compilation pour une machine 64 bits, car le comportement de dépassement de la plupart des instructions machine dépend de la largeur du registre.

Le comportement indéfini permet également davantage de vérifications au moment de la compilation par les compilateurs et l'analyse statique du programme .

Risques

Les normes C et C++ comportent plusieurs formes de comportement indéfini, qui offrent une plus grande liberté dans les implémentations du compilateur et les vérifications au moment de la compilation au détriment du comportement indéfini à l'exécution s'il est présent. En particulier, la norme ISO pour C comporte une annexe répertoriant les sources courantes de comportement indéfini. De plus, les compilateurs ne sont pas tenus de diagnostiquer le code qui repose sur un comportement indéfini. Par conséquent, il est courant que les programmeurs, même expérimentés, s'appuient sur un comportement indéfini soit par erreur, soit simplement parce qu'ils ne connaissent pas bien les règles du langage qui peuvent s'étendre sur des centaines de pages. Cela peut entraîner des bogues qui sont exposés lorsqu'un compilateur différent ou des paramètres différents sont utilisés. Les tests ou le fuzzing avec des vérifications de comportement indéfini dynamiques activées, par exemple les assainisseurs Clang , peuvent aider à détecter un comportement indéfini non diagnostiqué par le compilateur ou les analyseurs statiques.

Un comportement indéfini peut conduire à des vulnérabilités de sécurité dans les logiciels. Par exemple, les dépassements de tampon et autres vulnérabilités de sécurité dans les principaux navigateurs Web sont dus à un comportement indéfini. Lorsque les développeurs de GCC ont modifié leur compilateur en 2008 de telle sorte qu'il omettait certains contrôles de dépassement qui reposaient sur un comportement indéfini, le CERT a émis un avertissement contre les versions plus récentes du compilateur. Linux Weekly News a souligné que le même comportement a été observé dans PathScale C , Microsoft Visual C++ 2005 et plusieurs autres compilateurs ; l'avertissement a ensuite été modifié pour mettre en garde contre divers compilateurs.

Exemples en C et C++

Les principales formes de comportement indéfini en C peuvent être classées de manière générale comme suit : violations de la sécurité de la mémoire spatiale, violations de la sécurité de la mémoire temporelle, dépassement d'entier , violations d'aliasing strict, violations d'alignement, modifications non séquencées, courses de données et boucles qui n'effectuent pas d'E/S ni ne se terminent.

En C, l'utilisation d'une variable automatique avant qu'elle n'ait été initialisée produit un comportement indéfini, tout comme la division entière par zéro , le dépassement d'entier signé, l'indexation d'un tableau en dehors de ses limites définies (voir dépassement de tampon ) ou le déréférencement de pointeur nul . En général, toute instance de comportement indéfini laisse la machine d'exécution abstraite dans un état inconnu et provoque le comportement indéfini de l'ensemble du programme.

La tentative de modification d'une chaîne littérale provoque un comportement indéfini :

char * p = "wikipedia" ; // C valide, obsolète en C++98/C++03, mal formé à partir de C++11 p [ 0 ] = 'W' ; // comportement indéfini

La division entière par zéro entraîne un comportement indéfini :

int x = 1 ; return x / 0 ; // comportement indéfini

Certaines opérations de pointeur peuvent entraîner un comportement indéfini :

int arr [ 4 ] = { 0 , 1 , 2 , 3 }; int * p = arr + 5 ; // comportement indéfini pour l'indexation hors limites p = NULL ; int a = * p ; // comportement indéfini pour le déréférencement d'un pointeur nul

En C et C++, la comparaison relationnelle de pointeurs vers des objets (pour une comparaison inférieure ou supérieure à) n'est strictement définie que si les pointeurs pointent vers des membres du même objet ou des éléments du même tableau . Exemple :

int main ( void ) { int a = 0 ; int b = 0 ; return & a < & b ; /* comportement indéfini */ }

Atteindre la fin d'une fonction renvoyant une valeur (autre que main()) sans instruction de retour entraîne un comportement indéfini si la valeur de l'appel de fonction est utilisée par l'appelant :

int f () { } /* comportement indéfini si la valeur de l'appel de fonction est utilisée*/

La modification d'un objet entre deux points de séquence plus d'une fois produit un comportement indéfini. Il existe des changements considérables dans les causes du comportement indéfini par rapport aux points de séquence à partir de C++11. Les compilateurs modernes peuvent émettre des avertissements lorsqu'ils rencontrent plusieurs modifications non séquencées sur le même objet. L'exemple suivant provoquera un comportement indéfini en C et en C++.

int f ( int i ) { return i ++ + i ++ ; /* comportement indéfini : deux modifications non séquencées de i */ }

Lors de la modification d'un objet entre deux points de séquence, la lecture de la valeur de l'objet à d'autres fins que la détermination de la valeur à stocker est également un comportement indéfini.

a [ i ] = i ++ ; // comportement indéfini printf ( "%d %d " , ++ n , power ( 2 , n )); // également comportement indéfini

En C/C++, le décalage binaire d'une valeur d'un nombre de bits qui est soit un nombre négatif, soit supérieur ou égal au nombre total de bits de cette valeur entraîne un comportement indéfini. Le moyen le plus sûr (quel que soit le fournisseur du compilateur) est de toujours conserver le nombre de bits à décaler (l'opérande droit des opérateurs binaires<< et ) dans la plage : [ ] (où est l'opérande gauche). >> 0, sizeof value * CHAR_BIT - 1value

int num = -1 ; unsigned int val = 1 << num ; // décalage par un nombre négatif - comportement indéfini
num = 32 ; // ou tout autre nombre supérieur à 31 val = 1 << num ; // le littéral '1' est typé comme un entier 32 bits - dans ce cas, le décalage de plus de 31 bits est un comportement indéfini
num = 64 ; // ou tout autre nombre supérieur à 63 unsigned long long val2 = 1ULL << num ; // le littéral '1ULL' est typé comme un entier de 64 bits - dans ce cas, le décalage de plus de 63 bits est un comportement indéfini

Exemples en Rust

Bien que le comportement indéfini ne soit jamais présent dans Rust sûr , il est possible d'invoquer un comportement indéfini dans Rust non sécurisé de plusieurs manières. Par exemple, la création d'une référence non valide (une référence qui ne fait pas référence à une valeur valide) invoque un comportement indéfini immédiat :

fn 
main () { // La ligne suivante invoque un comportement indéfini immédiat. let _null_reference : & i32 = unsafe { std :: mem :: zeroed () }; }

Il n’est pas nécessaire d’utiliser la référence ; un comportement indéfini est invoqué simplement à partir de la création d’une telle référence.

Plus d articles de Worldlex Wiki

Revenez a l index pour explorer davantage de pages sur l histoire, la science, la culture, la geographie et la societe en francais.

Explorer l index