Comportement indéfini - Undefined behavior

En programmation informatique , le comportement indéfini ( UB ) est le résultat de l'exécution d'un programme dont le comportement est prescrit pour être imprévisible, dans la spécification de langage à laquelle le code informatique adhère. Ceci est différent du comportement non spécifié , 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 plate - forme (comme l' ABI ou la documentation du traducteur ).

Dans la communauté C , un comportement indéfini peut être appelé avec humour " démons nasaux ", d'après un article sur comp.std.c qui expliquait le comportement indéfini comme permettant au compilateur de faire tout ce qu'il veut, même "pour faire voler 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 , tant 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 . Un comportement indéfini est le nom d'une liste de conditions que le programme ne doit pas remplir.

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 mappée sur une fonctionnalité spécifique à la machine, et le compilateur n'avait pas à générer de code supplémentaire pour le runtime adapter les effets secondaires pour correspondre à la sémantique imposée par le langage. Le code source du programme a été écrit avec une connaissance préalable du compilateur spécifique et des plates - formes qu'il prendrait en charge.

Cependant, la standardisation progressive des plates-formes a rendu cet avantage moins avantageux, en particulier dans les nouvelles versions de C. Désormais, les cas de comportement non défini représentent généralement des bogues sans ambiguïté dans le code, par exemple l'indexation d'un tableau en dehors de ses limites. Par définition, l' environnement d'exécution peut supposer qu'un comportement non défini ne se produit jamais ; par conséquent, certaines conditions invalides 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' optimisation et de micro-optimisation prématurées , qui conduisent à un comportement incorrect si l'état du programme remplit l'une de ces conditions. Le compilateur peut également supprimer les vérifications explicites qui peuvent avoir été dans le code source, sans en informer le programmeur ; par exemple, détecter un comportement indéfini en testant s'il s'est produit n'est pas garanti de fonctionner, par définition. Cela rend difficile, voire impossible, la programmation d'une option portable à sécurité intégrée (des solutions non portables sont possibles pour certaines constructions).

Le développement actuel du compilateur évalue et compare généralement les performances du compilateur avec des références conçues autour de micro-optimisations, même sur des plates-formes principalement utilisées sur le marché des ordinateurs de bureau et des ordinateurs portables à usage général (telles que amd64). Par conséquent, un comportement non défini offre une grande marge d'amélioration des performances du compilateur, car le code source d'une instruction de code source spécifique peut être mappé à n'importe quoi lors de l'exécution.

C et C ++, le compilateur est autorisé à donner une compilation de diagnostic dans ces cas, mais pas à: la mise en œuvre sera considérée comme correcte , quoi qu'elle fasse dans de tels cas, analogue à ne pas-soins termes dans la logique numérique . Il est de la responsabilité du programmeur d'écrire du code qui n'invoque jamais un comportement indéfini, bien que les implémentations du compilateur soient autorisées à émettre des diagnostics lorsque cela se produit. De nos jours, les compilateurs ont des drapeaux qui permettent de tels diagnostics, par exemple, activent le-fsanitize "désinfectant de comportement non défini" ( UBSan ) dans gcc 4.9 et dans clang . Cependant, ce drapeau n'est pas la valeur par défaut et son activation est un choix de qui construit le code.

Dans certaines circonstances, il peut y avoir des restrictions spécifiques sur 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 indiquant qu'aucune instruction accessible par l'utilisateur ne peut provoquer un trou dans la sécurité du système d'exploitation ; ainsi, une CPU réelle serait autorisée à corrompre les registres d'utilisateurs en réponse à une telle instruction, mais ne serait pas autorisée, par exemple, à passer en mode superviseur .

La plate-forme d' exécution peut également fournir des restrictions ou des garanties sur un comportement non défini, si la chaîne d'outils ou l' environnement d'exécution documentent explicitement que des constructions spécifiques trouvées dans le code source sont mappées à des mécanismes spécifiques bien définis disponibles lors de l'exécution. Par exemple, un interpréteur peut documenter un certain comportement 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 d'une manière qui dépend de la version du compilateur : la documentation de cette version du compilateur et la spécification de l'ABI peuvent fournir des restrictions sur un comportement non dé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 non défini peut entraîner un plantage du programme ou même des échecs plus difficiles à détecter et donner l'impression que le programme fonctionne normalement, comme une perte silencieuse de données et la production de résultats incorrects.

Avantages

Documenter une opération comme un comportement non dé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 à plus d'opportunités d'optimisation.

Un exemple pour le langage C :

int foo(unsigned char x)
{
     int value = 2147483600; /* assuming 32-bit int and 8-bit char */
     value += x;
     if (value < 2147483600)
        bar();
     return value;
}

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

int foo(unsigned char x)
{
     int value = 2147483600;
     value += x;
     return value;
}

Si le compilateur avait été forcé de supposer que le débordement 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 loin le while-loop ici en appliquant l' analyse de la plage de valeurs : en inspectant foo(), il sait que la valeur initiale pointée par ptrxne peut éventuellement dépasser 47 (comme plus déclencherait un comportement non défini dans foo()), donc la vérification initiale de *ptrx > 60volonté toujours être faux dans un programme conforme. Pour aller plus loin, puisque le résultat zn'est désormais jamais utilisé et foo()n'a pas d'effets secondaires, 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 elle foo()est définie dans un fichier objet compilé séparément .

Un autre avantage d'autoriser le débordement d'entier signé à être indéfini est qu'il permet de stocker et de manipuler la valeur d'une variable dans un registre de processeur qui est 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 " int " sur une machine 64 bits , un scénario courant), alors le compilateur peut utiliser en toute sécurité un 64- bit entier pour la variable dans le code machine qu'elle produit, sans changer le comportement défini du code. Si un programme dépendait du comportement d'un débordement d'entier 32 bits, un compilateur devrait insérer une logique supplémentaire lors de la compilation pour une machine 64 bits, car le comportement de débordement de la plupart des instructions machine dépend de la largeur du registre.

Un comportement non défini permet également plus de vérifications au moment de la compilation par les compilateurs et l'analyse de programme statique .

Des risques

Les normes C et C++ ont plusieurs formes de comportement indéfini tout au long, qui offrent une liberté accrue dans les implémentations du compilateur et les vérifications au moment de la compilation au détriment du comportement d'exécution indéfini s'il est présent. En particulier, la norme ISO pour C a une annexe répertoriant les sources communes de comportement indéfini. De plus, les compilateurs ne sont pas obligés de diagnostiquer le code qui repose sur un comportement non dé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 peut 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 les contrôles dynamiques de comportement non définis activés, par exemple les désinfectants Clang , peuvent aider à détecter les comportements non définis non diagnostiqués par le compilateur ou les analyseurs statiques.

Un comportement non défini peut entraîner des failles de sécurité dans les logiciels. Par exemple, les débordements de mémoire tampon et d'autres failles de sécurité dans les principaux navigateurs Web sont dus à un comportement indéfini. Le problème de l'année 2038 est un autre exemple dû au débordement d'entier signé . Lorsque les développeurs de GCC ont modifié leur compilateur en 2008 de telle sorte qu'il a omis certaines vérifications de débordement qui reposaient sur un comportement non défini, le CERT a émis un avertissement contre les nouvelles versions 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 comme suit : violations de sécurité de la mémoire spatiale, violations de la sécurité de la mémoire temporelle, débordement d'entier , violations d'alias strictes, 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 de toute variable automatique avant qu'elle n'ait été initialisée donne un comportement indéfini, tout comme la division d' entier par zéro , le débordement d'entier signé, l'indexation d'un tableau en dehors de ses limites définies (voir buffer overflow ) 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 entraîne l'indéfinition du comportement de l'ensemble du programme.

Tenter de modifier un littéral de chaîne entraîne un comportement indéfini :

char *p = "wikipedia"; // valid C, deprecated in C++98/C++03, ill-formed as of C++11
p[0] = 'W'; // undefined behavior

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

int x = 1;
return x / 0; // undefined behavior

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

int arr[4] = {0, 1, 2, 3};
int *p = arr + 5;  // undefined behavior for indexing out of bounds
p = 0;
int a = *p;        // undefined behavior for dereferencing a null pointer

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; /* undefined behavior */
}

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

int f()
{
}  /* undefined behavior if the value of the function call is used*/

La modification d'un objet entre deux points de séquence plus d'une fois produit un comportement indéfini. Il y a des changements considérables dans les causes d'un 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 du même objet. L'exemple suivant provoquera un comportement indéfini à la fois en C et en C++.

int f(int i) {
  return i++ + i++; /* undefined behavior: two unsequenced modifications to i */
}

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

a[i] = i++; // undefined behavior
printf("%d %d\n", ++n, power(2, n)); // also undefined behavior

En C/C++, le décalage au niveau du bit 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 dans cette valeur entraîne un comportement indéfini. Le moyen le plus sûr (quel que soit le fournisseur du compilateur) est de toujours garder le nombre de bits à décaler (l'opérande droit des opérateurs <<et au niveau du >> bit ) dans la plage : < > (où est l'opérande gauche). 0, sizeof(value)*CHAR_BIT - 1value

int num = -1;
unsigned int val = 1 << num; //shifting by a negative number - undefined behavior

num = 32; //or whatever number greater than 31
val = 1 << num; //the literal '1' is typed as a 32-bit integer - in this case shifting by more than 31 bits is undefined behavior

num = 64; //or whatever number greater than 63
unsigned long long val2 = 1ULL << num; //the literal '1ULL' is typed as a 64-bit integer - in this case shifting by more than 63 bits is undefined behavior

Voir également

Les références

Lectures complémentaires

Liens externes