提问者:小点点

“volatile”的定义就是这个volatile,还是GCC有一些标准遵从性问题?


我需要一个函数(如WinAPI的SecureZeroMemory)始终将内存归零并且不会优化掉,即使编译器认为此后再也不会访问内存。似乎是易变的完美候选者。但是我实际上遇到了一些问题,使其与GCC一起工作。下面是一个示例函数:

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

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

很简单。但是如果你调用它,GCC实际生成的代码会随着编译器的版本和你实际试图归零的字节数而变化。https://godbolt.org/g/cMaQm2

  • GCC 4.4.7 和 4.5.3 从不忽略易失性。
  • GCC 4.6.4 和 4.7.3 忽略数组大小 1、2 和 4 的易失性。
  • GCC 4.8.1 至 4.9.2 忽略数组大小 1 和 2 的易失性。
  • GCC 5.1
  • 到 5.3 忽略数组大小 1、2、4、8 的易失性。
  • GCC 6.1 只是忽略了任何数组大小的它(一致性的加分)。

我测试过的任何其他编译器(clang、icc、vc)都会生成我们所期望的存储,具有任何编译器版本和任何数组大小。所以在这一点上我在想,这是一个(相当老和严重吗?)GCC编译器bug,还是标准中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;
}

可能的写将使除6.1之外的所有GCC版本生成预期的存储。在内存Geofence中进行注释也将使GCC 6.1生成存储,尽管仅与可能的写结合使用。

有人还建议清空缓存。微软根本不会尝试刷新“SecureZeroMemory”中的缓存。无论如何,缓存很可能会很快失效,所以这可能没什么大不了的。此外,如果另一个程序试图探测数据,或者如果它将被写入页面文件,它将始终是零版本。

还有一些关于GCC 6.1在独立函数中使用memset()的问题。godbolt上的GCC 6.1编译器可能是一个不完整的版本,因为GCC 6.1似乎会为一些人的独立函数生成一个正常的循环(就像godbolt上的5.3一样)。(看zwol回答的评论。)


共3个答案

匿名用户

GCC的行为可能是符合的,即使它不是,您也不应该依赖< code>volatile在这种情况下做您想做的事情。C委员会设计了< code>volatile,用于内存映射的硬件寄存器和在异常控制流期间修改的变量(例如信号处理程序和< code>setjmp)。这些是它唯一可靠的东西。用作一般的“不要优化这个”注释是不安全的。

特别是,该标准在一个关键点上不明确。(我已经把你的代码转换成C了;这里C和C不应该有任何分歧。我还手动完成了在有问题的优化之前会发生的内联,以显示编译器在那一点上“看到”了什么。)

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

内存清除循环通过可变限定的左值访问 arr,但 arr 本身不会声明为易失性。因此,至少可以说允许 C 编译器推断循环创建的存储是“死的”,并完全删除循环。C理由中有一段文字暗示委员会打算要求保留这些商店,但标准本身实际上并没有提出这一要求,正如我所读的那样。

有关标准要求或不要求的更多讨论,请参阅为什么易失性局部变量与易失性参数不同地进行优化,以及为什么优化器从后者生成无运算循环?,通过易失性引用/指针访问声明的非易失性对象是否赋予所述访问易失性规则?,以及GCC错误71793。

有关委员会认为volatile的更多信息,请在C99基本原理中搜索“volatile”一词。John Regehr的论文“Volatiles is Miscompile”详细说明了生产编译器可能无法满足程序员对<code>volatile</code>的期望。LLVM团队的一系列文章“每个C程序员都应该知道的未定义行为”没有具体涉及volatile,但将帮助您了解现代C编译器如何以及为什么不是“可移植汇编程序”。

对于如何实现一个函数来完成您想要< code > volatilezeromememory 做的事情的实际问题:不管标准要求或打算要求什么,最明智的做法是假设您不能为此使用< code>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在任何情况下都没有内联。它必须在自己的源文件中,并且不能进行链接时优化。

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

(我建议调用函数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加速指令,如果< code>expand_key和< code>encrypt_with_ek是内联的,编译器可能能够将< code>ek完全保留在向量寄存器文件中,直到调用< code>explicit_bzero为止,这将迫使它将敏感数据复制到堆栈中,只是为了擦除它,更糟糕的是,对仍然在向量寄存器中的密钥不做任何事情!

匿名用户

我需要一个函数(比如WinAPI中的SecureZeroMemory),它总是将内存归零,

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

至于这种行为是否符合波动,这有点不好说,而且波动据说长期以来一直受到bug的困扰。

一个问题是,规范中说“对易失性对象的访问严格按照抽象机器的规则进行评估。”但这仅指“易失性”对象,而不是通过添加了易失性的指针访问非易失性。所以很明显,如果编译器可以告诉你实际上不是在访问一个易失性对象,那么根本不需要将该对象视为易失性。

匿名用户

我将这个版本作为可移植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;
    }
}

现在您有了对易失性对象的写访问,而不仅仅是通过对象的易失性视图对非易失性对象进行访问。

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

要在对象的生命周期中而不是结束时使用这种归零,调用方应该使用placement new将原始类型的新实例放回原处。

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

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

在这一点上,它是一个单行程序,根本不保证有一个助手函数。