"volatile"的定义是否如此不稳定,还是 GCC 存在一些标准合规性问题?

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

本文关键字:存在 标准 问题 合规性 GCC 定义 volatile 是否 不稳定 还是      更新时间:2023-10-16

我需要一个函数(如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编译器错误,还是标准中易失性的定义不精确,这实际上是符合行为,使得基本上不可能编写可移植的"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"中的缓存。无论如何,缓存可能会很快失效,所以这可能没什么大不了的。此外,如果另一个程序正在尝试探测数据,或者如果它将被写入页面文件,它将始终是零版本。

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

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

特别是,该标准在一个关键点上不明确。 (我已经将您的代码转换为 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本身不会声明为volatile。 因此,至少可以说允许 C 编译器推断循环创建的存储是"死的",并完全删除循环。 C理由中有一段文字暗示委员会打算要求保留这些商店,但标准本身实际上并没有提出这一要求,正如我所读的那样。

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

有关委员会认为volatile用途的更多信息,请在C99理由中搜索"易失性"一词。John Regehr的论文"Volatiles被误编译"详细说明了程序员对volatile的期望如何不能被生产编译器满足。LLVM团队的系列文章"每个C程序员应该知道的关于未定义行为的事情"并没有专门涉及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 ,这迫使它将敏感数据复制到堆栈上只是为了擦除它,而且,更糟糕的是,对仍然位于矢量寄存器中的密钥没有任何作用!

我需要一个函数(如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;
    }
}
现在,您拥有对易失性对象的

写入访问权限,而不仅仅是通过对象的易失性视图对非易失性对象进行的访问。

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

若要在对象的生存期内而不是在对象生存期内而不是在对象结束时使用此清零,调用方应使用放置new再次放置原始类型的新实例。

通过使用

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

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

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

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

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,以确保编译器不能对其值做出任何假设,即使它的计算结果始终为零。

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

正如其他答案中所解释的,易失性旨在确保始终生成具有副作用的访问,即使优化认为访问是多余的。访问内存映射外设是一个典型的用例。定义内存映射寄存器的常用解决方案如下所示:

#define PORT_A *(volatile short*)0x1234

当然,它可以定义为一个结构指针,描述外设的所有寄存器,地址可能来自某种配置结构,可能是填充的运行时。关键是编译器必须始终生成易失性访问,无论访问哪个内存区域。编译器不可能推测该地址上的内容。另一个典型的解决方案是读取状态寄存器也会清除所有设置的状态位。如果您只想清除状态,请进行虚拟(易失性)读取,然后丢弃结果。在任何情况下,任何编译器都不得优化访问。(这种虚拟读取对于在较慢的外围总线上同步延迟事务也至关重要)IAR(和其他一些)编译器使用的另一种解决方案是以标准方式创建易失性数据结构,并使用非标准@指令将结构放置在外设的固定地址。这也有效,编译器永远不会优化外围访问。那将是一场灾难,裸机MCU编程将无法正常工作。

我的猜测是,要归零的数组没有被定义为易失性,并且完全被消除了。将其地址强制转换为易失性指针不会保留数组本身。这是一个有趣的故障,因为因此"神圣"的易失性访问也被消除了。