web-dev-qa-db-fra.com

Comment puis-je empêcher l'optimiseur gcc de produire des opérations binaires incorrectes?

Considérez le programme suivant.

#include <stdio.h>

int negative(int A) {
    return (A & 0x80000000) != 0;
}
int divide(int A, int B) {
    printf("A = %d\n", A);
    printf("negative(A) = %d\n", negative(A));
    if (negative(A)) {
        A = ~A + 1;
        printf("A = %d\n", A);
        printf("negative(A) = %d\n", negative(A));
    }
    if (A < B) return 0;
    return 1;
}
int main(){
    divide(-2147483648, -1);
}

Lorsqu'il est compilé sans optimisation du compilateur, il produit les résultats attendus.

gcc  -Wall -Werror -g -o TestNegative TestNegative.c
./TestNegative
A = -2147483648
negative(A) = 1
A = -2147483648
negative(A) = 1

Lorsqu'il est compilé avec des optimisations de compilateur, il produit la sortie incorrecte suivante.

gcc -O3 -Wall -Werror -g -o TestNegative TestNegative.c
./TestNegative 
A = -2147483648
negative(A) = 1
A = -2147483648
negative(A) = 0

Je cours gcc version 5.4.0.

Y a-t-il une modification que je peux apporter au code source pour empêcher le compilateur de produire ce comportement sous -O3?

28
merlin2011
  1. -2147483648 ne fait pas ce que vous pensez qu'il fait. C n'a pas de constantes négatives. Comprendre limits.h et utilise INT_MIN à la place (à peu près tous les INT_MIN la définition sur deux machines complémentaires le définit comme (-INT_MAX - 1) pour une bonne raison).

  2. A = ~A + 1; invoque un comportement indéfini car ~A + 1 provoque un débordement d'entier.

Ce n'est pas le compilateur, c'est votre code.

84
Art

Le compilateur remplace votre instruction A = ~A + 1; Par une seule instruction neg, c'est-à-dire ce code:

int just_negate(int A) {
    A = ~A + 1;
    return A;
}

sera compilé pour:

just_negate(int):
  mov eax, edi
  neg eax         // just negate the input parameter
  ret

Mais le compilateur est également assez intelligent pour se rendre compte que si A & 0x80000000 Était différent de zéro avant la négation, il doit être nul après la négation, sauf si vous comptez sur un comportement non défini .

Cela signifie que la deuxième printf("negative(A) = %d\n", negative(A)); peut être optimisée "en toute sécurité" pour:

mov edi, OFFSET FLAT:.LC0    // .string "negative(A) = %d\n"
xor eax, eax                 // just set eax to zero
call printf

J'utilise en ligne Godbolt compiler Explorer pour vérifier l'assembly pour diverses optimisations du compilateur.

44
Groo

Pour expliquer en détail ce qui se passe ici:

  • Dans cette réponse, je suppose que long est de 32 bits et long long est de 64 bits. C'est le cas le plus courant, mais non garanti.

  • C n'a pas de contants entiers signés. -2147483648 est en fait de type long long, sur lequel vous appliquez l'opérateur unaire moins.

    Le compilateur sélectionne le type de la constante entière après avoir vérifié si 2147483648 peut s'adapter:

    • À l'intérieur d'un int? Non ça ne peut pas.
    • À l'intérieur d'un long? Non ça ne peut pas.
    • À l'intérieur d'un long long? Oui il peut. Le type de la constante entière sera donc long long. Ensuite, appliquez unaire moins sur ce long long.
  • Ensuite, vous essayez de montrer ce négatif long long à une fonction attendant un int. Un bon compilateur pourrait avertir ici. Vous forcez une conversion implicite vers un type plus petit ("conversion lvalue").
    Cependant, en supposant le complément de 2, la valeur -2147483648 peut tenir dans un int, donc aucun comportement défini par l'implémentation n'est nécessaire pour la conversion, ce qui aurait autrement été le cas.
  • La prochaine partie délicate est la fonction negative où vous utilisez 0x80000000. Ce n'est pas non plus un int, ni un long long, mais un unsigned int ( voir ceci pour une explication).

    Lorsque vous comparez votre int passé avec un unsigned int, "les conversions arithmétiques habituelles" ( voir ceci ) force une conversion implicite en int en unsigned int. Cela n'affecte pas le résultat dans ce cas spécifique, mais c'est pourquoi gcc -Wconversion les utilisateurs reçoivent un bel avertissement ici.

    (Astuce: activer -Wconversion déjà! C'est bon pour attraper des bogues subtils, mais ne fait pas partie de -Wall ou -Wextra.)

  • Ensuite, vous faites ~A, un inverse au niveau du bit de la représentation binaire de la valeur, se terminant par la valeur 0x7FFFFFFF. Il s’agit en fait de la même valeur que INT_MAX sur votre système 32 ou 64 bits. Ainsi 0x7FFFFFFF + 1 donne un débordement d'entier signé qui conduit à un comportement indéfini. C'est la raison pour laquelle le programme se comporte mal.

    Insolemment, nous pourrions changer le code en A = ~A + 1u; et soudain, tout fonctionne comme prévu, encore une fois à cause de la promotion implicite des nombres entiers.


Leçons apprises:

En C, les constantes entières, ainsi que les promotions entières implicites, sont très dangereuses et peu intuitives. Ils peuvent changer subtilement la signification du programme et introduire des bugs. À chaque opération dans C, vous devez considérer les types réels des opérandes impliqués.

Jouer avec C11 _Generic pourrait être un bon moyen de voir les types réels. Exemple:

#define TYPE_SAFE(val, type) _Generic((val), type: val)
...
(void) TYPE_SAFE(-2147483648, int); // won't compile, type is long or long long
(void) TYPE_SAFE(0x80000000, int);  // won't compile, type is unsigned int

De bonnes mesures de sécurité pour vous protéger contre de tels bogues sont de toujours utiliser stdint.h et d'utiliser MISRA-C.

17
Lundin

Vous comptez sur un comportement non défini. 0x7fffffff + 1 pour les entiers signés 32 bits entraîne un débordement d'entier signé, qui est un comportement indéfini selon la norme, donc tout se passe.

Dans gcc, vous pouvez forcer le comportement enveloppant en passant -fwrapv; encore, si vous n'avez aucun contrôle sur les indicateurs - et plus généralement, si vous voulez un programme plus portable - vous devriez faire toutes ces astuces sur les entiers unsigned, qui sont requis par la norme pour boucler (et ont une sémantique bien définie pour les opérations au niveau du bit, contrairement aux entiers signés).

Convertissez d'abord le int en unsigned (bien défini selon la norme, donne le résultat attendu), faites votre travail, reconvertissez en int - défini par l'implémentation (≠ non défini) ) pour des valeurs supérieures à la plage de int, mais réellement définies par chaque compilateur travaillant en complément de 2 pour faire la "bonne chose".

int divide(int A, int B) {
    printf("A = %d\n", A);
    printf("negative(A) = %d\n", negative(A));
    if (negative(A)) {
        A = ~((unsigned)A) + 1;
        printf("A = %d\n", A);
        printf("negative(A) = %d\n", negative(A));
    }
    if (A < B) return 0;
    return 1;
}

Votre version (à -O3):

A = -2147483648
negative(A) = 1
A = -2147483648
negative(A) = 0

Ma version (à -O3):

A = -2147483648
negative(A) = 1
A = -2147483648
negative(A) = 1
13
Matteo Italia