La définition de «volatile» est-elle aussi volatile, ou est-ce que GCC a des problèmes de conformité standard?


88

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 simplement 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 attendus, 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 est-ce que la définition de volatile dans la norme indique imprécise qu'il s'agit en fait d'un comportement conforme, ce qui rend pratiquement impossible d'écrire 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 depuis callMeMaybe () fera que toutes les versions de GCC sauf la 6.1 génèrent les magasins attendus. Commenter dans la clôture de la mémoire fera également générer les magasins par GCC 6.1, bien qu'en combinaison avec l'écriture possible de callMeMaybe ().

Quelqu'un a également suggéré de vider les caches. Microsoft n'essaye pas du tout de vider le cache dans "SecureZeroMemory". Le cache va probablement être 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 s'il allait être écrit dans le fichier d'échange, ce serait toujours la version remise à zéro.

Il y a aussi quelques inquiétudes à propos de GCC 6.1 utilisant memset () dans la fonction autonome. Le compilateur GCC 6.1 sur godbolt pourrait être une version 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. (Lisez les commentaires de la réponse de zwol.)


4
L'utilisation IMHO volatileest un bogue, sauf preuve du contraire. Mais très probablement un bug. volatileest tellement sous-spécifié qu'il est dangereux - ne l'utilisez pas.
Jesper Juhl

19
@JesperJuhl: Non, volatilec'est approprié dans ce cas.
Dietrich Epp

9
@NathanOliver: Cela ne fonctionnera pas, car les compilateurs peuvent optimiser les magasins morts même s'ils les utilisent memset. Le problème est que les compilateurs savent exactement ce que memsetfait.
Dietrich Epp

8
@PaulStelian: Cela ferait un volatilepointeur, nous voulons un pointeur sur volatile(nous ne nous soucions pas de savoir si ++c'est strict, mais si *p = 0c'est strict).
Dietrich Epp

7
@JesperJuhl: Il n'y a rien de sous-spécifié sur volatile.
GManNickG

Réponses:


81

Le comportement de GCC peut être conforme, et même s'il ne l'est pas, vous ne devriez pas vous fier volatileà ce que vous voulez dans des cas comme ceux-ci. Le comité C est conçu volatilepour 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 «n'optimisez pas ceci».

En particulier, la norme n'est pas claire sur un point clé. (J'ai converti votre code en C; il ne devrait pas y avoir de divergence entre C et C ++ ici. J'ai également fait manuellement l'inlining qui se produirait avant l'optimisation discutable, 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 arrvia une valeur l qualifiée volatile, mais arrelle-même n'est pas déclarée volatile. Il est donc au moins sans doute 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 un texte dans la justification C qui laisse entendre que le comité avait l' intention 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 plus de détails sur ce que la norme exige 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 no-op à partir de ce dernier? , Est - ce que l'accès à un objet non volatil déclaré via une référence / pointeur volatile confère des règles volatiles auxdits accès? , et le bogue GCC 71793 .

Pour en savoir plus sur ce à quoi le comité a pensé volatile , recherchez la justification C99 du mot «volatile». L'article de John Regehr " Les volatiles sont mal compilés " illustre en détail comment les attentes des programmeurs volatilepeuvent 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 un comportement indéfini " ne traite pas spécifiquement volatilemais vous aidera à comprendre comment et pourquoi les compilateurs C modernes ne sont pas des "assembleurs portables".


À la question pratique de savoir comment implémenter une fonction qui fait ce que vous vouliez volatileZeroMemoryfaire: indépendamment de ce que la norme exige ou était censée exiger, il serait plus sage de supposer que vous ne pouvez pas l'utiliser volatilepour cela. Il existe une alternative sur laquelle on peut compter pour fonctionner, car cela 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 cela memory_optimization_fencen'est en aucun cas intégré. 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 de compilateur, qui peuvent être utilisables dans certaines circonstances et peuvent générer un code plus précis (l'une d'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 elle est disponible sous ce nom dans plus d'une bibliothèque C. Il y a au moins quatre autres prétendants pour le nom, mais chacun n'a été adopté que par une seule bibliothèque C.)

Vous devez également savoir que, même si vous pouvez faire fonctionner cela, cela peut ne pas suffire. 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 que le matériel avec des instructions d'accélération AES, si expand_keyet encrypt_with_eksont en ligne, le compilateur peut être en mesure de rester ekentièrement dans le fichier de registre vectoriel - jusqu'à l'appel à explicit_bzero, 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 présentes dans les registres vectoriels!


6
C'est intéressant ... J'aimerais voir une référence aux commentaires du comité.
Dietrich Epp

10
Comment ce carré avec la définition du 6.7.3 (7) de volatileas [...] Par conséquent, toute expression faisant référence à un tel objet doit être évaluée strictement selon les règles de la machine abstraite, comme décrit en 5.1.2.3. En outre, à chaque point de séquence, la dernière valeur stockée dans l'objet doit correspondre à celle prescrite par la machine abstraite , sauf si elle est modifiée par les facteurs inconnus mentionnés précédemment. Ce qui constitue un accès à un objet de type volatile qualifié est défini par l'implémentation. ?
Iwillnotexist Idonotexist

15
@IwillnotexistIdonotexist Le mot clé dans ce passage est objet . volatile sig_atomic_t flag;est un objet volatil . *(volatile char *)fooest simplement un accès via une valeur l volatile qualifiée et la norme ne l'exige pas pour avoir des effets spéciaux.
zwol le

3
La norme précise les critères auxquels un élément doit répondre pour être une mise en œuvre «conforme». Il ne fait aucun effort pour décrire les critères auxquels une implémentation sur une plate-forme donnée doit répondre pour être une «bonne» implémentation ou une «utilisable». Le traitement de GCC volatilepeut être suffisant pour en faire une implémentation "conforme", mais cela ne veut pas dire qu'il suffit d'être "bon" ou "utile". Pour de nombreux types de programmation de systèmes, il devrait être considéré comme terriblement déficient à cet égard.
supercat du

3
La spécification C dit aussi assez directement "Une implémentation réelle n'a pas besoin d'évaluer une partie d'une expression si elle peut déduire que sa valeur n'est pas utilisée et qu'aucun effet secondaire nécessaire n'est produit ( y compris ceux causés par l'appel d'une fonction ou l'accès à un objet volatil ) . " (Soulignez le mien).
Johannes Schaub - litb

15

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 à cela que sert la fonction standard memset_s.


Quant à savoir si ce comportement avec volatil est conforme ou non, qui est un peu difficile à dire, et volatile a été dit avoir été longtemps 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 '', n'accédant pas à un objet non volatile via un pointeur auquel a été ajouté 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 volatil après tout.


4
Remarque: cela fait partie de la norme C11 et n'est pas encore disponible dans toutes les chaînes d'outils.
Dietrich Epp

5
Il est intéressant de noter que cette fonction est standardisée pour C11 mais pas pour C ++ 11, C ++ 14 ou C ++ 17. Donc, techniquement, ce n'est pas une solution pour C ++, mais je suis d'accord que cela semble être la meilleure option d'un point de vue pratique. À ce stade, je me demande si le comportement de GCC est conforme ou non. Edit: En fait, VS 2015 n'a pas de memset_s, donc ce n'est pas encore si portable.
cooky451

2
@ cooky451 Je pensais que C ++ 17 extrait la bibliothèque standard C11 par référence (voir deuxième Misc).
nwp le

13
En outre, décrire memset_scomme la norme C11 est une surestimation. Il fait partie de l'Annexe K, qui est facultative en C11 (et donc également facultative en C ++). En gros, tous les implémenteurs, y compris Microsoft, dont c'était l'idée en premier lieu (!), Ont refusé de le reprendre; la dernière fois que j'ai entendu dire qu'ils parlaient de le mettre au rebut dans C-next.
zwol le

8
@ cooky451 Dans certains cercles, Microsoft est réputé pour forcer des choses dans le standard C sur les objections de tous les autres et ne pas se soucier de l'implémenter eux-mêmes. (L'exemple le plus flagrant de ceci est l'assouplissement par C99 des règles pour ce que le type sous-jacent de size_test autorisé à être. L'ABI Win64 n'est pas conforme à C90. Cela aurait été ... pas bien , mais pas terrible ... si MSVC avait en fait repris des choses comme C99 uintmax_tet %zuen temps opportun, mais ils ne l'ont pas fait .)
zwol

2

J'offre 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 effectués via une vue volatile de l'objet.

La différence sémantique est qu'elle met maintenant officiellement fin à la durée de vie de tout objet (s) occupant la région mémoire, car la mémoire a été réutilisée. Ainsi, l'accès à l'objet après la remise à zéro de son contenu est maintenant un comportement indéfini (auparavant, il aurait été un comportement non défini dans la plupart des cas, mais certaines exceptions existaient sûrement).

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

Le code peut être raccourci (bien que 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 one-liner et justifie à peine une fonction d'aide du tout.


2
Si les accès à l'objet après l'exécution de la fonction invoquaient UB, cela signifierait que ces accès pourraient donner les valeurs que l'objet détenait avant qu'il ne soit "effacé". Comment n'est-ce pas le contraire de la sécurité?
supercat

0

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' zeroobjet est déclaré volatilequi garantit que le compilateur ne peut faire aucune hypothèse sur sa valeur même s'il évalue toujours comme zéro.

L'expression d'affectation finale lit à partir d'un index volatil dans le tableau et stocke la valeur dans un objet volatile. Étant donné que 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.


1
Cela ne fonctionne pas du tout ... il suffit de regarder le code qui est généré.
cooky451

1
Après avoir mieux lu mon ASM généré, il semble intégrer l'appel de fonction et conserver la boucle, mais ne pas stocker *ptrpendant cette boucle, ou en fait quoi que ce soit du tout ... juste une boucle. wtf, voilà mon cerveau.
underscore_d

3
@underscore_d C'est parce qu'il optimise le magasin tout en préservant la lecture du volatile.
D Krueger

1
Ouais, et le résultat est immuable edx: j'obtiens ceci:.L16: subq $1, %rax; movzbl -1(%rsp), %edx; jne .L16
underscore_d

1
Si je change la fonction pour permettre de passer un volatile unsigned char constoctet de remplissage arbitraire ... il ne le lit même pas . L'appel intégré généré à volatileFill()est juste [load RAX with sizeof] .L9: subq $1, %rax; jne .L9. Pourquoi l'optimiseur (A) ne relit-il pas l'octet de remplissage et (B) se donne-t-il la peine de conserver la boucle là où il ne fait rien?
underscore_d
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.