Une affectation en hexadécimal en C

Affectation de la valeur -2147483648 à un int impossible en hexadécimal ?

Le problème exposé dans ce sujet a été résolu.

Salut à tous,

Ma problématique est très simple : j’essaye actuellement d’entrer la valeur -2147483648 dans un int en héxadécimal. Je précise que ce nombre est la valeur mathématique minimale représentable dans les int sur ma machine (mes int font 4 octets, et les nombres négatifs sont représentés par complément à deux).

Voici donc le code que je propose : int a = -0x80000000;. Et voilà ! Maintenant viennent 2 questions.

PREMIERE QUESTION

Si je me fie au cours, voici comment se passe la conversion :

  • On a une constante hexadécimale, dont la valeur (2147483648) n’est pas représentable dans un int : elle sera représentée dans un unsigned int (voir le cours : "Les opérations mathématiques").
  • On a un moins devant cette valeur : on applique le complément à deux : on retombe sur la même valeur.

Voilà donc mon premier problème : on a une conversion de unsigned int vers int, et qui-plus-est d’une valeur non représentable dans un int, vers un int ! Cela me semble très peu "propre", qu’en pensez-vous ? Sur ma machine le code fonctionne mais je me demande si ce n’est pas un "coup de chance"…

SECONDE QUESTION

J’ai donc voulu savoir si le cas que j’étudie était couvert par la norme. Page 68 (du pdf) du draft de la norme proposé par le cours, je lis :

6.3.1.3 Signed and unsigned integers 1 When a value with integer type is converted to another integer type other than _Bool, if the value can be represented by the new type, it is unchanged. 2 Otherwise, if the new type is unsigned, the value is converted by repeatedly adding or subtracting one more than the maximum value that can be represented in the new type until the value is in the range of the new type.60) 3 Otherwise, the new type is signed and the value cannot be represented in it; either the result is implementation-defined or an implementation-defined signal is raised.

Et cela me fait un peu peur, car il semble que nous soyons dans le troisième cas : comportement indéfini ? Il serait donc en théorie impossible de faire mon affectation en hexadécimal ? Je n’ose croire ce que je déduis… Me trompe-je ? Je vous serais très reconnaissant de m’éclairer un peu :)

Bonne soirée à tous

+0 -0

Salut,

Il n’y a pas de comportement indéfini ici, par contre c’est un comportement qui dépend de l’implémentation.

  • Tu as une constante entière de type unsigned int avec pour valeur 2147483648 (0x80000000), celle-ci est représentable dans un unsigned int, pas de problème.
  • Ensuite, tu appliques la négation, qui doit donner un résultat non signé. Plus précisément, tu essayes de stocker -2147483648 dans un unsigned int. Dans un tel cas, la règle de l’alinéa 2 du paragraphe que tu cites s’applique : on ajoute le maximum du type plus un jusqu’à ce que cela soit représentable. Ici, on fait donc -2147483648 + (4294967295 + 1), ce qui donne 2147483648, on est revenu au point de départ.
  • Finalement, tu essayes de convertir 2147483648 vers le type int, qui est trop petit pour l’accueillir, c’est un comportement dépendant de l’implémentation (troisième alinéa du paragraphe que tu cites). Le plus souvent, comme dans ce cas-ci, la valeur boucle, tu te retrouves donc avec le minimum du type, qui était le résultat que tu attendais, par « chance » en effet.
+2 -0

@Taurre Je comprends, merci pour la réponse, elle soulève cependant des zones d’ombre pour moi :

  • Je pensais que le "-" ne faisait que "forcer" un bête complément à deux sur la valeur à laquelle on l’appliquait et qu’il ne fallait pas chercher plus loin. En l’occurrence, tu me dis qu’il faut d’abord voir si l’opposé de la valeur est représentable, puis appliquer les règles vues plus haut. Donc si je comprends bien, "dans notre dos", le compilateur inverse la valeur "correctement" et ensuite il essaye de stocker cette nouvelle valeur dans le bon type ? Ou est-ce juste une façon de se représenter les choses ? Mais alors que fait vraiment le compilateur quand il doit faire la négation d’un entier finalement ?

  • As-tu des références sur ceci ? J’ai du mal à trouver le passage correspondant dans la norme…

  • Si je comprends bien la fin de ton message, il n’y a donc aucun moyen "sûr" d’affecter ma valeur à un int en hexadécimal ?

  • Quelle est la différence entre un comportement indéfini et un comportement dépendant de l’implémentation ? Bêtement, je vois un "comportement dépendant de l’implémentation", comme un comportement qui dépend du choix des concepteurs du compilateur ? Mais du coup ce n’est pas spécifié par la norme et donc… Ce comportement serait indéfini par la norme ? (je ne vois donc pas la diff entre comportement indéfini et dépendant de l’implémentation)

Salut,

Quelle est la différence entre un comportement indéfini et un comportement dépendant de l’implémentation ? Bêtement, je vois un "comportement dépendant de l’implémentation", comme un comportement qui dépend du choix des concepteurs du compilateur ? Mais du coup ce n’est pas spécifié par la norme et donc… Ce comportement serait indéfini par la norme ? (je ne vois donc pas la diff entre comportement indéfini et dépendant de l’implémentation)

La différence est en fait très grande. Dans le cas d’un comportement défini par l’implémentation, le comportement est défini par les développeurs du compilo et se doit d’être prévisible. En disant que quelque chose est implementation defined, la norme ne demande pas un comportement particulier qu’elle impose, mais elle demande à ce que le comportement soit prévisible et bien défini par les concepteurs de l’implémentation. En d’autre terme, un programme qui exploite un comportement défini par l’implémentation reste un programme valide (i.e. il a du sens), et les implémentations sérieuses ne vont pas changer ce comportement du jour au lendemain.

Dans le cas d’un comportement indéfini, les concepteurs du compilo ont le droit de faire ce qu’ils veulent, et en particulier de partir du principe que les comportements indéfinis ne se produisent pas dans un programme valide (il y a en pratique énormément d’optimisations qui reposent là-dessus). Ça veut dire qu’un programme qui repose sur un undefined behavior (UB) n’est pas un programme valide et il n’y a aucune garantie sur son comportement même en amont de l’UB parce que le compilo peut faire ce qu’il veut d’une branche qui mène à un UB. Ça veut dire aussi que le comportement effectivement observé pour un programme avec un UB va dépendre de la version du compilateur, du hardware sur lequel il tourne, des options de compilation, et potentiellement d’autres choses. Bref, c’est un bug en puissance très embêtant parce que son comportement est potentiellement difficilement reproductible, et en plus il a une machine à remonter le temps comme il peut affecter les branches qui mène à lui.

Pour comprendre comment un UB peut affecter du code en amont, voici un exemple très simple :

int f(int* a, int b) {
    if (a == NULL) {
        printf("b == %d\n", b);
    }
    return *a; // UB potentiel si a est NULL
}

int main() {
    int* a = NULL;
    int b = 4;
    f(a, b);
}

Si on exécute ce programme en l’état, la ligne 5 déréférence un pointeur NULL, ce qui est un UB. En pratique, si le déréférencement se produit effectivement, l’OS va se charger d’émettre une SEGFAULT et le programme plante. Là où ça devient rigolo, c’est que le compilateur a le droit de partir du principe que la ligne 5 est tout le temps valide, et donc que a est non NULL, et donc virer le bloc if complètement. Il est donc possible d’exécuter ce programme et qu’il plante sur la ligne 5 sans avoir affiché la valeur de b au préalable.

Un peu plus subtil, le compilateur a aussi le droit de voir qu’on ne se sert pas du retour de f de toute façon et virer le return parce qu’il part du principe qu’il est valide et donc que l’avoir ou l’enlever ne change pas le comportement du programme. Ton code est en fait invalide, mais il se met à tomber en marche parce que le compilo vire la partie problématique (c’est ce qui se passe avec GCC et clang sur ma machine). Tu te retrouves avec un code qui plante lorsque compilé sans optimisation, et qui marche par accident lorsque compilé avec optimisations.

Sur ce cas simple, on voit rapidement le problème, quelque soit les choix fait par le compilo. Sur des cas réalistes plus compliqués, c’est un bon moyen de perdre beaucoup de temps en cherchant le problème au mauvais endroit.

+0 -0

@adri1 Passionnant. Quel genre de métier dans l’industrie nécessite de se pencher sur ces considérations (en dehors des développeurs de compilateurs eux-même) ? Est-ce qu’on étudie ce genre de choses à la fac ou sont-ce des choses que l’on creuse pour soi-même ? Si tu as des références je les noterais volontiers 😇

Je fais des simulations numériques sur super-calculateurs. J’ai pas étudié l’info en tant que telle à la fac (formation en physique plutôt), mais de facto on doit s’y intéresser pour pas faire n’importe quoi en terme de performances et aussi de développement logiciel (la quantité de dette technique sur la plupart des codes de physiciens fait vite suer :-° ). Et bien sûr, comme on est coincés sur des langages mal fichus comme C, C++, ou Fortran, pour des raisons historiques, de perfs, et de disponibilités des libs de calculs, on est obligé de se cogner les normes et faire la chasse aux UBs si on veut pas faire n’importe quoi. J’imagine que n’importe quel métier qui demande d’écrire du code non trivial dans ces langages va demander de connaitre ces questions un minimum.

+1 -0
  • (1) Je pensais que le "-" ne faisait que "forcer" un bête complément à deux sur la valeur à laquelle on l’appliquait. (2) En l’occurrence, tu me dis qu’il faut d’abord voir si l’opposé de la valeur est représentable, puis appliquer les règles vues plus haut. Donc si je comprends bien, "dans notre dos", le compilateur inverse la valeur "correctement" et ensuite il essaye de stocker cette nouvelle valeur dans le bon type ? Ou est-ce juste une façon de se représenter les choses ? Mais alors que fait vraiment le compilateur quand il doit faire la négation d’un entier finalement ?

(1) La norme n’impose pas le complément à deux comme représentation ;)

(2) En l’occurrence, c’est beaucoup plus simple que ça : il s’avère que le compilateur n’introduit rien qui ait besoin de ce paquet de conditions, il utilise l’instruction idoine du processeur qui a le bon goût de faire ce qu’il faut quand on est dans les bornes et d’avoir un comportement explicable facilement quand on y est pas. Et quand il veut faire les choses à compile time, il se contente de simuler ce comportement.

As-tu des références sur ceci ? J’ai du mal à trouver le passage correspondant dans la norme…

Dans C11: 6.3.1.3.

  • Si je comprends bien la fin de ton message, il n’y a donc aucun moyen "sûr" d’affecter ma valeur à un int en hexadécimal ?

Le moyen que tu as utilisé est sûr sur ton architecture cible (et c’est le comportement des architectures les plus communes et de loin). Et il y a même plus simple:

int a = 0x80000000 ;
  • Quelle est la différence entre un comportement indéfini et un comportement dépendant de l’implémentation ?

Sinon pour compléter la réponse d'@adri1, il y a aussi les unspecified behaviors. Qui sont en gros des comportements implementation-defined qui ne sont pas tenus d’être documentés par l’implémentation et pour lesquels la norme fournit plusieurs alternatives.

Est-ce qu’on étudie ce genre de choses à la fac ou sont-ce des choses que l’on creuse pour soi-même ? Si tu as des références je les noterais volontiers 😇

Généralement les facs ne creusent pas ce genre de sujets qui sont beaucoup trop spécifiques des langages et ne présente aucun intérêt pédagogique vu que la réponse à "pourquoi on a fait comme ça ?" oscille très souvent quelque part entre :

  • on croyait que c’était plus facile de faire comme ça,
  • on n’avait pas fait attention à tous les problèmes que ça allait engendrer,
  • l’industriel X faisait comme ça, et avait bien envie de faire chier l’industriel Y qui ne faisait pas pareil.

Bref, c’est pas franchement glorieux. Et le résultat c’est que lorsque l’on a besoin de savoir ce que doit réellement faire une opération dans l’un des multiples corner-cases du langage:

  • la forme de la norme est inefficace (l’information est dispersée au possible),
  • un manuel écrit en anglais est par nature imprécis (et les modèles mathématiques de la norme ne sont pas officiels).

Quel genre de métier dans l’industrie nécessite de se pencher sur ces considérations (en dehors des développeurs de compilateurs eux-même) ?

En vrai ou en pratique ? Parce qu’en vrai à partir du moment où tu utilises C pour produire du soft, tu ne devrais jamais ignorer ce genre de choses, puisque tout ce qui a trait aux undefined behaviors a tout le potentiel nécessaire pour provoquer des catastrophes. En pratique …

Si les négatifs ne sont pas stockés en complément à 2. On peut écrire : int int_min = -2147483647 - 1; qui ne passe pas par des conversions signed/unsigned. Mais il me semble que la plage minimale garantie dans un int, c’est [-32767,+32767].

Le plus portable pour avoir le min s’écrit tout simplement: int int_min = INT_MIN;.

Puisque le sujet a l’air résolu, j’en profite pour pousser une question à moitié en rapport avec celui-ci :

Est-ce que les comportements indéfinis ou variables selon l’implémentation sont importants en C ? J’ai l’impression qu’une grande partie de la complexité de ce langage vient de ces subtilités. On pourrait imaginer (on peut tout imaginer) qu’une future version du langage (ou un langage parallèle) s’en débarrasse en normalisant ces comportements. Au-delà de l’exercice intellectuel, est-ce que ça aurait un intérêt ? Une chance quelconque d’arriver et d’être utilisé ? Est-ce que ça casserait des fonctionnalités qui sont aujourd’hui indispensables ?

Ça existe, ça s’appelle Rust :) . Je ne blague qu’à moitié : faire de tels changements dans C nécessiterait de toute façon une refonte tellement profonde des compilos que la compatibilité ne pourrait être conservée nulle part ou presque. Du code qui fonctionne aujourd’hui avec un certain niveau de performances aujourd’hui ne pourrait certainement pas continuer à le faire si on supprimait les UBs à cause du coût des vérifications dynamiques.

La présence d’UBs n’est pas vraiment un problème. De toute façon, il ne faut pas en avoir dans son code. Le problème, c’est que les conditions qui amènent à des UBs sont pénibles à traquer pour de mauvaises raisons. Par exemple, le mécanisme des promotions implicites rend tout ça particulièrement peu logique à travers d’autres raisons que celles que j’ai listé plus haut. Les conditions de validité des opérations sur les pointeurs (pas la mémoire pointée ou pas, juste leur lecture, leur comparaison, etc) sont merdiques. Bref, c’est chiant de bien tout couvrir.

Mais avoir un comportement défini pour tout c’est pas forcément une solution puisqu’au bout du compte il faudra de toute façon assurer l’absence de ces comportements quand même si on veut que le programme ait le comportement qu’on a spécifié.

C’est utile d’avoir des UBs dans la norme, ça donne plein de possibilités d’optimisation, ça donne la liberté aux implémentations de pouvoir faire des choix (et changer ces choix plus tard), ça évite d’avoir des contrôles supplémentaires de partout, etc. Oui, on peut faire sans, mais oui, on le paie assez cher en compensation à l’exécution. Dans certain cas d’utilisation le coût devient rapidement inacceptable.

Ceci dit si on pouvait brancher un vrai analyseur statique (interpréteurs abstraits, preuve de programmes, …) au cul d’un compilateur Rust, on s’offrirait la possibilité de faire dégager tous les contrôles qui ne sont pas possibles à virer trivialement.

Problème actuellement avec Rust, la seule documentation réelle de l’ensemble du langage, c’est son implémentation, il n’y a pas de réel document de référence qui couvre tout et qui fait loi sur ce que chaque opération doit faire.

@dalfab la constante INT_MIN c’est le minimum garanti par la norme, ou c’est ce que mon processeur va utiliser et qui est détecté à la compilation ? (auquel cas ce ne serait pas du tout portable ?)

@SpaceFox je pense que la réponse de @jo_link_noir est un élément allant dans le sens que le C "essaye" de s’uniformiser, mais après il y a toujours l’inertie de l’industrie et les coûts qu’il y aurait à tout recoder proprement lol La technologie c’est comme la bourse… Bien malin celui qui pourrait prédire quelle technologie va l’emporter sur ses concurrentes

INT_MIN, est défini comme correspondant à la plus petite valeur dans un int, alors en fonction du compilateur si on est dans le cas du complément à 2 (dépend du processeur) elle peut valoir -32768, -2147483648 ou -9223372036854775808. C’est garanti, donc c’est portable.

Après 50 ans, la norme C indique que les négatifs sont désormais forcément en complément à 2. S’ils ne l’ont pas fait avant, c’est parce que certains processeurs utilisaient d’autre moyens. Vraisemblablement tous les processeurs actuels utilisent le complément à 2, le code C écrit peut utiliser cela, et la norme ne fait que poser un fait établi.

Etre portable en C, c’est rester compatible avec la quasi totalité des processeurs. D’autre langages comme Rust s’écartent de cette capacité en prenant un risque. Par exemple le processeur MSP320 ne gère que des données de taille 16 bits, donc sizeof(char)=sizeof(short)=sizeof(int)=1, on peut utiliser long de 4 octets mais il est loin d’être optimal. C’est quasi-impossible d’y mettre un code Rust (u8 n’existe pas et u32 est non optimal). C’est un choix.

Est-ce que les comportements indéfinis ou variables selon l’implémentation sont importants en C ? J’ai l’impression qu’une grande partie de la complexité de ce langage vient de ces subtilités. On pourrait imaginer (on peut tout imaginer) qu’une future version du langage (ou un langage parallèle) s’en débarrasse en normalisant ces comportements. Au-delà de l’exercice intellectuel, est-ce que ça aurait un intérêt ? Une chance quelconque d’arriver et d’être utilisé ? Est-ce que ça casserait des fonctionnalités qui sont aujourd’hui indispensables ?

SpaceFox

Un changement à ce niveau n’arrivera définitivement jamais, notamment au vu de comment la normalisation fonctionne. La normalisation est aux mains des implémentations et rien qui ne leur demande trop de travail ou ne leur pose le moindre problème trop ennuyeux ne passera. C’est en un sens logique parce que sont elles qui doivent se taper le boulot, mais également induit une énorme inertie, parce qu’il y a énormément de dettes techniques et des implémentations très spécifiques qui empêchent des progrès. C’est très bien expliqué par un membre du comité de normalisation dans cet article (en anglais).

Le C restera mal ou heureusement peu ou prou ce qu’il est aujourd’hui, jusqu’à sa fin.

Je pensais que le "-" ne faisait que "forcer" un bête complément à deux sur la valeur à laquelle on l’appliquait et qu’il ne fallait pas chercher plus loin. En l’occurrence, tu me dis qu’il faut d’abord voir si l’opposé de la valeur est représentable, puis appliquer les règles vues plus haut. Donc si je comprends bien, "dans notre dos", le compilateur inverse la valeur "correctement" et ensuite il essaye de stocker cette nouvelle valeur dans le bon type ? Ou est-ce juste une façon de se représenter les choses ? Mais alors que fait vraiment le compilateur quand il doit faire la négation d’un entier finalement ?

AScriabine

Le raisonnement que je donne est le raisonnement abstrait, au niveau de la norme. Comme l’a précisé @Ksass`Peuk, le compilateur traduira ça en instructions qui donneront le résultat final, mais sans passer par ce type de raisonnement.

  • Si je comprends bien la fin de ton message, il n’y a donc aucun moyen "sûr" d’affecter ma valeur à un int en hexadécimal ?
AScriabine

Pour compléter ce qui a été dit, si tu veux un moyen « sûr » au sens de « le comportement est défini par la norme », tu dois assigner une valeur dont la norme garantie qu’elle peut être stockée dans un int. Donc soit utiliser les constantes définies dans l’en-tête <limits.h> (comme l’a suggéré @dalfab), soit te contenter des minimums garantis par la norme, soit -32.767 à 32.767 pour le type int (-32.768 à 32.767 en C23, comme l’a précisé @jo_link_noir).

+1 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte