¿Es la definición de “volátil” así de volátil, o GCC tiene algunos problemas de cumplimiento estándar?

89

Necesito una función que (como SecureZeroMemory de WinAPI) siempre ponga a cero la memoria y no se optimice, incluso si el compilador piensa que la memoria nunca se volverá a acceder después de eso. Parece un candidato perfecto para volátiles. Pero estoy teniendo algunos problemas para que esto funcione con GCC. Aquí hay una función de ejemplo:

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

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

Suficientemente simple. Pero el código que GCC realmente genera si lo llama varía enormemente con la versión del compilador y la cantidad de bytes que está intentando poner a cero. https://godbolt.org/g/cMaQm2

  • GCC 4.4.7 y 4.5.3 nunca ignoran lo volátil.
  • GCC 4.6.4 y 4.7.3 ignoran los volátiles para los tamaños de matriz 1, 2 y 4.
  • GCC 4.8.1 hasta 4.9.2 ignoran volátil para los tamaños de matriz 1 y 2.
  • GCC 5.1 hasta 5.3 ignoran los volátiles para los tamaños de matriz 1, 2, 4, 8.
  • GCC 6.1 simplemente lo ignora para cualquier tamaño de matriz (puntos de bonificación por coherencia).

Cualquier otro compilador que haya probado (clang, icc, vc) genera las tiendas que uno esperaría, con cualquier versión del compilador y cualquier tamaño de matriz. Entonces, en este punto, me pregunto, ¿se trata de un error del compilador de GCC (bastante antiguo y severo?), O la definición de volátil en el estándar es tan imprecisa que esto es realmente un comportamiento conforme, lo que hace que sea esencialmente imposible escribir un portátil " ¿Función SecureZeroMemory "?

Editar: Algunas observaciones interesantes.

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

La posible escritura desde callMeMaybe () hará que todas las versiones de GCC excepto la 6.1 generen las tiendas esperadas.Comentar en la valla de memoria también hará que GCC 6.1 genere las tiendas, aunque solo en combinación con la posible escritura de callMeMaybe ().

Alguien también sugirió vaciar los cachés. Microsoft no intenta vaciar la caché en absoluto en "SecureZeroMemory".Es probable que la caché se invalide bastante rápido de todos modos, por lo que probablemente esto no sea un gran problema. Además, si otro programa intentaba sondear los datos, o si se iban a escribir en el archivo de paginación, siempre sería la versión puesta a cero.

También existen algunas preocupaciones sobre el uso de memset () en GCC 6.1 en la función independiente. El compilador GCC 6.1 en godbolt podría tener una compilación rota, ya que GCC 6.1 parece generar un bucle normal (como lo hace 5.3 en godbolt) para la función independiente para algunas personas. (Lea los comentarios de la respuesta de zwol).

cooky451
fuente
4
En mi humilde opinión, el uso volatilees un error a menos que se demuestre lo contrario. Pero lo más probable es que sea un error. volatileestá tan subespecificado como para ser peligroso, simplemente no lo use.
Jesper Juhl
19
@JesperJuhl: No, volatilees apropiado en este caso.
Dietrich Epp
9
@NathanOliver: Eso no funcionará, porque los compiladores pueden optimizar las tiendas muertas incluso si usan memset. El problema es que los compiladores saben exactamente lo que memsethace.
Dietrich Epp
8
@PaulStelian: Eso sería un volatilepuntero, queremos un puntero a volatile(no nos importa si ++es estricto, pero si *p = 0es estricto).
Dietrich Epp
7
@JesperJuhl: No hay nada subespecificado sobre volátiles.
GManNickG

Respuestas:

82

El comportamiento de GCC puede ser conforme, e incluso si no lo es, no debe confiar en volatilehacer lo que quiera en casos como estos. El comité C diseñado volatilepara registros de hardware mapeados en memoria y para variables modificadas durante un flujo de control anormal (por ejemplo, manejadores de señales y setjmp). Esas son las únicas cosas para las que es confiable. No es seguro utilizarlo como una anotación general de "no optimizar esto".

En particular, el estándar no está claro en un punto clave. (He convertido su código a C; no debería haber ninguna divergencia entre C y C ++ aquí. También hice manualmente la inserción que ocurriría antes de la optimización cuestionable, para mostrar lo que el compilador "ve" en ese punto .)

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

El bucle de borrado de memoria accede a arrtravés de un lvalue calificado como volátil, pero no se declara en arrsí mismo . Por lo tanto, es posible argumentar que al menos se permite que el compilador de C infiera que las tiendas hechas por el ciclo están "muertas" y borre el ciclo por completo. Hay un texto en el fundamento C que implica que el comité tenía la intención de exigir que esas tiendas se preserven, pero el estándar en sí no establece ese requisito, como lo leo.volatile

Para obtener más información sobre lo que requiere o no el estándar, consulte ¿Por qué una variable local volátil se optimiza de manera diferente a un argumento volátil y por qué el optimizador genera un bucle no operativo a partir de este último? , ¿El acceso a un objeto no volátil declarado a través de una referencia / puntero volátil confiere reglas volátiles sobre dichos accesos? y error de GCC 71793 .

Para obtener más información sobre lo que pensó el comité volatile, busque en el C99 Justificación de la palabra "volátil". El artículo de John Regehr " Los volátiles están malcompilados " ilustra en detalle cómo las expectativas de los programadores volatilepueden no ser satisfechas por los compiladores de producción. La serie de ensayos del equipo de LLVM " Lo que todo programador de C debe saber sobre el comportamiento indefinido " no trata específicamente, volatilepero lo ayudará a comprender cómo y por qué los compiladores de C modernos no son "ensambladores portátiles".


A la pregunta práctica de cómo implementar una función que hace lo que usted quería volatileZeroMemoryhacer: Independientemente de lo que el estándar requiera o esté destinado a requerir, sería más prudente asumir que no puede usarlo volatilepara esto. No es una alternativa que puede ser invocada para el trabajo, ya que rompería demasiado otras cosas si no lo hizo el trabajo:

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

Sin embargo, debe asegurarse absolutamente de que memory_optimization_fenceno esté incluido bajo ninguna circunstancia. Debe estar en su propio archivo fuente y no debe estar sujeto a la optimización del tiempo de enlace.

Hay otras opciones, que dependen de las extensiones del compilador, que pueden usarse en algunas circunstancias y pueden generar un código más estricto (una de ellas apareció en una edición anterior de esta respuesta), pero ninguna es universal.

(Recomiendo llamar a la función explicit_bzero , porque está disponible con ese nombre en más de una biblioteca C. Hay al menos otros cuatro candidatos para el nombre, pero cada uno ha sido adoptado solo por una sola biblioteca C).

También debe saber que, incluso si puede hacer que esto funcione, puede que no sea suficiente. En 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);
}

Suponiendo hardware con instrucciones de aceleración AES, si expand_keyy encrypt_with_ekestán en línea, el compilador puede ser capaz de mantenerse ekcompletamente en el archivo de registro vectorial, hasta la llamada a explicit_bzero, lo que lo obliga a copiar los datos confidenciales en la pila solo para borrarlos y lo que es peor, ¡no hace nada con las claves que todavía están en los registros vectoriales!

zwol
fuente
6
Eso es interesante ... Me interesaría ver una referencia a los comentarios del comité.
Dietrich Epp
10
¿Cómo cuadra esto con la definición de 6.7.3 (7) de volatilecomo [...] Por lo tanto, cualquier expresión que se refiera a tal objeto debe evaluarse estrictamente de acuerdo con las reglas de la máquina abstracta, como se describe en 5.1.2.3. Además, en cada punto de secuencia, el último valor almacenado en el objeto deberá coincidir con el prescrito por la máquina abstracta , salvo que lo modifiquen los factores desconocidos mencionados anteriormente. Lo que constituye un acceso a un objeto que tiene un tipo calificado volátil está definido por la implementación. ?
Iwillnotexist Idonotexist
15
@IwillnotexistIdonotexist La palabra clave en ese pasaje es objeto . volatile sig_atomic_t flag;es un objeto volátil . *(volatile char *)fooes simplemente un acceso a través de un valor l calificado para volátiles y el estándar no requiere que tenga efectos especiales.
zwol
3
El Estándar dice qué criterios debe cumplir algo para ser una implementación "conforme". No hace ningún esfuerzo por describir qué criterios debe cumplir una implementación en una plataforma determinada para ser una implementación "buena" o "utilizable". El tratamiento de GCC de volatilepuede ser suficiente para convertirlo en una implementación "compatible", pero eso no significa que sea suficiente para ser "bueno" o "útil". Para muchos tipos de programación de sistemas, debería considerarse lamentablemente deficiente en esos aspectos.
supercat
3
La especificación C también dice directamente "Una implementación real no necesita evaluar parte de una expresión si puede deducir que su valor no se usa y que no se producen efectos secundarios necesarios ( incluidos los causados ​​por llamar a una función o acceder a un objeto volátil ) . " (enfatiza el mío).
Johannes Schaub - litb
15

Necesito una función que (como SecureZeroMemory de WinAPI) siempre ponga a cero la memoria y no se optimice,

Para eso es la función estándar memset_s.


En cuanto a si este comportamiento con volátiles se ajusta o no, es un poco difícil de decir, y se ha dicho que los volátiles han estado plagados de errores durante mucho tiempo.

Un problema es que las especificaciones dicen que "los accesos a objetos volátiles se evalúan estrictamente de acuerdo con las reglas de la máquina abstracta". Pero eso solo se refiere a 'objetos volátiles', sin acceder a un objeto no volátil a través de un puntero al que se le ha agregado volátil. Entonces, aparentemente, si un compilador puede decir que realmente no está accediendo a un objeto volátil, no es necesario que trate el objeto como volátil después de todo.

bames53
fuente
4
Nota: Esto es parte del estándar C11 y aún no está disponible en todas las cadenas de herramientas.
Dietrich Epp
5
Cabe señalar que, curiosamente, esta función está estandarizada para C11 pero no para C ++ 11, C ++ 14 o C ++ 17. Entonces, técnicamente, no es una solución para C ++, pero estoy de acuerdo en que esta parece ser la mejor opción desde una perspectiva práctica. En este punto, me pregunto si el comportamiento de GCC es conforme o no. Editar: En realidad, VS 2015 no tiene memset_s, por lo que aún no es tan portátil.
cooky451
2
@ cooky451 Pensé que C ++ 17 extrae la biblioteca estándar C11 por referencia (ver segundo Misc).
nwp
14
Además, describir memset_scomo estándar C11 es una exageración. Forma parte del anexo K, que es opcional en C11 (y, por tanto, también opcional en C ++). Básicamente, todos los implementadores, incluido Microsoft, cuya idea fue en primer lugar (!), Se han negado a aceptarlo; Lo último que escuché fue que estaban hablando de eliminarlo en C-siguiente.
zwol
8
@ cooky451 En ciertos círculos, Microsoft es conocido por forzar cosas en el estándar C sobre básicamente las objeciones de todos los demás y luego no molestarse en implementarlas ellos mismos. (El ejemplo más atroz de esto es la relajación de C99 de las reglas de lo que size_tse permite que sea el tipo subyacente de . La ABI de Win64 no es conforme con C90. Eso habría sido ... no está bien , pero no es terrible ... si MSVC en realidad había recogido cosas de C99 como uintmax_ty %zude manera oportuna, pero no lo hicieron )
zwol
2

Ofrezco esta versión como C ++ portátil (aunque la semántica es 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;
    }
}

Ahora tiene acceso de escritura a un objeto volátil , no simplemente accesos a un objeto no volátil realizado a través de una vista volátil del objeto.

La diferencia semántica es que ahora termina formalmente la vida útil de cualquier objeto que ocupara la región de la memoria, porque la memoria ha sido reutilizada. Entonces, el acceso al objeto después de poner a cero su contenido ahora es seguramente un comportamiento indefinido (anteriormente habría sido un comportamiento indefinido en la mayoría de los casos, pero seguramente existieron algunas excepciones).

Para usar esta newpuesta a cero durante la vida de un objeto en lugar de al final, la persona que llama debe usar la ubicación para volver a colocar una nueva instancia del tipo original.

El código se puede hacer más corto (aunque menos claro) usando la inicialización de valor:

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

y en este punto es de una sola línea y apenas justifica una función de ayuda.

Ben Voigt
fuente
2
Si los accesos al objeto después de que se ejecuta la función invocarían UB, eso significaría que dichos accesos podrían producir los valores que el objeto tenía antes de ser "borrado". ¿Cómo no es eso lo opuesto a la seguridad?
supercat
0

Debería ser posible escribir una versión portátil de la función utilizando un objeto volátil en el lado derecho y obligando al compilador a conservar las tiendas en la matriz.

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

El zeroobjeto se declara de manera volatileque garantiza que el compilador no pueda hacer suposiciones sobre su valor aunque siempre se evalúe como cero.

La expresión de asignación final lee un índice volátil en la matriz y almacena el valor en un objeto volátil. Dado que esta lectura no se puede optimizar, asegura que el compilador debe generar las tiendas especificadas en el ciclo.

D Krueger
fuente
1
Esto no funciona en absoluto ... solo mire el código que se está generando.
cooky451
1
Habiendo leído mejor mi ASM generado, parece alinear la llamada a la función y retener el bucle, pero no hacer ningún almacenamiento *ptrdurante ese bucle, o en realidad nada en absoluto ... solo bucle. wtf, ahí va mi cerebro.
underscore_d
3
@underscore_d Es porque está optimizando la tienda al tiempo que conserva la lectura de los volátiles.
D Krueger
1
Sí, y arroja el resultado a un invariable edx: obtengo esto:.L16: subq $1, %rax; movzbl -1(%rsp), %edx; jne .L16
underscore_d
1
Si cambio la función para permitir pasar un volatile unsigned char constbyte de relleno arbitrario ... ni siquiera lo lee . La llamada en línea generada a volatileFill()es solo [load RAX with sizeof] .L9: subq $1, %rax; jne .L9. ¿Por qué el optimizador (A) no vuelve a leer el byte de relleno y (B) se molesta en preservar el bucle donde no hace nada?
underscore_d