“volatile”的定义是否如此不稳定,或者GCC是否存在一些标准遵从性问题?

gz5pxeao  于 2022-11-13  发布在  其他
关注(0)|答案(5)|浏览(135)

我需要一个函数(比如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从不忽略易失性。
  • 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编译器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;
}

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回答的评论。)

jw5wzhpr

jw5wzhpr1#

GCC的行为 * 可能 * 是符合的,即使不是,在这种情况下,您不应该依赖volatile来做您想要做的事情。C委员会为内存Map的硬件寄存器和异常控制流期间修改的变量设计了volatile(例如信号处理程序和setjmp)。**这些是它唯一可靠的对象。**作为一般的“不要优化此输出”注解使用是不安全的。
特别是,这个标准在一个关键点上不清楚。这里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理由中有文字暗示委员会的意思是要求保留这些悬挂物,但是标准本身实际上并没有做出这样的要求。
有关标准要求或不要求的更多讨论,请参见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
想知道委员会认为X 1 M6 N1 X的用途在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,这迫使它 * 将敏感数据复制到堆栈上 *,只是为了擦除它,更糟的是,根本就不会处理向量寄存器中的密钥!

0md85ypi

0md85ypi2#

我需要一个函数(如WinAPI中的SecureZeroMemory)始终将内存归零并且不会被优化,
这就是标准函数memset_s的作用。
至于volatile的这种行为是否符合,那就有点难说了,而且volatile一直以来都是said的bug。
一个问题是规范中说“对volatile对象的访问严格按照抽象机的规则进行评估”。但这只指“volatile对象”,而不是通过添加了volatile的指针访问非volatile对象。因此,显然,如果编译器可以判断出你并不是真正访问volatile对象,那么它就不需要将该对象视为volatile对象。

jxct1oxe

jxct1oxe3#

我提供的这个版本是可移植的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对象进行访问。
语义上的区别在于,它现在正式结束了占用内存区域的任何对象的生存期,因为内存已经被重用了。因此,在将其内容置零之后访问对象现在肯定是未定义行为(以前在大多数情况下,这是未定义行为,但肯定存在一些例外)。
要在对象的生存期内而不是在结束时使用这种清零,调用方应该使用placement new将原始类型的新示例再次放回。
通过使用值初始化,代码可以变得更短(尽管不那么清晰):

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

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

cidc1ykv

cidc1ykv4#

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

hvvq6cgz

hvvq6cgz5#

如其他答案所述,volatile旨在确保始终生成具有副作用的访问,即使该访问被优化视为冗余访问。访问存储器Map外设是一个典型的用例。定义存储器Map寄存器的常见解决方案如下所示:

#define PORT_A *(volatile short*)0x1234

当然,它可以被定义为一个结构指针,描述外设的所有寄存器,地址可能来自某个配置结构,可能是运行时填充的。关键是编译器必须始终生成volatile访问,编译器不可能推测出在那个地址上有什么。另一个典型的解决方案是阅读状态寄存器也会清除所有设置的状态位。(volatile)读取,并舍弃结果。在任何情况下,存取都不得可以被任何编译器优化掉。(这种伪读取对于同步较慢外围总线上的延迟事务也是至关重要的)另一种解决方案,IAR(和其他一些)编译器使用的一种方法是以标准方式创建易失性数据结构,并使用非标准@指令将该结构放置在外设的固定地址。这种方法同样有效,因为编译器永远不会优化外设访问。这将是一场灾难,裸机MCU编程将无法像它那样工作。
我猜想要清零的数组没有被定义为volatile,它被完全删除了。将其地址转换为volatile指针并不能保留数组本身。这是一个有趣的小故障,因为结果是“神圣的”volatile访问也被删除了。

相关问题