J'ai récemment lu que le débordement d'entier signé en C et C++ provoque un comportement indéfini:
Si, lors de l'évaluation d'une expression, le résultat n'est pas défini mathématiquement ou n'est pas dans la plage de valeurs représentables pour son type, le comportement n'est pas défini.
J'essaie actuellement de comprendre la raison du comportement indéfini ici. Je pensais qu'un comportement indéfini se produit ici parce que l'entier commence à manipuler la mémoire autour d'elle lorsqu'elle devient trop grande pour s'adapter au type sous-jacent.
J'ai donc décidé d'écrire un petit programme de test dans Visual Studio 2015 pour tester cette théorie avec le code suivant:
#include <stdio.h>
#include <limits.h>
struct TestStruct
{
char pad1[50];
int testVal;
char pad2[50];
};
int main()
{
TestStruct test;
memset(&test, 0, sizeof(test));
for (test.testVal = 0; ; test.testVal++)
{
if (test.testVal == INT_MAX)
printf("Overflowing\r\n");
}
return 0;
}
J'ai utilisé une structure ici pour éviter tout problème de protection de Visual Studio en mode débogage, comme le remplissage temporaire des variables de pile, etc. La boucle sans fin devrait provoquer plusieurs débordements de test.testVal
, et c'est effectivement le cas, mais sans autre conséquence que le débordement lui-même.
J'ai jeté un œil au vidage de la mémoire lors de l'exécution des tests de débordement avec le résultat suivant (test.testVal
avait une adresse mémoire de 0x001CFAFC
):
0x001CFAE5 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x001CFAFC 94 53 ca d8 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Comme vous le voyez, la mémoire autour de l'int qui déborde en permanence est restée "intacte". J'ai testé cela plusieurs fois avec une sortie similaire. Jamais de mémoire autour du débordement int endommagé.
Que se passe t-il ici? Pourquoi la mémoire n'est-elle pas endommagée autour de la variable test.testVal
? Comment cela peut-il provoquer un comportement indéfini?
J'essaie de comprendre mon erreur et pourquoi il n'y a pas de corruption de mémoire lors d'un débordement d'entier.
Vous comprenez mal la raison d'un comportement indéfini. La raison n'est pas la corruption de la mémoire autour de l'entier - il occupera toujours la même taille que les entiers - mais l'arithmétique sous-jacente.
Étant donné qu'il n'est pas nécessaire que les entiers signés soient codés en complément de 2, il ne peut pas y avoir d'indications spécifiques sur ce qui va se passer lorsqu'ils débordent. Un codage ou un comportement du processeur différents peuvent entraîner des résultats différents de dépassement de capacité, y compris, par exemple, des interruptions de programme dues à des interruptions.
Et comme pour tout comportement indéfini, même si votre matériel utilise le complément de 2 pour son arithmétique et a défini des règles de débordement, les compilateurs ne sont pas liés par eux. Par exemple, pendant longtemps, GCC a optimisé toutes les vérifications qui ne se réaliseraient que dans un environnement à complément à 2. Par exemple, if (x > x + 1) f()
va être supprimé du code optimisé, car le débordement signé est un comportement non défini, ce qui signifie qu'il ne se produit jamais (du point de vue du compilateur, les programmes ne contiennent jamais de code produisant un comportement non défini), ce qui signifie x
ne peut jamais être supérieur à x + 1
.
Les auteurs de la norme ont laissé le débordement d'entier indéfini car certaines plates-formes matérielles peuvent intercepter de manière dont les conséquences pourraient être imprévisibles (y compris éventuellement l'exécution de code aléatoire et la corruption de mémoire qui en résulte). Bien que le matériel à deux compléments avec une gestion prévisible des débordements enveloppants était à peu près établi en tant que norme au moment de la publication de la norme C89 (parmi les nombreuses architectures de micro-ordinateurs reprogrammables que j'ai examinées, aucune autre utilisation), les auteurs de la norme ne voulait empêcher personne de produire des implémentations C sur des machines plus anciennes.
Sur les implémentations qui ont implémenté une sémantique enveloppante silencieuse complémentaire à deux, du code comme
int test(int x)
{
int temp = (x==INT_MAX);
if (x+1 <= 23) temp+=2;
return temp;
}
100% de manière fiable, renverrait 3 une fois passé une valeur de INT_MAX, car l'ajout de 1 à INT_MAX donnerait INT_MIN, qui est bien sûr inférieur à 23.
Dans les années 1990, les compilateurs ont utilisé le fait que le débordement d'entier était un comportement indéfini, plutôt que d'être défini comme un habillage à complément à deux, pour permettre diverses optimisations, ce qui signifiait que les résultats exacts des calculs qui débordaient ne seraient pas prévisibles, mais des aspects du comportement qui n'étaient pas 'dépend pas des résultats exacts resterait sur les rails. Un compilateur des années 1990, étant donné le code ci-dessus, pourrait probablement le traiter comme si l'ajout de 1 à INT_MAX donnait une valeur numériquement supérieure à INT_MAX, provoquant ainsi la fonction à renvoyer 1 plutôt que 3, ou il pouvait se comporter comme les anciens compilateurs, produisant 3. Remarque que dans le code ci-dessus, un tel traitement pourrait enregistrer une instruction sur de nombreuses plates-formes, car (x + 1 <= 23) serait équivalent à (x <= 22). Un compilateur peut ne pas être cohérent dans son choix de 1 ou 3, mais le code généré ne ferait rien d'autre que donner l'une de ces valeurs.
Depuis lors, cependant, il est devenu plus à la mode pour les compilateurs d'utiliser l'échec de la norme pour imposer des exigences sur le comportement du programme en cas de dépassement d'entier (un échec motivé par l'existence de matériel dont les conséquences pourraient être véritablement imprévisibles) pour justifier d'avoir des compilateurs lancer le code complètement hors du Rails en cas de débordement. Un compilateur moderne pourrait remarquer que le programme invoquera un comportement indéfini si x == INT_MAX, et ainsi conclure que la fonction ne recevra jamais cette valeur . Si la fonction ne reçoit jamais cette valeur, la comparaison avec INT_MAX peut être omise. Si la fonction ci-dessus a été appelée à partir d'une autre unité de traduction avec x == INT_MAX, elle peut donc renvoyer 0 ou 2; si elle est appelée à partir de la même unité de traduction. , l'effet pourrait être encore plus bizarre car un compilateur étendrait ses inférences sur x à l'appelant.
Quant à savoir si un débordement entraînerait une corruption de la mémoire, il pourrait en avoir sur certains anciens matériels. Sur les anciens compilateurs fonctionnant sur du matériel moderne, ce ne sera pas le cas. Sur les compilateurs hyper-modernes, le débordement annule le tissu du temps et de la causalité, donc tous les paris sont désactivés. Le débordement dans l'évaluation de x + 1 pourrait effectivement corrompre la valeur de x qui avait été vue par la comparaison précédente avec INT_MAX, en faisant comme si la valeur de x en mémoire avait été corrompue. En outre, un tel comportement du compilateur supprimera souvent la logique conditionnelle qui aurait empêché d'autres types de corruption de mémoire, permettant ainsi à une corruption de mémoire arbitraire de se produire.
Le comportement de dépassement d'entier n'est pas défini par la norme C++. Cela signifie que toute implémentation de C++ est libre de faire ce qu'elle veut.
En pratique, cela signifie: ce qui est le plus pratique pour l'implémenteur. Et comme la plupart des implémenteurs traitent int
comme une valeur à deux compléments, l'implémentation la plus courante de nos jours est de dire qu'une somme débordante de deux nombres positifs est un nombre négatif qui a une certaine relation avec le vrai résultat. Il s'agit d'une mauvaise réponse et elle est autorisée par la norme, car la norme autorise tout.
Il y a un argument pour dire que le débordement d'entier doit être traité comme une erreur , tout comme la division d'entier par zéro. L'architecture '86 a même l'instruction INTO
pour déclencher une exception en cas de débordement. À un moment donné, cet argument peut gagner suffisamment de poids pour en faire des compilateurs traditionnels, auquel cas un débordement d'entier peut provoquer un crash. Cela est également conforme à la norme C++, qui permet à une implémentation de faire n'importe quoi.
Vous pourriez imaginer une architecture dans laquelle les nombres étaient représentés comme des chaînes à terminaison nulle de façon peu endienne, avec un octet zéro disant "fin de nombre". L'addition peut être effectuée en ajoutant octet par octet jusqu'à ce qu'un zéro octet soit atteint. Dans une telle architecture, un débordement d'entier pourrait remplacer un zéro à la suite par un un, ce qui donnerait au résultat une apparence bien plus longue et potentiellement corrompue à l'avenir. Cela est également conforme à la norme C++.
Enfin, comme indiqué dans certaines autres réponses, une grande partie de la génération et de l'optimisation du code dépend du raisonnement du compilateur sur le code qu'il génère et comment il s'exécuterait. Dans le cas d'un débordement d'entier, il est entièrement licite pour le compilateur (a) de générer du code pour l'addition qui donne des résultats négatifs lors de l'ajout de grands nombres positifs et (b) d'informer sa génération de code en sachant que l'ajout de grands nombres positifs donne un résultat positif. Ainsi par exemple
if (a+b>0) x=a+b;
pourrait, si le compilateur sait que a
et b
sont positifs, pas la peine d'effectuer un test, mais sans condition d'ajouter a
à b
et de mettre le résultat dans x
. Sur une machine à deux compléments, cela pourrait conduire à mettre une valeur négative dans x
, en violation apparente de l'intention du code. Ce serait tout à fait conforme à la norme.
Le comportement indéfini n'est pas défini. Cela peut planter votre programme. Cela peut ne rien faire du tout. Il peut faire exactement ce que vous attendiez. Il peut invoquer des démons nasaux. Il peut supprimer tous vos fichiers. Le compilateur est libre d'émettre le code qu'il souhaite (ou pas du tout) lorsqu'il rencontre un comportement indéfini.
Toute instance de comportement indéfini fait que le programme entier n'est pas défini - pas seulement l'opération qui n'est pas définie, donc le compilateur peut faire ce qu'il veut pour n'importe quelle partie de votre programme. Y compris le voyage dans le temps: n comportement indéfini peut entraîner un voyage dans le temps (entre autres, mais le voyage dans le temps est le plus amusant).
Il existe de nombreuses réponses et articles de blog sur un comportement indéfini, mais voici mes favoris. Je vous suggère de les lire si vous souhaitez en savoir plus sur le sujet.
En plus des conséquences de l'optimisation ésotérique, vous devez prendre en compte d'autres problèmes, même avec le code que vous attendez naïvement qu'un compilateur non optimisant génère.
Même si vous savez que l'architecture est complémentaire (ou autre), une opération débordée peut ne pas définir d'indicateurs comme prévu, donc une instruction comme if(a + b < 0)
peut prendre la mauvaise branche: étant donné deux grands nombres positifs, donc quand additionné, il déborde et le résultat, de sorte que les puristes à deux compléments affirment, est négatif, mais l'instruction d'addition peut ne pas réellement définir le drapeau négatif)
Une opération en plusieurs étapes peut avoir eu lieu dans un registre plus large que sizeof (int), sans être tronquée à chaque étape, et donc une expression comme (x << 5) >> 5
peut ne pas couper les cinq bits de gauche comme vous le supposez.
Les opérations de multiplication et de division peuvent utiliser un registre secondaire pour les bits supplémentaires dans le produit et le dividende. Si la multiplication "ne peut pas" déborder, le compilateur est libre de supposer que le registre secondaire est nul (ou -1 pour les produits négatifs) et de ne pas le réinitialiser avant la division. Donc, une expression comme x * y / z
peut utiliser un produit intermédiaire plus large que prévu.
Certains de ces sons ressemblent à une précision supplémentaire, mais c'est une précision supplémentaire qui n'est pas attendue, ne peut être prédite ni invoquée, et viole votre modèle mental de "chaque opération accepte les opérandes à deux compléments de N bits et renvoie le N le moins significatif" bits du résultat pour l'opération suivante "
Il n'est pas défini quelle valeur est représentée par le int
. Il n'y a pas de "débordement" dans la mémoire comme vous le pensiez.