A definição de “volátil” é tão volátil ou o GCC está tendo alguns problemas de conformidade padrão?


88

Eu preciso de uma função que (como SecureZeroMemory do WinAPI) sempre zera a memória e não seja otimizada, mesmo se o compilador achar que a memória nunca mais será acessada depois disso. Parece um candidato perfeito para volátil. Mas estou tendo alguns problemas para realmente fazer isso funcionar com o GCC. Aqui está um exemplo de função:

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

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

Simples o suficiente. Mas o código que o GCC realmente gera se você chamá-lo varia muito com a versão do compilador e a quantidade de bytes que você está tentando zerar. https://godbolt.org/g/cMaQm2

  • GCC 4.4.7 e 4.5.3 nunca ignoram o volátil.
  • GCC 4.6.4 e 4.7.3 ignoram voláteis para tamanhos de array 1, 2 e 4
  • GCC 4.8.1 até 4.9.2 ignora volátil para tamanhos de array 1 e 2.
  • GCC 5.1 até 5.3 ignora volátil para tamanhos de matriz 1, 2, 4, 8.
  • O GCC 6.1 simplesmente o ignora para qualquer tamanho de array (pontos de bônus por consistência).

Qualquer outro compilador que eu testei (clang, icc, vc) gera os armazenamentos esperados, com qualquer versão do compilador e qualquer tamanho de array. Então, neste ponto, estou me perguntando, este é um bug do compilador GCC (muito antigo e grave?) Ou a definição de volátil no padrão imprecisa que este é realmente um comportamento em conformidade, tornando essencialmente impossível escrever um portátil " Função SecureZeroMemory "?

Edit: Algumas observações interessantes.

#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;
}

A possível gravação de callMeMaybe () fará com que todas as versões do GCC, exceto 6.1, gerem os armazenamentos esperados. Comentar na cerca de memória também fará com que o GCC 6.1 gere os armazenamentos, embora apenas em combinação com a possível escrita de callMeMaybe ().

Alguém também sugeriu esvaziar os caches. A Microsoft não tenta liberar o cache em "SecureZeroMemory". O cache provavelmente será invalidado muito rápido de qualquer maneira, então isso provavelmente não será um grande problema. Além disso, se outro programa estivesse tentando sondar os dados, ou se fosse gravado no arquivo de página, seria sempre a versão zerada.

Existem também algumas preocupações sobre o GCC 6.1 usando memset () na função autônoma. O compilador GCC 6.1 no godbolt pode ter uma compilação quebrada, já que o GCC 6.1 parece gerar um loop normal (como o 5.3 faz no godbolt) para a função autônoma para algumas pessoas. (Leia os comentários da resposta de zwol.)


4
O uso de IMHO volatileé um bug, a menos que se prove o contrário. Mas provavelmente um bug. volatileé tão subespecificado que pode ser perigoso - apenas não o use.
Jesper Juhl

19
@JesperJuhl: Não, volatileé apropriado neste caso.
Dietrich Epp

9
@NathanOliver: Isso não vai funcionar, porque os compiladores podem otimizar os armazenamentos mortos, mesmo se usarem memset. O problema é que os compiladores sabem exatamente o que memsetfaz.
Dietrich Epp

8
@PaulStelian: Isso faria um volatileponteiro para o qual queremos um ponteiro volatile(não nos importamos se ++é estrito, mas se *p = 0é estrito).
Dietrich Epp

7
@JesperJuhl: Não há nada subespecificado sobre voláteis.
GManNickG

Respostas:


81

O comportamento do GCC pode estar em conformidade e, mesmo que não seja, você não deve confiar em volatilefazer o que quiser em casos como esses. O comitê C projetado volatilepara registradores de hardware mapeados em memória e para variáveis ​​modificadas durante fluxo de controle anormal (por exemplo, manipuladores de sinal e setjmp). Essas são as únicas coisas para as quais é confiável. Não é seguro usar como uma anotação geral "não otimize isto".

Em particular, o padrão não é claro em um ponto chave. (Converti seu código para C; não deve haver nenhuma divergência entre C e C ++ aqui. Também fiz manualmente o inlining que ocorreria antes da otimização questionável, para mostrar o que o compilador "vê" naquele ponto .)

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;
}

O loop de limpeza de memória acessa arrpor meio de um lvalue qualificado por volátil, mas arrele próprio não é declarado volatile. Portanto, é pelo menos indiscutivelmente permitido para o compilador C inferir que os armazenamentos feitos pelo loop estão "mortos" e excluir o loop completamente. Há um texto na justificativa C que implica que o comitê pretendia exigir que esses armazenamentos fossem preservados, mas o próprio padrão não faz esse requisito, conforme eu li.

Para obter mais discussão sobre o que o padrão exige ou não, consulte Por que uma variável local volátil é otimizada de forma diferente de um argumento volátil e por que o otimizador gera um loop autônomo a partir do último? , O acesso a um objeto declarado não volátil por meio de uma referência / ponteiro volátil confere regras voláteis a esses acessos? e bug do GCC 71793 .

Para obter mais informações sobre o que o comitê pensava volatile , pesquise a justificativa do C99 para a palavra "volátil". O artigo de John Regehr " Volatiles are Miscompiled " ilustra em detalhes como as expectativas do programador de volatilepodem não ser satisfeitas pelos compiladores de produção. A série de ensaios da equipe do LLVM " O que Todo Programador C Deve Saber Sobre o Comportamento Indefinido " não aborda especificamente, volatilemas o ajudará a entender como e por que os compiladores C modernos não são "montadores portáteis".


Para a questão prática de como implementar uma função que faz o que você deseja volatileZeroMemory: independentemente do que o padrão exige ou deveria exigir, seria mais sensato presumir que você não pode usar volatilepara isso. Não é uma alternativa que pode ser invocado para o trabalho, porque iria quebrar demasiado outras coisas, se ele não funcionou:

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) {}

No entanto, você deve ter certeza absoluta de que memory_optimization_fencenão está inline em nenhuma circunstância. Ele deve estar em seu próprio arquivo de origem e não deve estar sujeito à otimização de tempo de link.

Existem outras opções, dependendo de extensões do compilador, que podem ser utilizáveis ​​em algumas circunstâncias e podem gerar código mais restrito (uma delas apareceu em uma edição anterior desta resposta), mas nenhuma é universal.

(Eu recomendo chamar a função explicit_bzero, porque ela está disponível com esse nome em mais de uma biblioteca C. Existem pelo menos quatro outros contendores para o nome, mas cada um foi adotado apenas por uma única biblioteca C).

Você também deve saber que, mesmo que consiga fazer isso funcionar, pode não ser suficiente. Em particular, considere

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);
}

Assumindo hardware com instruções de aceleração AES, se expand_keye encrypt_with_ekestiverem embutidos, o compilador pode ser capaz de manter ekinteiramente no arquivo de registro vetorial - até a chamada de explicit_bzero, o que o força a copiar os dados confidenciais para a pilha apenas para apagá-los e, pior, não faz nada sobre as chaves que ainda estão nos registradores vetoriais!


6
Isso é interessante ... Eu gostaria de ver uma referência aos comentários do comitê.
Dietrich Epp

10
Como funciona este quadrado com a definição de 6.7.3 (7) de volatilecomo [...] Portanto, qualquer expressão referente a tal objeto deve ser avaliada estritamente de acordo com as regras da máquina abstrata, conforme descrito em 5.1.2.3. Além disso, em cada ponto de sequência, o último valor armazenado no objeto deve corresponder ao prescrito pela máquina abstrata , exceto quando modificado pelos fatores desconhecidos mencionados anteriormente. O que constitui um acesso a um objeto que possui um tipo qualificado por volátil é definido pela implementação. ?
Eu não existirei

15
@IwillnotexistIdonotexist A palavra-chave nessa passagem é objeto . volatile sig_atomic_t flag;é um objeto volátil . *(volatile char *)fooé meramente um acesso por meio de um lvalue qualificado volátil e a norma não exige que tenha quaisquer efeitos especiais.
zwol

3
O Padrão diz quais critérios algo deve atender para ser uma implementação "compatível". Ele não faz nenhum esforço para descrever quais critérios uma implementação em uma determinada plataforma deve atender para ser uma implementação "boa" ou "utilizável". O tratamento do GCC volatilepode ser suficiente para torná-lo uma implementação "compatível", mas isso não significa que seja suficiente para ser "bom" ou "útil". Para muitos tipos de programação de sistemas, ele deve ser considerado lamentavelmente deficiente nesses aspectos.
supercat

3
A especificação C também diz diretamente "Uma implementação real não precisa avaliar parte de uma expressão se puder deduzir que seu valor não é usado e que nenhum efeito colateral necessário é produzido ( incluindo qualquer um causado pela chamada de uma função ou acesso a um objeto volátil ) . " (enfatize o meu).
Johannes Schaub - litb

15

Eu preciso de uma função que (como SecureZeroMemory do WinAPI) sempre zera a memória e não seja otimizada,

É para isso que serve a função padrão memset_s.


Se esse comportamento com o volátil está em conformidade ou não, isso é um pouco difícil de dizer, e o volátil tem sido disse que o está infestado de bugs.

Um problema é que as especificações dizem que "os acessos a objetos voláteis são avaliados estritamente de acordo com as regras da máquina abstrata." Mas isso se refere apenas a 'objetos voláteis', não acessando um objeto não volátil por meio de um ponteiro que teve volátil adicionado. Então, aparentemente, se um compilador pode dizer que você não está realmente acessando um objeto volátil, não é necessário tratar o objeto como volátil, afinal.


4
Observação: isso faz parte do padrão C11 e ainda não está disponível em todas as cadeias de ferramentas.
Dietrich Epp

5
É interessante notar que esta função é padronizada para C11, mas não para C ++ 11, C ++ 14 ou C ++ 17. Então, tecnicamente, não é uma solução para C ++, mas concordo que essa parece ser a melhor opção de uma perspectiva prática. Neste ponto, eu me pergunto se o comportamento do GCC está em conformidade ou não. Edit: Na verdade, o VS 2015 não tem memset_s, então ainda não é tão portátil.
cooky451

2
@ cooky451 Achei que o C ++ 17 puxa a biblioteca padrão C11 por referência (consulte a segunda Misc).
nwp

13
Além disso, descrever memset_scomo padrão C11 é um exagero. Faz parte do Anexo K, que é opcional em C11 (e, portanto, também opcional em C ++). Basicamente, todos os implementadores, incluindo a Microsoft, cuja ideia foi em primeiro lugar (!), Se recusaram a pegá-lo; A última vez que ouvi que eles estavam falando sobre desmantelá-lo em C-next.
zwol

8
@ cooky451 Em certos círculos, a Microsoft é notória por forçar coisas para o padrão C, basicamente sobre as objeções de todos os outros e, em seguida, não se preocupar em implementá-lo ela mesma. (O exemplo mais flagrante disso é o relaxamento do C99 das regras para o que o tipo subjacente de size_té permitido. O Win64 ABI não está em conformidade com o C90. Isso teria sido ... não ok , mas não terrível ... se O MSVC havia captado coisas como C99 uintmax_te %zuem tempo hábil, mas não o fizeram .)
zwol

2

Eu ofereço esta versão como C ++ portátil (embora a semântica seja sutilmente diferente):

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

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

Agora você tem acesso de gravação a um objeto volátil , não apenas acessos a um objeto não volátil feito por meio de uma visualização volátil do objeto.

A diferença semântica é que agora termina formalmente o tempo de vida de qualquer objeto que ocupou a região da memória, porque a memória foi reutilizada. Portanto, o acesso ao objeto após zerar seu conteúdo agora é certamente um comportamento indefinido (anteriormente, seria um comportamento indefinido na maioria dos casos, mas algumas exceções certamente existiam).

Para usar esse zeramento durante a vida útil de um objeto em vez de no final, o chamador deve usar o posicionamento new para colocar uma nova instância do tipo original de volta.

O código pode ser mais curto (embora menos claro) usando a inicialização de valor:

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

e, neste ponto, é uma linha única e quase não justifica uma função auxiliar.


2
Se os acessos ao objeto após a execução da função invocassem o UB, isso significaria que tais acessos poderiam produzir os valores que o objeto mantinha antes de ser "limpo". Como isso não é o oposto de segurança?
supercat

0

Deve ser possível escrever uma versão portátil da função usando um objeto volátil no lado direito e forçando o compilador a preservar os armazenamentos no array.

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];
}

O zeroobjeto é declarado, o volatileque garante que o compilador não possa fazer suposições sobre seu valor, embora sempre seja avaliado como zero.

A expressão de atribuição final lê um índice volátil na matriz e armazena o valor em um objeto volátil. Como essa leitura não pode ser otimizada, ela garante que o compilador gere os armazenamentos especificados no loop.


1
Isso não funciona de jeito nenhum ... basta olhar para o código que está sendo gerado.
cooky451

1
Tendo lido melhor o meu ASM gerado, parece que inline a chamada de função e retém o loop, mas não faz nenhum armazenamento *ptrdurante aquele loop, ou na verdade qualquer coisa ... apenas loop. wtf, lá se vai meu cérebro.
sublinhado_d

3
@underscore_d É porque está otimizando a loja enquanto preserva a leitura do volátil.
D Krueger

1
Sim, e despeja o resultado para um inalterável edx: Eu entendo:.L16: subq $1, %rax; movzbl -1(%rsp), %edx; jne .L16
sublinhado_d

1
Se eu mudar a função para permitir a passagem de um volatile unsigned char constbyte de preenchimento arbitrário ... ele nem lê . A chamada embutida gerada para volatileFill()é justa [load RAX with sizeof] .L9: subq $1, %rax; jne .L9. Por que o otimizador (A) não relê o byte de preenchimento e (B) se preocupa em preservar o loop onde não faz nada?
sublinhado_d
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.