87 votes

Est-ce que la définition de "volatile" est si volatile, ou est-ce que GCC a des problèmes de conformité aux standards ?

J'ai besoin d'une fonction qui (comme SecureZeroMemory de la WinAPI) met toujours à zéro la mémoire et n'est pas optimisée, même si le compilateur pense que la mémoire ne sera plus jamais accessible après cela. Cela semble être un candidat parfait pour le volatile. Mais j'ai quelques problèmes pour que cela fonctionne avec GCC. Voici un exemple de fonction :

void volatileZeroMemory(volatile void* ptr, unsigned long long size)
{
    volatile unsigned char* bytePtr = (volatile unsigned char*)ptr;

    while (size--)
    {
        *bytePtr++ = 0;
    }
}

C'est assez simple. Mais le code que GCC génère réellement si vous l'appelez varie énormément en fonction de la version du compilateur et de la quantité d'octets que vous essayez réellement de réduire à zéro. https://godbolt.org/g/cMaQm2

  • GCC 4.4.7 et 4.5.3 n'ignorent jamais le volatile.
  • GCC 4.6.4 et 4.7.3 ignorent le volatile pour les tableaux de taille 1, 2 et 4.
  • GCC 4.8.1 jusqu'à 4.9.2 ignore volatile pour les tableaux de taille 1 et 2.
  • GCC 5.1 jusqu'à 5.3 ignore le volatile pour les tableaux de taille 1, 2, 4, 8.
  • GCC 6.1 l'ignore simplement pour toute taille de tableau (points bonus pour la cohérence).

Tous les autres compilateurs que j'ai testés (clang, icc, vc) génèrent les magasins que l'on pourrait attendre, avec n'importe quelle version du compilateur et n'importe quelle taille de tableau. Donc, à ce stade, je me demande s'il s'agit d'un bug (assez ancien et grave ?) du compilateur GCC, ou si la définition de volatile dans la norme est si imprécise qu'il s'agit en fait d'un comportement conforme, rendant essentiellement impossible l'écriture d'une fonction "SecureZeroMemory" portable ?

Edit : Quelques observations intéressantes.

#include <cstddef>
#include <cstdint>
#include <cstring>
#include <atomic>

void callMeMaybe(char* buf);

void volatileZeroMemory(volatile void* ptr, std::size_t size)
{
    for (auto bytePtr = static_cast<volatile std::uint8_t*>(ptr); size-- > 0; )
    {
        *bytePtr++ = 0;
    }

    //std::atomic_thread_fence(std::memory_order_release);
}

std::size_t foo()
{
    char arr[8];
    callMeMaybe(arr);
    volatileZeroMemory(arr, sizeof arr);
    return sizeof arr;
}

L'écriture possible de callMeMaybe() fera que toutes les versions de GCC sauf 6.1 génèrent les magasins attendus. Le fait de commenter dans la barrière de mémoire fera également que GCC 6.1 génère les magasins, bien que seulement en combinaison avec l'écriture possible de callMeMaybe().

Quelqu'un a également suggéré de vider les caches. Microsoft fait no essayer de vider le cache en "SecureZeroMemory". Le cache sera probablement invalidé assez rapidement de toute façon, donc ce n'est probablement pas un gros problème. De plus, si un autre programme essayait de sonder les données, ou si elles devaient être écrites dans le fichier de page, ce serait toujours la version mise à zéro.

Il y a également quelques soucis concernant l'utilisation de memset() dans la fonction autonome de GCC 6.1. Le compilateur GCC 6.1 sur godbolt pourrait être une construction cassée, car GCC 6.1 semble générer une boucle normale (comme 5.3 le fait sur godbolt) pour la fonction autonome pour certaines personnes. (Lire les commentaires de la réponse de zwol).

81voto

Zack Points 44583

Le comportement du CCG mai être conforme, et même s'il ne l'est pas, vous ne devez pas compter sur volatile pour faire ce que vous voulez dans des cas comme celui-ci. Le comité C a conçu volatile pour les registres matériels mis en mémoire et pour les variables modifiées au cours d'un flux de contrôle anormal (par exemple, les gestionnaires de signaux et les systèmes de gestion de l'information). setjmp ). Ce sont les seules choses pour lesquelles il est fiable. Il n'est pas sûr de l'utiliser comme une annotation générale "n'optimisez pas ceci".

En particulier, la norme n'est pas claire sur un point essentiel. (J'ai converti votre code en C ; il n'y a pas d'erreur. ne devrait pas être une divergence entre C et C++ ici. J'ai également effectué manuellement l'inlining qui se produirait avant l'optimisation discutable, pour montrer ce que le compilateur "voit" à ce moment-là).

extern void use_arr(void *, size_t);
void foo(void)
{
    char arr[8];
    use_arr(arr, sizeof arr);

    for (volatile char *p = (volatile char *)arr;
         p < (volatile char *)(arr + 8);
         p++)
      *p = 0;
}

La boucle d'effacement de la mémoire accède arr à travers une lvalue qualifiée de volatile, mais arr elle-même est no déclaré volatile . Le compilateur C est donc autorisé, du moins en théorie, à déduire que les enregistrements effectués par la boucle sont "morts" et à supprimer complètement la boucle. Il y a un texte dans le raisonnement du C qui implique que le comité signifiait d'exiger que ces magasins soient préservés, mais la norme elle-même n'impose pas cette exigence, d'après ce que je lis.

Pour une discussion plus approfondie sur ce que la norme exige ou n'exige pas, voir Pourquoi une variable locale volatile est-elle optimisée différemment d'un argument volatile, et pourquoi l'optimiseur génère-t-il une boucle no-op à partir de ce dernier ? , L'accès à un objet déclaré non volatile par le biais d'une référence/pointeur volatile confère-t-il des règles de volatilité auxdits accès ? et Bogue GCC 71793 .

Pour en savoir plus sur ce que le comité pensée volatile était pour, chercher le Raison d'être de C99 pour le mot "volatile". L'article de John Regehr " Les volatiles sont mal compilées "illustre en détail comment les attentes des programmeurs en matière de volatile peut ne pas être satisfaite par les compilateurs de production. La série d'essais de l'équipe LLVM " Ce que tout programmeur C devrait savoir sur le comportement indéfini "ne traite pas spécifiquement de volatile mais vous aidera à comprendre comment et pourquoi les compilateurs C modernes sont no "assembleurs portables".


Au pratique La question est de savoir comment mettre en œuvre une fonction qui fait ce que vous voulez. volatileZeroMemory à faire : Indépendamment de ce que la norme exige ou était censée exiger, il serait plus sage de supposer que vous ne pouvez utiliser volatile pour ça. Il y a es une alternative dont on peut être sûr qu'elle fonctionne, parce qu'elle casserait beaucoup trop d'autres choses si elle ne fonctionnait pas :

extern void memory_optimization_fence(void *ptr, size_t size);
inline void
explicit_bzero(void *ptr, size_t size)
{
   memset(ptr, 0, size);
   memory_optimization_fence(ptr, size);
}

/* in a separate source file */
void memory_optimization_fence(void *unused1, size_t unused2) {}

Cependant, vous devez absolument vous assurer que memory_optimization_fence n'est en aucun cas inlined. Il doit se trouver dans son propre fichier source et ne doit pas être soumis à une optimisation au moment de la liaison.

Il existe d'autres options, reposant sur des extensions du compilateur, qui peuvent être utilisables dans certaines circonstances et générer un code plus serré (l'une d'entre elles est apparue dans une édition précédente de cette réponse), mais aucune n'est universelle.

(Je recommande d'appeler la fonction explicit_bzero car il est disponible sous ce nom dans plus d'une bibliothèque C. Il existe au moins quatre autres prétendants à ce nom, mais chacun d'entre eux n'a été adopté que par une seule bibliothèque C).

Sachez également que, même si vous y parvenez, cela peut ne pas être suffisant. En particulier, pensez à

struct aes_expanded_key { __uint128_t rndk[16]; };

void encrypt(const char *key, const char *iv,
             const char *in, char *out, size_t size)
{
    aes_expanded_key ek;
    expand_key(key, ek);
    encrypt_with_ek(ek, iv, in, out, size);
    explicit_bzero(&ek, sizeof ek);
}

En supposant un matériel avec des instructions d'accélération AES, si expand_key y encrypt_with_ek sont inline, le compilateur peut être en mesure de garder ek entièrement dans le fichier de registre vectoriel -- jusqu'à ce que l'appel à explicit_bzero ce qui l'oblige à copier les données sensibles sur la pile juste pour l'effacer, et, pire, ne fait rien pour les clés qui sont toujours dans les registres vectoriels !

15voto

bames53 Points 38303

J'ai besoin d'une fonction qui (comme SecureZeroMemory de l'interface WinAPI) mette toujours à zéro la mémoire et ne soit pas optimisée,

C'est ce que la fonction standard memset_s est pour.


Quant à savoir si ce comportement avec volatile est conforme ou non, c'est un peu difficile à dire, et volatile a été a déclaré ont longtemps été infestés de bogues.

L'un des problèmes est que les spécifications stipulent que "les accès aux objets volatils sont évalués strictement selon les règles de la machine abstraite". Mais cela ne fait référence qu'aux "objets volatils", et non à l'accès à un objet non volatil via un pointeur auquel a été ajouté un objet volatil. Donc apparemment, si un compilateur peut dire que vous n'accédez pas vraiment à un objet volatil, il n'est pas obligé de traiter l'objet comme volatil après tout.

2voto

Ben Voigt Points 151460

Je propose cette version comme C++ portable (bien que la sémantique soit subtilement différente) :

void volatileZeroMemory(volatile void* const ptr, unsigned long long size)
{
    volatile unsigned char* bytePtr = new (ptr) volatile unsigned char[size];

    while (size--)
    {
        *bytePtr++ = 0;
    }
}

Maintenant vous avez des accès en écriture à un objet volatile et non pas simplement des accès à un objet non volatile effectués par le biais d'une vue volatile de l'objet.

La différence sémantique est qu'elle met maintenant formellement fin à la durée de vie de l'objet ou des objets qui occupaient la région de mémoire, car la mémoire a été réutilisée. Ainsi, l'accès à l'objet après la mise à zéro de son contenu est désormais un comportement indéfini (auparavant, ce comportement aurait été indéfini dans la plupart des cas, mais certaines exceptions existaient certainement).

Pour utiliser cette mise à zéro pendant la durée de vie d'un objet plutôt qu'à la fin, l'appelant doit utiliser le placement new pour remettre une nouvelle instance du type original.

Le code peut être rendu plus court (mais moins clair) en utilisant l'initialisation des valeurs :

void volatileZeroMemory(volatile void* const ptr, unsigned long long size)
{
    new (ptr) volatile unsigned char[size] ();
}

et à ce stade, il s'agit d'une ligne unique qui ne justifie guère une fonction d'aide.

0voto

D Krueger Points 847

Il devrait être possible d'écrire une version portable de la fonction en utilisant un objet volatile sur le côté droit et en forçant le compilateur à préserver les magasins du tableau.

void volatileZeroMemory(void* ptr, unsigned long long size)
{
    volatile unsigned char zero = 0;
    unsigned char* bytePtr = static_cast<unsigned char*>(ptr);

    while (size--)
    {
        *bytePtr++ = zero;
    }

    zero = static_cast<unsigned char*>(ptr)[zero];
}

En zero est déclaré volatile qui garantit que le compilateur ne peut pas faire d'hypothèses sur sa valeur, même si elle est toujours évaluée à zéro.

L'expression d'affectation finale lit à partir d'un index volatile dans le tableau et stocke la valeur dans un objet volatile. Comme cette lecture ne peut pas être optimisée, elle garantit que le compilateur doit générer les magasins spécifiés dans la boucle.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X