web-dev-qa-db-fra.com

La définition de «volatile» est-elle volatile ou le GCC a-t-il des problèmes de conformité standard?

J'ai besoin d'une fonction qui (comme SecureZeroMemory de WinAPI) remet toujours à zéro la mémoire et ne soit pas optimisée, même si le compilateur pense que la mémoire ne sera plus jamais accessible après cela. Semble être un candidat parfait pour volatile. Mais j'ai des 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;
    }
}

Assez simple. Mais le code que GCC génère réellement si vous l'appelez varie énormément avec la version du compilateur et la quantité d'octets que vous essayez de mettre à 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 volatile pour les tailles de tableau 1, 2 et 4.
  • GCC 4.8.1 jusqu'à 4.9.2 ignore volatile pour les tailles de tableau 1 et 2.
  • GCC 5.1 jusqu'à 5.3 ignore volatile pour les tailles de tableau 1, 2, 4, 8.
  • GCC 6.1 l'ignore pour n'importe quelle taille de tableau (points bonus pour la cohérence).

Tout autre compilateur que j'ai testé (clang, icc, vc) génère les magasins auxquels on peut s'attendre, avec n'importe quelle version de compilateur et n'importe quelle taille de tableau. Donc, à ce stade, je me demande, est-ce un bogue du compilateur GCC (assez ancien et grave?), Ou la définition de volatile dans la norme imprécise-t-elle qu'il s'agit en fait d'un comportement conforme, ce qui rend pratiquement impossible l'écriture d'un portable " Fonction SecureZeroMemory "?

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 à partir de callMeMaybe () fera en sorte que toutes les versions de GCC sauf 6.1 génèrent les magasins attendus. Le fait de commenter dans la clôture de mémoire fera également à GCC 6.1 générer les magasins, bien qu'en combinaison avec l'écriture possible à partir de callMeMaybe ().

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

Il y a également quelques soucis concernant GCC 6.1 utilisant memset () dans la fonction autonome. 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.)

87
cooky451

Le comportement de GCC peut-être être conforme, et même si ce n'est pas le cas, vous ne devriez pas compter sur volatile pour faire ce que vous voulez dans des cas comme ceux-ci. Le comité C a conçu volatile pour les registres matériels mappés en mémoire et pour les variables modifiées pendant un flux de contrôle anormal (par exemple, les gestionnaires de signaux et 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 "ne pas optimiser cela".

En particulier, la norme n'est pas claire sur un point clé. (J'ai converti votre code en C; il y a ne devrait pas il y a ici une divergence entre C et C++. J'ai également fait manuellement l'inline qui se produirait avant l'optimisation douteuse, pour montrer ce que le compilateur "voit" à ce stade.)

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 via une valeur l volatile, mais arr elle-même est pas déclarée volatile. Par conséquent, il est au moins permis au compilateur C de déduire que les magasins créés par la boucle sont "morts" et de supprimer complètement la boucle. Il y a du texte dans la justification C qui implique que le comité voulait exiger que ces magasins soient préservés, mais la norme elle-même ne fait pas cette exigence, comme Je l'ai lu.

Pour plus de détails sur ce que la norme requiert ou non, voir Pourquoi une variable locale volatile est-elle optimisée différemment d'un argument volatil, et pourquoi l'optimiseur génère-t-il une boucle sans opération à partir de ce dernier? , L'accès à un objet non volatile déclaré via une référence/pointeur volatile confère-t-il des règles volatiles auxdits accès? , et Bogue GCC 7179 .

Pour en savoir plus sur ce que le comité a pensévolatile, recherchez C99 Justification pour le mot "volatile". L'article de John Regehr " Volatiles are Miscompiled " illustre en détail comment les attentes des programmeurs pour volatile peuvent ne pas être satisfaites par les compilateurs de production. La série d'essais de l'équipe LLVM " Ce que tout programmeur C devrait savoir sur le comportement non défini " ne touche pas spécifiquement à volatile mais vous aidera à comprendre comment et pourquoi les compilateurs C modernes sont pas "assembleurs portables".


À la question pratique de savoir comment implémenter une fonction qui fait ce que vous vouliez volatileZeroMemory: indépendamment de ce que la norme exige ou était destiné à exiger, il serait plus sage de supposer que vous ne pouvez pas utiliser volatile pour cela. Il y a est une alternative qui peut être utilisée pour fonctionner, car elle casserait beaucoup trop d'autres choses si cela 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 inséré. Il doit se trouver dans son propre fichier source et ne doit pas être soumis à une optimisation du temps de liaison.

Il existe d'autres options, reposant sur des extensions de compilateur, qui peuvent être utilisables dans certaines circonstances et peuvent générer du code plus strict (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 plusieurs bibliothèques C. Il y a au moins quatre autres candidats pour le nom, mais chacun n'a été adopté que par une seule bibliothèque C.)

Vous devez également savoir que, même si cela peut fonctionner, cela peut ne pas être suffisant. En particulier, considérez

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 du matériel avec des instructions d'accélération AES, si expand_key et encrypt_with_ek sont en ligne, le compilateur peut être en mesure de conserver ek entièrement dans le fichier de registre vectoriel - jusqu'à l'appel à explicit_bzero, ce qui l'oblige à copier les données sensibles sur la pile juste pour l'effacer, et, pire encore, ne fait absolument rien des clés qui sont toujours dans les registres vectoriels !

80
zwol

J'ai besoin d'une fonction qui (comme SecureZeroMemory de WinAPI) remet 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é dit d'avoir longtemps été en proie à des bugs.

Un problème est que les spécifications disent 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 volatile via un pointeur auquel a été ajouté un volatile. Donc, apparemment, si un compilateur peut dire que vous n'accédez pas vraiment à un objet volatil, il n'est pas nécessaire de traiter l'objet comme volatile après tout.

15
bames53

Je propose cette version en 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;
    }
}

Vous avez maintenant des accès en écriture à un objet volatil, et pas seulement des accès à un objet non volatile réalisés via une vue volatile de l'objet.

La différence sémantique est qu'elle met désormais officiellement fin à la durée de vie de tout objet occupant la région de la 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, il aurait été un comportement 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 placement new pour remettre une nouvelle instance du type d'origine.

Le code peut être raccourci (quoique moins clair) en utilisant l'initialisation de la valeur:

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

et à ce stade, il s'agit d'un monoplace et justifie à peine une fonction d'aide.

2
Ben Voigt

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 à conserver les magasins dans le 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];
}

L'objet zero est déclaré volatile ce qui garantit que le compilateur ne peut faire aucune hypothèse sur sa valeur même s'il est toujours évalué à 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.

0
D Krueger