2 points par GN⁺ 2 시간 전 | 1 commentaires | Partager sur WhatsApp
  • Si la valeur d’un float après suppression de sa partie fractionnaire sort de la plage du type entier cible, un comportement indéfini (UB) se produit ; les conversions implicites, les casts fonctionnels et static_cast sont tous concernés
  • -Wall et -Wextra ne signalent pas ce cas, et -Wconversion ne détecte que les conversions implicites, ce qui le rend facile à manquer
  • La fonction de conversion rétrécissante sûre gsl::narrow de Microsoft GSL peut elle aussi provoquer un UB pour certaines entrées flottant→entier, et ne respecte donc pas le comportement documenté consistant à lancer une exception pour les valeurs non représentables
  • Sur x86, CVTTSS2SI traite les valeurs non représentables comme INT_MIN, tandis que sur AArch64, FCVTZS effectue une conversion saturante et transforme NaN en 0 ; les résultats peuvent donc varier selon le matériel
  • Pour convertir en toute sécurité, il faut vérifier la plage avant le cast ; l’option UBSan -fsanitize=float-cast-overflow de Clang et GCC permet de détecter le problème

Règles de conversion et limites de la détection

  • Selon les règles de conversion flottant-entier en C++, si, après suppression de la partie fractionnaire, la valeur n’entre pas dans le type entier cible, cela devient un comportement indéfini
    • Même si la cible est unsigned, l’arithmétique modulaire ne s’applique pas
    • int i0 = f, int(f) et static_cast<int>(f) peuvent tous provoquer un UB pour certaines entrées
  • Il est difficile de trouver tous les problèmes uniquement avec les avertissements courants des compilateurs
    • -Wall et -Wextra n’émettent aucun avertissement pour ces trois conversions
    • -Wconversion n’avertit que pour les conversions implicites
  • Même si le programme continue de s’exécuter sur le processeur et le compilateur actuels, le résultat peut varier selon la plateforme
    • Sur x86, CVTTSS2SI mappe les entrées non représentables vers INT_MIN
    • Sur AArch64, FCVTZS applique une saturation et mappe NaN vers 0
    • Un UB exécuté peut faire soudainement dysfonctionner le code lorsque le compilateur applique une autre conversion

Cas de GSL et réponse sûre

  • gsl::narrow de la Guidelines Support Library de Microsoft se présente comme une conversion rétrécissante sûre qui lance une exception pour les valeurs non représentables dans le type cible
    • En réalité, la conversion flottant→entier exécute d’abord un UB pour certaines entrées, ce qui ne correspond pas à la documentation
    • L’équipe GSL a estimé que, sur la plateforme cible, l’UB interne était inoffensif car il ne touchait pas de représentation matérielle de trap ; cette logique est restée reflétée dans le code, et le problème n’a pas été corrigé
  • La bonne solution consiste à vérifier la plage avant le cast
  • Dans l’Undefined Behavior Sanitizer de Clang et GCC, -fsanitize=float-cast-overflow permet de détecter cet UB
    • Il est recommandé de tester tout le code C++ avec UBSan

1 commentaires

 
GN⁺ 2 시간 전
Avis sur Lobste.rs
  • Je connaissais déjà pas mal de comportements indéfinis subtils en C et C++, mais ce cas m’a surpris.
    Rust a lui aussi hérité pendant un temps du même comportement indéfini, à cause des mêmes règles de conversion flottant→entier dans l’IR LLVM, et n’a été corrigé qu’en 2020 pour générer un IR plus complexe. Comme l’écart de performances est important, Rust fournit aussi une conversion flottant→entier sans vérification pour les boucles hautes performances où l’on sait que la valeur est finie et dans l’intervalle du type cible.
    Que la C++ Core Guidelines Library prenne cela à la légère est absurde. LLVM a utilisé les informations tirées de la conversion pour éliminer des vérifications de bornes, ce qui a conduit à un accès hors limites à un tableau malgré la présence d’une vérification. Si l’on prend au sérieux la sécurité mémoire et l’évitement des comportements indéfinis, on ne peut pas ignorer ce point, et il est décevant qu’Herb Sutter parle de « comportement indéfini bénin ».

  • Je savais qu’il y avait beaucoup de comportements indéfinis en C++, mais celui-ci est particulièrement surprenant. Je me demande si la conversion a été conçue pour être aussi rapide que possible, et laissée en comportement indéfini parce que les architectures ont des instructions qui traitent ces valeurs extrêmes différemment. Cela semble être la principale raison de ce genre de règles, un peu comme le dépassement d’entier signé.

    • Si c’est la raison, cela devrait être un comportement défini par l’implémentation, pas un comportement indéfini. La division par zéro, elle, peut piéger sur certaines architectures, donc je comprends qu’elle soit indéfinie.
      Le comportement indéfini devrait être limité aux cas où l’on ne peut pas garantir un résultat cohérent même sur une même plateforme, à cause d’effets hors de la machine abstraite du C, comme l’utilisation après libération, ou aux cas qui peuvent piéger sur certaines cibles.
      Une implémentation conforme au standard peut définir elle-même un comportement indéfini, et GCC le fait pour certains points. Si une sémantique stable peut être garantie sans coût en performances, on pourrait simplement définir le comportement comme celui de l’instruction flottant→entier propre à la cible, même si d’autres implémentations le garderaient indéfini.
    • C’est probablement ça. La famille fctiw de PowerPC effectue une conversion saturante, transforme NaN en INT_MIN et définit aussi des drapeaux FPSCR. Le fctid de Power ISA 64 bits applique le même traitement à des entiers plus grands, mais aucun des deux ne correspond au comportement d’AArch64 ou de x86.
  • Dans ce genre de cas, il serait plus approprié d’en faire un comportement défini par l’implémentation ou une valeur non spécifiée. Il n’est pas raisonnable que tout le programme échappe au contrôle du standard C++ simplement parce qu’on a converti l’infini en int.
    C++26 a supprimé plusieurs comportements indéfinis absurdes, et celui-ci est clairement un candidat à la suppression. D’après des essais, GCC et Clang ne semblent pas exploiter ce comportement indéfini pour l’optimisation, donc l’impact pratique paraît limité.

  • C’est encore un exemple désagréable où la spécification n’a aucune raison de qualifier cette opération de comportement indéfini.

  • Une raison de plus de détester IEEE 754. Sauf nécessité liée à d’autres bibliothèques ou aux performances, j’essaie autant que possible d’utiliser des entiers purs, des rationnels avec grands entiers au numérateur et au dénominateur, ou des décimaux à virgule fixe, plutôt que des flottants.

    • Cette affaire n’a rien à voir avec IEEE 754 et relève entièrement de la faute du C++.
  • Si l’on a utilisé C ou C++, la différence entre float32 et int32, voire int64, ne devrait pas surprendre. Un float32 avec un grand exposant peut représenter des valeurs entières bien plus grandes qu’un int64.
    Entre des formats qui ne sont pas des sur-ensembles l’un de l’autre et qui ont des capacités de représentation différentes, il n’y a aucune raison de supposer qu’une conversion flottant→entier soit sûre, indépendamment de la syntaxe du langage.

    • Tu passes à côté du point essentiel. Ne pas pouvoir préserver toutes les valeurs flottantes et avoir un comportement indéfini, ce n’est pas la même chose.
      uint32_t ne peut pas non plus représenter toutes les valeurs de uint64_t, mais la sémantique de conversion est définie par troncature. Ici, le problème fondamentalement différent est que, pour certaines entrées, le compilateur peut faire n’importe quoi.
    • Le fait que les plages représentables diffèrent n’empêche pas de définir une conversion sûre. Rust définit explicitement la conversion flottant→entier, et la spécification du langage Java 26 décrit en détail la procédure de conversion à la section 5.1.3.
      Pour un langage bas niveau, on pourrait aussi l’associer à des instructions d’assemblage comme CVTTSS2SI ou FCVTZS. Comme les langages bas niveau sont proches de l’assembleur et que d’autres comportements indéfinis dits « bénins » sont déjà très controversés, le fait que cette conversion permette un comportement indéfini est d’autant plus surprenant.
    • Selon le matériel, obtenir une valeur poubelle propre à l’implémentation, ou voir le matériel ou les outils de vérification piéger et arrêter le programme, n’a rien de très surprenant.
      Ce qui est surprenant, c’est que cela autorise aussi une mauvaise compilation étrange de code sans rapport, le formatage du disque dur, ou à « faire jaillir des démons de votre nez ».
      Le concept de comportement indéfini où tout peut arriver est pertinent pour une double libération ou une écriture hors limites dans un tableau, mais C et C++ en abusent même là où ils pourraient imposer des règles plus strictes, comme une valeur poubelle définie par l’implémentation ou l’arrêt du programme. Rust fait piéger certains dépassements d’entiers en mode debug et renvoie une valeur non spécifiée en mode release, mais ne permet pas pour autant de casser du code sans rapport.
      C++ n’a pas besoin de devenir Java ou Rust, mais il deviendrait clairement meilleur en réduisant les comportements indéfinis inutiles.
    • Pour moi, c’était nouveau. Je pensais que convertir en entier un flottant non représentable mettait simplement une valeur absurde dans l’entier résultant ; je ne savais pas que tout le programme était contaminé pour toujours et sortait du contrôle du standard C++.