Pourquoi le portage sur un débordement d'entier n'est pas une bonne idée

Cet article se concentre sur le comportement non défini et les optimisations du compilateur, en particulier dans le contexte d'un débordement d'entier signé.

Note du traducteur: en russe, il n'y a pas de correspondance claire dans le contexte utilisé du mot «wrap» / «wrapping». Il existe un terme mathématique " transfert ", qui est proche du phénomène décrit, et le terme "indicateur de portage" est un mécanisme permettant de définir un indicateur dans les processeurs lors d'un débordement d'entier. Une autre option de traduction peut être l'expression «rotation / retournement / révolution autour de zéro». Il reflète mieux le sens de «envelopper» par rapport à «transporter», car montre la transition des nombres en cas de débordement de la plage positive à la plage négative. Cependant, il s'est avéré que ces mots semblent inhabituels dans le texte pour les lecteurs de test. Pour simplifier, nous prendrons à l'avenir le mot «transfert» comme traduction du terme «envelopper».

Les compilateurs du langage C (et C ++) dans leur travail sont de plus en plus guidés par le concept de comportement indéfini - la notion que le comportement d'un programme pour certaines opérations n'est pas réglementé par la norme et que, lors de la génération de code objet, le compilateur a le droit de partir de l'hypothèse que le programme n'effectue pas de telles opérations. De nombreux programmeurs se sont opposés à cette approche, car le code généré dans ce cas peut ne pas se comporter comme l'auteur du programme prévu. Ce problème devient de plus en plus aigu, car les compilateurs utilisent des méthodes d'optimisation plus sophistiquées, qui seront probablement basées sur le concept de comportement indéfini.

Dans ce contexte, un exemple avec un dépassement d'entier signé est indicatif. La plupart des développeurs C écrivent du code pour les machines qui utilisent du code supplémentaire pour représenter des entiers, et l'addition et la soustraction dans cette représentation sont implémentées exactement de la même manière, en arithmétique non signée. Si la somme de deux entiers positifs avec un signe déborde - c'est-à-dire qu'elle devient plus grande que le type ne le permet - le processeur retournera une valeur qui, interprétée comme un complément binaire du nombre signé, sera considérée comme négative. Ce phénomène est appelé «transfert», car le résultat, ayant atteint la limite supérieure de la plage de valeurs, est «transféré» et part de la limite inférieure.

Pour cette raison, vous pouvez parfois voir ce code en C:

int b = a + 1000; if (b < a) { //  puts("input too large!"); return; } 

La tâche de l' instruction if est de détecter une condition de débordement (dans ce cas, elle se produit après avoir ajouté 1000 à la valeur de la variable a ) et de signaler une erreur. Le problème est qu'en C, le débordement d'entier signé est l'un des cas de comportement non défini. Pendant un certain temps, les compilateurs ont toujours considéré ces conditions comme fausses: si vous ajoutez 1000 (ou tout autre nombre positif) à un autre nombre, le résultat ne peut pas être inférieur à la valeur initiale. Si un débordement se produit, alors il y a un comportement indéfini, et ne pas autoriser cela est déjà (apparemment) la préoccupation du programmeur. Par conséquent, le compilateur peut décider que l'opérateur conditionnel peut être complètement supprimé à des fins d'optimisation (après tout, la condition est toujours fausse, elle n'affecte rien, vous pouvez donc vous en passer).

Le problème est qu'avec cette optimisation, le compilateur a supprimé la vérification que le programmeur a ajoutée spécifiquement pour détecter un comportement non défini et le traiter. Ici, vous pouvez voir comment cela se produit dans la pratique. (Remarque: godbolt.org, le site où se trouve l'exemple, est très cool! Vous pouvez éditer le code et voir immédiatement comment les différents compilateurs le traitent, et il y en a beaucoup. Expérimentez!). Veuillez noter que le compilateur ne supprime pas la vérification du débordement si vous changez le type en non signé, car le comportement du débordement non signé en C est défini (plus précisément, le résultat est transféré avec une arithmétique non signée, donc le débordement ne se produit pas réellement).

Est-ce donc faux? Quelqu'un dit oui, bien qu'il soit évident que de nombreux développeurs de compilateurs considèrent cette décision comme légale. Si je comprends bien, les principaux arguments des partisans (edit: dépendante de l'implémentation) du transfert lors du débordement sont les suivants:

  • Le débordement est un comportement utile.
  • La migration est le comportement attendu par les programmeurs.
  • La sémantique du comportement de débordement indéfini ne fournit pas un avantage notable.
  • Le standard du langage C pour un comportement non défini permet à l'implémentation «d'ignorer complètement la situation et le résultat sera imprévisible», mais cela ne donne pas au compilateur le droit d'optimiser le code en supposant que la situation avec un comportement non défini ne se produit pas du tout.

Analysons tour à tour chaque élément:

Migration de débordement - Comportement utile?

La migration est utile principalement lorsque vous devez suivre un débordement qui s'est déjà produit. (S'il y a d'autres problèmes qui peuvent être résolus par transfert et ne peuvent pas être résolus en utilisant des variables entières non signées, je ne peux pas rappeler immédiatement de tels exemples, et je soupçonne qu'il y en a peu). Bien que le transfert simplifie vraiment le problème de l'utilisation de variables incorrectement débordées, ce n'est certainement pas une panacée (rappelez-vous la multiplication ou l'ajout de deux quantités inconnues avec un signe inconnu).

Dans des cas triviaux, lorsque le transfert vous permet simplement de suivre le débordement qui s'est produit, il n'est pas difficile non plus de savoir à l'avance s'il se produira. Notre exemple peut être réécrit comme suit:

 if (a > INT_MAX - 1000) { //    puts("input too large!"); return; } int b = a + 1000; 

Autrement dit, au lieu de calculer la somme, puis de déterminer si un dépassement s'est produit ou non, en vérifiant la cohérence mathématique du résultat, vous pouvez vérifier si la somme dépasse le nombre maximal que le type convient. (Si le signe des deux opérandes est inconnu, la vérification devra être très compliquée, mais il en va de même pour la vérification lors du transfert).

Compte tenu de tout cela, je trouve l'argument peu convaincant que le transfert est utile dans la plupart des cas.

La migration est-elle attendue par les programmeurs?

Il est plus difficile de contester cet argument, car il est évident que le code d'au moins certains programmeurs C suppose une sémantique de transfert avec un débordement d'entier signé. Mais ce fait à lui seul ne suffit pas pour considérer une telle sémantique préférable (notez que certains compilateurs vous permettent de l'activer si nécessaire).

Une solution évidente au problème (les programmeurs s'attendent à ce comportement) consiste à obliger le compilateur à donner un avertissement lorsqu'il optimise le code, en supposant qu'il n'y a pas de comportement non défini. Malheureusement, comme nous l'avons vu dans l'exemple sur godbolt.org en utilisant le lien ci-dessus, les compilateurs ne le font pas toujours (Gcc version 7.3 - oui, mais version 8.1 - non, donc il y a un pas en arrière).

La sémantique du comportement de débordement indéfini ne donne-t-elle aucun avantage notable?

Si cette remarque est vraie dans tous les cas, elle servirait d'argument fort en faveur du fait que les compilateurs devraient adhérer à la sémantique de transfert par défaut, car il serait probablement préférable d'autoriser les vérifications de débordement, même si ce mécanisme est incorrect d'un point de vue technique - bien que serait parce qu'il peut être utilisé dans du code potentiellement cassé.

Je suppose que cette optimisation (suppression des vérifications de conditions mathématiquement contradictoires) dans les programmes C ordinaires peut souvent être négligée, car leurs auteurs s'efforcent d'obtenir les meilleures performances et optimisent toujours le code manuellement: c'est-à-dire, s'il est évident que cette instruction if contient une condition , ce qui ne sera jamais vrai, le programmeur est susceptible de le supprimer lui-même. En fait, j'ai découvert que dans plusieurs études l'efficacité d'une telle optimisation était remise en question, testée et jugée pratiquement insignifiante dans le cadre des tests de contrôle. Cependant, bien que cette optimisation ne donne presque jamais un avantage dans le langage C, les générateurs de code et les optimisations de compilateur sont pour la plupart universels et peuvent être utilisés dans d'autres langages - et pour eux, cette conclusion peut être incorrecte. Prenons le langage C ++ avec sa tradition, disons, de s'appuyer sur l'optimiseur pour supprimer les constructions redondantes dans le code du modèle, plutôt que de le faire manuellement. Mais il y a des langages qui sont convertis par le transporteur en C, et le code redondant qu'ils contiennent est également optimisé par les compilateurs C.

De plus, même si vous continuez à vérifier les débordements, ce n'est pas du tout un fait que le coût direct du transfert de variables entières sera minime même sur les machines utilisant du code supplémentaire. L'architecture Mips, par exemple, ne peut effectuer des opérations arithmétiques que dans des registres de taille fixe (32 bits). Le type short int , en règle générale, a une taille de 16 bits et char - 8 bits; lorsqu'une variable d'un de ces types est stockée dans le registre, sa taille augmente et pour la transférer correctement, il sera nécessaire d'effectuer au moins une opération supplémentaire et, éventuellement, d'utiliser un registre supplémentaire (pour accueillir le masque de bits correspondant). Je dois admettre que je n'ai pas traité le code pour Mips depuis longtemps, donc je ne suis pas sûr du coût exact de ces opérations, mais je suis sûr qu'il n'est pas nul et que les mêmes problèmes peuvent se produire sur d'autres architectures RISC.

Une norme de langage interdit-elle d’éviter l’encapsulage des variables s’il est prévu par l’architecture?

Si vous regardez, cet argument est particulièrement faible. Son essence est que la norme ne permet à l'implémentation (compilateur) d'interpréter le «comportement indéfini» que dans une mesure limitée. Dans le texte de la norme elle-même - dans ce fragment auquel les défenseurs du transfert font appel - ce qui suit est dit (cela fait partie de la définition du terme «comportement indéfini»):

REMARQUE: Un comportement indéfini peut prendre la forme d'ignorer complètement la situation, tandis que le résultat sera imprévisible, ...

L'idée est que les mots «ignorant complètement la situation» ne suggèrent pas qu'un événement conduisant à un comportement indéfini - par exemple, un débordement lors de l'ajout - ne peut pas se produire, mais plutôt que si c'est le cas, le compilateur devrait continuer à fonctionner comme s'il était dans que jamais, mais tenez également compte du résultat qui se produira s'il envoie au processeur une demande pour effectuer une telle opération (en d'autres termes, comme si le code source était traduit dans le code machine de manière simple et naïve).

Tout d'abord, il convient de noter que ce texte est donné en tant que «note», et n'est donc pas normatif (c'est-à-dire qu'il ne peut pas prescrire quelque chose), selon la directive ISO mentionnée dans l'introduction de la norme:

Conformément à la partie 3 des directives ISO / CEI, cette préface, introduction au texte, notes, notes de bas de page et exemples est également à titre informatif uniquement.

Puisque ce passage «comportement indéfini» est une note, il ne prescrit rien. Veuillez noter que la définition actuelle de «comportement indéfini» est:

comportement résultant de l'utilisation d'une conception logicielle intolérable ou incorrecte ou de données incorrectes, à laquelle la présente Norme internationale n'impose aucune exigence .

J'ai souligné l'idée principale: aucune exigence n'est imposée pour un comportement indéfini; la liste des «types possibles de comportement indéfini» dans la note ne contient que des exemples et ne peut pas être la prescription finale. L'expression «ne fait aucune demande» ne peut être interprétée autrement.

Certains, développant cet argument, soutiennent que, quel que soit le texte, le comité de langue, lorsqu'il a formulé ces mots, signifiait que le comportement dans son ensemble devrait correspondre à l'architecture du matériel sur lequel le programme s'exécute, autant que possible, impliquant une traduction naïve en code machine. Cela peut être vrai, même si je n'ai vu aucune preuve (par exemple, des documents historiques) à l'appui de cet argument. Cependant, même s'il en était ainsi, ce n'est pas un fait que cette déclaration s'applique à la version actuelle du texte.

Dernières pensées

Les arguments en faveur du transfert sont largement intenables. Peut-être l'argument le plus fort est-il obtenu si nous les combinons: les programmeurs moins expérimentés (qui ne connaissent pas les subtilités du langage C et le comportement indéfini qu'il contient) s'attendent parfois à un transfert, et cela ne réduit pas les performances - bien que ce dernier ne soit pas vrai dans tous les cas, et la première partie n'est pas concluante si vous le considérez séparément.

Personnellement, je préférerais que les débordements soient bloqués (piégeage) plutôt que d'envelopper. Autrement dit, de sorte que le programme se bloque et ne continue pas à fonctionner - avec un comportement incertain ou des résultats potentiellement incorrects, car dans les deux cas, une vulnérabilité apparaît. Une telle solution, bien sûr, réduira légèrement les performances sur la plupart des (?) Architectures, en particulier sur x86, mais, d'autre part, les erreurs de débordement seront immédiatement identifiées et elles ne pourront pas tirer parti ou obtenir des résultats incorrects en les utilisant en cours de route. programmes. De plus, en théorie, les compilateurs avec cette approche pourraient supprimer en toute sécurité les contrôles de débordement redondants, car cela ne se produira certainement pas, bien que, comme je le vois, ni Clang ni GCC ne saisissent cette occasion.

Heureusement, à la fois l'interruption et le portage sont implémentés dans le compilateur que j'utilise le plus souvent est GCC. Pour basculer entre les modes, les arguments de ligne de commande -ftrapv et -fwrapv sont respectivement utilisés.

Bien sûr, il existe de nombreuses actions conduisant à un comportement indéfini - le débordement d'entier n'est que l'une d'entre elles. Je ne pense pas du tout qu'il soit utile d'interpréter tous ces cas comme un comportement indéfini, et je suis sûr qu'il existe de nombreuses situations spécifiques où la sémantique doit être déterminée par le langage ou, du moins, être laissée à la discrétion des implémentations. Et j'ai peur des interprétations trop libres de ce concept par les fabricants de compilateurs: si le comportement du compilateur ne répond pas aux idées intuitives des développeurs, en particulier de ceux qui lisent personnellement le texte de la norme, cela peut conduire à de vraies erreurs; si le gain de performance dans ce cas est négligeable, il vaut mieux abandonner de telles interprétations. Dans l'un des articles suivants, j'examinerai probablement certains de ces problèmes.

Supplément (daté du 24 août 2018)

J'ai réalisé qu'une grande partie de ce qui précède pourrait être mieux écrite. Ci-dessous, je résume et explique brièvement mes paroles et j'ajoute quelques remarques mineures:

  • Je n'ai pas soutenu qu'un comportement indéfini est préférable à un débordement - plutôt que, dans la pratique, le transfert n'est pas beaucoup mieux qu'un comportement indéfini. En particulier, des problèmes de sécurité peuvent être obtenus dans le premier cas, et dans le second - et je parie que de nombreuses vulnérabilités causées par des débordements qui n'ont pas été détectées à temps (à l'exception de celles pour lesquelles le compilateur est responsable de la suppression des vérifications erronées) provenaient en fait de - en raison du transfert du résultat, mais pas en raison d'un comportement indéfini associé au débordement.
  • Le seul véritable avantage du transfert est que les contrôles de dépassement de capacité ne sont pas supprimés. Bien que de cette façon, vous puissiez protéger le code de certains scénarios d'attaque, il est probable que certains débordements ne seront pas vérifiés du tout (c'est-à-dire que le programmeur oubliera d'ajouter une telle vérification) et passera inaperçu.
  • Si le problème de sécurité n'est pas si important et que la vitesse élevée du programme apparaît, alors un comportement indéfini donnera une optimisation plus rentable et une augmentation plus importante de la productivité, au moins dans certains cas. D'un autre côté, si la sécurité passe avant tout, le portage est lourd de vulnérabilités.
  • Cela signifie que si vous choisissez entre interruption, transfert et comportement indéfini, il y a très peu de tâches dans lesquelles le transfert peut être utile.
  • En ce qui concerne les vérifications du débordement qui s'est produit, je pense que les laisser est nocif, car cela crée la fausse impression qu'elles fonctionnent et fonctionneront toujours. L'interruption des débordements évite ce problème; avertissements adéquats - atténuez-le.
  • Je pense que tout développeur qui écrit du code critique pour la sécurité devrait idéalement avoir une bonne maîtrise de la sémantique du langage dans lequel il écrit, et être conscient de ses pièges. Pour C, cela signifie que vous devez connaître la sémantique du débordement et les subtilités d'un comportement indéfini. Il est triste que certains programmeurs n'aient pas atteint ce niveau.
  • J'ai découvert que «la plupart des programmeurs C s'attendent à ce que la migration soit le comportement par défaut», mais je n'en connais pas les preuves. (Dans l'article, j'ai écrit «quelques programmeurs», parce que je connais plusieurs exemples de la vie réelle, et en général je doute que quiconque conteste cela).
  • Il y a deux problèmes différents: ce que la norme de langage C requiert et ce que les compilateurs doivent implémenter. J'aime (généralement) la façon dont la norme définit le comportement de débordement non défini. Dans cet article, je parle de ce que les compilateurs devraient faire.
  • Lorsque le débordement est interrompu, il n'est pas nécessaire de vérifier chaque opération pour cela. Idéalement, le programme avec cette approche se comporte de manière cohérente en termes de règles mathématiques ou cesse de fonctionner. Dans ce cas, l'existence d'un «débordement temporaire» devient possible, ce qui n'entraîne pas l'apparition d'un résultat incorrect. Ensuite, l'expression a + b - b et l'expression (a * b) / b peuvent être optimisées en a (la première est également possible pendant le transfert, mais la seconde n'est plus présente).

Remarque Une traduction de l'article est publiée sur le blog avec la permission de l'auteur. Texte original: Davin McCall " Envelopper le débordement d'entier n'est pas une bonne idée ".

Liens connexes supplémentaires de l'équipe PVS-Studio:

  1. Andrey Karpov. Un comportement indéfini est plus proche que vous ne le pensez .
  2. Will Dietz, Peng Li, John Regehr et Vikram Adve. Comprendre le dépassement d'entier en C / C ++ .
  3. V1026. La variable est incrémentée dans la boucle. Un comportement non défini se produira en cas de dépassement d'entier signé .
  4. Stackoverflow Le dépassement d'entier signé est-il toujours un comportement non défini en C ++?

Source: https://habr.com/ru/post/fr427683/


All Articles