【问题标题】:Is the definition of "volatile" this volatile, or is GCC having some standard compliancy problems?“易失性”的定义是这种易变的,还是 GCC 存在一些标准合规性问题?
【发布时间】:2016-11-08 21:49:33
【问题描述】:

我需要一个函数(例如 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 实际生成的代码会随着编译器版本和你实际尝试归零的字节数而变化很大。 https://godbolt.org/g/cMaQm2

  • GCC 4.4.7 和 4.5.3 永远不会忽略 volatile。
  • GCC 4.6.4 和 4.7.3 忽略数组大小为 1、2 和 4 的 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;
}

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 似乎为某些人的独立功能生成了一个正常的循环(就像 Godbolt 上的 5.3 一样)。 (阅读 zwol 的答案。)

【问题讨论】:

  • 恕我直言,使用volatile 是一个错误,除非另有证明。但很可能是一个错误。 volatile 指定不足以至于很危险 - 只是不要使用它。
  • @JesperJuhl:不,volatile 适合这种情况。
  • @NathanOliver:那行不通,因为编译器可以优化死存储,即使他们使用memset。问题是编译器确切地知道memset 做了什么。
  • @PaulStelian:这将产生一个volatile 指针,我们想要一个指向volatile 的指针(我们不关心++ 是否严格,但*p = 0 是否严格)。
  • @JesperJuhl:没有任何关于 volatile 的详细说明。

标签: c++ c gcc standards


【解决方案1】:

GCC 的行为可能符合要求,即使不符合要求,您也不应该依赖volatile 在此类情况下做您想做的事情。 C 委员会设计了volatile 用于内存映射硬件寄存器和异常控制流期间修改的变量(例如信号处理程序和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;
}

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

有关标准要求或不要求的更多讨论,请参阅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?Does accessing a declared non-volatile object through a volatile reference/pointer confer volatile rules upon said accesses?GCC bug 71793

有关委员会想法volatile 的更多信息,请在C99 Rationale 中搜索“volatile”一词。 John Regehr 的论文“Volatiles are Miscompiled”详细说明了生产编译器可能无法满足程序员对volatile 的期望。 LLVM 团队的系列文章“What Every C Programmer Should Know About Undefined Behavior”并未具体涉及 volatile,但会帮助您了解现代 C 编译器如何以及为什么不是“便携式汇编器”。


关于如何实现一个函数来完成你想让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 在任何情况下都不会内联。它必须在自己的源文件中,并且不得进行链接时优化。

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

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

【讨论】:

  • 这很有趣...我有兴趣看到对委员会 cmets 的引用。
  • 这个平方如何与 6.7.3(7) 的 volatile 定义为 [...] 因此,任何引用此类对象的表达式都应严格按照抽象机的规则,如 5.1.2.3 中所述。 此外,在每个序列点,最后存储在对象中的值应与抽象机规定的值一致,除非前面提到的未知因素对其进行了修改。什么构成对具有 volatile 限定类型的对象的访问是实现定义的。 ?
  • @IwillnotexistIdonotexist 这段话的关键词是objectvolatile sig_atomic_t flag; 是一个易失的对象*(volatile char *)foo 只是通过 volatile 限定的左值访问,标准不要求它具有任何特殊效果。
  • 标准说明了某些东西必须满足什么标准才能成为“合规”的实现。它没有努力描述给定平台上的实现必须满足哪些标准才能成为“好”实现或“可用”实现。 GCC 对volatile 的处理可能足以使其成为“合规”实现,但这并不意味着它足够“好”或“有用”。对于许多类型的系统编程,它在这些方面应该被视为严重不足。
  • C 规范也直接说 “如果一个实际的实现可以推断出它的值没有被使用并且没有产生所需的副作用(包括任何由调用函数或访问 volatile 对象引起的)。”(强调我的)。
【解决方案2】:

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

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


至于这种与 volatile 的行为是否符合,这有点难说,而 volatile 一直是said 长期以来一直被 bug 困扰。

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

【讨论】:

  • 注意:这是 C11 标准的一部分,尚未在所有工具链中可用。
  • 有趣的是,这个函数是为 C11 标准化的,但不是为 C++11、C++14 或 C++17 标准化的。所以从技术上讲,它不是 C++ 的解决方案,但我同意从实际角度来看,这似乎是最佳选择。在这一点上,我确实想知道 GCC 的行为是否符合要求。编辑:实际上,VS 2015 没有 memset_s,所以它还不是那么便携。
  • @cooky451 我以为C++17 pulls the C11 standard library in by reference(见第二个杂项)。
  • 另外,将memset_s 描述为 C11 标准是夸大其词。它是附件 K 的一部分,在 C11 中是可选的(因此在 C++ 中也是可选的)。基本上所有的实现者,包括微软,最初的想法(!),都拒绝接受它;上次我听说他们在 C-next 中讨论废弃它。
  • @cooky451 在某些圈子里,Microsoft 臭名昭著,因为基本上不顾其他所有人的反对,将东西强加到 C 标准中,然后又懒得自己去实现它。 (这方面最令人震惊的例子是 C99 放宽了允许 size_t 的底层类型的规则。Win64 ABI 不符合 C90。那将是......不是 ok i>,但并不可怕......如果 MSVC 真的及时收到了像 uintmax_t%zu 这样的 C99 内容,但他们没有。)
【解决方案3】:

我将此版本作为可移植 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] ();
}

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

【讨论】:

  • 如果在函数执行后访问对象会调用 UB,这意味着此类访问可能会产生对象在“清除”之前持有的值。这不是安全的对立面吗?
【解决方案4】:

应该可以通过在右侧使用 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 对象中。由于此读取无法优化,因此它确保编译器必须生成循环中指定的存储。

【讨论】:

  • 这根本行不通……看看生成的代码就行了。
  • 更好地阅读了我生成的 ASM mo',它似乎内联了函数调用并保留了循环,但在该循环期间没有对 *ptr 进行任何存储,或者实际上 任何东西 ...只是循环。 wtf,我的大脑走了。
  • @underscore_d 这是因为它优化了存储,同时保留了 volatile 的读取。
  • 是的,它会将结果转储到不变的edx:我明白了:.L16: subq $1, %rax; movzbl -1(%rsp), %edx; jne .L16
  • 如果我更改函数以允许传递任意volatile unsigned char const 填充字节... 它甚至不会读取它。生成的对volatileFill() 的内联调用只是[load RAX with sizeof] .L9: subq $1, %rax; jne .L9。为什么优化器 (A) 不重新读取填充字节并且 (B) 费心保留它不做任何事情的循环?
猜你喜欢
  • 2023-02-10
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多