c ++ - “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永远不会忽略volatile。
  • 对于数组大小为1,2和4,GCC 4.6.4和4.7.3忽略volatile。
  • GCC 4.8.1直到4.9.2忽略数组大小1和2的volatile。
  • GCC 5.1直到5.3忽略数组大小1,2,4,8的volatile。
  • GCC 6.1只是忽略任何数组大小(一致性的奖励点)。

我测试过的任何其他编译器(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;
}

callMeMaybe()的可能写入将使除6.1之外的所有GCC版本生成预期的存储。 在内存栅栏中进行注释也会使GCC 6.1生成存储,尽管只能与callMeMaybe()的可能写入一起使用。

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

在独立功能中使用memset()也存在一些关于GCC 6.1的问题。 Godbolt上的GCC 6.1编译器可能是一个破坏的构建,因为GCC 6.1似乎为某些人的独立函数生成了一个正常的循环(就像在godbolt上的5.3一样)。 (阅读zwol答案的评论。)

cooky451 asked 2019-09-09T04:42:31Z
4个解决方案
80 votes

海湾合作委员会的行为可能符合规定,即使不符合,也不应该依赖expand_key在这些情况下做你想做的事情。 C委员会为存储器映射的硬件寄存器和在异常控制流程期间修改的变量(例如信号处理器和ek)设计了encrypt_with_ek。 这是唯一可靠的东西。 使用作为一般的“不优化这个”注释是不安全的。

特别是,关键点上的标准尚不清楚。 (我已经将你的代码转换为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;
}

内存清除循环通过volatile限定的左值访问expand_key,但encrypt_with_ek本身未声明为ek.因此,至少可以说C编译器推断循环所做的存储是“死”,并且 完全删除循环。 C原理中的文字暗示委员会意味着要求保留这些商店,但标准本身实际上并没有像我读到的那样提出要求。

有关标准执行或不需要的更多讨论,请参阅为什么volatile变量局部变量与volatile参数的优化不同,为什么优化器会从后者生成无操作循环?,访问声明的非易失性 对象通过易失性引用/指针赋予所述访问的易失性规则?和GCC bug 71793。

有关委员会认为expand_key的更多信息,请在C99理由中搜索“volatile”一词。 John Regehr的论文“Volatiles is Miscompiled”详细说明了生产编译器可能无法满足程序员对encrypt_with_ek的期望。 LLVM团队的一系列文章“每个C程序员应该知道的关于未定义行为的内容”并未特别涉及ek,但将帮助您了解现代C编译器不是“便携式汇编程序”的方式和原因。


关于如何实现一个能够做你想要的功能的实际问题expand_key要做:无论标准要求或意图要求什么,最明智的做法是假设你不能使用encrypt_with_ek。 有一种替代方案可以依赖于工作,因为如果不起作用,它会破坏太多其他东西:

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

但是,您必须确保expand_key在任何情况下都不会内联。 它必须位于自己的源文件中,并且不得进行链接时优化。

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

(我建议调用函数expand_key,因为它在多个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,这会强制它将敏感数据复制到堆栈上以便擦除 它,更糟糕的是,对于仍然位于向量寄存器中的键没有做任何事情!

zwol answered 2019-09-09T04:47:30Z
15 votes

我需要一个函数(如WinAPI中的SecureZeroMemory)总是将内存归零并且不会被优化掉,

这就是标准功能memset_s的用途。


至于挥发性的这种行为是否符合要求,这有点难以说,而且据说挥发性长期以来一直受到错误的困扰。

一个问题是规范说“根据抽象机器的规则严格评估对易失性对象的访问”。 但这只是指'volatile对象',而不是通过添加了volatile的指针访问非易失性对象。 显然,如果编译器可以告诉你实际上并没有访问volatile对象,那么就不需要将对象视为volatile。

bames53 answered 2019-09-09T04:48:16Z
2 votes

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

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

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

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

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

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

在这一点上,它是一个单行,几乎不保证辅助功能。

Ben Voigt answered 2019-09-09T04:50:12Z
0 votes

应该可以通过在右侧使用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,可确保编译器不会对其值进行任何假设,即使它始终计算为零。

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

D Krueger answered 2019-09-09T04:51:08Z
translate from https://stackoverflow.com:/questions/38230856/is-the-definition-of-volatile-this-volatile-or-is-gcc-having-some-standard-co