"volatile" 的定义是易变的,还是 GCC 存在一些标准合规性问题?

Is the definition of "volatile" this volatile, or is GCC having some standard compliancy problems?

我需要一个函数(如 WinAPI 中的 SecureZeroMemory)始终将内存归零并且不会被优化掉,即使编译器认为此后再也不会访问该内存。似乎是 volatile 的完美候选者。但是我在实际使用 GCC 时遇到了一些问题。这是一个示例函数:

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

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

很简单。但是,如果您调用 GCC,GCC 实际生成的代码会随着编译器版本和您实际尝试归零的字节数的不同而大相径庭。 https://godbolt.org/g/cMaQm2

我测试过的任何其他编译器(clang、icc、vc)都会生成人们期望的存储,具有任何编译器版本和任何数组大小。所以在这一点上我想知道,这是一个(相当古老且严重的?)GCC 编译器错误,还是标准中 volatile 的定义不精确,这实际上是符合规范的行为,这使得编写可移植的 "SecureZeroMemory"函数?

编辑:一些有趣的观察结果。

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

The possible write from callMeMaybe() will make all GCC versions except 6.1 generate the expected stores. 在内存栅栏中进行注释也会使 GCC 6.1 生成存储,尽管只能与来自 callMeMaybe() 的可能写入相结合。

有人还建议刷新缓存。 Microsoft does not try to flush the cache at all in "SecureZeroMemory". 缓存可能很快就会失效,所以这可能不是什么大问题。此外,如果另一个程序试图探测数据,或者如果它要被写入页面文件,它将始终是归零版本。

对于 GCC 6.1 在独立函数中使用 memset() 也存在一些担忧。 godbolt 上的 GCC 6.1 编译器可能是一个损坏的构建,因为 GCC 6.1 似乎为某些人的独立函数生成了一个正常循环(就像 5.3 在 godbolt 上所做的那样)。 (阅读 zwol 回答的评论。)

I need a function that (like SecureZeroMemory from the WinAPI) always zeros memory and doesn't get optimized away,

这就是标准函数 memset_s 的用途。


至于 volatile 的这种行为是否合规,这有点难说,而且 volatile 一直said 一直被 bug 所困扰。

一个问题是规范说 "Accesses to volatile objects are evaluated strictly according to the rules of the abstract machine." 但这仅指 'volatile objects',而不是通过添加了 volatile 的指针访问非易失性对象。所以很明显,如果编译器可以告诉您您实际上并没有访问 volatile 对象,那么就不需要将该对象视为 volatile。

GCC 的行为可能 符合规范,即使不符合规范,您也不应依赖volatile 在此类情况下执行您想要的操作。 C 委员会为内存映射硬件寄存器和在异常控制流期间修改的变量(例如信号处理程序和 setjmp)设计了 volatile这些是它唯一可靠的地方。用作一般 "don't optimize this out" 注释是不安全的。

特别是标准在一个关键点上不清楚。 (我已经将你的代码转换为 C;这里 不应该 是 C 和 C++ 之间的任何分歧。我还手动完成了在有问题的优化之前会发生的内联,以显示当时编译器 "sees" 的内容。)

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

内存清除循环通过 volatile 限定左值访问 arr,但 arr 本身 声明 volatile。因此,至少可以说允许 C 编译器推断循环所做的存储是 "dead",并完全删除循环。 C 基本原理中的文本暗示委员会 的意思是 要求保留这些存储,但正如我所读,标准本身实际上并没有提出该要求。

有关标准要求或不要求的内容的更多讨论,请参阅 Why is a volatile local variable optimised differently from a volatile argument, and why does the optimiser generate a no-op loop from the latter?, , and GCC bug 71793

有关委员会 的想法 volatile 的更多信息,请搜索 C99 Rationale for the word "volatile". John Regehr's paper "Volatiles are Miscompiled" illustrates in detail how programmer expectations for volatile may not be satisfied by production compilers. The LLVM team's series of essays "What Every C Programmer Should Know About Undefined Behavior“没有具体涉及 volatile 但会帮助您了解现代 C 编译器如何以及为什么 不是 "portable assemblers".


对于实用问题,即如何实现您想要volatileZeroMemory做的功能:无论标准要求或旨在要求什么,最好假设您不能为此使用 volatile一个可以依赖的替代方案,因为如果它不起作用,它会破坏太多其他东西:

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

但是,您必须绝对确保 memory_optimization_fence 在任何情况下都不会被内联。它必须在自己的源文件中,并且不能进行 link 时间优化。

还有其他选项,依赖于编译器扩展,在某些情况下可能可用,并且可以生成更紧凑的代码(其中一个出现在本答案的前一版本中),但 none 是通用的。

(我建议调用函数 explicit_bzero,因为它在不止一个 C 库中以该名称提供。至少有四个其他竞争者使用该名称,但每个仅被一个人采用单个 C 库。)

你也应该知道,即使你能让这个工作,也可能还不够。特别是考虑

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

假设硬件具有 AES 加速指令,如果 expand_keyencrypt_with_ek 是内联的,编译器可能能够将 ek 完全保留在向量寄存器文件中——直到调用到 explicit_bzero,这迫使它 将敏感数据复制到堆栈上 只是为了擦除它,更糟糕的是,对仍然存在的密钥没有做任何该死的事情坐在向量寄存器中!

应该可以通过在右侧使用 volatile 对象并强制编译器将存储保留到数组来编写该函数的可移植版本。

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

zero 对象被声明为 volatile,确保编译器不会对其值做出任何假设,即使它始终计算为零。

最后的赋值表达式从数组中的可变索引中读取并将值存储在可变对象中。由于无法优化此读取,因此它确保编译器必须生成循环中指定的存储。

我将此版本作为可移植的 C++ 提供(尽管语义略有不同):

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

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

现在您可以对 volatile 对象 进行写访问,而不仅仅是通过对象的 volatile 视图访问非 volatile 对象。

语义上的区别在于,它现在正式结束了占用内存区域的任何对象的生命周期,因为内存已被重用。因此,在将其内容清零后访问该对象现在肯定是未定义的行为(以前在大多数情况下它是未定义的行为,但肯定存在一些例外)。

要在对象的生命周期而不是结束时使用此归零,调用者应使用放置 new 再次放回原始类型的新实例。

通过使用值初始化可以使代码更短(尽管不太清晰):

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

在这一点上,它是一个单行代码,几乎不需要辅助函数。